diff --git a/pkg/artifact.go b/pkg/artifact.go index 7316d0a..e2e1885 100644 --- a/pkg/artifact.go +++ b/pkg/artifact.go @@ -4,6 +4,8 @@ import ( "fmt" "io" "os" + "path/filepath" + "strings" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/content" @@ -15,7 +17,15 @@ func LoadArtifactFromFile(filename string, mediaType string) (*ocispec.Descripto return nil, nil, fmt.Errorf("error loading artifact from file: %w", err) } - return LoadArtifactFromReader(file, mediaType) + desc, artifactBytes, err := LoadArtifactFromReader(file, mediaType) + if err != nil { + return nil, nil, err + } + + // Add filename annotation if missing + AddFilenameAnnotationIfMissing(desc, filename) + + return desc, artifactBytes, nil } func LoadArtifactFromReader(reader io.ReadCloser, mediaType string) (*ocispec.Descriptor, []byte, error) { @@ -31,3 +41,21 @@ func LoadArtifactFromReader(reader io.ReadCloser, mediaType string) (*ocispec.De return &desc, artifactBytes, nil } + +// AddFilenameAnnotationIfMissing adds a title annotation to the descriptor using the base filename +// if the annotation doesn't already exist or is empty. This function modifies the descriptor in-place. +func AddFilenameAnnotationIfMissing(desc *ocispec.Descriptor, filename string) { + if desc.Annotations == nil || desc.Annotations[ocispec.AnnotationTitle] == "" { + if desc.Annotations == nil { + desc.Annotations = make(map[string]string) + } + // Use only the base filename, not the full path + // Handle both Unix and Windows path separators regardless of platform + basename := filepath.Base(filename) + // If filepath.Base didn't extract properly (e.g., Windows paths on Unix), try manual extraction + if lastSlash := strings.LastIndexByte(basename, '\\'); lastSlash >= 0 { + basename = basename[lastSlash+1:] + } + desc.Annotations[ocispec.AnnotationTitle] = basename + } +} diff --git a/pkg/artifact_test.go b/pkg/artifact_test.go index 649c8a0..ffd6593 100644 --- a/pkg/artifact_test.go +++ b/pkg/artifact_test.go @@ -4,6 +4,8 @@ import ( "bytes" "io" "testing" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) func TestLoadArtifactFromFile(t *testing.T) { @@ -60,3 +62,259 @@ func TestLoadArtifactFromReader(t *testing.T) { t.Errorf("expected desc.Digest to be 'sha256:40b61fe1b15af0a4d5402735b26343e8cf8a045f4d81710e6108a21d91eaf366', got: %s", desc.Digest.String()) } } + +func TestLoadArtifactFromFile_AddsFilenameAnnotation(t *testing.T) { + // Define the path to the test file + filePath := "../examples/artifact.example.json" + mediaType := "application/json" + expectedFilename := "artifact.example.json" // Only the base filename, not the full path + + // Call the function with the test file path and media type + desc, _, err := LoadArtifactFromFile(filePath, mediaType) + + // Check that there was no error + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // Check that the artifact filename annotation is set with just the filename + if desc.Annotations == nil { + t.Fatalf("expected annotations to be set, got nil") + } + + title, exists := desc.Annotations[ocispec.AnnotationTitle] + if !exists { + t.Errorf("expected annotation %s to exist", ocispec.AnnotationTitle) + } + if title != expectedFilename { + t.Errorf("expected annotation %s to be '%s', got: %s", ocispec.AnnotationTitle, expectedFilename, title) + } +} + +func TestLoadArtifactFromReader_NoAnnotations(t *testing.T) { + // Test that LoadArtifactFromReader doesn't add annotations + testData := []byte(`{"test": "data"}`) + mediaType := "application/json" + + // Create a test reader with the test data + reader := io.NopCloser(bytes.NewReader(testData)) + + // Call the function with the test reader and media type + desc, _, err := LoadArtifactFromReader(reader, mediaType) + + // Check that there was no error + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // Check that no annotations are set since LoadArtifactFromReader doesn't add title annotations + if desc.Annotations != nil { + if _, exists := desc.Annotations[ocispec.AnnotationTitle]; exists { + t.Errorf("expected no %s annotation from LoadArtifactFromReader, but it was set", ocispec.AnnotationTitle) + } + } +} + +func TestLoadArtifactFromFile_FilenameExtractionFromPath(t *testing.T) { + // Test that the filename is correctly extracted from various path formats + // Since we can't easily test different paths to the same file, we just verify + // that the existing test file produces the expected filename + filePath := "../examples/artifact.example.json" + expectedFilename := "artifact.example.json" + mediaType := "application/json" + + // Call the function + desc, _, err := LoadArtifactFromFile(filePath, mediaType) + + // Check that there was no error + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // Verify that only the filename (not the path) is in the annotation + if desc.Annotations == nil { + t.Fatalf("expected annotations to be set, got nil") + } + + title, exists := desc.Annotations[ocispec.AnnotationTitle] + if !exists { + t.Errorf("expected annotation %s to exist", ocispec.AnnotationTitle) + } + if title != expectedFilename { + t.Errorf("expected annotation %s to be '%s', got: %s", ocispec.AnnotationTitle, expectedFilename, title) + } +} + +func TestAddFilenameAnnotationIfMissing_AddsAnnotationWhenMissing(t *testing.T) { + // Test adding annotation when descriptor has no annotations + desc := &ocispec.Descriptor{ + MediaType: "application/json", + Size: 100, + Digest: "sha256:abc123", + } + filename := "../path/to/test-file.json" + expectedFilename := "test-file.json" + + AddFilenameAnnotationIfMissing(desc, filename) + + // Check that annotations were created and title was set + if desc.Annotations == nil { + t.Fatalf("expected annotations to be created, got nil") + } + + title, exists := desc.Annotations[ocispec.AnnotationTitle] + if !exists { + t.Errorf("expected annotation %s to exist", ocispec.AnnotationTitle) + } + if title != expectedFilename { + t.Errorf("expected annotation %s to be '%s', got: %s", ocispec.AnnotationTitle, expectedFilename, title) + } +} + +func TestAddFilenameAnnotationIfMissing_AddsAnnotationWhenEmpty(t *testing.T) { + // Test adding annotation when descriptor has empty annotations map + desc := &ocispec.Descriptor{ + MediaType: "application/json", + Size: 100, + Digest: "sha256:abc123", + Annotations: make(map[string]string), + } + filename := "test-file.json" + expectedFilename := "test-file.json" + + AddFilenameAnnotationIfMissing(desc, filename) + + // Check that title annotation was added + title, exists := desc.Annotations[ocispec.AnnotationTitle] + if !exists { + t.Errorf("expected annotation %s to exist", ocispec.AnnotationTitle) + } + if title != expectedFilename { + t.Errorf("expected annotation %s to be '%s', got: %s", ocispec.AnnotationTitle, expectedFilename, title) + } +} + +func TestAddFilenameAnnotationIfMissing_AddsAnnotationWhenTitleEmpty(t *testing.T) { + // Test adding annotation when descriptor has annotations but empty title + desc := &ocispec.Descriptor{ + MediaType: "application/json", + Size: 100, + Digest: "sha256:abc123", + Annotations: map[string]string{ + "other.annotation": "some-value", + ocispec.AnnotationTitle: "", // Empty title + }, + } + filename := "C:\\Windows\\System32\\test-file.json" + expectedFilename := "test-file.json" + + AddFilenameAnnotationIfMissing(desc, filename) + + // Check that title annotation was set + title, exists := desc.Annotations[ocispec.AnnotationTitle] + if !exists { + t.Errorf("expected annotation %s to exist", ocispec.AnnotationTitle) + } + if title != expectedFilename { + t.Errorf("expected annotation %s to be '%s', got: %s", ocispec.AnnotationTitle, expectedFilename, title) + } + + // Check that other annotations are preserved + other, exists := desc.Annotations["other.annotation"] + if !exists || other != "some-value" { + t.Errorf("expected other annotations to be preserved") + } +} + +func TestAddFilenameAnnotationIfMissing_PreservesExistingAnnotation(t *testing.T) { + // Test that existing non-empty title annotation is preserved + existingTitle := "existing-title.json" + desc := &ocispec.Descriptor{ + MediaType: "application/json", + Size: 100, + Digest: "sha256:abc123", + Annotations: map[string]string{ + ocispec.AnnotationTitle: existingTitle, + "other.annotation": "some-value", + }, + } + filename := "new-filename.json" + + AddFilenameAnnotationIfMissing(desc, filename) + + // Check that existing title annotation was preserved + title, exists := desc.Annotations[ocispec.AnnotationTitle] + if !exists { + t.Errorf("expected annotation %s to exist", ocispec.AnnotationTitle) + } + if title != existingTitle { + t.Errorf("expected annotation %s to be preserved as '%s', got: %s", ocispec.AnnotationTitle, existingTitle, title) + } + + // Check that other annotations are preserved + other, exists := desc.Annotations["other.annotation"] + if !exists || other != "some-value" { + t.Errorf("expected other annotations to be preserved") + } +} + +func TestAddFilenameAnnotationIfMissing_HandlesVariousPathFormats(t *testing.T) { + // Test that the function correctly extracts filenames from various path formats + testCases := []struct { + name string + inputPath string + expectedName string + }{ + { + name: "relative path with slash", + inputPath: "../examples/test.json", + expectedName: "test.json", + }, + { + name: "simple filename", + inputPath: "test.json", + expectedName: "test.json", + }, + { + name: "absolute unix path", + inputPath: "/path/to/test.json", + expectedName: "test.json", + }, + { + name: "absolute windows path", + inputPath: "C:\\Windows\\System32\\test.json", + expectedName: "test.json", + }, + { + name: "current directory", + inputPath: "./test.json", + expectedName: "test.json", + }, + { + name: "windows style backslash", + inputPath: "examples\\test.json", + expectedName: "test.json", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + desc := &ocispec.Descriptor{ + MediaType: "application/json", + Size: 100, + Digest: "sha256:abc123", + } + + AddFilenameAnnotationIfMissing(desc, tc.inputPath) + + // Check that the correct filename was extracted + title, exists := desc.Annotations[ocispec.AnnotationTitle] + if !exists { + t.Errorf("expected annotation %s to exist", ocispec.AnnotationTitle) + } + if title != tc.expectedName { + t.Errorf("for path '%s', expected annotation %s to be '%s', got: %s", tc.inputPath, ocispec.AnnotationTitle, tc.expectedName, title) + } + }) + } +} diff --git a/pkg/sbom.go b/pkg/sbom.go index d25f89d..eb8cce9 100644 --- a/pkg/sbom.go +++ b/pkg/sbom.go @@ -33,6 +33,7 @@ type SPDXDocument struct { // LoadSBOMFromFile opens a file given by filename, reads its contents, and loads it into an SPDX document. // It also calculates the file size and generates an OCI descriptor for the file. // It returns the loaded SPDX document, the OCI descriptor, and any error encountered. +// If the descriptor doesn't have a title annotation, it will be added using the base filename. func LoadSBOMFromFile(filename string, strict bool) (*SPDXDocument, *ocispec.Descriptor, []byte, error) { file, err := os.Open(filename) if err != nil { @@ -40,7 +41,15 @@ func LoadSBOMFromFile(filename string, strict bool) (*SPDXDocument, *ocispec.Des } defer file.Close() - return LoadSBOMFromReader(file, strict) + doc, desc, sbomBytes, err := LoadSBOMFromReader(file, strict) + if err != nil { + return nil, nil, nil, err + } + + // Add filename annotation if missing + AddFilenameAnnotationIfMissing(desc, filename) + + return doc, desc, sbomBytes, nil } // LoadSBOMFromReader reads an SPDX document from an io.ReadCloser, generates an OCI descriptor for the document, diff --git a/pkg/sbom_test.go b/pkg/sbom_test.go index d20e6c2..1109bb3 100644 --- a/pkg/sbom_test.go +++ b/pkg/sbom_test.go @@ -5,6 +5,8 @@ import ( "io" "strings" "testing" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) const spdxStr string = `{ @@ -167,3 +169,51 @@ func TestGetAnnotations(t *testing.T) { t.Errorf("expected creators annotation to be 'Tool: SPDX-Java-Tools-v2.1.20, Organization: Source Auditor Inc.', got: %v", annotations[OCI_ANNOTATION_CREATORS]) } } + +func TestLoadSBOMFromFile_AddsFilenameAnnotation(t *testing.T) { + // Define the path to the test file + filePath := "../examples/SPDXJSONExample-v2.3.spdx.json" + expectedFilename := "SPDXJSONExample-v2.3.spdx.json" // Only the base filename, not the full path + + // Call the function with the test file path + _, desc, _, err := LoadSBOMFromFile(filePath, true) + + // Check that there was no error + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // Check that the artifact filename annotation is set with just the filename + if desc.Annotations == nil { + t.Fatalf("expected annotations to be set, got nil") + } + + title, exists := desc.Annotations[ocispec.AnnotationTitle] + if !exists { + t.Errorf("expected annotation %s to exist", ocispec.AnnotationTitle) + } + if title != expectedFilename { + t.Errorf("expected annotation %s to be '%s', got: %s", ocispec.AnnotationTitle, expectedFilename, title) + } +} + +func TestLoadSBOMFromReader_NoAnnotations(t *testing.T) { + // Test that LoadSBOMFromReader doesn't add annotations + // Create a test reader with the SPDX JSON data + reader := io.NopCloser(strings.NewReader(spdxStr)) + + // Call the function with the test reader + _, desc, _, err := LoadSBOMFromReader(reader, true) + + // Check that there was no error + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // Check that no title annotation is set since LoadSBOMFromReader doesn't add title annotations + if desc.Annotations != nil { + if _, exists := desc.Annotations[ocispec.AnnotationTitle]; exists { + t.Errorf("expected no %s annotation from LoadSBOMFromReader, but it was set", ocispec.AnnotationTitle) + } + } +}