From e151b6f8c95a26b6e138e9a51711eac727ee3fd0 Mon Sep 17 00:00:00 2001 From: Roboshyim Date: Thu, 23 Jul 2026 16:56:22 +0000 Subject: [PATCH 1/2] feat(project): add standalone project sbom command Expose the existing CycloneDX 1.7 SBOM generator used by project ci as shopware-cli project sbom so merchants can produce the artifact without running the full CI pipeline. - Reuse writeProjectSBOM for both project ci and project sbom - Keep CI skip-when-lock-missing behavior unchanged - Fail with a clear non-zero exit when lock is missing for project sbom - Flags: --format cyclonedx-json, --output/-o, --include-dev-dependencies - Tests + README usage example Closes shopware/shopware-cli#1239 --- README.md | 7 ++ cmd/project/ci.go | 65 ++-------- cmd/project/ci_test.go | 53 -------- cmd/project/project_sbom.go | 207 +++++++++++++++++++++++++++++++ cmd/project/project_sbom_test.go | 204 ++++++++++++++++++++++++++++++ 5 files changed, 425 insertions(+), 111 deletions(-) create mode 100644 cmd/project/project_sbom.go create mode 100644 cmd/project/project_sbom_test.go diff --git a/README.md b/README.md index 4685b52a..75169096 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,13 @@ shopware-cli extension --help shopware-cli project --help ``` +Generate a CycloneDX SBOM for a project without running the full CI build: + +```bash +shopware-cli project sbom +shopware-cli project sbom ./my-shop --format cyclonedx-json --output sbom.json +``` + If you need CI-friendly behavior, disable prompts: ```bash diff --git a/cmd/project/ci.go b/cmd/project/ci.go index ecdc3d53..01e5b7e1 100644 --- a/cmd/project/ci.go +++ b/cmd/project/ci.go @@ -10,8 +10,6 @@ import ( "path/filepath" "strings" - "github.com/shyim/go-composer" - "github.com/shyim/go-composer/sbom" "github.com/spf13/cobra" "github.com/shopware/shopware-cli/internal/ci" @@ -21,7 +19,6 @@ import ( "github.com/shopware/shopware-cli/internal/mjml" "github.com/shopware/shopware-cli/internal/shop" "github.com/shopware/shopware-cli/internal/system" - "github.com/shopware/shopware-cli/internal/tui" "github.com/shopware/shopware-cli/logging" ) @@ -363,66 +360,18 @@ func createEmptySnippetFolder(root string) error { return nil } -// generateProjectSBOM reads composer.lock from the project root and writes a -// CycloneDX SBOM JSON document to the configured path. When composer.lock is -// absent (for example when composer install was skipped on a project without -// PHP dependencies) the step is a no-op. +// generateProjectSBOM writes a CycloneDX SBOM for the CI pipeline. When +// composer.lock is absent (for example when composer install was skipped on a +// project without PHP dependencies) the step is a no-op so the rest of project +// ci can continue. Output path and format match the defaults of `project sbom`. func generateProjectSBOM(ctx context.Context, root string) error { section := ci.Default.Section(ctx, "Generating SBOM") defer section.End(ctx) - lockPath := path.Join(root, "composer.lock") - if _, err := os.Stat(lockPath); os.IsNotExist(err) { - logging.FromContext(ctx).Infof("Skipping SBOM generation: %s not found", lockPath) - return nil - } - - lock, err := composer.ReadLock(lockPath) - if err != nil { - return fmt.Errorf("read composer.lock: %w", err) - } - - projectComposer, err := composer.ReadJson(path.Join(root, "composer.json")) - appName := "shopware-project" - appVersion := "" - if err == nil && projectComposer != nil { - if projectComposer.Name != "" { - appName = projectComposer.Name - } - if projectComposer.Version != "" { - appVersion = projectComposer.Version - } - } - - bom, err := sbom.Generate(lock, sbom.Options{ - ApplicationName: appName, - ApplicationVersion: appVersion, - ToolGroup: "shopware", - ToolName: "shopware-cli", - ToolVersion: tui.AppVersion, - IncludeDevDependencies: false, + return writeProjectSBOM(ctx, root, projectSBOMOptions{ + // CI historically skips when lock is missing rather than failing the build. + SkipMissingLock: true, }) - if err != nil { - return err - } - - data, err := sbom.Marshal(bom) - if err != nil { - return fmt.Errorf("marshal SBOM: %w", err) - } - - outputPath := filepath.Join(root, "sbom.cdx.json") - - if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { - return fmt.Errorf("create SBOM output directory: %w", err) - } - - if err := os.WriteFile(outputPath, data, 0o644); err != nil { - return fmt.Errorf("write SBOM: %w", err) - } - - logging.FromContext(ctx).Infof("Wrote SBOM with %d components to %s", len(bom.Components), outputPath) - return nil } func prepareComposerAuth(ctx context.Context, root string) (string, error) { diff --git a/cmd/project/ci_test.go b/cmd/project/ci_test.go index b78382e3..a83f3033 100644 --- a/cmd/project/ci_test.go +++ b/cmd/project/ci_test.go @@ -1,7 +1,6 @@ package project import ( - "encoding/json" "os" "os/exec" "path/filepath" @@ -54,58 +53,6 @@ func TestRunTransparentCommandFallsBackToProcessEnv(t *testing.T) { assert.Contains(t, proc.Cmd.Env, "LOCK_DSN=flock") } -func TestGenerateProjectSBOM(t *testing.T) { - root := t.TempDir() - - assert.NoError(t, os.WriteFile(filepath.Join(root, "composer.json"), []byte(`{ - "name": "acme/shop", - "version": "1.2.3" - }`), 0o644)) - - assert.NoError(t, os.WriteFile(filepath.Join(root, "composer.lock"), []byte(`{ - "packages": [ - { - "name": "symfony/console", - "version": "v6.3.0", - "type": "library", - "license": ["MIT"], - "require": {"php": ">=8.1"} - } - ], - "packages-dev": [ - {"name": "phpunit/phpunit", "version": "10.0.0", "license": ["BSD-3-Clause"]} - ] - }`), 0o644)) - - assert.NoError(t, generateProjectSBOM(t.Context(), root)) - - data, err := os.ReadFile(filepath.Join(root, "sbom.cdx.json")) - assert.NoError(t, err) - - doc := map[string]interface{}{} - assert.NoError(t, json.Unmarshal(data, &doc)) - - assert.Equal(t, "CycloneDX", doc["bomFormat"]) - assert.Equal(t, "1.7", doc["specVersion"]) - - metadata := doc["metadata"].(map[string]interface{}) - component := metadata["component"].(map[string]interface{}) - assert.Equal(t, "acme/shop", component["name"]) - assert.Equal(t, "1.2.3", component["version"]) - - components := doc["components"].([]interface{}) - assert.Len(t, components, 1, "dev dependencies excluded by default") - assert.Equal(t, "console", components[0].(map[string]interface{})["name"]) -} - -func TestGenerateProjectSBOMSkipsWhenLockMissing(t *testing.T) { - root := t.TempDir() - assert.NoError(t, generateProjectSBOM(t.Context(), root)) - - _, err := os.Stat(filepath.Join(root, "sbom.cdx.json")) - assert.True(t, os.IsNotExist(err), "no SBOM should be written when composer.lock is absent") -} - func TestProjectCISafetyCheck(t *testing.T) { t.Run("allows dirty git working tree in CI", func(t *testing.T) { root := newDirtyGitRepository(t) diff --git a/cmd/project/project_sbom.go b/cmd/project/project_sbom.go new file mode 100644 index 00000000..2eece8d0 --- /dev/null +++ b/cmd/project/project_sbom.go @@ -0,0 +1,207 @@ +package project + +import ( + "context" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/shyim/go-composer" + "github.com/shyim/go-composer/sbom" + "github.com/spf13/cobra" + + "github.com/shopware/shopware-cli/internal/tui" + "github.com/shopware/shopware-cli/logging" +) + +const ( + // defaultProjectSBOMOutput is the filename written by both `project ci` and + // `project sbom` when --output is omitted. Keep them identical so tooling + // that already consumes the CI artifact keeps working. + defaultProjectSBOMOutput = "sbom.cdx.json" + + // projectSBOMFormatCycloneDXJSON is the only format currently supported. + // The flag exists so additional formats can be added without breaking the CLI. + projectSBOMFormatCycloneDXJSON = "cyclonedx-json" +) + +// projectSBOMOptions configures writeProjectSBOM. +type projectSBOMOptions struct { + // OutputPath is the destination file. Empty means + // filepath.Join(root, defaultProjectSBOMOutput). Relative paths are resolved + // against the process working directory (same as other CLI --output flags). + OutputPath string + + // SkipMissingLock, when true, returns nil without writing a file if + // composer.lock is absent. Used by `project ci` so a lock-less tree does not + // fail the build. The standalone command leaves this false so a missing lock + // is a hard error. + SkipMissingLock bool + + // IncludeDevDependencies controls whether packages-dev from composer.lock are + // included. Defaults to false to match `project ci`. + IncludeDevDependencies bool +} + +var projectSbomCmd = &cobra.Command{ + Use: "sbom [path]", + Short: "Generate a CycloneDX SBOM from composer.lock", + Long: `Generate a Software Bill of Materials (SBOM) for a Shopware project. + +Reads composer.lock (and optionally composer.json for the root component name +and version) and writes a CycloneDX 1.7 JSON document — the same artifact that +project ci produces, without running the rest of the CI build. + +Examples: + # Write sbom.cdx.json into the current Shopware project + shopware-cli project sbom + + # Explicit project path and output file + shopware-cli project sbom ./my-shop \ + --format cyclonedx-json \ + --output sbom.json + +The command is non-interactive and exits non-zero when generation fails +(missing or unreadable composer.lock, unsupported format, write errors).`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + root, err := resolveProjectSbomRoot(args) + if err != nil { + return err + } + + format, err := cmd.Flags().GetString("format") + if err != nil { + return err + } + format = strings.ToLower(strings.TrimSpace(format)) + if format != projectSBOMFormatCycloneDXJSON { + return fmt.Errorf("unsupported SBOM format %q (supported: %s)", format, projectSBOMFormatCycloneDXJSON) + } + + output, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + + includeDev, err := cmd.Flags().GetBool("include-dev-dependencies") + if err != nil { + return err + } + + return writeProjectSBOM(cmd.Context(), root, projectSBOMOptions{ + OutputPath: output, + SkipMissingLock: false, + IncludeDevDependencies: includeDev, + }) + }, +} + +func init() { + projectRootCmd.AddCommand(projectSbomCmd) + projectSbomCmd.Flags().String("format", projectSBOMFormatCycloneDXJSON, "SBOM format (only cyclonedx-json is supported)") + projectSbomCmd.Flags().StringP("output", "o", "", fmt.Sprintf("Output file path (default: %s in the project root)", defaultProjectSBOMOutput)) + projectSbomCmd.Flags().Bool("include-dev-dependencies", false, "Include packages-dev from composer.lock (excluded by default, matching project ci)") +} + +// resolveProjectSbomRoot picks the project directory: an explicit path argument, +// otherwise the closest Shopware project (composer.json/lock walk), falling back +// to the working directory when no Shopware markers are found further up. +func resolveProjectSbomRoot(args []string) (string, error) { + if len(args) == 1 { + return filepath.Abs(args[0]) + } + + root, err := findClosestShopwareProject() + if err == nil { + return root, nil + } + + // findClosestShopwareProject fails when no Shopware markers exist. Still + // allow generating an SBOM from a plain composer project in cwd so the + // command is useful outside full Shopware trees. + cwd, cwdErr := os.Getwd() + if cwdErr != nil { + return "", err + } + return cwd, nil +} + +// writeProjectSBOM reads composer.lock from root and writes a CycloneDX SBOM. +// Shared by `project ci` and `project sbom` so both emit the same document. +func writeProjectSBOM(ctx context.Context, root string, opts projectSBOMOptions) error { + lockPath := path.Join(root, "composer.lock") + if _, err := os.Stat(lockPath); os.IsNotExist(err) { + if opts.SkipMissingLock { + logging.FromContext(ctx).Infof("Skipping SBOM generation: %s not found", lockPath) + return nil + } + return fmt.Errorf("composer.lock not found at %s; run composer install or pass a project path that contains a lock file", lockPath) + } else if err != nil { + return fmt.Errorf("stat composer.lock: %w", err) + } + + lock, err := composer.ReadLock(lockPath) + if err != nil { + return fmt.Errorf("read composer.lock: %w", err) + } + + projectComposer, err := composer.ReadJson(path.Join(root, "composer.json")) + appName := "shopware-project" + appVersion := "" + if err == nil && projectComposer != nil { + if projectComposer.Name != "" { + appName = projectComposer.Name + } + if projectComposer.Version != "" { + appVersion = projectComposer.Version + } + } + + bom, err := sbom.Generate(lock, sbom.Options{ + ApplicationName: appName, + ApplicationVersion: appVersion, + ToolGroup: "shopware", + ToolName: "shopware-cli", + ToolVersion: tui.AppVersion, + IncludeDevDependencies: opts.IncludeDevDependencies, + }) + if err != nil { + return err + } + + data, err := sbom.Marshal(bom) + if err != nil { + return fmt.Errorf("marshal SBOM: %w", err) + } + + outputPath, err := resolveProjectSBOMOutputPath(root, opts.OutputPath) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return fmt.Errorf("create SBOM output directory: %w", err) + } + + if err := os.WriteFile(outputPath, data, 0o644); err != nil { + return fmt.Errorf("write SBOM: %w", err) + } + + logging.FromContext(ctx).Infof("Wrote SBOM with %d components to %s", len(bom.Components), outputPath) + return nil +} + +func resolveProjectSBOMOutputPath(root, output string) (string, error) { + if strings.TrimSpace(output) == "" { + return filepath.Join(root, defaultProjectSBOMOutput), nil + } + if filepath.IsAbs(output) { + return output, nil + } + // Relative --output is resolved against the process cwd, matching dump and + // other file-output commands. + return filepath.Abs(output) +} diff --git a/cmd/project/project_sbom_test.go b/cmd/project/project_sbom_test.go new file mode 100644 index 00000000..b2b8f2d3 --- /dev/null +++ b/cmd/project/project_sbom_test.go @@ -0,0 +1,204 @@ +package project + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeMinimalComposerProject(t *testing.T, root string) { + t.Helper() + + require.NoError(t, os.WriteFile(filepath.Join(root, "composer.json"), []byte(`{ + "name": "acme/shop", + "version": "1.2.3" + }`), 0o644)) + + require.NoError(t, os.WriteFile(filepath.Join(root, "composer.lock"), []byte(`{ + "packages": [ + { + "name": "symfony/console", + "version": "v6.3.0", + "type": "library", + "license": ["MIT"], + "require": {"php": ">=8.1"} + } + ], + "packages-dev": [ + {"name": "phpunit/phpunit", "version": "10.0.0", "license": ["BSD-3-Clause"]} + ] + }`), 0o644)) +} + +func TestWriteProjectSBOM(t *testing.T) { + root := t.TempDir() + writeMinimalComposerProject(t, root) + + require.NoError(t, writeProjectSBOM(t.Context(), root, projectSBOMOptions{})) + + data, err := os.ReadFile(filepath.Join(root, defaultProjectSBOMOutput)) + require.NoError(t, err) + + doc := map[string]interface{}{} + require.NoError(t, json.Unmarshal(data, &doc)) + + assert.Equal(t, "CycloneDX", doc["bomFormat"]) + assert.Equal(t, "1.7", doc["specVersion"]) + + metadata := doc["metadata"].(map[string]interface{}) + component := metadata["component"].(map[string]interface{}) + assert.Equal(t, "acme/shop", component["name"]) + assert.Equal(t, "1.2.3", component["version"]) + + components := doc["components"].([]interface{}) + assert.Len(t, components, 1, "dev dependencies excluded by default") + assert.Equal(t, "console", components[0].(map[string]interface{})["name"]) +} + +func TestWriteProjectSBOMIncludeDevDependencies(t *testing.T) { + root := t.TempDir() + writeMinimalComposerProject(t, root) + + require.NoError(t, writeProjectSBOM(t.Context(), root, projectSBOMOptions{ + IncludeDevDependencies: true, + })) + + data, err := os.ReadFile(filepath.Join(root, defaultProjectSBOMOutput)) + require.NoError(t, err) + + doc := map[string]interface{}{} + require.NoError(t, json.Unmarshal(data, &doc)) + + components := doc["components"].([]interface{}) + assert.Len(t, components, 2, "dev dependencies included when requested") +} + +func TestWriteProjectSBOMCustomOutputPath(t *testing.T) { + root := t.TempDir() + writeMinimalComposerProject(t, root) + + outDir := t.TempDir() + outFile := filepath.Join(outDir, "custom-sbom.json") + + require.NoError(t, writeProjectSBOM(t.Context(), root, projectSBOMOptions{ + OutputPath: outFile, + })) + + data, err := os.ReadFile(outFile) + require.NoError(t, err) + + doc := map[string]interface{}{} + require.NoError(t, json.Unmarshal(data, &doc)) + assert.Equal(t, "CycloneDX", doc["bomFormat"]) + + _, err = os.Stat(filepath.Join(root, defaultProjectSBOMOutput)) + assert.True(t, os.IsNotExist(err), "default path must not be written when --output is set") +} + +func TestWriteProjectSBOMErrorsWhenLockMissing(t *testing.T) { + root := t.TempDir() + err := writeProjectSBOM(t.Context(), root, projectSBOMOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "composer.lock not found") +} + +func TestWriteProjectSBOMSkipsWhenLockMissingAndAllowed(t *testing.T) { + root := t.TempDir() + require.NoError(t, writeProjectSBOM(t.Context(), root, projectSBOMOptions{ + SkipMissingLock: true, + })) + + _, err := os.Stat(filepath.Join(root, defaultProjectSBOMOutput)) + assert.True(t, os.IsNotExist(err), "no SBOM should be written when composer.lock is absent") +} + +func TestGenerateProjectSBOMSkipsWhenLockMissing(t *testing.T) { + // CI wrapper must keep the historical skip-on-missing-lock behaviour. + root := t.TempDir() + assert.NoError(t, generateProjectSBOM(t.Context(), root)) + + _, err := os.Stat(filepath.Join(root, defaultProjectSBOMOutput)) + assert.True(t, os.IsNotExist(err), "no SBOM should be written when composer.lock is absent") +} + +func TestGenerateProjectSBOM(t *testing.T) { + root := t.TempDir() + writeMinimalComposerProject(t, root) + + assert.NoError(t, generateProjectSBOM(t.Context(), root)) + + data, err := os.ReadFile(filepath.Join(root, defaultProjectSBOMOutput)) + assert.NoError(t, err) + + doc := map[string]interface{}{} + assert.NoError(t, json.Unmarshal(data, &doc)) + + assert.Equal(t, "CycloneDX", doc["bomFormat"]) + assert.Equal(t, "1.7", doc["specVersion"]) +} + +func TestResolveProjectSBOMOutputPath(t *testing.T) { + root := t.TempDir() + + defaultPath, err := resolveProjectSBOMOutputPath(root, "") + require.NoError(t, err) + assert.Equal(t, filepath.Join(root, defaultProjectSBOMOutput), defaultPath) + + abs := filepath.Join(root, "out.json") + got, err := resolveProjectSBOMOutputPath(root, abs) + require.NoError(t, err) + assert.Equal(t, abs, got) + + // Relative paths resolve against cwd. + cwd, err := os.Getwd() + require.NoError(t, err) + got, err = resolveProjectSBOMOutputPath(root, "rel-sbom.json") + require.NoError(t, err) + assert.Equal(t, filepath.Join(cwd, "rel-sbom.json"), got) +} + +func TestProjectSbomCommandUnsupportedFormat(t *testing.T) { + root := t.TempDir() + writeMinimalComposerProject(t, root) + + require.NoError(t, projectSbomCmd.Flags().Set("format", "spdx-json")) + require.NoError(t, projectSbomCmd.Flags().Set("output", "")) + t.Cleanup(func() { + _ = projectSbomCmd.Flags().Set("format", projectSBOMFormatCycloneDXJSON) + _ = projectSbomCmd.Flags().Set("output", "") + _ = projectSbomCmd.Flags().Set("include-dev-dependencies", "false") + }) + + projectSbomCmd.SetContext(t.Context()) + err := projectSbomCmd.RunE(projectSbomCmd, []string{root}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported SBOM format") +} + +func TestProjectSbomCommandSuccess(t *testing.T) { + root := t.TempDir() + writeMinimalComposerProject(t, root) + + out := filepath.Join(root, "from-cmd.json") + require.NoError(t, projectSbomCmd.Flags().Set("format", projectSBOMFormatCycloneDXJSON)) + require.NoError(t, projectSbomCmd.Flags().Set("output", out)) + require.NoError(t, projectSbomCmd.Flags().Set("include-dev-dependencies", "false")) + t.Cleanup(func() { + _ = projectSbomCmd.Flags().Set("format", projectSBOMFormatCycloneDXJSON) + _ = projectSbomCmd.Flags().Set("output", "") + _ = projectSbomCmd.Flags().Set("include-dev-dependencies", "false") + }) + + projectSbomCmd.SetContext(t.Context()) + require.NoError(t, projectSbomCmd.RunE(projectSbomCmd, []string{root})) + + data, err := os.ReadFile(out) + require.NoError(t, err) + doc := map[string]interface{}{} + require.NoError(t, json.Unmarshal(data, &doc)) + assert.Equal(t, "1.7", doc["specVersion"]) +} From f215a0b92ae5abd4721ae0e90260d4aba5adee35 Mon Sep 17 00:00:00 2001 From: Roboshyim Date: Thu, 23 Jul 2026 17:02:36 +0000 Subject: [PATCH 2/2] refactor(project): move SBOM generator into internal/shop Address review feedback: keep cmd/project as cobra surface only and put the shared CycloneDX generation logic in internal/shop so project ci and project sbom both call shop.WriteProjectSBOM. --- cmd/project/ci.go | 4 +- cmd/project/project_sbom.go | 125 ++--------------------- cmd/project/project_sbom_test.go | 154 ++++++----------------------- internal/shop/project_sbom.go | 139 ++++++++++++++++++++++++++ internal/shop/project_sbom_test.go | 149 ++++++++++++++++++++++++++++ 5 files changed, 326 insertions(+), 245 deletions(-) create mode 100644 internal/shop/project_sbom.go create mode 100644 internal/shop/project_sbom_test.go diff --git a/cmd/project/ci.go b/cmd/project/ci.go index 01e5b7e1..80a16a66 100644 --- a/cmd/project/ci.go +++ b/cmd/project/ci.go @@ -19,6 +19,7 @@ import ( "github.com/shopware/shopware-cli/internal/mjml" "github.com/shopware/shopware-cli/internal/shop" "github.com/shopware/shopware-cli/internal/system" + "github.com/shopware/shopware-cli/internal/tui" "github.com/shopware/shopware-cli/logging" ) @@ -368,9 +369,10 @@ func generateProjectSBOM(ctx context.Context, root string) error { section := ci.Default.Section(ctx, "Generating SBOM") defer section.End(ctx) - return writeProjectSBOM(ctx, root, projectSBOMOptions{ + return shop.WriteProjectSBOM(ctx, root, shop.ProjectSBOMOptions{ // CI historically skips when lock is missing rather than failing the build. SkipMissingLock: true, + ToolVersion: tui.AppVersion, }) } diff --git a/cmd/project/project_sbom.go b/cmd/project/project_sbom.go index 2eece8d0..e57b3127 100644 --- a/cmd/project/project_sbom.go +++ b/cmd/project/project_sbom.go @@ -1,50 +1,16 @@ package project import ( - "context" "fmt" "os" - "path" "path/filepath" - "strings" - "github.com/shyim/go-composer" - "github.com/shyim/go-composer/sbom" "github.com/spf13/cobra" + "github.com/shopware/shopware-cli/internal/shop" "github.com/shopware/shopware-cli/internal/tui" - "github.com/shopware/shopware-cli/logging" ) -const ( - // defaultProjectSBOMOutput is the filename written by both `project ci` and - // `project sbom` when --output is omitted. Keep them identical so tooling - // that already consumes the CI artifact keeps working. - defaultProjectSBOMOutput = "sbom.cdx.json" - - // projectSBOMFormatCycloneDXJSON is the only format currently supported. - // The flag exists so additional formats can be added without breaking the CLI. - projectSBOMFormatCycloneDXJSON = "cyclonedx-json" -) - -// projectSBOMOptions configures writeProjectSBOM. -type projectSBOMOptions struct { - // OutputPath is the destination file. Empty means - // filepath.Join(root, defaultProjectSBOMOutput). Relative paths are resolved - // against the process working directory (same as other CLI --output flags). - OutputPath string - - // SkipMissingLock, when true, returns nil without writing a file if - // composer.lock is absent. Used by `project ci` so a lock-less tree does not - // fail the build. The standalone command leaves this false so a missing lock - // is a hard error. - SkipMissingLock bool - - // IncludeDevDependencies controls whether packages-dev from composer.lock are - // included. Defaults to false to match `project ci`. - IncludeDevDependencies bool -} - var projectSbomCmd = &cobra.Command{ Use: "sbom [path]", Short: "Generate a CycloneDX SBOM from composer.lock", @@ -76,9 +42,8 @@ The command is non-interactive and exits non-zero when generation fails if err != nil { return err } - format = strings.ToLower(strings.TrimSpace(format)) - if format != projectSBOMFormatCycloneDXJSON { - return fmt.Errorf("unsupported SBOM format %q (supported: %s)", format, projectSBOMFormatCycloneDXJSON) + if err := shop.ValidateProjectSBOMFormat(format); err != nil { + return err } output, err := cmd.Flags().GetString("output") @@ -91,18 +56,19 @@ The command is non-interactive and exits non-zero when generation fails return err } - return writeProjectSBOM(cmd.Context(), root, projectSBOMOptions{ + return shop.WriteProjectSBOM(cmd.Context(), root, shop.ProjectSBOMOptions{ OutputPath: output, SkipMissingLock: false, IncludeDevDependencies: includeDev, + ToolVersion: tui.AppVersion, }) }, } func init() { projectRootCmd.AddCommand(projectSbomCmd) - projectSbomCmd.Flags().String("format", projectSBOMFormatCycloneDXJSON, "SBOM format (only cyclonedx-json is supported)") - projectSbomCmd.Flags().StringP("output", "o", "", fmt.Sprintf("Output file path (default: %s in the project root)", defaultProjectSBOMOutput)) + projectSbomCmd.Flags().String("format", shop.ProjectSBOMFormatCycloneDXJSON, "SBOM format (only cyclonedx-json is supported)") + projectSbomCmd.Flags().StringP("output", "o", "", fmt.Sprintf("Output file path (default: %s in the project root)", shop.DefaultProjectSBOMOutput)) projectSbomCmd.Flags().Bool("include-dev-dependencies", false, "Include packages-dev from composer.lock (excluded by default, matching project ci)") } @@ -128,80 +94,3 @@ func resolveProjectSbomRoot(args []string) (string, error) { } return cwd, nil } - -// writeProjectSBOM reads composer.lock from root and writes a CycloneDX SBOM. -// Shared by `project ci` and `project sbom` so both emit the same document. -func writeProjectSBOM(ctx context.Context, root string, opts projectSBOMOptions) error { - lockPath := path.Join(root, "composer.lock") - if _, err := os.Stat(lockPath); os.IsNotExist(err) { - if opts.SkipMissingLock { - logging.FromContext(ctx).Infof("Skipping SBOM generation: %s not found", lockPath) - return nil - } - return fmt.Errorf("composer.lock not found at %s; run composer install or pass a project path that contains a lock file", lockPath) - } else if err != nil { - return fmt.Errorf("stat composer.lock: %w", err) - } - - lock, err := composer.ReadLock(lockPath) - if err != nil { - return fmt.Errorf("read composer.lock: %w", err) - } - - projectComposer, err := composer.ReadJson(path.Join(root, "composer.json")) - appName := "shopware-project" - appVersion := "" - if err == nil && projectComposer != nil { - if projectComposer.Name != "" { - appName = projectComposer.Name - } - if projectComposer.Version != "" { - appVersion = projectComposer.Version - } - } - - bom, err := sbom.Generate(lock, sbom.Options{ - ApplicationName: appName, - ApplicationVersion: appVersion, - ToolGroup: "shopware", - ToolName: "shopware-cli", - ToolVersion: tui.AppVersion, - IncludeDevDependencies: opts.IncludeDevDependencies, - }) - if err != nil { - return err - } - - data, err := sbom.Marshal(bom) - if err != nil { - return fmt.Errorf("marshal SBOM: %w", err) - } - - outputPath, err := resolveProjectSBOMOutputPath(root, opts.OutputPath) - if err != nil { - return err - } - - if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { - return fmt.Errorf("create SBOM output directory: %w", err) - } - - if err := os.WriteFile(outputPath, data, 0o644); err != nil { - return fmt.Errorf("write SBOM: %w", err) - } - - logging.FromContext(ctx).Infof("Wrote SBOM with %d components to %s", len(bom.Components), outputPath) - return nil -} - -func resolveProjectSBOMOutputPath(root, output string) (string, error) { - if strings.TrimSpace(output) == "" { - return filepath.Join(root, defaultProjectSBOMOutput), nil - } - if filepath.IsAbs(output) { - return output, nil - } - // Relative --output is resolved against the process cwd, matching dump and - // other file-output commands. - return filepath.Abs(output) -} diff --git a/cmd/project/project_sbom_test.go b/cmd/project/project_sbom_test.go index b2b8f2d3..93dc2f64 100644 --- a/cmd/project/project_sbom_test.go +++ b/cmd/project/project_sbom_test.go @@ -8,16 +8,27 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/shopware/shopware-cli/internal/shop" + "github.com/shopware/shopware-cli/internal/tui" ) -func writeMinimalComposerProject(t *testing.T, root string) { - t.Helper() +func TestGenerateProjectSBOMSkipsWhenLockMissing(t *testing.T) { + // CI wrapper must keep the historical skip-on-missing-lock behaviour. + root := t.TempDir() + assert.NoError(t, generateProjectSBOM(t.Context(), root)) + + _, err := os.Stat(filepath.Join(root, shop.DefaultProjectSBOMOutput)) + assert.True(t, os.IsNotExist(err), "no SBOM should be written when composer.lock is absent") +} + +func TestGenerateProjectSBOM(t *testing.T) { + root := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(root, "composer.json"), []byte(`{ "name": "acme/shop", "version": "1.2.3" }`), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(root, "composer.lock"), []byte(`{ "packages": [ { @@ -32,143 +43,25 @@ func writeMinimalComposerProject(t *testing.T, root string) { {"name": "phpunit/phpunit", "version": "10.0.0", "license": ["BSD-3-Clause"]} ] }`), 0o644)) -} - -func TestWriteProjectSBOM(t *testing.T) { - root := t.TempDir() - writeMinimalComposerProject(t, root) - - require.NoError(t, writeProjectSBOM(t.Context(), root, projectSBOMOptions{})) - - data, err := os.ReadFile(filepath.Join(root, defaultProjectSBOMOutput)) - require.NoError(t, err) - - doc := map[string]interface{}{} - require.NoError(t, json.Unmarshal(data, &doc)) - assert.Equal(t, "CycloneDX", doc["bomFormat"]) - assert.Equal(t, "1.7", doc["specVersion"]) - - metadata := doc["metadata"].(map[string]interface{}) - component := metadata["component"].(map[string]interface{}) - assert.Equal(t, "acme/shop", component["name"]) - assert.Equal(t, "1.2.3", component["version"]) - - components := doc["components"].([]interface{}) - assert.Len(t, components, 1, "dev dependencies excluded by default") - assert.Equal(t, "console", components[0].(map[string]interface{})["name"]) -} - -func TestWriteProjectSBOMIncludeDevDependencies(t *testing.T) { - root := t.TempDir() - writeMinimalComposerProject(t, root) - - require.NoError(t, writeProjectSBOM(t.Context(), root, projectSBOMOptions{ - IncludeDevDependencies: true, - })) - - data, err := os.ReadFile(filepath.Join(root, defaultProjectSBOMOutput)) - require.NoError(t, err) - - doc := map[string]interface{}{} - require.NoError(t, json.Unmarshal(data, &doc)) - - components := doc["components"].([]interface{}) - assert.Len(t, components, 2, "dev dependencies included when requested") -} - -func TestWriteProjectSBOMCustomOutputPath(t *testing.T) { - root := t.TempDir() - writeMinimalComposerProject(t, root) - - outDir := t.TempDir() - outFile := filepath.Join(outDir, "custom-sbom.json") - - require.NoError(t, writeProjectSBOM(t.Context(), root, projectSBOMOptions{ - OutputPath: outFile, - })) - - data, err := os.ReadFile(outFile) - require.NoError(t, err) - - doc := map[string]interface{}{} - require.NoError(t, json.Unmarshal(data, &doc)) - assert.Equal(t, "CycloneDX", doc["bomFormat"]) - - _, err = os.Stat(filepath.Join(root, defaultProjectSBOMOutput)) - assert.True(t, os.IsNotExist(err), "default path must not be written when --output is set") -} - -func TestWriteProjectSBOMErrorsWhenLockMissing(t *testing.T) { - root := t.TempDir() - err := writeProjectSBOM(t.Context(), root, projectSBOMOptions{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "composer.lock not found") -} - -func TestWriteProjectSBOMSkipsWhenLockMissingAndAllowed(t *testing.T) { - root := t.TempDir() - require.NoError(t, writeProjectSBOM(t.Context(), root, projectSBOMOptions{ - SkipMissingLock: true, - })) - - _, err := os.Stat(filepath.Join(root, defaultProjectSBOMOutput)) - assert.True(t, os.IsNotExist(err), "no SBOM should be written when composer.lock is absent") -} - -func TestGenerateProjectSBOMSkipsWhenLockMissing(t *testing.T) { - // CI wrapper must keep the historical skip-on-missing-lock behaviour. - root := t.TempDir() assert.NoError(t, generateProjectSBOM(t.Context(), root)) - _, err := os.Stat(filepath.Join(root, defaultProjectSBOMOutput)) - assert.True(t, os.IsNotExist(err), "no SBOM should be written when composer.lock is absent") -} - -func TestGenerateProjectSBOM(t *testing.T) { - root := t.TempDir() - writeMinimalComposerProject(t, root) - - assert.NoError(t, generateProjectSBOM(t.Context(), root)) - - data, err := os.ReadFile(filepath.Join(root, defaultProjectSBOMOutput)) + data, err := os.ReadFile(filepath.Join(root, shop.DefaultProjectSBOMOutput)) assert.NoError(t, err) doc := map[string]interface{}{} assert.NoError(t, json.Unmarshal(data, &doc)) - assert.Equal(t, "CycloneDX", doc["bomFormat"]) assert.Equal(t, "1.7", doc["specVersion"]) } -func TestResolveProjectSBOMOutputPath(t *testing.T) { - root := t.TempDir() - - defaultPath, err := resolveProjectSBOMOutputPath(root, "") - require.NoError(t, err) - assert.Equal(t, filepath.Join(root, defaultProjectSBOMOutput), defaultPath) - - abs := filepath.Join(root, "out.json") - got, err := resolveProjectSBOMOutputPath(root, abs) - require.NoError(t, err) - assert.Equal(t, abs, got) - - // Relative paths resolve against cwd. - cwd, err := os.Getwd() - require.NoError(t, err) - got, err = resolveProjectSBOMOutputPath(root, "rel-sbom.json") - require.NoError(t, err) - assert.Equal(t, filepath.Join(cwd, "rel-sbom.json"), got) -} - func TestProjectSbomCommandUnsupportedFormat(t *testing.T) { root := t.TempDir() - writeMinimalComposerProject(t, root) require.NoError(t, projectSbomCmd.Flags().Set("format", "spdx-json")) require.NoError(t, projectSbomCmd.Flags().Set("output", "")) t.Cleanup(func() { - _ = projectSbomCmd.Flags().Set("format", projectSBOMFormatCycloneDXJSON) + _ = projectSbomCmd.Flags().Set("format", shop.ProjectSBOMFormatCycloneDXJSON) _ = projectSbomCmd.Flags().Set("output", "") _ = projectSbomCmd.Flags().Set("include-dev-dependencies", "false") }) @@ -181,18 +74,27 @@ func TestProjectSbomCommandUnsupportedFormat(t *testing.T) { func TestProjectSbomCommandSuccess(t *testing.T) { root := t.TempDir() - writeMinimalComposerProject(t, root) + require.NoError(t, os.WriteFile(filepath.Join(root, "composer.json"), []byte(`{"name":"acme/shop","version":"1.2.3"}`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "composer.lock"), []byte(`{ + "packages":[{"name":"symfony/console","version":"v6.3.0","type":"library","license":["MIT"]}], + "packages-dev":[] + }`), 0o644)) out := filepath.Join(root, "from-cmd.json") - require.NoError(t, projectSbomCmd.Flags().Set("format", projectSBOMFormatCycloneDXJSON)) + require.NoError(t, projectSbomCmd.Flags().Set("format", shop.ProjectSBOMFormatCycloneDXJSON)) require.NoError(t, projectSbomCmd.Flags().Set("output", out)) require.NoError(t, projectSbomCmd.Flags().Set("include-dev-dependencies", "false")) t.Cleanup(func() { - _ = projectSbomCmd.Flags().Set("format", projectSBOMFormatCycloneDXJSON) + _ = projectSbomCmd.Flags().Set("format", shop.ProjectSBOMFormatCycloneDXJSON) _ = projectSbomCmd.Flags().Set("output", "") _ = projectSbomCmd.Flags().Set("include-dev-dependencies", "false") }) + // Ensure the tool version used by the command path is stable in tests. + prev := tui.AppVersion + tui.AppVersion = "test" + t.Cleanup(func() { tui.AppVersion = prev }) + projectSbomCmd.SetContext(t.Context()) require.NoError(t, projectSbomCmd.RunE(projectSbomCmd, []string{root})) diff --git a/internal/shop/project_sbom.go b/internal/shop/project_sbom.go new file mode 100644 index 00000000..788f5007 --- /dev/null +++ b/internal/shop/project_sbom.go @@ -0,0 +1,139 @@ +package shop + +import ( + "context" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/shyim/go-composer" + "github.com/shyim/go-composer/sbom" + + "github.com/shopware/shopware-cli/logging" +) + +const ( + // DefaultProjectSBOMOutput is the filename written by both `project ci` and + // `project sbom` when --output is omitted. Keep them identical so tooling + // that already consumes the CI artifact keeps working. + DefaultProjectSBOMOutput = "sbom.cdx.json" + + // ProjectSBOMFormatCycloneDXJSON is the only format currently supported. + // The flag exists so additional formats can be added without breaking the CLI. + ProjectSBOMFormatCycloneDXJSON = "cyclonedx-json" +) + +// ProjectSBOMOptions configures WriteProjectSBOM. +type ProjectSBOMOptions struct { + // OutputPath is the destination file. Empty means + // filepath.Join(root, DefaultProjectSBOMOutput). Relative paths are resolved + // against the process working directory (same as other CLI --output flags). + OutputPath string + + // SkipMissingLock, when true, returns nil without writing a file if + // composer.lock is absent. Used by `project ci` so a lock-less tree does not + // fail the build. The standalone command leaves this false so a missing lock + // is a hard error. + SkipMissingLock bool + + // IncludeDevDependencies controls whether packages-dev from composer.lock are + // included. Defaults to false to match `project ci`. + IncludeDevDependencies bool + + // ToolVersion is reported in the SBOM metadata tools entry (shopware-cli version). + ToolVersion string +} + +// WriteProjectSBOM reads composer.lock from root and writes a CycloneDX SBOM. +// Shared by `project ci` and `project sbom` so both emit the same document. +func WriteProjectSBOM(ctx context.Context, root string, opts ProjectSBOMOptions) error { + lockPath := path.Join(root, "composer.lock") + if _, err := os.Stat(lockPath); os.IsNotExist(err) { + if opts.SkipMissingLock { + logging.FromContext(ctx).Infof("Skipping SBOM generation: %s not found", lockPath) + return nil + } + return fmt.Errorf("composer.lock not found at %s; run composer install or pass a project path that contains a lock file", lockPath) + } else if err != nil { + return fmt.Errorf("stat composer.lock: %w", err) + } + + lock, err := composer.ReadLock(lockPath) + if err != nil { + return fmt.Errorf("read composer.lock: %w", err) + } + + projectComposer, err := composer.ReadJson(path.Join(root, "composer.json")) + appName := "shopware-project" + appVersion := "" + if err == nil && projectComposer != nil { + if projectComposer.Name != "" { + appName = projectComposer.Name + } + if projectComposer.Version != "" { + appVersion = projectComposer.Version + } + } + + toolVersion := opts.ToolVersion + if toolVersion == "" { + toolVersion = "dev" + } + + bom, err := sbom.Generate(lock, sbom.Options{ + ApplicationName: appName, + ApplicationVersion: appVersion, + ToolGroup: "shopware", + ToolName: "shopware-cli", + ToolVersion: toolVersion, + IncludeDevDependencies: opts.IncludeDevDependencies, + }) + if err != nil { + return err + } + + data, err := sbom.Marshal(bom) + if err != nil { + return fmt.Errorf("marshal SBOM: %w", err) + } + + outputPath, err := ResolveProjectSBOMOutputPath(root, opts.OutputPath) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return fmt.Errorf("create SBOM output directory: %w", err) + } + + if err := os.WriteFile(outputPath, data, 0o644); err != nil { + return fmt.Errorf("write SBOM: %w", err) + } + + logging.FromContext(ctx).Infof("Wrote SBOM with %d components to %s", len(bom.Components), outputPath) + return nil +} + +// ResolveProjectSBOMOutputPath returns the absolute path to write the SBOM to. +// Empty output uses DefaultProjectSBOMOutput under root; absolute paths are kept; +// relative paths resolve against the process working directory. +func ResolveProjectSBOMOutputPath(root, output string) (string, error) { + if strings.TrimSpace(output) == "" { + return filepath.Join(root, DefaultProjectSBOMOutput), nil + } + if filepath.IsAbs(output) { + return output, nil + } + return filepath.Abs(output) +} + +// ValidateProjectSBOMFormat returns an error when format is not supported. +func ValidateProjectSBOMFormat(format string) error { + format = strings.ToLower(strings.TrimSpace(format)) + if format != ProjectSBOMFormatCycloneDXJSON { + return fmt.Errorf("unsupported SBOM format %q (supported: %s)", format, ProjectSBOMFormatCycloneDXJSON) + } + return nil +} diff --git a/internal/shop/project_sbom_test.go b/internal/shop/project_sbom_test.go new file mode 100644 index 00000000..b82a9090 --- /dev/null +++ b/internal/shop/project_sbom_test.go @@ -0,0 +1,149 @@ +package shop + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeMinimalComposerProject(t *testing.T, root string) { + t.Helper() + + require.NoError(t, os.WriteFile(filepath.Join(root, "composer.json"), []byte(`{ + "name": "acme/shop", + "version": "1.2.3" + }`), 0o644)) + + require.NoError(t, os.WriteFile(filepath.Join(root, "composer.lock"), []byte(`{ + "packages": [ + { + "name": "symfony/console", + "version": "v6.3.0", + "type": "library", + "license": ["MIT"], + "require": {"php": ">=8.1"} + } + ], + "packages-dev": [ + {"name": "phpunit/phpunit", "version": "10.0.0", "license": ["BSD-3-Clause"]} + ] + }`), 0o644)) +} + +func TestWriteProjectSBOM(t *testing.T) { + root := t.TempDir() + writeMinimalComposerProject(t, root) + + require.NoError(t, WriteProjectSBOM(t.Context(), root, ProjectSBOMOptions{ + ToolVersion: "test", + })) + + data, err := os.ReadFile(filepath.Join(root, DefaultProjectSBOMOutput)) + require.NoError(t, err) + + doc := map[string]interface{}{} + require.NoError(t, json.Unmarshal(data, &doc)) + + assert.Equal(t, "CycloneDX", doc["bomFormat"]) + assert.Equal(t, "1.7", doc["specVersion"]) + + metadata := doc["metadata"].(map[string]interface{}) + component := metadata["component"].(map[string]interface{}) + assert.Equal(t, "acme/shop", component["name"]) + assert.Equal(t, "1.2.3", component["version"]) + + components := doc["components"].([]interface{}) + assert.Len(t, components, 1, "dev dependencies excluded by default") + assert.Equal(t, "console", components[0].(map[string]interface{})["name"]) +} + +func TestWriteProjectSBOMIncludeDevDependencies(t *testing.T) { + root := t.TempDir() + writeMinimalComposerProject(t, root) + + require.NoError(t, WriteProjectSBOM(t.Context(), root, ProjectSBOMOptions{ + IncludeDevDependencies: true, + ToolVersion: "test", + })) + + data, err := os.ReadFile(filepath.Join(root, DefaultProjectSBOMOutput)) + require.NoError(t, err) + + doc := map[string]interface{}{} + require.NoError(t, json.Unmarshal(data, &doc)) + + components := doc["components"].([]interface{}) + assert.Len(t, components, 2, "dev dependencies included when requested") +} + +func TestWriteProjectSBOMCustomOutputPath(t *testing.T) { + root := t.TempDir() + writeMinimalComposerProject(t, root) + + outDir := t.TempDir() + outFile := filepath.Join(outDir, "custom-sbom.json") + + require.NoError(t, WriteProjectSBOM(t.Context(), root, ProjectSBOMOptions{ + OutputPath: outFile, + ToolVersion: "test", + })) + + data, err := os.ReadFile(outFile) + require.NoError(t, err) + + doc := map[string]interface{}{} + require.NoError(t, json.Unmarshal(data, &doc)) + assert.Equal(t, "CycloneDX", doc["bomFormat"]) + + _, err = os.Stat(filepath.Join(root, DefaultProjectSBOMOutput)) + assert.True(t, os.IsNotExist(err), "default path must not be written when --output is set") +} + +func TestWriteProjectSBOMErrorsWhenLockMissing(t *testing.T) { + root := t.TempDir() + err := WriteProjectSBOM(t.Context(), root, ProjectSBOMOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "composer.lock not found") +} + +func TestWriteProjectSBOMSkipsWhenLockMissingAndAllowed(t *testing.T) { + root := t.TempDir() + require.NoError(t, WriteProjectSBOM(t.Context(), root, ProjectSBOMOptions{ + SkipMissingLock: true, + })) + + _, err := os.Stat(filepath.Join(root, DefaultProjectSBOMOutput)) + assert.True(t, os.IsNotExist(err), "no SBOM should be written when composer.lock is absent") +} + +func TestResolveProjectSBOMOutputPath(t *testing.T) { + root := t.TempDir() + + defaultPath, err := ResolveProjectSBOMOutputPath(root, "") + require.NoError(t, err) + assert.Equal(t, filepath.Join(root, DefaultProjectSBOMOutput), defaultPath) + + abs := filepath.Join(root, "out.json") + got, err := ResolveProjectSBOMOutputPath(root, abs) + require.NoError(t, err) + assert.Equal(t, abs, got) + + cwd, err := os.Getwd() + require.NoError(t, err) + got, err = ResolveProjectSBOMOutputPath(root, "rel-sbom.json") + require.NoError(t, err) + assert.Equal(t, filepath.Join(cwd, "rel-sbom.json"), got) +} + +func TestValidateProjectSBOMFormat(t *testing.T) { + assert.NoError(t, ValidateProjectSBOMFormat(ProjectSBOMFormatCycloneDXJSON)) + assert.NoError(t, ValidateProjectSBOMFormat("CycloneDX-JSON")) + + err := ValidateProjectSBOMFormat("spdx-json") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported SBOM format") +}