Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .coverage-threshold
Original file line number Diff line number Diff line change
@@ -1 +1 @@
65.5
65.9
32 changes: 26 additions & 6 deletions cmd/os-image-composer/compare_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand All @@ -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
Expand All @@ -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" {
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)

Expand All @@ -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)
}
}

Expand Down
67 changes: 67 additions & 0 deletions cmd/os-image-composer/compare_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -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
Expand Down
75 changes: 74 additions & 1 deletion cmd/os-image-composer/inspect_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -64,20 +78,79 @@ 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
}

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()

Expand Down
Loading
Loading