diff --git a/common/pkg/libartifact/store/store.go b/common/pkg/libartifact/store/store.go index 9845938235..23d6765f43 100644 --- a/common/pkg/libartifact/store/store.go +++ b/common/pkg/libartifact/store/store.go @@ -226,12 +226,56 @@ func (as ArtifactStore) Push(ctx context.Context, src, dest ArtifactReference, o return artifactDigest, nil } +// createNewArtifactManifest creates a new manifest with annotations. +func createNewArtifactManifest(options *libartTypes.AddOptions) specV1.Manifest { + // Set creation timestamp and other annotations + annotations := make(map[string]string) + if options.Annotations != nil { + annotations = maps.Clone(options.Annotations) + } + annotations[specV1.AnnotationCreated] = time.Now().UTC().Format(time.RFC3339Nano) + + return specV1.Manifest{ + Versioned: specs.Versioned{SchemaVersion: ManifestSchemaVersion}, + MediaType: specV1.MediaTypeImageManifest, + ArtifactType: options.ArtifactMIMEType, + Config: specV1.DescriptorEmptyJSON, + Layers: make([]specV1.Descriptor, 0), + Annotations: annotations, + } +} + +// cleanupAfterAppend removes previous image when doing an append. +func cleanupAfterAppend(ctx context.Context, oldDigest *digest.Digest, as ArtifactStore) error { + lrs, err := layout.List(as.storePath) + if err != nil { + return err + } + + for _, l := range lrs { + if oldDigest.String() == l.ManifestDescriptor.Digest.String() { + if _, ok := l.ManifestDescriptor.Annotations[specV1.AnnotationRefName]; ok { + continue + } + + if err := l.Reference.DeleteImage(ctx, as.SystemContext); err != nil { + return err + } + break + } + } + return nil +} + // Add takes one or more artifact blobs and add them to the local artifact store. The empty // string input is for possible custom artifact types. func (as ArtifactStore) Add(ctx context.Context, dest ArtifactReference, artifactBlobs []libartTypes.ArtifactBlob, options *libartTypes.AddOptions) (*digest.Digest, error) { if options.Append && len(options.ArtifactMIMEType) > 0 { return nil, errors.New("append option is not compatible with type option") } + if options.Append && options.Replace { + return nil, errors.New("append and replace options are mutually exclusive") + } locked := true as.lock.Lock() @@ -245,37 +289,17 @@ func (as ArtifactStore) Add(ctx context.Context, dest ArtifactReference, artifac var oldDigest *digest.Digest fileNames := map[string]struct{}{} - arty, lookupErr := as.lookupArtifactLocked(ctx, dest.ToArtifactStoreReference()) - if !options.Append { - // Check if artifact exists; in GetByName not getting an - // error means it exists - if lookupErr == nil { - return nil, fmt.Errorf("%s: %w", dest.String(), libartTypes.ErrArtifactAlreadyExists) - } + existingArtifact, lookupErr := as.lookupArtifactLocked(ctx, dest.ToArtifactStoreReference()) - // Set creation timestamp and other annotations - annotations := make(map[string]string) - if options.Annotations != nil { - annotations = maps.Clone(options.Annotations) - } - annotations[specV1.AnnotationCreated] = time.Now().UTC().Format(time.RFC3339Nano) - - artifactManifest = specV1.Manifest{ - Versioned: specs.Versioned{SchemaVersion: ManifestSchemaVersion}, - MediaType: specV1.MediaTypeImageManifest, - ArtifactType: options.ArtifactMIMEType, - // TODO This should probably be configurable once the CLI is capable - Config: specV1.DescriptorEmptyJSON, - Layers: make([]specV1.Descriptor, 0), - Annotations: annotations, - } - } else { + switch { + case options.Append: + // Append to existing artifact if lookupErr != nil { return nil, lookupErr } - artifactManifest = arty.Manifest.Manifest + artifactManifest = existingArtifact.Manifest.Manifest var err error - oldDigest, err = arty.GetDigest() + oldDigest, err = existingArtifact.GetDigest() if err != nil { return nil, err } @@ -284,6 +308,26 @@ func (as ArtifactStore) Add(ctx context.Context, dest ArtifactReference, artifac fileNames[value] = struct{}{} } } + case options.Replace: + // Replace existing artifact - delete old one if it exists, then create new + if lookupErr == nil { + ir, err := layout.NewReference(as.storePath, dest.String()) + if err != nil { + return nil, err + } + if err := ir.DeleteImage(ctx, as.SystemContext); err != nil { + return nil, err + } + } + + // Create new manifest + artifactManifest = createNewArtifactManifest(options) + default: + // Add new artifact - error if it already exists + if lookupErr == nil { + return nil, fmt.Errorf("%s: %w", dest.String(), libartTypes.ErrArtifactAlreadyExists) + } + artifactManifest = createNewArtifactManifest(options) } for _, artifact := range artifactBlobs { @@ -313,7 +357,7 @@ func (as ArtifactStore) Add(ctx context.Context, dest ArtifactReference, artifac // ImageDestination, in general, requires the caller to write a full image; here we may write only the added layers. // This works for the oci/layout transport we hard-code. for _, artifactBlob := range artifactBlobs { - if artifactBlob.BlobFilePath == "" && artifactBlob.BlobReader == nil || artifactBlob.BlobFilePath != "" && artifactBlob.BlobReader != nil { + if (artifactBlob.BlobFilePath == "" && artifactBlob.BlobReader == nil) || (artifactBlob.BlobFilePath != "" && artifactBlob.BlobReader != nil) { return nil, errors.New("Artifact.BlobFile or Artifact.BlobReader must be provided") } @@ -393,23 +437,9 @@ func (as ArtifactStore) Add(ctx context.Context, dest ArtifactReference, artifac // Clean up after append. Remove previous artifact from store. if oldDigest != nil { - lrs, err := layout.List(as.storePath) - if err != nil { + if err := cleanupAfterAppend(ctx, oldDigest, as); err != nil { return nil, err } - - for _, l := range lrs { - if oldDigest.String() == l.ManifestDescriptor.Digest.String() { - if _, ok := l.ManifestDescriptor.Annotations[specV1.AnnotationRefName]; ok { - continue - } - - if err := l.Reference.DeleteImage(ctx, as.SystemContext); err != nil { - return nil, err - } - break - } - } } return &artifactManifestDigest, nil } diff --git a/common/pkg/libartifact/store/store_test.go b/common/pkg/libartifact/store/store_test.go new file mode 100644 index 0000000000..001aa3a88d --- /dev/null +++ b/common/pkg/libartifact/store/store_test.go @@ -0,0 +1,873 @@ +package store + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/opencontainers/go-digest" + specV1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.podman.io/common/pkg/libartifact" + libartTypes "go.podman.io/common/pkg/libartifact/types" + "go.podman.io/image/v5/types" +) + +const ( + ArtifactTestMimeType = "application/vnd.test+type" + ArtifactReplaceTestMimeType = "application/vnd.replaced+type" +) + +// randomAlphanumeric generates a random alphanumeric string of the specified length. +func randomAlphanumeric(length int) (string, error) { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + result := make([]byte, length) + randomBytes := make([]byte, length) + + _, err := rand.Read(randomBytes) + if err != nil { + return "", err + } + + for i, b := range randomBytes { + result[i] = charset[int(b)%len(charset)] + } + + return string(result), nil +} + +// setupTestStore creates a new empty artifact store for testing. +func setupTestStore(t *testing.T) (*ArtifactStore, context.Context) { + t.Helper() + ctx := context.Background() + storePath := filepath.Join(t.TempDir(), "store") + sc := &types.SystemContext{} + + as, err := NewArtifactStore(storePath, sc) + require.NoError(t, err) + require.NotNil(t, as) + + return as, ctx +} + +// createTestBlob creates a temporary file with random content and returns an ArtifactBlob. +func createTestBlob(t *testing.T, fileName string, size int) (libartTypes.ArtifactBlob, [32]byte) { + t.Helper() + + // Generate random content + content := make([]byte, size) + _, err := rand.Read(content) + require.NoError(t, err) + + // Create temporary file + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, fileName) + err = os.WriteFile(filePath, content, 0o644) + require.NoError(t, err) + + return libartTypes.ArtifactBlob{ + BlobFilePath: filePath, + FileName: fileName, + }, sha256.Sum256(content) +} + +// helperAddArtifact is a test helper that adds an artifact to the store. +// It creates temporary files with random content and adds them as blobs. +// fileNames maps filename to size in bytes of random content to generate. +// If options is nil, uses a default with ArtifactMIMEType set to "application/vnd.test+type". +func helperAddArtifact(t *testing.T, as *ArtifactStore, refName string, fileNames map[string]int, options *libartTypes.AddOptions) (*digest.Digest, map[string][32]byte) { + t.Helper() + ctx := context.Background() + + // If options is nil, create default options + if options == nil { + options = &libartTypes.AddOptions{ + ArtifactMIMEType: ArtifactTestMimeType, + } + } + + // if no specific files were passed, create a random file + if fileNames == nil { + filename, err := randomAlphanumeric(5) + require.NoError(t, err) + fileNames = map[string]int{ + filename: 2, + } + } + + // Create artifact reference + ref, err := NewArtifactReference(refName) + require.NoError(t, err) + + // Create artifact blobs + blobs := make([]libartTypes.ArtifactBlob, 0, len(fileNames)) + checkSums := make(map[string][32]byte, len(fileNames)) + for fileName, size := range fileNames { + blob, checkSum256 := createTestBlob(t, fileName, size) + blobs = append(blobs, blob) + checkSums[fileName] = checkSum256 + } + + // Add artifact + artifactDigest, err := as.Add(ctx, ref, blobs, options) + require.NoError(t, err) + require.NotNil(t, artifactDigest) + + return artifactDigest, checkSums +} + +func TestNewArtifactStore(t *testing.T) { + // Test with valid absolute path + storePath := filepath.Join(t.TempDir(), "store") + sc := &types.SystemContext{} + + as, err := NewArtifactStore(storePath, sc) + assert.NoError(t, err) + assert.NotNil(t, as) + assert.Equal(t, storePath, as.storePath) + + // Verify the index file was created + indexPath := filepath.Join(storePath, "index.json") + _, err = os.Stat(indexPath) + assert.NoError(t, err) + + // Test with empty path + _, err = NewArtifactStore("", sc) + assert.Error(t, err) + assert.Contains(t, err.Error(), "store path cannot be empty") + + // Test with relative path + _, err = NewArtifactStore("relative/path", sc) + assert.Error(t, err) + assert.Contains(t, err.Error(), "must be absolute") +} + +func TestArtifactStore_Add(t *testing.T) { + as, ctx := setupTestStore(t) + + // Add artifact using helper with nil options (uses default) + fileNames := map[string]int{ + "testfile.txt": 1024, + } + + refName := "quay.io/test/artifact:v1" + artifactDigest, _ := helperAddArtifact(t, as, refName, fileNames, nil) + assert.NotEmpty(t, artifactDigest.String()) + + // Verify artifact was added to the store + artifacts, err := as.List(ctx) + require.NoError(t, err) + assert.Len(t, artifacts, 1) + + // Verify artifact properties + artifact := artifacts[0] + assert.Equal(t, refName, artifact.Name) + assert.Equal(t, ArtifactTestMimeType, artifact.Manifest.ArtifactType) + assert.Len(t, artifact.Manifest.Layers, 1) + + // Append another file to the same artifact + appendFileNames := map[string]int{ + "appended.txt": 512, + } + appendOptions := &libartTypes.AddOptions{ + Append: true, + } + + appendDigest, _ := helperAddArtifact(t, as, refName, appendFileNames, appendOptions) + assert.NotEmpty(t, appendDigest.String()) + + // Verify artifact now has 2 layers + artifacts, err = as.List(ctx) + require.NoError(t, err) + assert.Len(t, artifacts, 1) + + artifact = artifacts[0] + assert.Len(t, artifact.Manifest.Layers, 2) + + // Verify both files are present + foundFiles := make(map[string]bool) + for _, layer := range artifact.Manifest.Layers { + title := layer.Annotations[specV1.AnnotationTitle] + foundFiles[title] = true + } + assert.True(t, foundFiles["testfile.txt"]) + assert.True(t, foundFiles["appended.txt"]) + + // Replace the artifact with a completely new one + replaceFileNames := map[string]int{ + "replacement.bin": 2048, + } + replaceOptions := &libartTypes.AddOptions{ + Replace: true, + ArtifactMIMEType: ArtifactReplaceTestMimeType, + } + + replaceDigest, _ := helperAddArtifact(t, as, refName, replaceFileNames, replaceOptions) + assert.NotEmpty(t, replaceDigest.String()) + + // Verify artifact was replaced with only the new file + artifacts, err = as.List(ctx) + require.NoError(t, err) + assert.Len(t, artifacts, 1) + + artifact = artifacts[0] + assert.Len(t, artifact.Manifest.Layers, 1) + assert.Equal(t, ArtifactReplaceTestMimeType, artifact.Manifest.ArtifactType) + + // Verify only the replacement file is present + assert.Equal(t, "replacement.bin", artifact.Manifest.Layers[0].Annotations[specV1.AnnotationTitle]) +} + +func TestArtifactStore_Add_MultipleFiles(t *testing.T) { + as, ctx := setupTestStore(t) + + // Add artifact with multiple files using helper with nil options (uses default) + fileNames := map[string]int{ + "file1.txt": 512, + "file2.bin": 1024, + "file3.dat": 2048, + } + refName := "quay.io/test/multifile:v1" + artifactDigest, _ := helperAddArtifact(t, as, refName, fileNames, nil) + assert.NotEmpty(t, artifactDigest.String()) + + // Verify artifact was added to the store + artifacts, err := as.List(ctx) + require.NoError(t, err) + assert.Len(t, artifacts, 1) + + // Verify artifact has 3 artifact files + artifact := artifacts[0] + assert.Equal(t, refName, artifact.Name) + assert.Equal(t, ArtifactTestMimeType, artifact.Manifest.ArtifactType) + assert.Len(t, artifact.Manifest.Layers, 3) + + // Verify all file names are present in layer annotations + foundFiles := make(map[string]bool) + for _, layer := range artifact.Manifest.Layers { + title := layer.Annotations[specV1.AnnotationTitle] + foundFiles[title] = true + } + + // Ensure all the files exist by same name + for f := range fileNames { + assert.True(t, foundFiles[f], "file %s not found in artifact", f) + } + + // Verify layer sizes match expected sizes + for _, layer := range artifact.Manifest.Layers { + title := layer.Annotations[specV1.AnnotationTitle] + expectedSize := int64(fileNames[title]) + assert.Equal(t, expectedSize, layer.Size, "Layer size for %s should match", title) + } +} + +func TestArtifactStore_Add_CustomMIMEType(t *testing.T) { + as, ctx := setupTestStore(t) + + // Add artifact with custom MIME type + fileNames := map[string]int{ + "config.json": 256, + } + options := &libartTypes.AddOptions{ + ArtifactMIMEType: "application/vnd.custom+json", + } + + artifactDigest, _ := helperAddArtifact(t, as, "quay.io/test/custom:v1", fileNames, options) + assert.NotEmpty(t, artifactDigest.String()) + + // Verify artifact uses custom MIME type + artifacts, err := as.List(ctx) + require.NoError(t, err) + assert.Len(t, artifacts, 1) + + artifact := artifacts[0] + assert.Equal(t, "application/vnd.custom+json", artifact.Manifest.ArtifactType) +} + +func TestArtifactStore_Add_ReplaceNonExistent(t *testing.T) { + as, ctx := setupTestStore(t) + + // Verify store is empty + artifacts, err := as.List(ctx) + require.NoError(t, err) + assert.Empty(t, artifacts) + + // Try to Replace an artifact that doesn't exist yet + // This should succeed and create a new artifact (not error) + fileNames := map[string]int{ + "newfile.txt": 1024, + } + replaceOptions := &libartTypes.AddOptions{ + Replace: true, + ArtifactMIMEType: ArtifactTestMimeType, + } + + refName := "quay.io/test/nonexistent:v1" + artifactDigest, _ := helperAddArtifact(t, as, refName, fileNames, replaceOptions) + assert.NotEmpty(t, artifactDigest.String()) + + // Verify artifact was created successfully + artifacts, err = as.List(ctx) + require.NoError(t, err) + assert.Len(t, artifacts, 1) + + artifact := artifacts[0] + assert.Equal(t, refName, artifact.Name) + assert.Equal(t, ArtifactTestMimeType, artifact.Manifest.ArtifactType) + assert.Len(t, artifact.Manifest.Layers, 1) + assert.Equal(t, "newfile.txt", artifact.Manifest.Layers[0].Annotations[specV1.AnnotationTitle]) +} + +func TestArtifactStore_Add_AppendAndReplaceConflict(t *testing.T) { + as, ctx := setupTestStore(t) + + // First add an artifact normally + fileNames := map[string]int{ + "testfile.txt": 1024, + } + refName := "quay.io/test/conflict:v1" + artifactDigest, _ := helperAddArtifact(t, as, refName, fileNames, nil) + assert.NotEmpty(t, artifactDigest.String()) + + // Try to use both Append and Replace at the same time + // This should fail with an error + conflictFileNames := map[string]int{ + "conflict.txt": 512, + } + conflictOptions := &libartTypes.AddOptions{ + Append: true, + Replace: true, + } + + ref, err := NewArtifactReference(refName) + require.NoError(t, err) + + // Create artifact blobs + blobs := make([]libartTypes.ArtifactBlob, 0, len(conflictFileNames)) + for fileName, size := range conflictFileNames { + blob, _ := createTestBlob(t, fileName, size) + blobs = append(blobs, blob) + } + + // This should return an error about mutually exclusive options + _, err = as.Add(ctx, ref, blobs, conflictOptions) + require.Error(t, err) + assert.Contains(t, err.Error(), "append and replace options are mutually exclusive") +} + +func TestArtifactStore_Add_ReplaceChangesDigest(t *testing.T) { + as, ctx := setupTestStore(t) + + // Add initial artifact + fileNames := map[string]int{ + "original.txt": 1024, + } + refName := "quay.io/test/digest-change:v1" + originalDigest, _ := helperAddArtifact(t, as, refName, fileNames, nil) + assert.NotEmpty(t, originalDigest.String()) + + // Get the artifact to verify original digest + artifacts, err := as.List(ctx) + require.NoError(t, err) + require.Len(t, artifacts, 1) + + firstArtifactDigest, err := artifacts[0].GetDigest() + require.NoError(t, err) + assert.Equal(t, originalDigest.String(), firstArtifactDigest.String()) + + // Replace the artifact with different content + replaceFileNames := map[string]int{ + "replaced.txt": 2048, + } + replaceOptions := &libartTypes.AddOptions{ + Replace: true, + ArtifactMIMEType: ArtifactReplaceTestMimeType, + } + + replacedDigest, _ := helperAddArtifact(t, as, refName, replaceFileNames, replaceOptions) + assert.NotEmpty(t, replacedDigest.String()) + + // Verify the digest changed (it's a new artifact) + assert.NotEqual(t, originalDigest.String(), replacedDigest.String(), + "Replace should create a new artifact with a different digest") + + // Verify only one artifact exists with the new digest + artifacts, err = as.List(ctx) + require.NoError(t, err) + require.Len(t, artifacts, 1) + + finalArtifactDigest, err := artifacts[0].GetDigest() + require.NoError(t, err) + assert.Equal(t, replacedDigest.String(), finalArtifactDigest.String(), + "The artifact in the store should have the new digest") + assert.NotEqual(t, firstArtifactDigest.String(), finalArtifactDigest.String(), + "The old digest should no longer be in the store") +} + +func TestArtifactStore_Remove(t *testing.T) { + as, ctx := setupTestStore(t) + + // Add multiple artifacts + fileNames1 := map[string]int{ + "file1.txt": 1024, + } + helperAddArtifact(t, as, "quay.io/test/artifact1:v1", fileNames1, nil) + + fileNames2 := map[string]int{ + "file2.txt": 2048, + } + helperAddArtifact(t, as, "quay.io/test/artifact2:v1", fileNames2, nil) + + // Verify both artifacts exist + artifacts, err := as.List(ctx) + require.NoError(t, err) + assert.Len(t, artifacts, 2) + + // Get the first artifact and create a reference with it + artifact1 := artifacts[0] + digest1, err := artifact1.GetDigest() + require.NoError(t, err) + + // Remove the first artifact by digest + ref, err := NewArtifactStorageReference(digest1.Encoded()) + require.NoError(t, err) + + removedDigest, err := as.Remove(ctx, ref) + require.NoError(t, err) + require.NotNil(t, removedDigest) + assert.NotEmpty(t, removedDigest.String()) + + // Verify only one artifact remains + artifacts, err = as.List(ctx) + require.NoError(t, err) + assert.Len(t, artifacts, 1) + + // Get the remaining artifact + artifact2 := artifacts[0] + digest2, err := artifact2.GetDigest() + require.NoError(t, err) + + // Remove the second artifact by digest + ref2, err := NewArtifactStorageReference(digest2.Encoded()) + require.NoError(t, err) + + removedDigest2, err := as.Remove(ctx, ref2) + require.NoError(t, err) + require.NotNil(t, removedDigest2) + + // Verify store is now empty + artifacts, err = as.List(ctx) + require.NoError(t, err) + assert.Empty(t, artifacts) +} + +func TestArtifactStore_Inspect(t *testing.T) { + as, ctx := setupTestStore(t) + + // Add an artifact with multiple files + fileNames := map[string]int{ + "file1.txt": 512, + "file2.bin": 1024, + "file3.dat": 2048, + } + options := &libartTypes.AddOptions{ + ArtifactMIMEType: ArtifactTestMimeType, + Annotations: map[string]string{ + "custom.annotation": "test-value", + }, + } + + refName := "quay.io/test/inspect:v1" + helperAddArtifact(t, as, refName, fileNames, options) + + // Get the artifact from the list + artifacts, err := as.List(ctx) + require.NoError(t, err) + require.Len(t, artifacts, 1) + + // Create a reference using the artifact's digest + artifact := artifacts[0] + digest, err := artifact.GetDigest() + require.NoError(t, err) + + ref, err := NewArtifactStorageReference(digest.Encoded()) + require.NoError(t, err) + + // Inspect the artifact + inspectedArtifact, err := as.Inspect(ctx, ref) + require.NoError(t, err) + require.NotNil(t, inspectedArtifact) + + // Verify inspected artifact properties + assert.Equal(t, refName, inspectedArtifact.Name) + assert.Equal(t, ArtifactTestMimeType, inspectedArtifact.Manifest.ArtifactType) + assert.Len(t, inspectedArtifact.Manifest.Layers, 3) + + // Verify custom annotation is present + assert.Equal(t, "test-value", inspectedArtifact.Manifest.Annotations["custom.annotation"]) + + // Verify all files are present in layers + foundFiles := make(map[string]int64) + for _, layer := range inspectedArtifact.Manifest.Layers { + title := layer.Annotations[specV1.AnnotationTitle] + foundFiles[title] = layer.Size + } + assert.Equal(t, int64(512), foundFiles["file1.txt"]) + assert.Equal(t, int64(1024), foundFiles["file2.bin"]) + assert.Equal(t, int64(2048), foundFiles["file3.dat"]) + + // Verify total size calculation + totalSize := inspectedArtifact.TotalSizeBytes() + expectedTotal := int64(512 + 1024 + 2048) + assert.Equal(t, expectedTotal, totalSize) +} + +func TestArtifactStore_Extract(t *testing.T) { + as, ctx := setupTestStore(t) + + // Add an artifact with multiple files + fileNames := map[string]int{ + "file1.txt": 512, + "file2.bin": 1024, + "file3.dat": 2048, + } + + _, checkSums := helperAddArtifact(t, as, "quay.io/test/extract:v1", fileNames, nil) + + // Get the artifact from the list + artifacts, err := as.List(ctx) + require.NoError(t, err) + require.Len(t, artifacts, 1) + + // Create a reference using the artifact's digest + artifact := artifacts[0] + digest, err := artifact.GetDigest() + require.NoError(t, err) + + ref, err := NewArtifactStorageReference(digest.Encoded()) + require.NoError(t, err) + + // Extract to a directory + extractDir := t.TempDir() + err = as.Extract(ctx, ref, extractDir, &libartTypes.ExtractOptions{}) + require.NoError(t, err) + + for f := range fileNames { + content, err := os.ReadFile(filepath.Join(extractDir, f)) + require.NoError(t, err) + checkSumoExtractedArt := sha256.Sum256(content) + assert.Equal(t, checkSums[f], checkSumoExtractedArt) + } +} + +func TestArtifactStore_Extract_SingleFile(t *testing.T) { + as, ctx := setupTestStore(t) + + // Add an artifact with multiple files + fileNames := map[string]int{ + "file1.txt": 512, + "file2.bin": 1024, + } + + helperAddArtifact(t, as, "quay.io/test/extract-single:v1", fileNames, nil) + + // Get the artifact from the list + artifacts, err := as.List(ctx) + require.NoError(t, err) + require.Len(t, artifacts, 1) + + // Create a reference using the artifact's digest + artifact := artifacts[0] + digest, err := artifact.GetDigest() + require.NoError(t, err) + + ref, err := NewArtifactStorageReference(digest.Encoded()) + require.NoError(t, err) + + // Extract only one file by title + extractDir := t.TempDir() + err = as.Extract(ctx, ref, extractDir, &libartTypes.ExtractOptions{ + FilterBlobOptions: libartTypes.FilterBlobOptions{ + Title: "file1.txt", + }, + }) + require.NoError(t, err) + + // Verify only file1.txt was extracted + extractedFile1 := filepath.Join(extractDir, "file1.txt") + extractedFile2 := filepath.Join(extractDir, "file2.bin") + + stat1, err := os.Stat(extractedFile1) + require.NoError(t, err) + assert.Equal(t, int64(512), stat1.Size()) + + _, err = os.Stat(extractedFile2) + assert.True(t, os.IsNotExist(err)) +} + +func TestArtifactStore_List_Multiple(t *testing.T) { + as, ctx := setupTestStore(t) + + // Verify empty store returns empty list + artifacts, err := as.List(ctx) + require.NoError(t, err) + assert.Empty(t, artifacts) + + // Add multiple artifacts with different configurations + fileNames1 := map[string]int{ + "file1.txt": 512, + } + helperAddArtifact(t, as, "quay.io/test/artifact1:v1", fileNames1, nil) + + fileNames2 := map[string]int{ + "file2a.bin": 1024, + "file2b.dat": 2048, + } + options2 := &libartTypes.AddOptions{ + ArtifactMIMEType: "application/vnd.custom+type", + } + helperAddArtifact(t, as, "quay.io/test/artifact2:v2", fileNames2, options2) + + fileNames3 := map[string]int{ + "file3.json": 256, + } + helperAddArtifact(t, as, "docker.io/library/artifact3:latest", fileNames3, nil) + + // List all artifacts + artifacts, err = as.List(ctx) + require.NoError(t, err) + assert.Len(t, artifacts, 3) + + // Create a map of artifact names for easy lookup + artifactMap := make(map[string]*libartifact.Artifact) + for _, artifact := range artifacts { + artifactMap[artifact.Name] = artifact + } + + // Verify first artifact + artifact1, exists := artifactMap["quay.io/test/artifact1:v1"] + require.True(t, exists) + assert.Equal(t, ArtifactTestMimeType, artifact1.Manifest.ArtifactType) + assert.Len(t, artifact1.Manifest.Layers, 1) + assert.Equal(t, int64(512), artifact1.TotalSizeBytes()) + + // Verify second artifact + artifact2, exists := artifactMap["quay.io/test/artifact2:v2"] + require.True(t, exists) + assert.Equal(t, "application/vnd.custom+type", artifact2.Manifest.ArtifactType) + assert.Len(t, artifact2.Manifest.Layers, 2) + assert.Equal(t, int64(3072), artifact2.TotalSizeBytes()) + + // Verify third artifact + artifact3, exists := artifactMap["docker.io/library/artifact3:latest"] + require.True(t, exists) + assert.Equal(t, ArtifactTestMimeType, artifact3.Manifest.ArtifactType) + assert.Len(t, artifact3.Manifest.Layers, 1) + assert.Equal(t, int64(256), artifact3.TotalSizeBytes()) + + // Verify all artifacts have valid digests + for _, artifact := range artifacts { + digest, err := artifact.GetDigest() + require.NoError(t, err) + assert.NotEmpty(t, digest.String()) + } +} + +func TestDetermineBlobMIMEType(t *testing.T) { + tests := []struct { + name string + setupFunc func(t *testing.T) libartTypes.ArtifactBlob + expectedMIMEType string + expectNilReader bool + expectError bool + errorContains string + validateReaderFunc func(t *testing.T, reader io.Reader) + }{ + // TestDetermineBlobMIMEType_FromFile cases + { + name: "plain text file", + setupFunc: func(t *testing.T) libartTypes.ArtifactBlob { + tempDir := t.TempDir() + textFile := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(textFile, []byte("Hello, World!"), 0o644) + require.NoError(t, err) + + return libartTypes.ArtifactBlob{ + BlobFilePath: textFile, + FileName: "test.txt", + } + }, + expectedMIMEType: "text/plain; charset=utf-8", + expectNilReader: true, + expectError: false, + }, + { + name: "JSON file", + setupFunc: func(t *testing.T) libartTypes.ArtifactBlob { + tempDir := t.TempDir() + jsonFile := filepath.Join(tempDir, "test.json") + jsonContent := []byte(`{"key": "value", "number": 123}`) + err := os.WriteFile(jsonFile, jsonContent, 0o644) + require.NoError(t, err) + + return libartTypes.ArtifactBlob{ + BlobFilePath: jsonFile, + FileName: "test.json", + } + }, + expectedMIMEType: "text/plain; charset=utf-8", + expectNilReader: true, + expectError: false, + }, + { + name: "JPEG binary file", + setupFunc: func(t *testing.T) libartTypes.ArtifactBlob { + tempDir := t.TempDir() + binaryFile := filepath.Join(tempDir, "test.bin") + binaryContent := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46} + err := os.WriteFile(binaryFile, binaryContent, 0o644) + require.NoError(t, err) + + return libartTypes.ArtifactBlob{ + BlobFilePath: binaryFile, + FileName: "test.bin", + } + }, + expectedMIMEType: "image/jpeg", + expectNilReader: true, + expectError: false, + }, + // TestDetermineBlobMIMEType_SmallFile case + { + name: "small file less than 512 bytes", + setupFunc: func(t *testing.T) libartTypes.ArtifactBlob { + tempDir := t.TempDir() + smallFile := filepath.Join(tempDir, "small.txt") + smallContent := []byte("Small") + err := os.WriteFile(smallFile, smallContent, 0o644) + require.NoError(t, err) + + return libartTypes.ArtifactBlob{ + BlobFilePath: smallFile, + FileName: "small.txt", + } + }, + expectedMIMEType: "text/plain; charset=utf-8", + expectNilReader: true, + expectError: false, + }, + // TestDetermineBlobMIMEType_FromReader cases + { + name: "plain text reader", + setupFunc: func(t *testing.T) libartTypes.ArtifactBlob { + textContent := "This is plain text content" + return libartTypes.ArtifactBlob{ + BlobReader: strings.NewReader(textContent), + FileName: "test.txt", + } + }, + expectedMIMEType: "text/plain; charset=utf-8", + expectNilReader: false, + expectError: false, + validateReaderFunc: func(t *testing.T, reader io.Reader) { + content, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, "This is plain text content", string(content)) + }, + }, + { + name: "HTML content reader", + setupFunc: func(t *testing.T) libartTypes.ArtifactBlob { + htmlContent := "Test" + return libartTypes.ArtifactBlob{ + BlobReader: strings.NewReader(htmlContent), + FileName: "test.html", + } + }, + expectedMIMEType: "text/html; charset=utf-8", + expectNilReader: false, + expectError: false, + }, + { + name: "PNG binary content reader", + setupFunc: func(t *testing.T) libartTypes.ArtifactBlob { + binaryContent := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + return libartTypes.ArtifactBlob{ + BlobReader: bytes.NewReader(binaryContent), + FileName: "test.png", + } + }, + expectedMIMEType: "image/png", + expectNilReader: false, + expectError: false, + }, + // TestDetermineBlobMIMEType_Errors cases + { + name: "neither file path nor reader", + setupFunc: func(t *testing.T) libartTypes.ArtifactBlob { + return libartTypes.ArtifactBlob{ + FileName: "test.txt", + } + }, + expectError: true, + errorContains: "Artifact.BlobFile or Artifact.BlobReader must be provided", + }, + { + name: "both file path and reader provided", + setupFunc: func(t *testing.T) libartTypes.ArtifactBlob { + return libartTypes.ArtifactBlob{ + BlobFilePath: "/tmp/test.txt", + BlobReader: strings.NewReader("content"), + FileName: "test.txt", + } + }, + expectError: true, + errorContains: "Artifact.BlobFile or Artifact.BlobReader must be provided", + }, + { + name: "non-existent file", + setupFunc: func(t *testing.T) libartTypes.ArtifactBlob { + return libartTypes.ArtifactBlob{ + BlobFilePath: "/nonexistent/file.txt", + FileName: "file.txt", + } + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + blob := tt.setupFunc(t) + + reader, mimeType, err := determineBlobMIMEType(blob) + + if tt.expectError { + require.Error(t, err) + if len(tt.errorContains) > 0 { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedMIMEType, mimeType) + + if tt.expectNilReader { + assert.Nil(t, reader) + } else { + require.NotNil(t, reader) + if tt.validateReaderFunc != nil { + tt.validateReaderFunc(t, reader) + } + } + } + }) + } +}