diff --git a/.coverage-threshold b/.coverage-threshold index bf966088..8239e1ae 100644 --- a/.coverage-threshold +++ b/.coverage-threshold @@ -1 +1 @@ -65.5 \ No newline at end of file +65.9 \ No newline at end of file diff --git a/cmd/os-image-composer/compare_cmd.go b/cmd/os-image-composer/compare_cmd.go index 19cd7447..7e66bde1 100644 --- a/cmd/os-image-composer/compare_cmd.go +++ b/cmd/os-image-composer/compare_cmd.go @@ -14,7 +14,7 @@ import ( var ( prettyDiffJSON bool = true // Pretty-print JSON output outFormat string // "text" | "json" - outMode string = "" // "full" | "diff" | "summary" + outMode string = "" // "full" | "diff" | "summary" | "spdx" hashImages bool = false // Skip hashing during inspection ) @@ -39,7 +39,7 @@ func createCompareCommand() *cobra.Command { compareCmd.Flags().StringVar(&outFormat, "format", "text", "Output format: text or json") compareCmd.Flags().StringVar(&outMode, "mode", "", - "Output mode: full, diff, or summary (default: diff for text, full for json)") + "Output mode: full, diff, summary, or spdx (default: diff for text, full for json)") compareCmd.Flags().BoolVar(&hashImages, "hash-images", false, "Compute SHA256 hash of images during inspection (slower but enables binary identity verification") return compareCmd @@ -49,6 +49,10 @@ func resolveDefaults(format, mode string) (string, string) { format = strings.ToLower(format) mode = strings.ToLower(mode) + if mode == "spdx" { + return format, mode + } + // Set default mode if not specified if mode == "" { if format == "json" { @@ -67,6 +71,24 @@ func executeCompare(cmd *cobra.Command, args []string) error { imageFile2 := args[1] log.Infof("Comparing image files: (%s) & (%s)", imageFile1, imageFile2) + format, mode := resolveDefaults(outFormat, outMode) + + if mode == "spdx" { + spdxResult, err := imageinspect.CompareSPDXFiles(imageFile1, imageFile2) + if err != nil { + return fmt.Errorf("SPDX compare failed: %w", err) + } + + switch format { + case "json": + return writeCompareResult(cmd, spdxResult, prettyDiffJSON) + case "text": + return imageinspect.RenderSPDXCompareText(cmd.OutOrStdout(), spdxResult) + default: + return fmt.Errorf("invalid --format %q (expected text|json)", format) + } + } + inspector := newInspector(hashImages) image1, err1 := inspector.Inspect(imageFile1) @@ -80,8 +102,6 @@ func executeCompare(cmd *cobra.Command, args []string) error { compareResult := imageinspect.CompareImages(image1, image2) - format, mode := resolveDefaults(outFormat, outMode) - switch format { case "json": var payload any @@ -100,7 +120,7 @@ func executeCompare(cmd *cobra.Command, args []string) error { Summary imageinspect.CompareSummary `json:"summary"` }{EqualityClass: string(compareResult.Equality.Class), Summary: compareResult.Summary} default: - return fmt.Errorf("invalid --mode or --format %q (expected --mode=diff|summary|full) and --format=text|json", mode) + return fmt.Errorf("invalid --mode or --format %q (expected --mode=diff|summary|full|spdx) and --format=text|json", mode) } return writeCompareResult(cmd, payload, prettyDiffJSON) @@ -109,7 +129,7 @@ func executeCompare(cmd *cobra.Command, args []string) error { imageinspect.CompareTextOptions{Mode: mode}) default: - return fmt.Errorf("invalid --mode %q (expected text|json)", outMode) + return fmt.Errorf("invalid --format %q (expected text|json)", format) } } diff --git a/cmd/os-image-composer/compare_cmd_test.go b/cmd/os-image-composer/compare_cmd_test.go index a58950c3..f47ace39 100644 --- a/cmd/os-image-composer/compare_cmd_test.go +++ b/cmd/os-image-composer/compare_cmd_test.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "errors" + "os" + "path/filepath" "strings" "testing" @@ -262,6 +264,71 @@ func TestCompareCommand_InvalidModeErrors(t *testing.T) { } } +func TestCompareCommand_SPDXMode(t *testing.T) { + origNewInspector := newInspector + origOutFormat, origOutMode := outFormat, outMode + t.Cleanup(func() { + newInspector = origNewInspector + outFormat, outMode = origOutFormat, origOutMode + }) + + newInspector = func(hash bool) inspector { + return &fakeCompareInspector{errByPath: map[string]error{ + "a.spdx.json": errors.New("should not inspect images in spdx mode"), + "b.spdx.json": errors.New("should not inspect images in spdx mode"), + }} + } + + tmpDir := t.TempDir() + fromPath := filepath.Join(tmpDir, "a.spdx.json") + toPath := filepath.Join(tmpDir, "b.spdx.json") + + fromContent := `{"packages":[{"name":"acl","versionInfo":"2.3.1","downloadLocation":"https://example.com/acl.rpm"}]}` + toContent := `{"packages":[{"name":"acl","versionInfo":"2.3.2","downloadLocation":"https://example.com/acl.rpm"}]}` + + if err := os.WriteFile(fromPath, []byte(fromContent), 0644); err != nil { + t.Fatalf("write from SPDX file: %v", err) + } + if err := os.WriteFile(toPath, []byte(toContent), 0644); err != nil { + t.Fatalf("write to SPDX file: %v", err) + } + + t.Run("JSON", func(t *testing.T) { + cmd := &cobra.Command{} + outFormat = "json" + outMode = "spdx" + prettyDiffJSON = false + + s, err := runCompareExecute(t, cmd, []string{fromPath, toPath}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got imageinspect.SPDXCompareResult + decodeJSON(t, s, &got) + if got.Equal { + t.Fatalf("expected SPDX compare to be different") + } + if len(got.AddedPackages) == 0 || len(got.RemovedPackages) == 0 { + t.Fatalf("expected added/removed package entries, got %+v", got) + } + }) + + t.Run("Text", func(t *testing.T) { + cmd := &cobra.Command{} + outFormat = "text" + outMode = "spdx" + + s, err := runCompareExecute(t, cmd, []string{fromPath, toPath}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(s, "SPDX Compare") { + t.Fatalf("expected SPDX text header, got:\n%s", s) + } + }) +} + func TestCompareCommand_InvalidFormatErrors(t *testing.T) { origNewInspector := newInspector origOutFormat, origOutMode := outFormat, outMode diff --git a/cmd/os-image-composer/inspect_cmd.go b/cmd/os-image-composer/inspect_cmd.go index e21cd2a9..552c6f2d 100755 --- a/cmd/os-image-composer/inspect_cmd.go +++ b/cmd/os-image-composer/inspect_cmd.go @@ -3,6 +3,9 @@ package main import ( "encoding/json" "fmt" + "os" + "path/filepath" + "strings" "github.com/open-edge-platform/os-image-composer/internal/image/imageinspect" "github.com/open-edge-platform/os-image-composer/internal/utils/logger" @@ -20,10 +23,15 @@ var newInspector = func(hash bool) inspector { return imageinspect.NewDiskfsInspector(hash) // returns *DiskfsInspector which satisfies inspector } +var newInspectorWithSBOM = func(hash bool, inspectSBOM bool) inspector { + return imageinspect.NewDiskfsInspectorWithOptions(hash, inspectSBOM) +} + // Output format command flags var ( outputFormat string = "text" // Output format for the inspection results prettyJSON bool = false // Pretty-print JSON output + sbomOutPath string = "" // Optional destination path for extracted SBOM manifest ) // createInspectCommand creates the inspect subcommand @@ -55,6 +63,12 @@ func createInspectCommand() *cobra.Command { inspectCmd.Flags().BoolVar(&prettyJSON, "pretty", false, "Pretty-print JSON output (only for --format json)") + inspectCmd.Flags().StringVar(&sbomOutPath, "extract-sbom", "", + "Extract embedded SPDX manifest (if present) to this file or directory path") + if extractFlag := inspectCmd.Flags().Lookup("extract-sbom"); extractFlag != nil { + extractFlag.NoOptDefVal = "." + } + return inspectCmd } @@ -64,13 +78,31 @@ func executeInspect(cmd *cobra.Command, args []string) error { imageFile := args[0] log.Infof("Inspecting image file: %s", imageFile) - inspector := newInspector(false) + extractFlagSet := cmd.Flags().Changed("extract-sbom") + resolvedSBOMOutPath := strings.TrimSpace(sbomOutPath) + if extractFlagSet && resolvedSBOMOutPath == "" { + resolvedSBOMOutPath = "." + } + + inspectSBOM := extractFlagSet || resolvedSBOMOutPath != "" + var inspector inspector + if inspectSBOM { + inspector = newInspectorWithSBOM(false, true) + } else { + inspector = newInspector(false) + } inspectionResults, err := inspector.Inspect(imageFile) if err != nil { return fmt.Errorf("image inspection failed: %v", err) } + if inspectSBOM { + if err := writeExtractedSBOM(inspectionResults.SBOM, resolvedSBOMOutPath); err != nil { + return fmt.Errorf("failed to extract SBOM: %w", err) + } + } + if err := writeInspectionResult(cmd, inspectionResults, outputFormat, prettyJSON); err != nil { return err } @@ -78,6 +110,47 @@ func executeInspect(cmd *cobra.Command, args []string) error { return nil } +func writeExtractedSBOM(sbom imageinspect.SBOMSummary, outPath string) error { + if !sbom.Present || len(sbom.Content) == 0 { + if len(sbom.Notes) > 0 { + return fmt.Errorf("embedded SBOM not found: %s", strings.Join(sbom.Notes, "; ")) + } + return fmt.Errorf("embedded SBOM not found") + } + + outPath = strings.TrimSpace(outPath) + if outPath == "" { + outPath = "." + } + outPath = filepath.Clean(outPath) + fileName := sbom.FileName + if fileName == "" { + fileName = "spdx_manifest.json" + } + + var destination string + if info, err := os.Stat(outPath); err == nil && info.IsDir() { + destination = filepath.Join(outPath, fileName) + } else if strings.HasSuffix(strings.ToLower(outPath), ".json") { + destination = outPath + } else { + if err := os.MkdirAll(outPath, 0755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + destination = filepath.Join(outPath, fileName) + } + + if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil { + return fmt.Errorf("create parent directory: %w", err) + } + + if err := os.WriteFile(destination, sbom.Content, 0644); err != nil { + return fmt.Errorf("write SBOM file: %w", err) + } + + return nil +} + func writeInspectionResult(cmd *cobra.Command, summary *imageinspect.ImageSummary, format string, pretty bool) error { out := cmd.OutOrStdout() diff --git a/cmd/os-image-composer/inspect_cmd_test.go b/cmd/os-image-composer/inspect_cmd_test.go index c4620ea5..07af56a4 100755 --- a/cmd/os-image-composer/inspect_cmd_test.go +++ b/cmd/os-image-composer/inspect_cmd_test.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "errors" + "os" + "path/filepath" "strings" "testing" @@ -15,9 +17,14 @@ import ( // resetInspectFlags resets inspect flags to defaults. func resetInspectFlags() { outputFormat = "text" + prettyJSON = false + sbomOutPath = "" newInspector = func(hash bool) inspector { return imageinspect.NewDiskfsInspector(hash) } + newInspectorWithSBOM = func(hash bool, inspectSBOM bool) inspector { + return imageinspect.NewDiskfsInspectorWithOptions(hash, inspectSBOM) + } } // fakeInspector is a tiny test double so we can cover output branches without @@ -91,6 +98,16 @@ func TestCreateInspectCommand(t *testing.T) { } }) + t.Run("ExtractSBOMFlagOptionalValue", func(t *testing.T) { + extractFlag := cmd.Flags().Lookup("extract-sbom") + if extractFlag == nil { + t.Fatal("--extract-sbom flag should be registered") + } + if extractFlag.NoOptDefVal != "." { + t.Fatalf("expected --extract-sbom NoOptDefVal '.', got %q", extractFlag.NoOptDefVal) + } + }) + t.Run("ArgsValidation", func(t *testing.T) { if cmd.Args == nil { t.Fatal("Args validator should be set") @@ -128,6 +145,7 @@ func TestInspectCommand_HelpOutput(t *testing.T) { "inspect", "IMAGE_FILE", "--format", + "--extract-sbom", } for _, s := range expected { if !strings.Contains(out, s) { @@ -136,6 +154,243 @@ func TestInspectCommand_HelpOutput(t *testing.T) { } } +func TestWriteExtractedSBOM(t *testing.T) { + t.Run("NoSBOMPresent_NoWrite", func(t *testing.T) { + tmpDir := t.TempDir() + err := writeExtractedSBOM(imageinspect.SBOMSummary{}, tmpDir) + if err == nil { + t.Fatalf("expected error when SBOM is not present") + } + if !strings.Contains(err.Error(), "embedded SBOM not found") { + t.Fatalf("expected missing SBOM error, got: %v", err) + } + }) + + t.Run("WritesToDirectory", func(t *testing.T) { + tmpDir := t.TempDir() + sbom := imageinspect.SBOMSummary{ + Present: true, + FileName: "spdx_manifest_deb_demo.json", + Content: []byte(`{"packages":[]}`), + } + + err := writeExtractedSBOM(sbom, tmpDir) + if err != nil { + t.Fatalf("writeExtractedSBOM returned error: %v", err) + } + + outFile := filepath.Join(tmpDir, sbom.FileName) + data, readErr := os.ReadFile(outFile) + if readErr != nil { + t.Fatalf("failed to read written SBOM file: %v", readErr) + } + if string(data) != string(sbom.Content) { + t.Fatalf("written SBOM content mismatch") + } + }) + + t.Run("WritesToExplicitFile", func(t *testing.T) { + tmpDir := t.TempDir() + outFile := filepath.Join(tmpDir, "custom-spdx.json") + sbom := imageinspect.SBOMSummary{ + Present: true, + Content: []byte(`{"packages":[{"name":"acl"}]}`), + } + + err := writeExtractedSBOM(sbom, outFile) + if err != nil { + t.Fatalf("writeExtractedSBOM returned error: %v", err) + } + + data, readErr := os.ReadFile(outFile) + if readErr != nil { + t.Fatalf("failed to read written SBOM file: %v", readErr) + } + if string(data) != string(sbom.Content) { + t.Fatalf("written SBOM content mismatch") + } + }) + + t.Run("EmptyOutPathDefaultsToCurrentDirectory", func(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get cwd: %v", err) + } + + tmpDir := t.TempDir() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir to temp dir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(cwd) + }) + + sbom := imageinspect.SBOMSummary{ + Present: true, + FileName: "spdx_manifest_default.json", + Content: []byte(`{"packages":[]}`), + } + + err = writeExtractedSBOM(sbom, "") + if err != nil { + t.Fatalf("writeExtractedSBOM returned error: %v", err) + } + + data, readErr := os.ReadFile(filepath.Join(tmpDir, sbom.FileName)) + if readErr != nil { + t.Fatalf("failed to read default-path SBOM file: %v", readErr) + } + if string(data) != string(sbom.Content) { + t.Fatalf("written SBOM content mismatch") + } + }) +} + +func TestInspectCommand_ExtractSBOMWithoutValue(t *testing.T) { + defer resetInspectFlags() + + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get cwd: %v", err) + } + tmpDir := t.TempDir() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir to temp dir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(cwd) + }) + + newInspector = func(hash bool) inspector { + return &fakeInspector{ + summary: &imageinspect.ImageSummary{ + File: "fake.img", + SBOM: imageinspect.SBOMSummary{ + Present: true, + FileName: "spdx_manifest_rpm_demo.json", + Content: []byte(`{"packages":[]}`), + }, + }, + } + } + newInspectorWithSBOM = func(hash bool, inspectSBOM bool) inspector { + return &fakeInspector{ + summary: &imageinspect.ImageSummary{ + File: "fake.img", + SBOM: imageinspect.SBOMSummary{ + Present: true, + FileName: "spdx_manifest_rpm_demo.json", + Content: []byte(`{"packages":[]}`), + }, + }, + } + } + + cmd := createInspectCommand() + _, err = execCmd(t, cmd, "--extract-sbom", "fake.img") + if err != nil { + t.Fatalf("expected inspect with bare --extract-sbom to succeed, got: %v", err) + } + + if _, statErr := os.Stat(filepath.Join(tmpDir, "spdx_manifest_rpm_demo.json")); statErr != nil { + t.Fatalf("expected extracted SBOM file in current directory, stat err: %v", statErr) + } +} + +func TestExecuteInspect_InspectorSelection(t *testing.T) { + defer resetInspectFlags() + + t.Run("UsesRegularInspectorWhenExtractNotRequested", func(t *testing.T) { + resetInspectFlags() + regularCalled := false + sbomInspectorCalled := false + + newInspector = func(hash bool) inspector { + regularCalled = true + return &fakeInspector{summary: &imageinspect.ImageSummary{File: "fake.img", SizeBytes: 1}} + } + newInspectorWithSBOM = func(hash bool, inspectSBOM bool) inspector { + sbomInspectorCalled = true + return &fakeInspector{summary: &imageinspect.ImageSummary{File: "fake.img", SizeBytes: 1}} + } + + cmd := createInspectCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := executeInspect(cmd, []string{"fake.img"}) + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + if !regularCalled { + t.Fatalf("expected regular inspector constructor to be called") + } + if sbomInspectorCalled { + t.Fatalf("did not expect SBOM inspector constructor when extraction not requested") + } + }) + + t.Run("UsesSBOMInspectorWhenExtractRequested", func(t *testing.T) { + resetInspectFlags() + tmpDir := t.TempDir() + outFile := filepath.Join(tmpDir, "out.json") + + regularCalled := false + sbomInspectorCalled := false + + newInspector = func(hash bool) inspector { + regularCalled = true + return &fakeInspector{summary: &imageinspect.ImageSummary{File: "fake.img", SizeBytes: 1}} + } + newInspectorWithSBOM = func(hash bool, inspectSBOM bool) inspector { + sbomInspectorCalled = true + return &fakeInspector{ + summary: &imageinspect.ImageSummary{ + File: "fake.img", + SBOM: imageinspect.SBOMSummary{ + Present: true, + FileName: "spdx_manifest_demo.json", + Content: []byte(`{"packages":[]}`), + }, + }, + } + } + + cmd := createInspectCommand() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + if err := cmd.Flags().Set("extract-sbom", outFile); err != nil { + t.Fatalf("set extract-sbom flag: %v", err) + } + + err := executeInspect(cmd, []string{"fake.img"}) + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + if !sbomInspectorCalled { + t.Fatalf("expected SBOM inspector constructor to be called") + } + if regularCalled { + t.Fatalf("did not expect regular inspector constructor when extraction is requested") + } + if _, statErr := os.Stat(outFile); statErr != nil { + t.Fatalf("expected extracted SBOM file at %s: %v", outFile, statErr) + } + }) +} + +func TestWriteExtractedSBOM_MissingIncludesNotes(t *testing.T) { + err := writeExtractedSBOM(imageinspect.SBOMSummary{ + Notes: []string{"first reason", "second reason"}, + }, "ignored.json") + if err == nil { + t.Fatalf("expected error when SBOM is missing") + } + if !strings.Contains(err.Error(), "first reason") || !strings.Contains(err.Error(), "second reason") { + t.Fatalf("expected notes in error message, got: %v", err) + } +} + func TestExecuteInspect_DirectCall(t *testing.T) { defer resetInspectFlags() diff --git a/docs/architecture/os-image-composer-cli-specification.md b/docs/architecture/os-image-composer-cli-specification.md index eeac8d95..314709c3 100644 --- a/docs/architecture/os-image-composer-cli-specification.md +++ b/docs/architecture/os-image-composer-cli-specification.md @@ -227,6 +227,7 @@ os-image-composer inspect [flags] IMAGE_FILE | ---- | ----------- | | `--format STRING` | Output format: `text`, `json`, or `yaml` (default: `text`) | | `--pretty` | Pretty-print JSON output (only for `--format=json`; default: `false`) | +| `--extract-sbom FILE` | Extracts SBOM and saves the output in FILE, default filename is used if FILE is not specified | **Description:** @@ -267,6 +268,9 @@ os-image-composer inspect --format=json --pretty my-image.raw # Inspect and output YAML os-image-composer inspect --format=yaml my-image.raw + +# Inspect and extract SPDX data from an IMAGE +os-image-composer inspect my-image.raw --extract-sbom my-sbom.json ``` ### Compare Command @@ -281,13 +285,15 @@ os-image-composer compare [flags] IMAGE_FILE1 IMAGE_FILE2 - `IMAGE_FILE1` - Path to the first RAW image file (required) - `IMAGE_FILE2` - Path to the second RAW image file (required) +- `SPDX_FILE1` - Path to the first SPDX JSON file (required if `--mode=spdx`) +- `SPDX_FILE2` - Path to the second SPDX JSON file (required if `--mode=spdx`) **Flags:** | Flag | Description | | ---- | ----------- | | `--format STRING` | Output format: `text` or `json` (default: `text`) | -| `--mode STRING` | Compare mode: `diff` (partition/FS changes), `summary` (high-level counts), or `full` (complete image metadata). Default: `diff` for text, `full` for JSON | +| `--mode STRING` | Compare mode: `diff` (partition/FS changes), `summary` (high-level counts), `full` (complete image metadata) or `spdx` (compare SBOM differences). Default: `diff` for text, `full` for JSON | | `--pretty` | Pretty-print JSON output (only for `--format=json`; default: `false`) | | `--hash-images` | Perform image hashing for verifying binary identical image (default `false`) | @@ -320,6 +326,7 @@ The compare command performs a deep structural comparison of two images and repo - `diff`: Detailed changes (partitions, filesystems, EFI binaries) - `summary`: High-level counts (added, removed, modified counts) - `full`: Complete image metadata plus all diffs +- `spdx`: Compares two SPDX JSON files **Output:** @@ -344,6 +351,9 @@ os-image-composer compare --format=json --mode=diff image-v1.raw image-v2.raw # Perform comparison with image hashing enabled with details text diff os-image-composer compare --hash-images=true image-v1.raw image-v2.raw + +# Perform SPDX comparison +os-image-composer compare --format=json --mode=spdx spdx-file1.json spdx-file2.json ``` ### Cache Command diff --git a/internal/image/imageinspect/compare.go b/internal/image/imageinspect/compare.go index b7d5d906..f94ad1e1 100644 --- a/internal/image/imageinspect/compare.go +++ b/internal/image/imageinspect/compare.go @@ -27,6 +27,7 @@ type CompareSummary struct { PartitionsChanged bool `json:"partitionsChanged,omitempty"` FilesystemsChanged bool `json:"filesystemsChanged,omitempty"` EFIBinariesChanged bool `json:"efiBinariesChanged,omitempty"` + SBOMChanged bool `json:"sbomChanged,omitempty"` AddedCount int `json:"addedCount,omitempty"` RemovedCount int `json:"removedCount,omitempty"` @@ -40,6 +41,21 @@ type ImageDiff struct { Partitions PartitionDiff `json:"partitions,omitempty"` EFIBinaries EFIBinaryDiff `json:"efiBinaries,omitempty"` Verity *VerityDiff `json:"verity,omitempty" yaml:"verity,omitempty"` + SBOM *SBOMDiff `json:"sbom,omitempty" yaml:"sbom,omitempty"` +} + +// SBOMDiff represents differences in embedded SBOM metadata. +type SBOMDiff struct { + Added *SBOMSummary `json:"added,omitempty" yaml:"added,omitempty"` + Removed *SBOMSummary `json:"removed,omitempty" yaml:"removed,omitempty"` + Changed bool `json:"changed,omitempty" yaml:"changed,omitempty"` + + Format *ValueDiff[string] `json:"format,omitempty" yaml:"format,omitempty"` + FileName *ValueDiff[string] `json:"fileName,omitempty" yaml:"fileName,omitempty"` + SizeBytes *ValueDiff[int64] `json:"sizeBytes,omitempty" yaml:"sizeBytes,omitempty"` + SHA256 *ValueDiff[string] `json:"sha256,omitempty" yaml:"sha256,omitempty"` + CanonicalSHA256 *ValueDiff[string] `json:"canonicalSha256,omitempty" yaml:"canonicalSha256,omitempty"` + PackageCount *ValueDiff[int] `json:"packageCount,omitempty" yaml:"packageCount,omitempty"` } // MetaDiff represents differences in image-level metadata. @@ -49,9 +65,9 @@ type MetaDiff struct { // VerityDiff represents differences in dm-verity configuration. type VerityDiff struct { - Added *VerityInfo `json:"added,omitempty" yaml:"added,omitempty"` - Removed *VerityInfo `json:"removed,omitempty" yaml:"removed,omitempty"` - Changed bool `json:"changed,omitempty" yaml:"changed,omitempty"` + Added *VeritySummary `json:"added,omitempty" yaml:"added,omitempty"` + Removed *VeritySummary `json:"removed,omitempty" yaml:"removed,omitempty"` + Changed bool `json:"changed,omitempty" yaml:"changed,omitempty"` Enabled *ValueDiff[bool] `json:"enabled,omitempty" yaml:"enabled,omitempty"` Method *ValueDiff[string] `json:"method,omitempty" yaml:"method,omitempty"` @@ -302,6 +318,13 @@ func CompareImages(from, to *ImageSummary) ImageCompareResult { res.Summary.Changed = true } + // --- SBOM --- + res.Diff.SBOM = compareSBOM(from.SBOM, to.SBOM) + if res.Diff.SBOM != nil && res.Diff.SBOM.Changed { + res.Summary.SBOMChanged = true + res.Summary.Changed = true + } + // Deterministic ordering for stable JSON normalizeCompareResult(&res) @@ -352,7 +375,7 @@ func compareMeta(from, to ImageSummary) MetaDiff { return out } -func compareVerity(from, to *VerityInfo) *VerityDiff { +func compareVerity(from, to *VeritySummary) *VerityDiff { // Both nil = no difference if from == nil && to == nil { return nil @@ -402,6 +425,62 @@ func compareVerity(from, to *VerityInfo) *VerityDiff { return diff } +func compareSBOM(from, to SBOMSummary) *SBOMDiff { + if !from.Present && !to.Present { + return nil + } + + diff := &SBOMDiff{} + + if !from.Present && to.Present { + diff.Added = &to + diff.Changed = true + return diff + } + + if from.Present && !to.Present { + diff.Removed = &from + diff.Changed = true + return diff + } + + if strings.TrimSpace(from.Format) != strings.TrimSpace(to.Format) { + diff.Format = &ValueDiff[string]{From: from.Format, To: to.Format} + diff.Changed = true + } + if strings.TrimSpace(from.FileName) != strings.TrimSpace(to.FileName) { + diff.FileName = &ValueDiff[string]{From: from.FileName, To: to.FileName} + diff.Changed = true + } + if from.SizeBytes != to.SizeBytes { + diff.SizeBytes = &ValueDiff[int64]{From: from.SizeBytes, To: to.SizeBytes} + diff.Changed = true + } + + if from.PackageCount != to.PackageCount { + diff.PackageCount = &ValueDiff[int]{From: from.PackageCount, To: to.PackageCount} + diff.Changed = true + } + + fromCanonical := strings.TrimSpace(from.CanonicalSHA256) + toCanonical := strings.TrimSpace(to.CanonicalSHA256) + if fromCanonical != "" || toCanonical != "" { + if fromCanonical != toCanonical { + diff.CanonicalSHA256 = &ValueDiff[string]{From: from.CanonicalSHA256, To: to.CanonicalSHA256} + diff.Changed = true + } + } else if strings.TrimSpace(from.SHA256) != strings.TrimSpace(to.SHA256) { + diff.SHA256 = &ValueDiff[string]{From: from.SHA256, To: to.SHA256} + diff.Changed = true + } + + if !diff.Changed { + return nil + } + + return diff +} + // comparePartitionTable compares two PartitionTableSummary objects and returns a PartitionTableDiff. func comparePartitionTable(from, to PartitionTableSummary) PartitionTableDiff { var d PartitionTableDiff @@ -891,6 +970,32 @@ func tallyDiffs(d ImageDiff) diffTally { } } + if d.SBOM != nil && d.SBOM.Changed { + if d.SBOM.Added != nil { + t.addMeaningful(1, "SBOM added") + } else if d.SBOM.Removed != nil { + t.addMeaningful(1, "SBOM removed") + } else { + if d.SBOM.CanonicalSHA256 != nil { + t.addMeaningful(1, "SBOM canonical content changed") + } else if d.SBOM.SHA256 != nil { + t.addMeaningful(1, "SBOM raw content changed") + } + if d.SBOM.PackageCount != nil { + t.addMeaningful(1, "SBOM package count changed") + } + if d.SBOM.Format != nil { + t.addMeaningful(1, "SBOM format changed") + } + if d.SBOM.FileName != nil { + t.addVolatile(1, "SBOM file name changed") + } + if d.SBOM.SizeBytes != nil { + t.addVolatile(1, "SBOM size changed") + } + } + } + return t } diff --git a/internal/image/imageinspect/compare_test.go b/internal/image/imageinspect/compare_test.go index 9d2969f0..dcbedfeb 100644 --- a/internal/image/imageinspect/compare_test.go +++ b/internal/image/imageinspect/compare_test.go @@ -1084,23 +1084,23 @@ func TestCompareVerity_NilCasesAndFieldChanges(t *testing.T) { t.Fatalf("expected nil diff when both verity values are nil") } - added := compareVerity(nil, &VerityInfo{Enabled: true, Method: "systemd-verity", RootDevice: "/dev/vda2", HashPartition: 3}) + added := compareVerity(nil, &VeritySummary{Enabled: true, Method: "systemd-verity", RootDevice: "/dev/vda2", HashPartition: 3}) if added == nil || !added.Changed || added.Added == nil { t.Fatalf("expected added verity diff") } - removed := compareVerity(&VerityInfo{Enabled: true, Method: "systemd-verity"}, nil) + removed := compareVerity(&VeritySummary{Enabled: true, Method: "systemd-verity"}, nil) if removed == nil || !removed.Changed || removed.Removed == nil { t.Fatalf("expected removed verity diff") } - from := &VerityInfo{ + from := &VeritySummary{ Enabled: true, Method: "systemd-verity", RootDevice: "/dev/vda2", HashPartition: 3, } - to := &VerityInfo{ + to := &VeritySummary{ Enabled: false, Method: "custom-initramfs", RootDevice: "/dev/vda3", diff --git a/internal/image/imageinspect/fs_raw.go b/internal/image/imageinspect/fs_raw.go index 1182dc15..268d2233 100755 --- a/internal/image/imageinspect/fs_raw.go +++ b/internal/image/imageinspect/fs_raw.go @@ -6,8 +6,12 @@ import ( "io" "os" "path" + "path/filepath" "sort" "strings" + + "github.com/open-edge-platform/os-image-composer/internal/config/manifest" + "github.com/open-edge-platform/os-image-composer/internal/utils/shell" ) // FAT filesystem reader implementation (for raw reads from disk images) @@ -486,6 +490,239 @@ func readFileFromFAT(v *fatVol, filePath string) (string, error) { return "", fmt.Errorf("file not found: %s", filePath) } +// readSBOMFromRawPartition reads an embedded SPDX SBOM from a raw partition. +// It finds the SBOM filename/path in a raw partition, then reads it via the +// generic raw partition file reader. +func readSBOMFromRawPartition(img io.ReaderAt, partOff int64, partSize uint64, fsType string) ([]byte, string, string, error) { + fsType = strings.ToLower(strings.TrimSpace(fsType)) + + if fsType == "ext4" || fsType == "ext3" || fsType == "ext2" { + partitionFilePath, cleanup, err := extractPartitionToTempFile(img, partOff, partSize) + if err != nil { + return nil, "", "", err + } + defer cleanup() + + sbomFileName, sbomPath, err := findSBOMFileInExtPartitionImage(partitionFilePath) + if err != nil { + return nil, "", "", err + } + + content, err := readFileFromExtPartitionImage(partitionFilePath, sbomPath) + if err != nil { + return nil, "", "", fmt.Errorf("read SBOM file %s: %w", sbomPath, err) + } + + return content, sbomFileName, sbomPath, nil + } + + sbomFileName, sbomPath, err := findSBOMFileInRawPartition(img, partOff, partSize, fsType) + if err != nil { + return nil, "", "", err + } + + content, err := readFileFromRawPartition(img, partOff, partSize, fsType, sbomPath) + if err != nil { + return nil, "", "", fmt.Errorf("read SBOM file %s: %w", sbomPath, err) + } + + return content, sbomFileName, sbomPath, nil +} + +func findSBOMFileInExtPartitionImage(partitionFilePath string) (string, string, error) { + dumpDir, err := os.MkdirTemp("", "oic-sbom-rdump-*") + if err != nil { + return "", "", fmt.Errorf("create temp dump directory: %w", err) + } + defer func() { + _ = os.RemoveAll(dumpDir) + }() + + rdumpCmd := fmt.Sprintf("debugfs -R 'rdump %s %s' %s", manifest.ImageSBOMPath, dumpDir, partitionFilePath) + if _, err = shell.ExecCmd(rdumpCmd, false, shell.HostPath, nil); err != nil { + return "", "", fmt.Errorf("debugfs rdump failed: %w", err) + } + + jsonFiles := collectJSONFilesFromDir(dumpDir) + if len(jsonFiles) == 0 { + return "", "", fmt.Errorf("SBOM directory present but no SPDX JSON file found") + } + + fileNames := make([]string, 0, len(jsonFiles)) + for _, filePath := range jsonFiles { + fileNames = append(fileNames, filepath.Base(filePath)) + } + + sbomFileName, found := pickSBOMFileNameFromNames(fileNames) + if !found { + return "", "", fmt.Errorf("SBOM directory present but no SPDX JSON file found") + } + + sbomPath := path.Join(manifest.ImageSBOMPath, sbomFileName) + return sbomFileName, sbomPath, nil +} + +func findSBOMFileInRawPartition(img io.ReaderAt, partOff int64, partSize uint64, fsType string) (string, string, error) { + fsType = strings.ToLower(strings.TrimSpace(fsType)) + + switch { + case isVFATLike(fsType): + volume, err := openFAT(img, partOff) + if err != nil { + return "", "", fmt.Errorf("open FAT volume: %w", err) + } + + sbomDir := strings.Trim(manifest.ImageSBOMPath, "/") + entries, err := volume.listDir(sbomDir) + if err != nil { + return "", "", fmt.Errorf("list SBOM directory: %w", err) + } + + sbomFileName, found := pickSBOMFileNameFromFAT(entries) + if !found { + return "", "", fmt.Errorf("SBOM directory present but no SPDX JSON file found") + } + + sbomPath := path.Join(manifest.ImageSBOMPath, sbomFileName) + return sbomFileName, sbomPath, nil + + case fsType == "ext4" || fsType == "ext3" || fsType == "ext2": + partitionFilePath, cleanup, err := extractPartitionToTempFile(img, partOff, partSize) + if err != nil { + return "", "", err + } + defer cleanup() + + return findSBOMFileInExtPartitionImage(partitionFilePath) + + default: + return "", "", fmt.Errorf("filesystem %q is not supported for raw SPDX extraction yet", emptyOr(fsType, "unknown")) + } +} + +// readFileFromRawPartition reads a file from a raw partition by path. +// It dispatches to filesystem-specific readers based on fsType. +func readFileFromRawPartition(img io.ReaderAt, partOff int64, partSize uint64, fsType, filePath string) ([]byte, error) { + fsType = strings.ToLower(strings.TrimSpace(fsType)) + + switch { + case isVFATLike(fsType): + return readFileFromRawFAT(img, partOff, filePath) + case fsType == "ext4" || fsType == "ext3" || fsType == "ext2": + return readFileFromRawExt(img, partOff, partSize, filePath) + default: + return nil, fmt.Errorf("filesystem %q is not supported for raw file reads", emptyOr(fsType, "unknown")) + } +} + +func readFileFromRawFAT(img io.ReaderAt, partOff int64, filePath string) ([]byte, error) { + volume, err := openFAT(img, partOff) + if err != nil { + return nil, fmt.Errorf("open FAT volume: %w", err) + } + + content, err := readFileFromFAT(volume, filePath) + if err != nil { + return nil, err + } + + return []byte(content), nil +} + +func readFileFromRawExt(img io.ReaderAt, partOff int64, partSize uint64, filePath string) ([]byte, error) { + partitionFilePath, cleanup, err := extractPartitionToTempFile(img, partOff, partSize) + if err != nil { + return nil, err + } + defer cleanup() + + return readFileFromExtPartitionImage(partitionFilePath, filePath) +} + +func readFileFromExtPartitionImage(partitionFilePath, filePath string) ([]byte, error) { + + outFile, err := os.CreateTemp("", "oic-ext-read-*.bin") + if err != nil { + return nil, fmt.Errorf("create temp output file: %w", err) + } + outFilePath := outFile.Name() + _ = outFile.Close() + defer func() { + _ = os.Remove(outFilePath) + }() + + normalizedPath := filePath + if !strings.HasPrefix(normalizedPath, "/") { + normalizedPath = "/" + normalizedPath + } + + dumpCmd := fmt.Sprintf("debugfs -R 'dump %s %s' %s", normalizedPath, outFilePath, partitionFilePath) + if _, err = shell.ExecCmd(dumpCmd, false, shell.HostPath, nil); err != nil { + return nil, fmt.Errorf("debugfs dump failed: %w", err) + } + + content, err := os.ReadFile(outFilePath) + if err != nil { + return nil, fmt.Errorf("read extracted file: %w", err) + } + + return content, nil +} + +func extractPartitionToTempFile(img io.ReaderAt, partOff int64, partSize uint64) (string, func(), error) { + if partSize == 0 { + return "", nil, fmt.Errorf("partition size is zero") + } + + debugfsExists, existsErr := shell.IsCommandExist("debugfs", shell.HostPath) + if existsErr != nil { + return "", nil, fmt.Errorf("failed to check debugfs availability: %w", existsErr) + } + if !debugfsExists { + return "", nil, fmt.Errorf("debugfs command is not available") + } + + partitionFile, err := os.CreateTemp("", "oic-partition-*.img") + if err != nil { + return "", nil, fmt.Errorf("create temp partition file: %w", err) + } + + partitionFilePath := partitionFile.Name() + + partitionReader := io.NewSectionReader(img, partOff, int64(partSize)) + if _, err = io.Copy(partitionFile, partitionReader); err != nil { + _ = partitionFile.Close() + _ = os.Remove(partitionFilePath) + return "", nil, fmt.Errorf("copy partition bytes: %w", err) + } + + if closeErr := partitionFile.Close(); closeErr != nil { + _ = os.Remove(partitionFilePath) + return "", nil, fmt.Errorf("close temp partition file: %w", closeErr) + } + + cleanup := func() { + _ = os.Remove(partitionFilePath) + } + + return partitionFilePath, cleanup, nil +} + +func collectJSONFilesFromDir(rootDir string) []string { + var files []string + _ = filepath.WalkDir(rootDir, func(filePath string, dirEntry os.DirEntry, walkErr error) error { + if walkErr != nil || dirEntry == nil || dirEntry.IsDir() { + return nil + } + if strings.HasSuffix(strings.ToLower(dirEntry.Name()), ".json") { + files = append(files, filePath) + } + return nil + }) + sort.Strings(files) + return files +} + // generateBootloaderConfigPaths builds a prioritized list of candidate configuration // file paths for a given `kind` on the provided FAT volume. It prefers files // under /EFI/* (inspecting actual subdirectories) and falls back to common diff --git a/internal/image/imageinspect/helpers_test.go b/internal/image/imageinspect/helpers_test.go index f55acf91..c0da8e00 100644 --- a/internal/image/imageinspect/helpers_test.go +++ b/internal/image/imageinspect/helpers_test.go @@ -564,7 +564,7 @@ func TestRenderVerityInfo(t *testing.T) { var buf bytes.Buffer // Test with dm-verity enabled - v := &VerityInfo{ + v := &VeritySummary{ Enabled: true, Method: "custom-initramfs", RootDevice: "/dev/mapper/rootfs_verity", @@ -598,7 +598,7 @@ func TestRenderVerityInfo(t *testing.T) { func TestRenderVerityInfo_Disabled(t *testing.T) { var buf bytes.Buffer - v := &VerityInfo{ + v := &VeritySummary{ Enabled: false, } @@ -619,7 +619,7 @@ func TestRenderVerityDiffText(t *testing.T) { // Test dm-verity enabled diff := &VerityDiff{ Changed: true, - Added: &VerityInfo{ + Added: &VeritySummary{ Enabled: true, Method: "systemd-verity", RootDevice: "/dev/sda2", @@ -645,7 +645,7 @@ func TestRenderVerityDiffText_Removed(t *testing.T) { diff := &VerityDiff{ Changed: true, - Removed: &VerityInfo{ + Removed: &VeritySummary{ Enabled: true, Method: "custom-initramfs", RootDevice: "/dev/mapper/test", diff --git a/internal/image/imageinspect/imageinspect.go b/internal/image/imageinspect/imageinspect.go index 4a5ca79b..a2d2301e 100755 --- a/internal/image/imageinspect/imageinspect.go +++ b/internal/image/imageinspect/imageinspect.go @@ -27,12 +27,12 @@ type ImageSummary struct { SHA256 string `json:"sha256,omitempty"` SizeBytes int64 `json:"sizeBytes,omitempty"` PartitionTable PartitionTableSummary `json:"partitionTable,omitempty"` - Verity *VerityInfo `json:"verity,omitempty" yaml:"verity,omitempty"` - // SBOM SBOMSummary `json:"sbom,omitempty"` + Verity *VeritySummary `json:"verity,omitempty" yaml:"verity,omitempty"` + SBOM SBOMSummary `json:"sbom,omitempty" yaml:"sbom,omitempty"` } -// VerityInfo holds dm-verity detection information. -type VerityInfo struct { +// VeritySummary holds dm-verity detection information. +type VeritySummary struct { Enabled bool `json:"enabled" yaml:"enabled"` Method string `json:"method,omitempty" yaml:"method,omitempty"` // "systemd-verity", "custom-initramfs", "unknown" RootDevice string `json:"rootDevice,omitempty" yaml:"rootDevice,omitempty"` @@ -53,6 +53,20 @@ type PartitionTableSummary struct { MisalignedPartitions []int `json:"misalignedPartitions,omitempty" yaml:"misalignedPartitions,omitempty"` } +// SBOMSummary holds information about the Software Bill of Materials (SBOM) if available. +type SBOMSummary struct { + Present bool `json:"present,omitempty" yaml:"present,omitempty"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` + FileName string `json:"fileName,omitempty" yaml:"fileName,omitempty"` + Format string `json:"format,omitempty" yaml:"format,omitempty"` // e.g., "spdx", "cyclonedx" + SizeBytes int64 `json:"sizeBytes,omitempty" yaml:"sizeBytes,omitempty"` + SHA256 string `json:"sha256,omitempty" yaml:"sha256,omitempty"` + CanonicalSHA256 string `json:"canonicalSha256,omitempty" yaml:"canonicalSha256,omitempty"` + PackageCount int `json:"packageCount,omitempty" yaml:"packageCount,omitempty"` + Content []byte `json:"-" yaml:"-"` + Notes []string `json:"notes,omitempty" yaml:"notes,omitempty"` +} + // FreeSpanSummary captures the largest unallocated extent on disk (by LBA). type FreeSpanSummary struct { StartLBA uint64 `json:"startLba" yaml:"startLba"` @@ -232,12 +246,17 @@ type diskAccessorFS interface { } type DiskfsInspector struct { - HashImages bool - logger *zap.SugaredLogger + HashImages bool + InspectSBOM bool + logger *zap.SugaredLogger } func NewDiskfsInspector(hash bool) *DiskfsInspector { - return &DiskfsInspector{HashImages: hash, logger: logger.Logger()} + return &DiskfsInspector{HashImages: hash, InspectSBOM: false, logger: logger.Logger()} +} + +func NewDiskfsInspectorWithOptions(hash bool, inspectSBOM bool) *DiskfsInspector { + return &DiskfsInspector{HashImages: hash, InspectSBOM: inspectSBOM, logger: logger.Logger()} } func (d *DiskfsInspector) Inspect(imagePath string) (*ImageSummary, error) { @@ -365,12 +384,27 @@ func (d *DiskfsInspector) inspectCore( // Detect dm-verity configuration verityInfo := detectVerity(ptSummary) + // Detect SBOM information if requested by user + var sbomInfo SBOMSummary + if d.InspectSBOM { + sbomInfo = inspectSBOMFromImageRaw(img, ptSummary) + if !sbomInfo.Present { + fsSummary := inspectSBOMFromImageFilesystem(disk, ptSummary) + if fsSummary.Present { + sbomInfo = fsSummary + } else { + sbomInfo.Notes = append(sbomInfo.Notes, fsSummary.Notes...) + } + } + } + return &ImageSummary{ File: imagePath, SizeBytes: sizeBytes, PartitionTable: ptSummary, SHA256: sha256sum, Verity: verityInfo, + SBOM: sbomInfo, }, nil } @@ -630,8 +664,8 @@ func hashBytesHex(data []byte) string { } // detectVerity inspects the partition table and UKI cmdline to detect dm-verity configuration -func detectVerity(pt PartitionTableSummary) *VerityInfo { - info := &VerityInfo{} +func detectVerity(pt PartitionTableSummary) *VeritySummary { + info := &VeritySummary{} // Look for hash partition (common names/types) hashPartLoopIdx := -1 diff --git a/internal/image/imageinspect/renderer_text.go b/internal/image/imageinspect/renderer_text.go index 920e6448..b2309d88 100755 --- a/internal/image/imageinspect/renderer_text.go +++ b/internal/image/imageinspect/renderer_text.go @@ -38,6 +38,7 @@ func RenderCompareText(w io.Writer, r *ImageCompareResult, opts CompareTextOptio fmt.Fprintf(w, "PartitionTableChanged: %v\n", s.PartitionTableChanged) fmt.Fprintf(w, "PartitionsChanged: %v\n", s.PartitionsChanged) fmt.Fprintf(w, "EFIBinariesChanged: %v\n", s.EFIBinariesChanged) + fmt.Fprintf(w, "SBOMChanged: %v\n", s.SBOMChanged) obj := computeObjectCountsFromDiff(r.Diff) fmt.Fprintf(w, "Counts (objects): +%d -%d ~%d\n", obj.added, obj.removed, obj.modified) @@ -147,6 +148,32 @@ func RenderCompareText(w io.Writer, r *ImageCompareResult, opts CompareTextOptio renderVerityDiffText(w, r.Diff.Verity) } + if r.Diff.SBOM != nil && r.Diff.SBOM.Changed { + fmt.Fprintln(w) + fmt.Fprintln(w, "SBOM:") + s := r.Diff.SBOM + if s.Added != nil { + fmt.Fprintf(w, " Added: %s (%s)\n", s.Added.FileName, shortHash(s.Added.CanonicalSHA256)) + } else if s.Removed != nil { + fmt.Fprintf(w, " Removed: %s (%s)\n", s.Removed.FileName, shortHash(s.Removed.CanonicalSHA256)) + } else { + if s.Format != nil { + fmt.Fprintf(w, " Format: %s -> %s\n", s.Format.From, s.Format.To) + } + if s.FileName != nil { + fmt.Fprintf(w, " FileName: %s -> %s\n", s.FileName.From, s.FileName.To) + } + if s.PackageCount != nil { + fmt.Fprintf(w, " PackageCount: %d -> %d\n", s.PackageCount.From, s.PackageCount.To) + } + if s.CanonicalSHA256 != nil { + fmt.Fprintf(w, " CanonicalSHA256: %s -> %s\n", shortHash(s.CanonicalSHA256.From), shortHash(s.CanonicalSHA256.To)) + } else if s.SHA256 != nil { + fmt.Fprintf(w, " SHA256: %s -> %s\n", shortHash(s.SHA256.From), shortHash(s.SHA256.To)) + } + } + } + // Full mode: image metadata & volatile / meaningful remove reasons if mode == "full" { renderImagesBlock(w, r.From, r.To) @@ -179,6 +206,24 @@ func RenderSummaryText(w io.Writer, summary *ImageSummary, opts TextOptions) err fmt.Fprintln(w) renderVerityInfo(w, summary.Verity) } + + if summary.SBOM.Present { + fmt.Fprintln(w) + fmt.Fprintln(w, "SBOM") + fmt.Fprintln(w, "----") + fmt.Fprintf(w, "Path:\t%s\n", summary.SBOM.Path) + fmt.Fprintf(w, "Format:\t%s\n", summary.SBOM.Format) + fmt.Fprintf(w, "Size:\t%s (%d bytes)\n", humanBytes(summary.SBOM.SizeBytes), summary.SBOM.SizeBytes) + if strings.TrimSpace(summary.SBOM.CanonicalSHA256) != "" { + fmt.Fprintf(w, "CanonicalSHA256:\t%s\n", summary.SBOM.CanonicalSHA256) + } + if strings.TrimSpace(summary.SBOM.SHA256) != "" { + fmt.Fprintf(w, "SHA256:\t%s\n", summary.SBOM.SHA256) + } + if summary.SBOM.PackageCount > 0 { + fmt.Fprintf(w, "PackageCount:\t%d\n", summary.SBOM.PackageCount) + } + } // Detailed per-partition filesystem blocks (ONLY ONCE) for _, p := range summary.PartitionTable.Partitions { if p.Filesystem == nil || isFilesystemEmpty(p.Filesystem) { @@ -194,6 +239,42 @@ func RenderSummaryText(w io.Writer, summary *ImageSummary, opts TextOptions) err return nil } +// RenderSPDXCompareText renders a concise text report for SPDX manifest comparison. +func RenderSPDXCompareText(w io.Writer, result *SPDXCompareResult) error { + if result == nil { + return fmt.Errorf("RenderSPDXCompareText: result is nil") + } + + fmt.Fprintln(w, "SPDX Compare") + fmt.Fprintln(w, "============") + fmt.Fprintf(w, "From:\t%s\n", result.FromPath) + fmt.Fprintf(w, "To:\t%s\n", result.ToPath) + fmt.Fprintf(w, "Equal:\t%v\n", result.Equal) + fmt.Fprintf(w, "Packages:\t%d -> %d\n", result.FromPackageCount, result.ToPackageCount) + + if strings.TrimSpace(result.FromCanonicalHash) != "" || strings.TrimSpace(result.ToCanonicalHash) != "" { + fmt.Fprintf(w, "CanonicalSHA256:\t%s -> %s\n", result.FromCanonicalHash, result.ToCanonicalHash) + } + + if len(result.AddedPackages) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "Added packages:") + for _, pkg := range result.AddedPackages { + fmt.Fprintf(w, " + %s\n", pkg) + } + } + + if len(result.RemovedPackages) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "Removed packages:") + for _, pkg := range result.RemovedPackages { + fmt.Fprintf(w, " - %s\n", pkg) + } + } + + return nil +} + // renderPartitionSummaryLine prints a compact one-liner for a partition in compare output. // NOTE: This is intentionally more compact than the inspect partition table. func renderPartitionSummaryLine(w io.Writer, prefix string, p PartitionSummary) { @@ -377,6 +458,17 @@ func computeObjectCountsFromDiff(d ImageDiff) objectCounts { } } + if d.SBOM != nil && d.SBOM.Changed { + switch { + case d.SBOM.Added != nil: + c.added++ + case d.SBOM.Removed != nil: + c.removed++ + default: + c.modified++ + } + } + return c } @@ -569,7 +661,7 @@ func renderPartitionTableHeader(w io.Writer, pt PartitionTableSummary) { _ = tw.Flush() } -func renderVerityInfo(w io.Writer, v *VerityInfo) { +func renderVerityInfo(w io.Writer, v *VeritySummary) { fmt.Fprintln(w, "dm-verity Configuration") fmt.Fprintln(w, "-----------------------") diff --git a/internal/image/imageinspect/renderer_text_test.go b/internal/image/imageinspect/renderer_text_test.go index 01d1f351..86a1f156 100644 --- a/internal/image/imageinspect/renderer_text_test.go +++ b/internal/image/imageinspect/renderer_text_test.go @@ -191,3 +191,181 @@ func TestRenderEFIBinaryDiffText_FullBranches(t *testing.T) { } } } + +func TestRenderCompareText_NilResult(t *testing.T) { + var buf bytes.Buffer + err := RenderCompareText(&buf, nil, CompareTextOptions{Mode: "diff"}) + if err == nil { + t.Fatalf("expected error for nil compare result") + } +} + +func TestRenderCompareText_Modes(t *testing.T) { + rich := &ImageCompareResult{ + From: ImageSummary{File: "from.img", SizeBytes: 1024}, + To: ImageSummary{File: "to.img", SizeBytes: 2048}, + Equality: Equality{ + Class: EqualityDifferent, + VolatileDiffs: 1, + MeaningfulDiffs: 2, + }, + Summary: CompareSummary{ + Changed: true, + PartitionTableChanged: true, + PartitionsChanged: true, + EFIBinariesChanged: true, + SBOMChanged: true, + }, + Diff: ImageDiff{ + Image: MetaDiff{SizeBytes: &ValueDiff[int64]{From: 1024, To: 2048}}, + PartitionTable: PartitionTableDiff{ + Changed: true, + Type: &ValueDiff[string]{From: "gpt", To: "mbr"}, + }, + Partitions: PartitionDiff{ + Added: []PartitionSummary{{Index: 2, Name: "data", Type: "linux", StartLBA: 2048, EndLBA: 4095, SizeBytes: 1024}}, + Removed: []PartitionSummary{{Index: 1, Name: "root", Type: "linux", StartLBA: 34, EndLBA: 2047, SizeBytes: 1024}}, + Modified: []ModifiedPartitionSummary{{ + Key: "idx=3", + Changes: []FieldChange{{ + Field: "name", + From: "old", + To: "new", + }}, + }}, + }, + Verity: &VerityDiff{ + Changed: true, + Method: &ValueDiff[string]{From: "systemd-verity", To: "custom-initramfs"}, + RootDevice: &ValueDiff[string]{From: "/dev/sda2", To: "/dev/sda3"}, + }, + SBOM: &SBOMDiff{ + Changed: true, + FileName: &ValueDiff[string]{From: "spdx_a.json", To: "spdx_b.json"}, + PackageCount: &ValueDiff[int]{From: 1, To: 2}, + CanonicalSHA256: &ValueDiff[string]{From: strings.Repeat("a", 64), To: strings.Repeat("b", 64)}, + }, + }, + } + + var summaryBuf bytes.Buffer + if err := RenderCompareText(&summaryBuf, rich, CompareTextOptions{Mode: "summary"}); err != nil { + t.Fatalf("summary mode render error: %v", err) + } + summaryOut := summaryBuf.String() + for _, want := range []string{"Changed:", "PartitionTableChanged:", "SBOMChanged:", "Counts (objects):"} { + if !strings.Contains(summaryOut, want) { + t.Fatalf("summary output missing %q:\n%s", want, summaryOut) + } + } + + var diffBuf bytes.Buffer + if err := RenderCompareText(&diffBuf, rich, CompareTextOptions{Mode: "diff"}); err != nil { + t.Fatalf("diff mode render error: %v", err) + } + diffOut := diffBuf.String() + for _, want := range []string{"Partition table:", "Partitions:", "dm-verity:", "SBOM:"} { + if !strings.Contains(diffOut, want) { + t.Fatalf("diff output missing %q:\n%s", want, diffOut) + } + } + + unchanged := &ImageCompareResult{ + From: ImageSummary{File: "from.img"}, + To: ImageSummary{File: "to.img"}, + Equality: Equality{Class: EqualitySemantic}, + Summary: CompareSummary{Changed: false}, + } + + var fullBuf bytes.Buffer + if err := RenderCompareText(&fullBuf, unchanged, CompareTextOptions{Mode: "full"}); err != nil { + t.Fatalf("full mode render error: %v", err) + } + fullOut := fullBuf.String() + if !strings.Contains(fullOut, "No changes detected.") { + t.Fatalf("expected unchanged full output, got:\n%s", fullOut) + } +} + +func TestRenderSummaryText_AndSPDXCompareText(t *testing.T) { + var summaryBuf bytes.Buffer + summary := &ImageSummary{ + File: "image.raw", + SizeBytes: 4096, + SHA256: strings.Repeat("a", 64), + PartitionTable: PartitionTableSummary{ + Type: "gpt", + LogicalSectorSize: 512, + PhysicalSectorSize: 4096, + ProtectiveMBR: true, + Partitions: []PartitionSummary{{ + Index: 1, + Name: "root", + Type: "linux", + StartLBA: 2048, + EndLBA: 4095, + SizeBytes: 1024, + Filesystem: &FilesystemSummary{ + Type: "ext4", + Label: "rootfs", + UUID: "uuid-1", + }, + }}, + }, + Verity: &VeritySummary{ + Enabled: true, + Method: "systemd-verity", + RootDevice: "/dev/sda2", + HashPartition: 3, + }, + SBOM: SBOMSummary{ + Present: true, + Path: "/usr/share/sbom/spdx_manifest.json", + Format: "spdx", + SizeBytes: 123, + SHA256: strings.Repeat("b", 64), + CanonicalSHA256: strings.Repeat("c", 64), + PackageCount: 10, + }, + } + + if err := RenderSummaryText(&summaryBuf, summary, TextOptions{}); err != nil { + t.Fatalf("RenderSummaryText error: %v", err) + } + summaryOut := summaryBuf.String() + for _, want := range []string{"OS Image Summary", "Partition Table", "dm-verity", "SBOM", "Partition 1 filesystem details"} { + if !strings.Contains(summaryOut, want) { + t.Fatalf("summary output missing %q:\n%s", want, summaryOut) + } + } + + if err := RenderSummaryText(&summaryBuf, nil, TextOptions{}); err == nil { + t.Fatalf("expected nil summary error") + } + + var spdxBuf bytes.Buffer + spdx := &SPDXCompareResult{ + FromPath: "from.json", + ToPath: "to.json", + Equal: false, + FromPackageCount: 1, + ToPackageCount: 2, + FromCanonicalHash: strings.Repeat("d", 64), + ToCanonicalHash: strings.Repeat("e", 64), + AddedPackages: []string{"pkg-new"}, + RemovedPackages: []string{"pkg-old"}, + } + if err := RenderSPDXCompareText(&spdxBuf, spdx); err != nil { + t.Fatalf("RenderSPDXCompareText error: %v", err) + } + spdxOut := spdxBuf.String() + for _, want := range []string{"SPDX Compare", "Added packages:", "Removed packages:"} { + if !strings.Contains(spdxOut, want) { + t.Fatalf("spdx compare output missing %q:\n%s", want, spdxOut) + } + } + + if err := RenderSPDXCompareText(&spdxBuf, nil); err == nil { + t.Fatalf("expected nil SPDX compare error") + } +} diff --git a/internal/image/imageinspect/sbom.go b/internal/image/imageinspect/sbom.go new file mode 100644 index 00000000..a7b6d303 --- /dev/null +++ b/internal/image/imageinspect/sbom.go @@ -0,0 +1,468 @@ +package imageinspect + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path" + "sort" + "strings" + + "github.com/open-edge-platform/os-image-composer/internal/config/manifest" +) + +func inspectSBOMFromImageRaw(img io.ReaderAt, pt PartitionTableSummary) SBOMSummary { + summary := SBOMSummary{Format: "spdx"} + rootCandidates := rankRootPartitionCandidates(pt) + if len(rootCandidates) == 0 { + summary.Notes = append(summary.Notes, "SBOM root partition candidate not found") + return summary + } + + for _, candidateIndex := range rootCandidates { + partitionSummary := pt.Partitions[candidateIndex] + fsType := "" + if partitionSummary.Filesystem != nil { + fsType = strings.ToLower(strings.TrimSpace(partitionSummary.Filesystem.Type)) + } + + partOff := partitionStartOffset(pt, partitionSummary) + var ( + sbomData []byte + sbomFileName string + sbomPath string + err error + ) + + sbomData, sbomFileName, sbomPath, err = readSBOMFromRawPartition(img, partOff, partitionSummary.SizeBytes, fsType) + + if err != nil { + summary.Notes = append(summary.Notes, + fmt.Sprintf("failed to read SBOM from partition %d (%s): %v", + partitionSummary.Index, partitionSummary.Name, err)) + continue + } + + summary.Present = true + summary.Path = sbomPath + summary.FileName = sbomFileName + summary.SizeBytes = int64(len(sbomData)) + summary.SHA256 = sha256Hex(sbomData) + summary.Content = append([]byte(nil), sbomData...) + + canonicalSHA, pkgCount, canonicalErr := canonicalSPDXSHA256(sbomData) + if canonicalErr != nil { + summary.Notes = append(summary.Notes, "SBOM SPDX parse failed; compare falls back to raw hash") + return summary + } + + summary.CanonicalSHA256 = canonicalSHA + summary.PackageCount = pkgCount + return summary + } + + summary.Notes = append(summary.Notes, "SBOM not found at /usr/share/sbom") + return summary +} + +func inspectSBOMFromImageFilesystem(disk diskAccessorFS, pt PartitionTableSummary) SBOMSummary { + summary := SBOMSummary{Format: "spdx"} + rootCandidates := rankRootPartitionCandidates(pt) + if len(rootCandidates) == 0 { + summary.Notes = append(summary.Notes, "SBOM root partition candidate not found") + return summary + } + + dirCandidates := []string{manifest.ImageSBOMPath, strings.TrimPrefix(manifest.ImageSBOMPath, "/")} + + for _, candidateIndex := range rootCandidates { + partitionSummary := pt.Partitions[candidateIndex] + partitionNumber, ok := diskfsPartitionNumberForSummary(disk, partitionSummary) + if !ok { + continue + } + + filesystemHandle, err := disk.GetFilesystem(partitionNumber) + if err != nil || filesystemHandle == nil { + continue + } + + for _, sbomDir := range dirCandidates { + sbomDir = strings.TrimSpace(sbomDir) + if sbomDir == "" { + continue + } + + sbomDirEntries, readDirErr := filesystemHandle.ReadDir(sbomDir) + if readDirErr != nil { + continue + } + + sbomFileName, found := pickSBOMFileNameFromFS(sbomDirEntries) + if !found { + summary.Notes = append(summary.Notes, "SBOM directory present but no SPDX JSON file found") + continue + } + + fileCandidates := []string{ + path.Join(sbomDir, sbomFileName), + path.Join(strings.TrimPrefix(sbomDir, "/"), sbomFileName), + } + + for _, sbomFilePath := range fileCandidates { + sbomFile, openErr := filesystemHandle.OpenFile(sbomFilePath, os.O_RDONLY) + if openErr != nil { + continue + } + + sbomData, readErr := io.ReadAll(sbomFile) + if closeErr := sbomFile.Close(); closeErr != nil { + summary.Notes = append(summary.Notes, fmt.Sprintf("failed to close SBOM file %s", sbomFilePath)) + } + if readErr != nil { + summary.Notes = append(summary.Notes, fmt.Sprintf("failed to read SBOM file %s", sbomFilePath)) + continue + } + + summary.Present = true + summary.Path = path.Join(manifest.ImageSBOMPath, sbomFileName) + summary.FileName = sbomFileName + summary.SizeBytes = int64(len(sbomData)) + summary.SHA256 = sha256Hex(sbomData) + summary.Content = append([]byte(nil), sbomData...) + + canonicalSHA, pkgCount, canonicalErr := canonicalSPDXSHA256(sbomData) + if canonicalErr != nil { + summary.Notes = append(summary.Notes, "SBOM SPDX parse failed; compare falls back to raw hash") + return summary + } + + summary.CanonicalSHA256 = canonicalSHA + summary.PackageCount = pkgCount + return summary + } + } + } + + summary.Notes = append(summary.Notes, "SBOM not found at /usr/share/sbom") + return summary +} + +func partitionStartOffset(pt PartitionTableSummary, partitionSummary PartitionSummary) int64 { + logicalSectorSize := pt.LogicalSectorSize + if partitionSummary.LogicalSectorSize > 0 { + logicalSectorSize = int64(partitionSummary.LogicalSectorSize) + } + + if logicalSectorSize <= 0 { + logicalSectorSize = 512 + } + + return int64(partitionSummary.StartLBA) * logicalSectorSize +} + +func pickSBOMFileNameFromFAT(entries []fatDirEntry) (string, bool) { + var preferred []string + var fallback []string + + for _, entry := range entries { + if entry.isDir { + continue + } + name := strings.TrimSpace(entry.name) + if name == "" { + continue + } + lowerName := strings.ToLower(name) + if !strings.HasSuffix(lowerName, ".json") { + continue + } + + if strings.HasPrefix(lowerName, "spdx_manifest") { + preferred = append(preferred, name) + continue + } + fallback = append(fallback, name) + } + + if len(preferred) > 0 { + sort.Strings(preferred) + return preferred[0], true + } + if len(fallback) > 0 { + sort.Strings(fallback) + return fallback[0], true + } + + return "", false +} + +func pickSBOMFileNameFromFS(entries []os.FileInfo) (string, bool) { + fileNames := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry == nil || entry.IsDir() { + continue + } + fileNames = append(fileNames, entry.Name()) + } + + return pickSBOMFileNameFromNames(fileNames) +} + +func pickSBOMFileNameFromNames(fileNames []string) (string, bool) { + var preferred []string + var fallback []string + + for _, fileName := range fileNames { + name := strings.TrimSpace(fileName) + if name == "" { + continue + } + + lowerName := strings.ToLower(name) + if !strings.HasSuffix(lowerName, ".json") { + continue + } + + if strings.HasPrefix(lowerName, "spdx_manifest") { + preferred = append(preferred, name) + continue + } + fallback = append(fallback, name) + } + + if len(preferred) > 0 { + sort.Strings(preferred) + return preferred[0], true + } + if len(fallback) > 0 { + sort.Strings(fallback) + return fallback[0], true + } + + return "", false +} + +func rankRootPartitionCandidates(pt PartitionTableSummary) []int { + indexes := make([]int, 0, len(pt.Partitions)) + for idx := range pt.Partitions { + indexes = append(indexes, idx) + } + + score := func(partitionSummary PartitionSummary) int { + name := strings.ToLower(strings.TrimSpace(partitionSummary.Name)) + fsType := "" + if partitionSummary.Filesystem != nil { + fsType = strings.ToLower(strings.TrimSpace(partitionSummary.Filesystem.Type)) + } + + total := 0 + if strings.Contains(name, "root") || strings.Contains(name, "rootfs") { + total += 200 + } + if strings.Contains(name, "system") { + total += 120 + } + if fsType == "ext4" || fsType == "ext3" || fsType == "ext2" || fsType == "xfs" || fsType == "btrfs" { + total += 100 + } + if fsType == "squashfs" { + total += 80 + } + if isVFATLike(fsType) { + total += 20 + } + + return total + } + + sort.Slice(indexes, func(i, j int) bool { + left := pt.Partitions[indexes[i]] + right := pt.Partitions[indexes[j]] + + leftScore := score(left) + rightScore := score(right) + if leftScore != rightScore { + return leftScore > rightScore + } + + if left.SizeBytes != right.SizeBytes { + return left.SizeBytes > right.SizeBytes + } + + return left.Index < right.Index + }) + + return indexes +} + +type spdxComparableDoc struct { + Packages []spdxComparablePackage `json:"packages"` +} + +type spdxComparablePackage struct { + Name string `json:"name,omitempty"` + VersionInfo string `json:"versionInfo,omitempty"` + DownloadLocation string `json:"downloadLocation,omitempty"` + Supplier string `json:"supplier,omitempty"` + LicenseDeclared string `json:"licenseDeclared,omitempty"` + LicenseConcluded string `json:"licenseConcluded,omitempty"` + Checksum []spdxComparableDigest `json:"checksum,omitempty"` +} + +type spdxComparableDigest struct { + Algorithm string `json:"algorithm"` + ChecksumValue string `json:"checksumValue"` +} + +func canonicalSPDXSHA256(spdxData []byte) (string, int, error) { + _, canonicalHash, pkgCount, err := parseAndCanonicalizeSPDX(spdxData) + if err != nil { + return "", 0, fmt.Errorf("failed to parse SPDX JSON: %w", err) + } + + return canonicalHash, pkgCount, nil +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +// SPDXCompareResult is the result of comparing two SPDX manifest files. +type SPDXCompareResult struct { + FromPath string `json:"fromPath" yaml:"fromPath"` + ToPath string `json:"toPath" yaml:"toPath"` + Equal bool `json:"equal" yaml:"equal"` + FromSHA256 string `json:"fromSha256,omitempty" yaml:"fromSha256,omitempty"` + ToSHA256 string `json:"toSha256,omitempty" yaml:"toSha256,omitempty"` + FromCanonicalHash string `json:"fromCanonicalSha256,omitempty" yaml:"fromCanonicalSha256,omitempty"` + ToCanonicalHash string `json:"toCanonicalSha256,omitempty" yaml:"toCanonicalSha256,omitempty"` + FromPackageCount int `json:"fromPackageCount,omitempty" yaml:"fromPackageCount,omitempty"` + ToPackageCount int `json:"toPackageCount,omitempty" yaml:"toPackageCount,omitempty"` + AddedPackages []string `json:"addedPackages,omitempty" yaml:"addedPackages,omitempty"` + RemovedPackages []string `json:"removedPackages,omitempty" yaml:"removedPackages,omitempty"` +} + +// CompareSPDXFiles compares two SPDX JSON files using canonicalized package content. +func CompareSPDXFiles(fromPath, toPath string) (*SPDXCompareResult, error) { + fromData, err := os.ReadFile(fromPath) + if err != nil { + return nil, fmt.Errorf("read from SPDX file: %w", err) + } + + toData, err := os.ReadFile(toPath) + if err != nil { + return nil, fmt.Errorf("read to SPDX file: %w", err) + } + + fromDoc, fromCanonicalHash, fromCount, err := parseAndCanonicalizeSPDX(fromData) + if err != nil { + return nil, fmt.Errorf("parse from SPDX file: %w", err) + } + + toDoc, toCanonicalHash, toCount, err := parseAndCanonicalizeSPDX(toData) + if err != nil { + return nil, fmt.Errorf("parse to SPDX file: %w", err) + } + + added, removed := diffSPDXPackages(fromDoc.Packages, toDoc.Packages) + + result := &SPDXCompareResult{ + FromPath: fromPath, + ToPath: toPath, + FromSHA256: sha256Hex(fromData), + ToSHA256: sha256Hex(toData), + FromCanonicalHash: fromCanonicalHash, + ToCanonicalHash: toCanonicalHash, + FromPackageCount: fromCount, + ToPackageCount: toCount, + AddedPackages: added, + RemovedPackages: removed, + } + + result.Equal = result.FromCanonicalHash == result.ToCanonicalHash && len(added) == 0 && len(removed) == 0 + + return result, nil +} + +func parseAndCanonicalizeSPDX(spdxData []byte) (spdxComparableDoc, string, int, error) { + var spdxDoc spdxComparableDoc + if err := json.Unmarshal(spdxData, &spdxDoc); err != nil { + return spdxComparableDoc{}, "", 0, err + } + + for packageIndex := range spdxDoc.Packages { + sort.Slice(spdxDoc.Packages[packageIndex].Checksum, func(i, j int) bool { + left := spdxDoc.Packages[packageIndex].Checksum[i] + right := spdxDoc.Packages[packageIndex].Checksum[j] + if left.Algorithm == right.Algorithm { + return left.ChecksumValue < right.ChecksumValue + } + return left.Algorithm < right.Algorithm + }) + } + + sort.Slice(spdxDoc.Packages, func(i, j int) bool { + left := spdxDoc.Packages[i] + right := spdxDoc.Packages[j] + + if left.Name != right.Name { + return left.Name < right.Name + } + if left.VersionInfo != right.VersionInfo { + return left.VersionInfo < right.VersionInfo + } + if left.DownloadLocation != right.DownloadLocation { + return left.DownloadLocation < right.DownloadLocation + } + return left.Supplier < right.Supplier + }) + + canonicalJSON, err := json.Marshal(spdxDoc) + if err != nil { + return spdxComparableDoc{}, "", 0, err + } + + return spdxDoc, sha256Hex(canonicalJSON), len(spdxDoc.Packages), nil +} + +func diffSPDXPackages(fromPkgs, toPkgs []spdxComparablePackage) ([]string, []string) { + fromSet := make(map[string]struct{}, len(fromPkgs)) + toSet := make(map[string]struct{}, len(toPkgs)) + + for _, pkg := range fromPkgs { + fromSet[spdxPackageKey(pkg)] = struct{}{} + } + + for _, pkg := range toPkgs { + toSet[spdxPackageKey(pkg)] = struct{}{} + } + + var added []string + var removed []string + + for key := range toSet { + if _, exists := fromSet[key]; !exists { + added = append(added, key) + } + } + + for key := range fromSet { + if _, exists := toSet[key]; !exists { + removed = append(removed, key) + } + } + + sort.Strings(added) + sort.Strings(removed) + + return added, removed +} + +func spdxPackageKey(pkg spdxComparablePackage) string { + return strings.Join([]string{pkg.Name, pkg.VersionInfo, pkg.DownloadLocation}, "|") +} diff --git a/internal/image/imageinspect/sbom_test.go b/internal/image/imageinspect/sbom_test.go new file mode 100644 index 00000000..8d70bc68 --- /dev/null +++ b/internal/image/imageinspect/sbom_test.go @@ -0,0 +1,644 @@ +package imageinspect + +import ( + "bytes" + "encoding/binary" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCanonicalSPDXSHA256_StableAcrossOrder(t *testing.T) { + first := []byte(`{ + "packages": [ + { + "name": "zlib", + "versionInfo": "1.2.13", + "downloadLocation": "https://example.com/zlib.rpm", + "checksum": [ + {"algorithm": "SHA1", "checksumValue": "bbb"}, + {"algorithm": "SHA256", "checksumValue": "aaa"} + ] + }, + { + "name": "acl", + "versionInfo": "2.3.1", + "downloadLocation": "https://example.com/acl.rpm", + "checksum": [ + {"algorithm": "SHA256", "checksumValue": "ccc"} + ] + } + ] +}`) + + second := []byte(`{ + "packages": [ + { + "name": "acl", + "versionInfo": "2.3.1", + "downloadLocation": "https://example.com/acl.rpm", + "checksum": [ + {"algorithm": "SHA256", "checksumValue": "ccc"} + ] + }, + { + "name": "zlib", + "versionInfo": "1.2.13", + "downloadLocation": "https://example.com/zlib.rpm", + "checksum": [ + {"algorithm": "SHA256", "checksumValue": "aaa"}, + {"algorithm": "SHA1", "checksumValue": "bbb"} + ] + } + ] +}`) + + h1, n1, err := canonicalSPDXSHA256(first) + if err != nil { + t.Fatalf("canonicalSPDXSHA256(first) error: %v", err) + } + h2, n2, err := canonicalSPDXSHA256(second) + if err != nil { + t.Fatalf("canonicalSPDXSHA256(second) error: %v", err) + } + + if h1 != h2 { + t.Fatalf("expected canonical hash to be stable across order, got %q != %q", h1, h2) + } + if n1 != 2 || n2 != 2 { + t.Fatalf("expected package count 2, got %d and %d", n1, n2) + } +} + +func TestCanonicalSPDXSHA256_DetectsContentChange(t *testing.T) { + base := []byte(`{"packages":[{"name":"acl","versionInfo":"2.3.1"}]}`) + changed := []byte(`{"packages":[{"name":"acl","versionInfo":"2.3.2"}]}`) + + h1, _, err := canonicalSPDXSHA256(base) + if err != nil { + t.Fatalf("canonicalSPDXSHA256(base) error: %v", err) + } + h2, _, err := canonicalSPDXSHA256(changed) + if err != nil { + t.Fatalf("canonicalSPDXSHA256(changed) error: %v", err) + } + + if h1 == h2 { + t.Fatalf("expected canonical hash to differ after content change") + } +} + +func TestCompareImages_SBOMAdded(t *testing.T) { + from := &ImageSummary{} + to := &ImageSummary{ + SBOM: SBOMSummary{ + Present: true, + FileName: "spdx_manifest_deb_demo_20260101_000000.json", + Format: "spdx", + CanonicalSHA256: "abc", + }, + } + + res := CompareImages(from, to) + if !res.Summary.SBOMChanged { + t.Fatalf("expected Summary.SBOMChanged=true") + } + if res.Diff.SBOM == nil || !res.Diff.SBOM.Changed || res.Diff.SBOM.Added == nil { + t.Fatalf("expected SBOM added diff, got %+v", res.Diff.SBOM) + } +} + +func TestCompareImages_SBOMCanonicalChanged(t *testing.T) { + from := &ImageSummary{ + SBOM: SBOMSummary{ + Present: true, + FileName: "spdx_manifest_rpm_demo_20260101_000000.json", + Format: "spdx", + CanonicalSHA256: "aaa", + PackageCount: 100, + }, + } + to := &ImageSummary{ + SBOM: SBOMSummary{ + Present: true, + FileName: "spdx_manifest_rpm_demo_20260102_000000.json", + Format: "spdx", + CanonicalSHA256: "bbb", + PackageCount: 101, + }, + } + + res := CompareImages(from, to) + if res.Diff.SBOM == nil || !res.Diff.SBOM.Changed { + t.Fatalf("expected SBOM diff, got %+v", res.Diff.SBOM) + } + if res.Diff.SBOM.CanonicalSHA256 == nil { + t.Fatalf("expected canonical SBOM hash diff") + } + if res.Diff.SBOM.PackageCount == nil { + t.Fatalf("expected SBOM package count diff") + } + if res.Equality.Class != EqualityDifferent { + t.Fatalf("expected EqualityDifferent, got %s", res.Equality.Class) + } +} + +func TestCompareSPDXFiles(t *testing.T) { + tmpDir := t.TempDir() + fromPath := filepath.Join(tmpDir, "from.spdx.json") + toPath := filepath.Join(tmpDir, "to.spdx.json") + + fromContent := `{"packages":[{"name":"acl","versionInfo":"2.3.1","downloadLocation":"https://example.com/acl.rpm"}]}` + toContent := `{"packages":[{"name":"acl","versionInfo":"2.3.2","downloadLocation":"https://example.com/acl.rpm"}]}` + + if err := os.WriteFile(fromPath, []byte(fromContent), 0644); err != nil { + t.Fatalf("write from SPDX file: %v", err) + } + if err := os.WriteFile(toPath, []byte(toContent), 0644); err != nil { + t.Fatalf("write to SPDX file: %v", err) + } + + result, err := CompareSPDXFiles(fromPath, toPath) + if err != nil { + t.Fatalf("CompareSPDXFiles error: %v", err) + } + + if result.Equal { + t.Fatalf("expected SPDX files to differ") + } + if result.FromPackageCount != 1 || result.ToPackageCount != 1 { + t.Fatalf("expected package counts 1 and 1, got %d and %d", result.FromPackageCount, result.ToPackageCount) + } + if len(result.AddedPackages) != 1 || len(result.RemovedPackages) != 1 { + t.Fatalf("expected one added and one removed package key, got added=%d removed=%d", + len(result.AddedPackages), len(result.RemovedPackages)) + } +} + +func TestPartitionStartOffset_DefaultSectorSize(t *testing.T) { + pt := PartitionTableSummary{LogicalSectorSize: 512} + part := PartitionSummary{StartLBA: 2048} + + off := partitionStartOffset(pt, part) + if off != 2048*512 { + t.Fatalf("unexpected offset: got %d, want %d", off, 2048*512) + } +} + +func TestPartitionStartOffset_PartitionSectorSizeOverride(t *testing.T) { + pt := PartitionTableSummary{LogicalSectorSize: 512} + part := PartitionSummary{StartLBA: 100, LogicalSectorSize: 4096} + + off := partitionStartOffset(pt, part) + if off != 100*4096 { + t.Fatalf("unexpected offset: got %d, want %d", off, 100*4096) + } +} + +func TestPartitionStartOffset_FallbackTo512(t *testing.T) { + pt := PartitionTableSummary{LogicalSectorSize: 0} + part := PartitionSummary{StartLBA: 33} + + off := partitionStartOffset(pt, part) + if off != 33*512 { + t.Fatalf("unexpected offset: got %d, want %d", off, 33*512) + } +} + +func TestPickSBOMFileNameFromFAT_PrefersSPDXManifest(t *testing.T) { + entries := []fatDirEntry{ + {name: "notes.txt"}, + {name: "a.json"}, + {name: "spdx_manifest_demo_20260101_000000.json"}, + {name: "dir", isDir: true}, + } + + got, ok := pickSBOMFileNameFromFAT(entries) + if !ok { + t.Fatalf("expected to find sbom file") + } + if got != "spdx_manifest_demo_20260101_000000.json" { + t.Fatalf("unexpected file: got %q", got) + } +} + +func TestPickSBOMFileNameFromNames_FallbackAndSorted(t *testing.T) { + names := []string{"z.json", "b.json", "README.md"} + + got, ok := pickSBOMFileNameFromNames(names) + if !ok { + t.Fatalf("expected fallback json file") + } + if got != "b.json" { + t.Fatalf("unexpected fallback file: got %q, want %q", got, "b.json") + } +} + +func TestPickSBOMFileNameFromNames_NoJSON(t *testing.T) { + names := []string{"README.md", "manifest.txt"} + + _, ok := pickSBOMFileNameFromNames(names) + if ok { + t.Fatalf("expected no file match") + } +} + +func TestPickSBOMFileNameFromFS_PrefersSPDXManifest(t *testing.T) { + tmp := t.TempDir() + spdxFile := filepath.Join(tmp, "spdx_manifest_pkg.json") + fallbackFile := filepath.Join(tmp, "a.json") + dirPath := filepath.Join(tmp, "subdir") + + if err := os.WriteFile(spdxFile, []byte("{}"), 0644); err != nil { + t.Fatalf("write spdx file: %v", err) + } + if err := os.WriteFile(fallbackFile, []byte("{}"), 0644); err != nil { + t.Fatalf("write fallback file: %v", err) + } + if err := os.Mkdir(dirPath, 0755); err != nil { + t.Fatalf("create subdir: %v", err) + } + + spdxInfo, err := os.Stat(spdxFile) + if err != nil { + t.Fatalf("stat spdx file: %v", err) + } + fallbackInfo, err := os.Stat(fallbackFile) + if err != nil { + t.Fatalf("stat fallback file: %v", err) + } + dirInfo, err := os.Stat(dirPath) + if err != nil { + t.Fatalf("stat subdir: %v", err) + } + + got, ok := pickSBOMFileNameFromFS([]os.FileInfo{dirInfo, fallbackInfo, spdxInfo}) + if !ok { + t.Fatalf("expected sbom file from fs") + } + if got != "spdx_manifest_pkg.json" { + t.Fatalf("unexpected file: got %q", got) + } +} + +func TestRankRootPartitionCandidates_PrefersRootAndExt4(t *testing.T) { + pt := PartitionTableSummary{ + Partitions: []PartitionSummary{ + { + Index: 1, Name: "efi", SizeBytes: 100, + Filesystem: &FilesystemSummary{Type: "vfat"}, + }, + { + Index: 2, Name: "rootfs", SizeBytes: 1000, + Filesystem: &FilesystemSummary{Type: "ext4"}, + }, + { + Index: 3, Name: "data", SizeBytes: 2000, + Filesystem: &FilesystemSummary{Type: "xfs"}, + }, + }, + } + + got := rankRootPartitionCandidates(pt) + if len(got) != 3 { + t.Fatalf("unexpected candidate count: got %d", len(got)) + } + if got[0] != 1 { + t.Fatalf("expected rootfs partition index position 1 to rank first, got %d", got[0]) + } +} + +func TestRankRootPartitionCandidates_TieBreakBySizeThenIndex(t *testing.T) { + pt := PartitionTableSummary{ + Partitions: []PartitionSummary{ + {Index: 5, Name: "system", SizeBytes: 100, Filesystem: &FilesystemSummary{Type: "ext4"}}, + {Index: 2, Name: "system", SizeBytes: 200, Filesystem: &FilesystemSummary{Type: "ext4"}}, + {Index: 1, Name: "system", SizeBytes: 200, Filesystem: &FilesystemSummary{Type: "ext4"}}, + }, + } + + got := rankRootPartitionCandidates(pt) + if len(got) != 3 { + t.Fatalf("unexpected candidate count: got %d", len(got)) + } + + if got[0] != 2 || got[1] != 1 || got[2] != 0 { + t.Fatalf("unexpected ordering: got %v, want [2 1 0]", got) + } +} + +func TestCollectJSONFilesFromDir_RecursiveAndSorted(t *testing.T) { + tmp := t.TempDir() + nested := filepath.Join(tmp, "nested") + if err := os.Mkdir(nested, 0755); err != nil { + t.Fatalf("create nested dir: %v", err) + } + + jsonA := filepath.Join(tmp, "b.json") + jsonB := filepath.Join(nested, "a.json") + txt := filepath.Join(tmp, "ignore.txt") + + for _, filePath := range []string{jsonA, jsonB, txt} { + if err := os.WriteFile(filePath, []byte("x"), 0644); err != nil { + t.Fatalf("write test file %s: %v", filePath, err) + } + } + + got := collectJSONFilesFromDir(tmp) + if len(got) != 2 { + t.Fatalf("unexpected json file count: got %d, want %d", len(got), 2) + } + seen := map[string]bool{} + for _, filePath := range got { + seen[filepath.Base(filePath)] = true + } + if !seen["a.json"] || !seen["b.json"] { + t.Fatalf("unexpected json files: %v", got) + } +} + +func TestFindSBOMFileInRawPartition_UnsupportedFilesystem(t *testing.T) { + _, _, err := findSBOMFileInRawPartition(bytes.NewReader(nil), 0, 0, "ntfs") + if err == nil { + t.Fatalf("expected error for unsupported filesystem") + } + if !strings.Contains(err.Error(), "not supported for raw SPDX extraction yet") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFindSBOMFileInRawPartition_VFATOpenError(t *testing.T) { + img := bytes.NewReader(make([]byte, 512)) // invalid FAT boot sector signature + + _, _, err := findSBOMFileInRawPartition(img, 0, 512, "vfat") + if err == nil { + t.Fatalf("expected FAT open error") + } + if !strings.Contains(err.Error(), "open FAT volume") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestReadFileFromRawPartition_UnsupportedFilesystem(t *testing.T) { + _, err := readFileFromRawPartition(bytes.NewReader(nil), 0, 0, "unknownfs", "/usr/share/sbom/file.json") + if err == nil { + t.Fatalf("expected error for unsupported filesystem") + } + if !strings.Contains(err.Error(), "not supported for raw file reads") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestReadFileFromRawPartition_VFATOpenError(t *testing.T) { + img := bytes.NewReader(make([]byte, 512)) // invalid FAT boot sector signature + + _, err := readFileFromRawPartition(img, 0, 512, "vfat", "/usr/share/sbom/spdx_manifest.json") + if err == nil { + t.Fatalf("expected FAT open error") + } + if !strings.Contains(err.Error(), "open FAT volume") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestReadSBOMFromRawPartition_UnsupportedFilesystem(t *testing.T) { + _, _, _, err := readSBOMFromRawPartition(bytes.NewReader(nil), 0, 0, "squashfs") + if err == nil { + t.Fatalf("expected error for unsupported filesystem") + } + if !strings.Contains(err.Error(), "not supported for raw SPDX extraction yet") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDecode83Name_NoExtension(t *testing.T) { + got := decode83Name([]byte("KERNEL " + " ")) + if got != "KERNEL" { + t.Fatalf("unexpected 8.3 decode: got %q", got) + } +} + +func TestIsEOC_Branches(t *testing.T) { + v32 := &fatVol{kind: fat32} + if v32.isEOC(0x0FFFFFF7) { + t.Fatalf("did not expect EOC for FAT32 pre-threshold") + } + if !v32.isEOC(0x0FFFFFF8) { + t.Fatalf("expected EOC for FAT32 threshold") + } + + v16 := &fatVol{kind: fat16} + if v16.isEOC(0xFFF7) { + t.Fatalf("did not expect EOC for FAT16 pre-threshold") + } + if !v16.isEOC(0xFFF8) { + t.Fatalf("expected EOC for FAT16 threshold") + } +} + +func TestClusterOff_Branches(t *testing.T) { + v := &fatVol{dataStart: 4096, clusterSize: 1024} + if got := v.clusterOff(0); got != 4096 { + t.Fatalf("unexpected offset for cluster <2: got %d", got) + } + if got := v.clusterOff(2); got != 4096 { + t.Fatalf("unexpected offset for cluster 2: got %d", got) + } + if got := v.clusterOff(5); got != 4096+3*1024 { + t.Fatalf("unexpected offset for cluster 5: got %d", got) + } +} + +func TestFatEntry_FAT16AndFAT32(t *testing.T) { + buf := make([]byte, 128) + + // FAT16 entry for cluster 3 at offset 6 + binary.LittleEndian.PutUint16(buf[6:8], 0x1234) + v16 := &fatVol{kind: fat16, fatStart: 0, r: bytes.NewReader(buf)} + entry16, err := v16.fatEntry(3) + if err != nil { + t.Fatalf("fat16 entry error: %v", err) + } + if entry16 != 0x1234 { + t.Fatalf("unexpected fat16 entry: got 0x%x", entry16) + } + + // FAT32 entry for cluster 4 at offset 16; upper 4 bits must be masked + binary.LittleEndian.PutUint32(buf[16:20], 0xF2345678) + v32 := &fatVol{kind: fat32, fatStart: 0, r: bytes.NewReader(buf)} + entry32, err := v32.fatEntry(4) + if err != nil { + t.Fatalf("fat32 entry error: %v", err) + } + if entry32 != 0x02345678 { + t.Fatalf("unexpected fat32 masked entry: got 0x%x", entry32) + } +} + +func TestParseDirEntries_SkipsDeletedVolumeAndDots(t *testing.T) { + buf := make([]byte, 32*5) + + // deleted entry + buf[0] = 0xE5 + + // volume label entry (should be skipped) + copy(buf[32:43], []byte("VOLLABEL ")) + buf[32+11] = 0x08 + + // dot entry (should be skipped) + copy(buf[64:75], []byte(". ")) + buf[64+11] = 0x10 + + // valid file entry + copy(buf[96:107], []byte("README TXT")) + buf[96+11] = 0x20 + binary.LittleEndian.PutUint16(buf[96+26:96+28], 7) + binary.LittleEndian.PutUint32(buf[96+28:96+32], 42) + + // end marker + buf[128] = 0x00 + + entries, err := parseDirEntries(buf) + if err != nil { + t.Fatalf("parseDirEntries error: %v", err) + } + if len(entries) != 1 { + t.Fatalf("unexpected entry count: got %d", len(entries)) + } + if entries[0].name != "README.TXT" { + t.Fatalf("unexpected entry name: %q", entries[0].name) + } + if entries[0].size != 42 { + t.Fatalf("unexpected entry size: %d", entries[0].size) + } +} + +func TestReadRootDir_FAT16(t *testing.T) { + buf := make([]byte, 512) + copy(buf[0:11], []byte("CONFIG CFG")) + buf[11] = 0x20 + binary.LittleEndian.PutUint16(buf[26:28], 3) + binary.LittleEndian.PutUint32(buf[28:32], 11) + + v := &fatVol{ + r: bytes.NewReader(buf), + kind: fat16, + bytsPerSec: 512, + rootDirStart: 0, + rootDirSectors: 1, + } + + entries, err := v.readRootDir() + if err != nil { + t.Fatalf("readRootDir error: %v", err) + } + if len(entries) != 1 || entries[0].name != "CONFIG.CFG" { + t.Fatalf("unexpected root dir entries: %+v", entries) + } +} + +func TestReadDirFromCluster_DetectsLoop(t *testing.T) { + buf := make([]byte, 256) + // FAT16 cluster 2 -> 2 (self-loop) at fat offset 4 + binary.LittleEndian.PutUint16(buf[4:6], 2) + + v := &fatVol{ + r: bytes.NewReader(buf), + kind: fat16, + fatStart: 0, + dataStart: 128, + clusterSize: 32, + } + + _, err := v.readDirFromCluster(2) + if err == nil { + t.Fatalf("expected FAT loop detection error") + } + if !strings.Contains(err.Error(), "FAT loop detected") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInspectSBOMFromImageRaw_NoRootCandidate(t *testing.T) { + summary := inspectSBOMFromImageRaw(bytes.NewReader(nil), PartitionTableSummary{}) + if summary.Present { + t.Fatalf("expected no SBOM present") + } + if len(summary.Notes) == 0 || !strings.Contains(summary.Notes[0], "root partition candidate not found") { + t.Fatalf("unexpected notes: %v", summary.Notes) + } +} + +func TestInspectSBOMFromImageRaw_UnsupportedFilesystem(t *testing.T) { + pt := PartitionTableSummary{ + LogicalSectorSize: 512, + Partitions: []PartitionSummary{ + { + Index: 1, + Name: "rootfs", + StartLBA: 2048, + SizeBytes: 4096, + Filesystem: &FilesystemSummary{ + Type: "unknownfs", + }, + }, + }, + } + + summary := inspectSBOMFromImageRaw(bytes.NewReader(nil), pt) + if summary.Present { + t.Fatalf("expected no SBOM present") + } + if len(summary.Notes) < 2 { + t.Fatalf("expected at least two notes, got %v", summary.Notes) + } + if !strings.Contains(summary.Notes[0], "failed to read SBOM from partition") { + t.Fatalf("unexpected first note: %q", summary.Notes[0]) + } + if !strings.Contains(summary.Notes[len(summary.Notes)-1], "SBOM not found") { + t.Fatalf("unexpected final note: %q", summary.Notes[len(summary.Notes)-1]) + } +} + +func TestCompareSPDXFiles_Equal(t *testing.T) { + tmpDir := t.TempDir() + fromPath := filepath.Join(tmpDir, "from.spdx.json") + toPath := filepath.Join(tmpDir, "to.spdx.json") + + content := `{"packages":[{"name":"acl","versionInfo":"2.3.1","downloadLocation":"https://example.com/acl.rpm"}]}` + if err := os.WriteFile(fromPath, []byte(content), 0644); err != nil { + t.Fatalf("write from SPDX file: %v", err) + } + if err := os.WriteFile(toPath, []byte(content), 0644); err != nil { + t.Fatalf("write to SPDX file: %v", err) + } + + result, err := CompareSPDXFiles(fromPath, toPath) + if err != nil { + t.Fatalf("CompareSPDXFiles error: %v", err) + } + if !result.Equal { + t.Fatalf("expected SPDX files to be equal") + } + if len(result.AddedPackages) != 0 || len(result.RemovedPackages) != 0 { + t.Fatalf("expected no package deltas, got added=%d removed=%d", len(result.AddedPackages), len(result.RemovedPackages)) + } +} + +func TestCompareSPDXFiles_FromReadError(t *testing.T) { + _, err := CompareSPDXFiles("/definitely/missing/from.json", "/definitely/missing/to.json") + if err == nil { + t.Fatalf("expected read error") + } + if !strings.Contains(err.Error(), "read from SPDX file") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCanonicalSPDXSHA256_InvalidJSON(t *testing.T) { + _, _, err := canonicalSPDXSHA256([]byte("not-json")) + if err == nil { + t.Fatalf("expected canonicalization error for invalid json") + } +} diff --git a/internal/utils/shell/shell.go b/internal/utils/shell/shell.go index e9bf0161..a0a579c5 100644 --- a/internal/utils/shell/shell.go +++ b/internal/utils/shell/shell.go @@ -42,6 +42,7 @@ var commandMap = map[string][]string{ "dd": {"/usr/bin/dd"}, "df": {"/usr/bin/df"}, "dirname": {"/usr/bin/dirname"}, + "debugfs": {"/usr/sbin/debugfs", "/usr/bin/debugfs"}, "dnf": {"/usr/bin/dnf"}, "dpkg": {"/usr/bin/dpkg"}, "dpkg-divert": {"/usr/bin/dpkg-divert"},