diff --git a/cmd/attest.go b/cmd/attest.go index db2df8dae..8cb4e88b2 100644 --- a/cmd/attest.go +++ b/cmd/attest.go @@ -153,11 +153,11 @@ func attestExec(ctx context.Context, _ *cobra.Command, args []string) error { return fmt.Errorf("attest command can only be used with image sources fetch directly from the registry, but discovered an image source of %q when given %q", si.ImageSource, userInput) } - if len(appConfig.Output) > 1 { + if len(appConfig.Outputs) > 1 { return fmt.Errorf("unable to generate attestation for more than one output") } - format := syft.FormatByName(appConfig.Output[0]) + format := syft.FormatByName(appConfig.Outputs[0]) predicateType := formatPredicateType(format) if predicateType == "" { return fmt.Errorf("could not produce attestation predicate for given format: %q. Available formats: %+v", formatAliases(format.ID()), formatAliases(attestFormats...)) diff --git a/cmd/output_writer.go b/cmd/output_writer.go index 1e0628f45..6a65bda1d 100644 --- a/cmd/output_writer.go +++ b/cmd/output_writer.go @@ -58,10 +58,7 @@ func parseOptions(outputs []string, defaultFile string) (out []sbom.WriterOption continue } - out = append(out, sbom.WriterOption{ - Format: format, - Path: file, - }) + out = append(out, sbom.NewWriterOption(format, file)) } return out, errs } diff --git a/cmd/packages.go b/cmd/packages.go index 059173a8b..bb90a94fd 100644 --- a/cmd/packages.go +++ b/cmd/packages.go @@ -222,7 +222,7 @@ func validateInputArgs(cmd *cobra.Command, args []string) error { } func packagesExec(_ *cobra.Command, args []string) error { - writer, err := makeWriter(appConfig.Output, appConfig.File) + writer, err := makeWriter(appConfig.Outputs, appConfig.File) if err != nil { return err } diff --git a/cmd/power_user.go b/cmd/power_user.go index 8b9df4c71..495328b97 100644 --- a/cmd/power_user.go +++ b/cmd/power_user.go @@ -73,10 +73,12 @@ func powerUserExec(_ *cobra.Command, args []string) error { // could be an image or a directory, with or without a scheme userInput := args[0] - writer, err := sbom.NewWriter(sbom.WriterOption{ - Format: syftjson.Format(), - Path: appConfig.File, - }) + writer, err := sbom.NewWriter( + sbom.NewWriterOption( + syftjson.Format(), + appConfig.File, + ), + ) if err != nil { return err } diff --git a/internal/config/application.go b/internal/config/application.go index d24d78bb0..c9085fe95 100644 --- a/internal/config/application.go +++ b/internal/config/application.go @@ -30,7 +30,7 @@ type parser interface { // Application is the main syft application configuration. type Application struct { ConfigPath string `yaml:",omitempty" json:"configPath"` // the location where the application config was read from (either from -c or discovered while loading) - Output []string `yaml:"output" json:"output" mapstructure:"output"` // -o, the format to use for output + Outputs []string `yaml:"output" json:"output" mapstructure:"output"` // -o, the format to use for output File string `yaml:"file" json:"file" mapstructure:"file"` // --file, the file to write report output to Quiet bool `yaml:"quiet" json:"quiet" mapstructure:"quiet"` // -q, indicates to not show any status output to stderr (ETUI or logging UI) CheckForAppUpdate bool `yaml:"check-for-app-update" json:"check-for-app-update" mapstructure:"check-for-app-update"` // whether to check for an application update on start up or not @@ -104,6 +104,7 @@ func (cfg *Application) parseConfigValues() error { for _, optionFn := range []func() error{ cfg.parseUploadOptions, cfg.parseLogLevelOption, + cfg.parseFile, } { if err := optionFn(); err != nil { return err @@ -126,6 +127,17 @@ func (cfg *Application) parseConfigValues() error { return nil } +func (cfg *Application) parseFile() error { + if cfg.File != "" { + expandedPath, err := homedir.Expand(cfg.File) + if err != nil { + return fmt.Errorf("unable to expand file path=%q: %w", cfg.File, err) + } + cfg.File = expandedPath + } + return nil +} + func (cfg *Application) parseUploadOptions() error { if cfg.Anchore.Host == "" && cfg.Anchore.Dockerfile != "" { return fmt.Errorf("cannot provide dockerfile option without enabling upload") diff --git a/internal/config/application_test.go b/internal/config/application_test.go new file mode 100644 index 000000000..cad03c336 --- /dev/null +++ b/internal/config/application_test.go @@ -0,0 +1,52 @@ +package config + +import ( + "github.com/docker/docker/pkg/homedir" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "path/filepath" + "testing" +) + +func TestApplication_parseFile(t *testing.T) { + tests := []struct { + name string + config Application + expected string + wantErr require.ErrorAssertionFunc + }{ + { + name: "expand home dir", + config: Application{ + File: "~/place.txt", + }, + expected: filepath.Join(homedir.Get(), "place.txt"), + }, + { + name: "passthrough other paths", + config: Application{ + File: "/other/place.txt", + }, + expected: "/other/place.txt", + }, + { + name: "no path", + config: Application{ + File: "", + }, + expected: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tt.config + + if tt.wantErr == nil { + tt.wantErr = require.NoError + } + + tt.wantErr(t, cfg.parseFile()) + assert.Equal(t, tt.expected, cfg.File) + }) + } +} diff --git a/internal/config/attest.go b/internal/config/attest.go index 72d6cf32b..f658b944f 100644 --- a/internal/config/attest.go +++ b/internal/config/attest.go @@ -1,19 +1,28 @@ package config import ( + "fmt" "os" + "github.com/mitchellh/go-homedir" "github.com/spf13/viper" ) type attest struct { - Key string `yaml:"key" json:"key" mapstructure:"key"` + Key string `yaml:"key" json:"key" mapstructure:"key"` // same as --key, file path to the private key // IMPORTANT: do not show the password in any YAML/JSON output (sensitive information) - Password string `yaml:"-" json:"-" mapstructure:"password"` + Password string `yaml:"-" json:"-" mapstructure:"password"` // password for the private key } -//nolint:unparam func (cfg *attest) parseConfigValues() error { + if cfg.Key != "" { + expandedPath, err := homedir.Expand(cfg.Key) + if err != nil { + return fmt.Errorf("unable to expand key path=%q: %w", cfg.Key, err) + } + cfg.Key = expandedPath + } + if cfg.Password == "" { // we allow for configuration via syft config/env vars and additionally interop with known cosign config env vars if pw, ok := os.LookupEnv("COSIGN_PASSWORD"); ok { diff --git a/internal/config/logging.go b/internal/config/logging.go index bb95425c3..6d6909d42 100644 --- a/internal/config/logging.go +++ b/internal/config/logging.go @@ -1,6 +1,9 @@ package config import ( + "fmt" + + "github.com/mitchellh/go-homedir" "github.com/sirupsen/logrus" "github.com/spf13/viper" ) @@ -13,6 +16,17 @@ type logging struct { FileLocation string `yaml:"file" json:"file-location" mapstructure:"file"` // the file path to write logs to } +func (cfg *logging) parseConfigValues() error { + if cfg.FileLocation != "" { + expandedPath, err := homedir.Expand(cfg.FileLocation) + if err != nil { + return fmt.Errorf("unable to expand log file path=%q: %w", cfg.FileLocation, err) + } + cfg.FileLocation = expandedPath + } + return nil +} + func (cfg logging) loadDefaultValues(v *viper.Viper) { v.SetDefault("log.structured", false) v.SetDefault("log.file", "") diff --git a/syft/sbom/multi_writer.go b/syft/sbom/multi_writer.go index da379d9f6..9dbb1124f 100644 --- a/syft/sbom/multi_writer.go +++ b/syft/sbom/multi_writer.go @@ -6,8 +6,8 @@ import ( "path" "github.com/anchore/syft/internal/log" - "github.com/hashicorp/go-multierror" + "github.com/mitchellh/go-homedir" ) // multiWriter holds a list of child sbom.Writers to apply all Write and Close operations to @@ -21,8 +21,21 @@ type WriterOption struct { Path string } +func NewWriterOption(f Format, p string) WriterOption { + expandedPath, err := homedir.Expand(p) + if err != nil { + log.Warnf("could not expand given writer output path=%q: %w", p, err) + // ignore errors + expandedPath = p + } + return WriterOption{ + Format: f, + Path: expandedPath, + } +} + // NewWriter create all report writers from input options; if a file is not specified, os.Stdout is used -func NewWriter(options ...WriterOption) (Writer, error) { +func NewWriter(options ...WriterOption) (_ Writer, err error) { if len(options) == 0 { return nil, fmt.Errorf("no output options provided") } @@ -30,9 +43,11 @@ func NewWriter(options ...WriterOption) (Writer, error) { out := &multiWriter{} defer func() { - // close any previously opened files; we can't really recover from any errors - if err := out.Close(); err != nil { - log.Warnf("unable to close sbom writers: %+v", err) + if err != nil { + // close any previously opened files; we can't really recover from any errors + if err := out.Close(); err != nil { + log.Warnf("unable to close sbom writers: %+v", err) + } } }() diff --git a/syft/sbom/multi_writer_test.go b/syft/sbom/multi_writer_test.go index d9c46cc14..be61c26f7 100644 --- a/syft/sbom/multi_writer_test.go +++ b/syft/sbom/multi_writer_test.go @@ -2,9 +2,11 @@ package sbom import ( "io" + "path/filepath" "strings" "testing" + "github.com/docker/docker/pkg/homedir" "github.com/stretchr/testify/assert" ) @@ -170,3 +172,33 @@ func TestOutputWriter(t *testing.T) { }) } } + +func TestNewWriterOption(t *testing.T) { + tests := []struct { + name string + path string + expected string + }{ + { + name: "expand home dir", + path: "~/place.txt", + expected: filepath.Join(homedir.Get(), "place.txt"), + }, + { + name: "passthrough other paths", + path: "/other/place.txt", + expected: "/other/place.txt", + }, + { + name: "no path", + path: "", + expected: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := NewWriterOption(dummyFormat("table"), tt.path) + assert.Equal(t, tt.expected, o.Path) + }) + } +} diff --git a/test/cli/packages_cmd_test.go b/test/cli/packages_cmd_test.go index 44bd7a92a..f35788e0d 100644 --- a/test/cli/packages_cmd_test.go +++ b/test/cli/packages_cmd_test.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "path/filepath" "strings" "testing" ) @@ -192,6 +193,26 @@ func TestPackagesCmdFlags(t *testing.T) { assertSuccessfulReturnCode, }, }, + { + name: "json-file-flag", + args: []string{"packages", "-o", "json", "--file", filepath.Join(tmp, "output-1.json"), coverageImage}, + assertions: []traitAssertion{ + assertSuccessfulReturnCode, + assertFileOutput(t, filepath.Join(tmp, "output-1.json"), + assertJsonReport, + ), + }, + }, + { + name: "json-output-flag-to-file", + args: []string{"packages", "-o", fmt.Sprintf("json=%s", filepath.Join(tmp, "output-2.json")), coverageImage}, + assertions: []traitAssertion{ + assertSuccessfulReturnCode, + assertFileOutput(t, filepath.Join(tmp, "output-2.json"), + assertJsonReport, + ), + }, + }, } for _, test := range tests { diff --git a/test/cli/trait_assertions_test.go b/test/cli/trait_assertions_test.go index 6df116ba6..cdfd1ab9f 100644 --- a/test/cli/trait_assertions_test.go +++ b/test/cli/trait_assertions_test.go @@ -10,10 +10,26 @@ import ( "testing" "github.com/acarl005/stripansi" + "github.com/stretchr/testify/require" ) type traitAssertion func(tb testing.TB, stdout, stderr string, rc int) +func assertFileOutput(tb testing.TB, path string, assertions ...traitAssertion) traitAssertion { + tb.Helper() + + return func(tb testing.TB, _, stderr string, rc int) { + content, err := os.ReadFile(path) + require.NoError(tb, err) + contentStr := string(content) + + for _, assertion := range assertions { + // treat the file content as stdout + assertion(tb, contentStr, stderr, rc) + } + } +} + func assertJsonReport(tb testing.TB, stdout, _ string, _ int) { tb.Helper() var data interface{}