diff --git a/common/pkg/libartifact/store/store.go b/common/pkg/libartifact/store/store.go index 0f79d4222b..41bd861b08 100644 --- a/common/pkg/libartifact/store/store.go +++ b/common/pkg/libartifact/store/store.go @@ -232,6 +232,9 @@ func (as ArtifactStore) Add(ctx context.Context, dest ArtifactReference, artifac 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() @@ -246,9 +249,58 @@ func (as ArtifactStore) Add(ctx context.Context, dest ArtifactReference, artifac 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 + + switch { + case options.Append: + // Append to existing artifact + if lookupErr != nil { + return nil, lookupErr + } + artifactManifest = arty.Manifest.Manifest + var err error + oldDigest, err = arty.GetDigest() + if err != nil { + return nil, err + } + for _, layer := range artifactManifest.Layers { + if value, ok := layer.Annotations[specV1.AnnotationTitle]; ok && value != "" { + 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 + } + var err2 error + oldDigest, err2 = arty.GetDigest() + if err2 != nil { + return nil, err2 + } + } + + // Create new manifest + 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, + Config: specV1.DescriptorEmptyJSON, + Layers: make([]specV1.Descriptor, 0), + Annotations: annotations, + } + default: + // Add new artifact - error if it already exists if lookupErr == nil { return nil, fmt.Errorf("%s: %w", dest.String(), libartTypes.ErrArtifactAlreadyExists) } @@ -269,21 +321,6 @@ func (as ArtifactStore) Add(ctx context.Context, dest ArtifactReference, artifac Layers: make([]specV1.Descriptor, 0), Annotations: annotations, } - } else { - if lookupErr != nil { - return nil, lookupErr - } - artifactManifest = arty.Manifest.Manifest - var err error - oldDigest, err = arty.GetDigest() - if err != nil { - return nil, err - } - for _, layer := range artifactManifest.Layers { - if value, ok := layer.Annotations[specV1.AnnotationTitle]; ok && value != "" { - fileNames[value] = struct{}{} - } - } } for _, artifact := range artifactBlobs { diff --git a/common/pkg/libartifact/store/store_test.go b/common/pkg/libartifact/store/store_test.go new file mode 100644 index 0000000000..52136791e1 --- /dev/null +++ b/common/pkg/libartifact/store/store_test.go @@ -0,0 +1,838 @@ +package store + +import ( + "bytes" + "context" + "crypto/rand" + "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" +) + +// 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 { + 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, + } +} + +// 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 { + t.Helper() + ctx := context.Background() + + // If options is nil, create default options + if options == nil { + options = &libartTypes.AddOptions{ + ArtifactMIMEType: "application/vnd.test+type", + } + } + + // if no specific files were passed, create a random file of 2k + 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)) + for fileName, size := range fileNames { + blobs = append(blobs, createTestBlob(t, fileName, size)) + } + + // Add artifact + artifactDigest, err := as.Add(ctx, ref, blobs, options) + require.NoError(t, err) + require.NotNil(t, artifactDigest) + + return artifactDigest +} + +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, "application/vnd.test+type", 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: "application/vnd.replaced+type", + } + + 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, "application/vnd.replaced+type", 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, "application/vnd.test+type", 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 + } + assert.True(t, foundFiles["file1.txt"], "file1.txt should be present") + assert.True(t, foundFiles["file2.bin"], "file2.bin should be present") + assert.True(t, foundFiles["file3.dat"], "file3.dat should be present") + + // 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: "application/vnd.test+type", + } + + 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, "application/vnd.test+type", 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 { + blobs = append(blobs, createTestBlob(t, fileName, size)) + } + + // 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: "application/vnd.replaced+type", + } + + 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: "application/vnd.test+type", + 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, "application/vnd.test+type", 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, + } + + 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) + + // Verify all files were extracted + extractedFile1 := filepath.Join(extractDir, "file1.txt") + extractedFile2 := filepath.Join(extractDir, "file2.bin") + extractedFile3 := filepath.Join(extractDir, "file3.dat") + + stat1, err := os.Stat(extractedFile1) + require.NoError(t, err) + assert.Equal(t, int64(512), stat1.Size()) + + stat2, err := os.Stat(extractedFile2) + require.NoError(t, err) + assert.Equal(t, int64(1024), stat2.Size()) + + stat3, err := os.Stat(extractedFile3) + require.NoError(t, err) + assert.Equal(t, int64(2048), stat3.Size()) + + // Verify file contents are not empty (random data was written) + content1, err := os.ReadFile(extractedFile1) + require.NoError(t, err) + assert.Len(t, content1, 512) + + content2, err := os.ReadFile(extractedFile2) + require.NoError(t, err) + assert.Len(t, content2, 1024) + + content3, err := os.ReadFile(extractedFile3) + require.NoError(t, err) + assert.Len(t, content3, 2048) +} + +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, "application/vnd.test+type", 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, "application/vnd.test+type", 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_FromFile(t *testing.T) { + tempDir := t.TempDir() + + // Test with plain text file + textFile := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(textFile, []byte("Hello, World!"), 0o644) + require.NoError(t, err) + + blob := libartTypes.ArtifactBlob{ + BlobFilePath: textFile, + FileName: "test.txt", + } + + reader, mimeType, err := determineBlobMIMEType(blob) + require.NoError(t, err) + assert.Nil(t, reader) + assert.Equal(t, "text/plain; charset=utf-8", mimeType) + + // Test with JSON file + jsonFile := filepath.Join(tempDir, "test.json") + jsonContent := []byte(`{"key": "value", "number": 123}`) + err = os.WriteFile(jsonFile, jsonContent, 0o644) + require.NoError(t, err) + + blob = libartTypes.ArtifactBlob{ + BlobFilePath: jsonFile, + FileName: "test.json", + } + + reader, mimeType, err = determineBlobMIMEType(blob) + require.NoError(t, err) + assert.Nil(t, reader) + assert.Equal(t, "text/plain; charset=utf-8", mimeType) + + // Test with binary file + 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) + + blob = libartTypes.ArtifactBlob{ + BlobFilePath: binaryFile, + FileName: "test.bin", + } + + reader, mimeType, err = determineBlobMIMEType(blob) + require.NoError(t, err) + assert.Nil(t, reader) + assert.Equal(t, "image/jpeg", mimeType) +} + +func TestDetermineBlobMIMEType_FromReader(t *testing.T) { + // Test with plain text reader + textContent := "This is plain text content" + blob := libartTypes.ArtifactBlob{ + BlobReader: strings.NewReader(textContent), + FileName: "test.txt", + } + + reader, mimeType, err := determineBlobMIMEType(blob) + require.NoError(t, err) + require.NotNil(t, reader) + assert.Equal(t, "text/plain; charset=utf-8", mimeType) + + // Verify the reader still has all the content + content, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, textContent, string(content)) + + // Test with HTML content + htmlContent := "Test" + blob = libartTypes.ArtifactBlob{ + BlobReader: strings.NewReader(htmlContent), + FileName: "test.html", + } + + reader, mimeType, err = determineBlobMIMEType(blob) + require.NoError(t, err) + require.NotNil(t, reader) + assert.Equal(t, "text/html; charset=utf-8", mimeType) + + // Test with binary content + binaryContent := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + blob = libartTypes.ArtifactBlob{ + BlobReader: bytes.NewReader(binaryContent), + FileName: "test.png", + } + + reader, mimeType, err = determineBlobMIMEType(blob) + require.NoError(t, err) + require.NotNil(t, reader) + assert.Equal(t, "image/png", mimeType) +} + +func TestDetermineBlobMIMEType_SmallFile(t *testing.T) { + tempDir := t.TempDir() + + // Test with file smaller than 512 bytes + smallFile := filepath.Join(tempDir, "small.txt") + smallContent := []byte("Small") + err := os.WriteFile(smallFile, smallContent, 0o644) + require.NoError(t, err) + + blob := libartTypes.ArtifactBlob{ + BlobFilePath: smallFile, + FileName: "small.txt", + } + + reader, mimeType, err := determineBlobMIMEType(blob) + require.NoError(t, err) + assert.Nil(t, reader) + assert.Equal(t, "text/plain; charset=utf-8", mimeType) +} + +func TestDetermineBlobMIMEType_Errors(t *testing.T) { + // Test with neither file path nor reader + blob := libartTypes.ArtifactBlob{ + FileName: "test.txt", + } + + _, _, err := determineBlobMIMEType(blob) + require.Error(t, err) + assert.Contains(t, err.Error(), "Artifact.BlobFile or Artifact.BlobReader must be provided") + + // Test with both file path and reader + blob = libartTypes.ArtifactBlob{ + BlobFilePath: "/tmp/test.txt", + BlobReader: strings.NewReader("content"), + FileName: "test.txt", + } + + _, _, err = determineBlobMIMEType(blob) + require.Error(t, err) + assert.Contains(t, err.Error(), "Artifact.BlobFile or Artifact.BlobReader must be provided") + + // Test with non-existent file + blob = libartTypes.ArtifactBlob{ + BlobFilePath: "/nonexistent/file.txt", + FileName: "file.txt", + } + + _, _, err = determineBlobMIMEType(blob) + require.Error(t, err) +}