Skip to content
Merged
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 = "."
}

Comment thread
magerstam marked this conversation as resolved.
Outdated
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