diff --git a/README.md b/README.md index 266a8869321..bdc24f58009 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,8 @@ may attempt to expand wildcards, so put those parameters in single quotes, like: ### Output formats -The output format for Syft is configurable as well: +The output format for Syft is configurable as well using the +`-o` (or `--output`) option: ``` syft packages -o @@ -127,6 +128,15 @@ Where the `formats` available are: - `spdx-json`: A JSON report conforming to the [SPDX 2.2 JSON Schema](https://github.com/spdx/spdx-spec/blob/v2.2/schemas/spdx-schema.json). - `table`: A columnar summary (default). +#### Multiple outputs + +Syft can also output _multiple_ files in differing formats by appending +`=` to the option, for example to output Syft JSON and SPDX JSON: + +```shell +syft packages -o json=sbom.syft.json -o spdx-json=sbom.spdx.json +``` + ## Private Registry Authentication ### Local Docker Credentials @@ -221,8 +231,12 @@ Configuration search paths: Configuration options (example values are the default): ```yaml -# the output format of the SBOM report (options: table, text, json) -# same as -o ; SYFT_OUTPUT env var +# the output format(s) of the SBOM report (options: table, text, json, spdx, ...) +# same as -o, --output, and SYFT_OUTPUT env var +# to specify multiple output files in differing formats, use a list: +# output: +# - "json=" +# - "spdx-json=" output: "table" # suppress all output (except for the SBOM report) @@ -238,8 +252,8 @@ check-for-app-update: true # a list of globs to exclude from scanning. same as --exclude ; for example: # exclude: -# - '/etc/**' -# - './out/**/*.json' +# - "/etc/**" +# - "./out/**/*.json" exclude: # cataloging packages is exposed through the packages and power-user subcommands diff --git a/cmd/event_loop_test.go b/cmd/event_loop_test.go index c816c444efa..782c27c1742 100644 --- a/cmd/event_loop_test.go +++ b/cmd/event_loop_test.go @@ -50,7 +50,7 @@ func Test_eventLoop_gracefulExit(t *testing.T) { t.Cleanup(testBus.Close) finalEvent := partybus.Event{ - Type: event.PresenterReady, + Type: event.Exit, } worker := func() <-chan error { @@ -182,7 +182,7 @@ func Test_eventLoop_unsubscribeError(t *testing.T) { t.Cleanup(testBus.Close) finalEvent := partybus.Event{ - Type: event.PresenterReady, + Type: event.Exit, } worker := func() <-chan error { @@ -251,8 +251,8 @@ func Test_eventLoop_handlerError(t *testing.T) { t.Cleanup(testBus.Close) finalEvent := partybus.Event{ - Type: event.PresenterReady, - Error: fmt.Errorf("unable to create presenter"), + Type: event.Exit, + Error: fmt.Errorf("an exit error occured"), } worker := func() <-chan error { @@ -376,7 +376,7 @@ func Test_eventLoop_uiTeardownError(t *testing.T) { t.Cleanup(testBus.Close) finalEvent := partybus.Event{ - Type: event.PresenterReady, + Type: event.Exit, } worker := func() <-chan error { diff --git a/cmd/output_writer.go b/cmd/output_writer.go new file mode 100644 index 00000000000..c444cb2360c --- /dev/null +++ b/cmd/output_writer.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/anchore/syft/internal/formats" + "github.com/anchore/syft/internal/output" + "github.com/anchore/syft/syft/format" + "github.com/anchore/syft/syft/sbom" + "github.com/hashicorp/go-multierror" +) + +// makeWriter creates a sbom.Writer for output or returns an error. this will either return a valid writer +// or an error but neither both and if there is no error, sbom.Writer.Close() should be called +func makeWriter(outputs []string, defaultFile string) (sbom.Writer, error) { + outputOptions, err := parseOptions(outputs, defaultFile) + if err != nil { + return nil, err + } + + writer, err := output.MakeWriter(outputOptions...) + if err != nil { + return nil, err + } + + return writer, nil +} + +// parseOptions utility to parse command-line option strings and retain the existing behavior of default format and file +func parseOptions(outputs []string, defaultFile string) (out []output.WriterOption, errs error) { + // always should have one option -- we generally get the default of "table", but just make sure + if len(outputs) == 0 { + outputs = append(outputs, string(format.TableOption)) + } + + for _, name := range outputs { + name = strings.TrimSpace(name) + + // split to at most two parts for = + parts := strings.SplitN(name, "=", 2) + + // the format option is the first part + name = parts[0] + + // default to the --file or empty string if not specified + file := defaultFile + + // If a file is specified as part of the output option, use that + if len(parts) > 1 { + file = parts[1] + } + + option := format.ParseOption(name) + if option == format.UnknownFormatOption { + errs = multierror.Append(errs, fmt.Errorf("bad output format: '%s'", name)) + continue + } + + encoder := formats.ByOption(option) + if encoder == nil { + errs = multierror.Append(errs, fmt.Errorf("unknown format: %s", outputFormat)) + continue + } + + out = append(out, output.WriterOption{ + Format: *encoder, + Path: file, + }) + } + return out, errs +} diff --git a/cmd/output_writer_test.go b/cmd/output_writer_test.go new file mode 100644 index 00000000000..5798d3ceec6 --- /dev/null +++ b/cmd/output_writer_test.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOutputWriterConfig(t *testing.T) { + tmp := t.TempDir() + "/" + + tests := []struct { + outputs []string + file string + err bool + expected []string + }{ + { + outputs: []string{}, + expected: []string{""}, + }, + { + outputs: []string{"json"}, + expected: []string{""}, + }, + { + file: "test-1.json", + expected: []string{"test-1.json"}, + }, + { + outputs: []string{"json=test-2.json"}, + expected: []string{"test-2.json"}, + }, + { + outputs: []string{"json=test-3-1.json", "spdx-json=test-3-2.json"}, + expected: []string{"test-3-1.json", "test-3-2.json"}, + }, + { + outputs: []string{"text", "json=test-4.json"}, + expected: []string{"", "test-4.json"}, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%s/%s", test.outputs, test.file), func(t *testing.T) { + outputs := test.outputs + for i, val := range outputs { + outputs[i] = strings.Replace(val, "=", "="+tmp, 1) + } + + file := test.file + if file != "" { + file = tmp + file + } + + _, err := makeWriter(test.outputs, file) + + if test.err { + assert.Error(t, err) + return + } else { + assert.NoError(t, err) + } + + for _, expected := range test.expected { + if expected != "" { + assert.FileExists(t, tmp+expected) + } else if file != "" { + assert.FileExists(t, file) + } else { + assert.NoFileExists(t, expected) + } + } + }) + } +} diff --git a/cmd/packages.go b/cmd/packages.go index ca4b29f4499..626c535b95b 100644 --- a/cmd/packages.go +++ b/cmd/packages.go @@ -10,7 +10,6 @@ import ( "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/anchore" "github.com/anchore/syft/internal/bus" - "github.com/anchore/syft/internal/formats" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/ui" "github.com/anchore/syft/internal/version" @@ -51,8 +50,7 @@ const ( ) var ( - packagesPresenterOpt format.Option - packagesCmd = &cobra.Command{ + packagesCmd = &cobra.Command{ Use: "packages [SOURCE]", Short: "Generate a package SBOM", Long: "Generate a packaged-based Software Bill Of Materials (SBOM) from container images and filesystems", @@ -63,14 +61,7 @@ var ( Args: validateInputArgs, SilenceUsage: true, SilenceErrors: true, - PreRunE: func(cmd *cobra.Command, args []string) error { - // set the presenter - presenterOption := format.ParseOption(appConfig.Output) - if presenterOption == format.UnknownFormatOption { - return fmt.Errorf("bad --output value '%s'", appConfig.Output) - } - packagesPresenterOpt = presenterOption - + PreRunE: func(cmd *cobra.Command, args []string) (err error) { if appConfig.Dev.ProfileCPU && appConfig.Dev.ProfileMem { return fmt.Errorf("cannot profile CPU and memory simultaneously") } @@ -102,14 +93,14 @@ func setPackageFlags(flags *pflag.FlagSet) { "scope", "s", cataloger.DefaultSearchConfig().Scope.String(), fmt.Sprintf("selection of layers to catalog, options=%v", source.AllScopes)) - flags.StringP( - "output", "o", string(format.TableOption), - fmt.Sprintf("report output formatter, options=%v", format.AllOptions), + flags.StringArrayP( + "output", "o", []string{string(format.TableOption)}, + fmt.Sprintf("report output format, options=%v", format.AllOptions), ) flags.StringP( "file", "", "", - "file to write the report output to (default is STDOUT)", + "file to write the default report output to (default is STDOUT)", ) // Upload options ////////////////////////////////////////////////////////// @@ -210,26 +201,26 @@ func validateInputArgs(cmd *cobra.Command, args []string) error { } func packagesExec(_ *cobra.Command, args []string) error { - // could be an image or a directory, with or without a scheme - userInput := args[0] + writer, err := makeWriter(appConfig.Output, appConfig.File) + if err != nil { + return err + } - reporter, closer, err := reportWriter() defer func() { - if err := closer(); err != nil { - log.Warnf("unable to write to report destination: %+v", err) + if err := writer.Close(); err != nil { + log.Warnf("unable to write to report destination: %w", err) } }() - if err != nil { - return err - } + // could be an image or a directory, with or without a scheme + userInput := args[0] return eventLoop( - packagesExecWorker(userInput), + packagesExecWorker(userInput, writer), setupSignals(), eventSubscription, stereoscope.Cleanup, - ui.Select(isVerbose(), appConfig.Quiet, reporter)..., + ui.Select(isVerbose(), appConfig.Quiet)..., ) } @@ -244,7 +235,7 @@ func isVerbose() (result bool) { return appConfig.CliOptions.Verbosity > 0 || isPipedInput } -func packagesExecWorker(userInput string) <-chan error { +func packagesExecWorker(userInput string, writer sbom.Writer) <-chan error { errs := make(chan error) go func() { defer close(errs) @@ -255,12 +246,6 @@ func packagesExecWorker(userInput string) <-chan error { return } - f := formats.ByOption(packagesPresenterOpt) - if f == nil { - errs <- fmt.Errorf("unknown format: %s", packagesPresenterOpt) - return - } - src, cleanup, err := source.New(userInput, appConfig.Registry.ToOptions(), appConfig.Exclusions) if err != nil { errs <- fmt.Errorf("failed to construct source from user input %q: %w", userInput, err) @@ -296,8 +281,8 @@ func packagesExecWorker(userInput string) <-chan error { } bus.Publish(partybus.Event{ - Type: event.PresenterReady, - Value: f.Presenter(s), + Type: event.Exit, + Value: func() error { return writer.Write(s) }, }) }() return errs diff --git a/cmd/power_user.go b/cmd/power_user.go index 57229036158..9a68634e1b5 100644 --- a/cmd/power_user.go +++ b/cmd/power_user.go @@ -9,6 +9,7 @@ import ( "github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/formats/syftjson" "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/internal/output" "github.com/anchore/syft/internal/ui" "github.com/anchore/syft/internal/version" "github.com/anchore/syft/syft/artifact" @@ -73,9 +74,16 @@ func powerUserExec(_ *cobra.Command, args []string) error { // could be an image or a directory, with or without a scheme userInput := args[0] - reporter, closer, err := reportWriter() + writer, err := output.MakeWriter(output.WriterOption{ + Format: syftjson.Format(), + Path: appConfig.File, + }) + if err != nil { + return err + } + defer func() { - if err := closer(); err != nil { + if err := writer.Close(); err != nil { log.Warnf("unable to write to report destination: %+v", err) } @@ -84,19 +92,15 @@ func powerUserExec(_ *cobra.Command, args []string) error { fmt.Fprintln(os.Stderr, deprecated) }() - if err != nil { - return err - } - return eventLoop( - powerUserExecWorker(userInput), + powerUserExecWorker(userInput, writer), setupSignals(), eventSubscription, stereoscope.Cleanup, - ui.Select(isVerbose(), appConfig.Quiet, reporter)..., + ui.Select(isVerbose(), appConfig.Quiet)..., ) } -func powerUserExecWorker(userInput string) <-chan error { +func powerUserExecWorker(userInput string, writer sbom.Writer) <-chan error { errs := make(chan error) go func() { defer close(errs) @@ -140,8 +144,8 @@ func powerUserExecWorker(userInput string) <-chan error { s.Relationships = append(s.Relationships, mergeRelationships(relationships...)...) bus.Publish(partybus.Event{ - Type: event.PresenterReady, - Value: syftjson.Format().Presenter(s), + Type: event.Exit, + Value: func() error { return writer.Write(s) }, }) }() diff --git a/cmd/report_writer.go b/cmd/report_writer.go deleted file mode 100644 index a6fb63bec09..00000000000 --- a/cmd/report_writer.go +++ /dev/null @@ -1,33 +0,0 @@ -package cmd - -import ( - "fmt" - "io" - "os" - "strings" - - "github.com/anchore/syft/internal/log" -) - -func reportWriter() (io.Writer, func() error, error) { - nop := func() error { return nil } - path := strings.TrimSpace(appConfig.File) - - switch len(path) { - case 0: - return os.Stdout, nop, nil - - default: - reportFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) - - if err != nil { - return nil, nop, fmt.Errorf("unable to create report file: %w", err) - } - - return reportFile, func() error { - log.Infof("report written to file=%q", path) - - return reportFile.Close() - }, nil - } -} diff --git a/go.mod b/go.mod index cb80d81dffd..cc6c0004ecf 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/adrg/xdg v0.2.1 github.com/alecthomas/jsonschema v0.0.0-20210301060011-54c507b6f074 github.com/anchore/client-go v0.0.0-20210222170800-9c70f9b80bcf - github.com/anchore/go-presenter v0.0.0-20211102174526-0dbf20f6c7fa github.com/anchore/go-rpmdb v0.0.0-20210914181456-a9c52348da63 github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04 github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b diff --git a/go.sum b/go.sum index 6470dce8723..82c402210a6 100644 --- a/go.sum +++ b/go.sum @@ -101,8 +101,6 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0= github.com/anchore/client-go v0.0.0-20210222170800-9c70f9b80bcf h1:DYssiUV1pBmKqzKsm4mqXx8artqC0Q8HgZsVI3lMsAg= github.com/anchore/client-go v0.0.0-20210222170800-9c70f9b80bcf/go.mod h1:FaODhIA06mxO1E6R32JE0TL1JWZZkmjRIAd4ULvHUKk= -github.com/anchore/go-presenter v0.0.0-20211102174526-0dbf20f6c7fa h1:mDLUAkgXsV5Z8D0EEj8eS6FBekolV/A+Xxbs9054bPw= -github.com/anchore/go-presenter v0.0.0-20211102174526-0dbf20f6c7fa/go.mod h1:29jwxTSAS6pBcrmuwf1U3r1Tqp1o1XpuiOJ0NT9NoGg= github.com/anchore/go-rpmdb v0.0.0-20210914181456-a9c52348da63 h1:C9W/LAydEz/qdUhx1MdjO9l8NEcFKYknkxDVyo9LAoM= github.com/anchore/go-rpmdb v0.0.0-20210914181456-a9c52348da63/go.mod h1:6qH8c6U/3CBVvDDDBZnPSTbTINq3cIdADUYTaVf75EM= github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04 h1:VzprUTpc0vW0nnNKJfJieyH/TZ9UYAnTZs5/gHTdAe8= diff --git a/internal/anchore/import_package_sbom.go b/internal/anchore/import_package_sbom.go index cafdf72c1c8..e12f1535592 100644 --- a/internal/anchore/import_package_sbom.go +++ b/internal/anchore/import_package_sbom.go @@ -25,7 +25,7 @@ type packageSBOMImportAPI interface { func packageSbomModel(s sbom.SBOM) (*external.ImagePackageManifest, error) { var buf bytes.Buffer - err := syftjson.Format().Presenter(s).Present(&buf) + err := syftjson.Format().Encode(&buf, s) if err != nil { return nil, fmt.Errorf("unable to serialize results: %w", err) } @@ -33,7 +33,7 @@ func packageSbomModel(s sbom.SBOM) (*external.ImagePackageManifest, error) { // the model is 1:1 the JSON output of today. As the schema changes, this will need to be converted into individual mappings. var model external.ImagePackageManifest if err = json.Unmarshal(buf.Bytes(), &model); err != nil { - return nil, fmt.Errorf("unable to convert JSON presenter output to import model: %w", err) + return nil, fmt.Errorf("unable to convert JSON output to import model: %w", err) } return &model, nil diff --git a/internal/anchore/import_package_sbom_test.go b/internal/anchore/import_package_sbom_test.go index 18af116a679..568eead4251 100644 --- a/internal/anchore/import_package_sbom_test.go +++ b/internal/anchore/import_package_sbom_test.go @@ -105,8 +105,7 @@ func TestPackageSbomToModel(t *testing.T) { } var buf bytes.Buffer - pres := syftjson.Format().Presenter(s) - if err := pres.Present(&buf); err != nil { + if err := syftjson.Format().Encode(&buf, s); err != nil { t.Fatalf("unable to get expected json: %+v", err) } diff --git a/internal/config/application.go b/internal/config/application.go index 54264bef985..1265aded54c 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 Presenter hint string to use for report formatting + Output []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 diff --git a/internal/config/registry_test.go b/internal/config/registry_test.go index 816b2b8e591..6d9ecf280e8 100644 --- a/internal/config/registry_test.go +++ b/internal/config/registry_test.go @@ -2,9 +2,10 @@ package config import ( "fmt" - "github.com/anchore/stereoscope/pkg/image" "testing" + "github.com/anchore/stereoscope/pkg/image" + "github.com/stretchr/testify/assert" ) diff --git a/internal/constants.go b/internal/constants.go index a2239fd7ba6..52411dc1f7f 100644 --- a/internal/constants.go +++ b/internal/constants.go @@ -4,7 +4,7 @@ const ( // ApplicationName is the non-capitalized name of the application (do not change this) ApplicationName = "syft" - // JSONSchemaVersion is the current schema version output by the JSON presenter + // JSONSchemaVersion is the current schema version output by the JSON encoder // This is roughly following the "SchemaVer" guidelines for versioning the JSON schema. Please see schema/json/README.md for details on how to increment. JSONSchemaVersion = "2.0.2" ) diff --git a/internal/formats/common/testutils/utils.go b/internal/formats/common/testutils/utils.go index 0a086c1e556..6edffed89b7 100644 --- a/internal/formats/common/testutils/utils.go +++ b/internal/formats/common/testutils/utils.go @@ -5,12 +5,12 @@ import ( "strings" "testing" - "github.com/anchore/go-presenter" "github.com/anchore/go-testutils" "github.com/anchore/stereoscope/pkg/filetree" "github.com/anchore/stereoscope/pkg/image" "github.com/anchore/stereoscope/pkg/imagetest" "github.com/anchore/syft/syft/distro" + "github.com/anchore/syft/syft/format" "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/source" @@ -32,7 +32,7 @@ func FromSnapshot() ImageOption { } } -func AssertPresenterAgainstGoldenImageSnapshot(t *testing.T, pres presenter.Presenter, testImage string, updateSnapshot bool, redactors ...redactor) { +func AssertEncoderAgainstGoldenImageSnapshot(t *testing.T, format format.Format, sbom sbom.SBOM, testImage string, updateSnapshot bool, redactors ...redactor) { var buffer bytes.Buffer // grab the latest image contents and persist @@ -40,11 +40,11 @@ func AssertPresenterAgainstGoldenImageSnapshot(t *testing.T, pres presenter.Pres imagetest.UpdateGoldenFixtureImage(t, testImage) } - err := pres.Present(&buffer) + err := format.Encode(&buffer, sbom) assert.NoError(t, err) actual := buffer.Bytes() - // replace the expected snapshot contents with the current presenter contents + // replace the expected snapshot contents with the current encoder contents if updateSnapshot { testutils.UpdateGoldenFileContents(t, actual) } @@ -66,14 +66,14 @@ func AssertPresenterAgainstGoldenImageSnapshot(t *testing.T, pres presenter.Pres } } -func AssertPresenterAgainstGoldenSnapshot(t *testing.T, pres presenter.Presenter, updateSnapshot bool, redactors ...redactor) { +func AssertEncoderAgainstGoldenSnapshot(t *testing.T, format format.Format, sbom sbom.SBOM, updateSnapshot bool, redactors ...redactor) { var buffer bytes.Buffer - err := pres.Present(&buffer) + err := format.Encode(&buffer, sbom) assert.NoError(t, err) actual := buffer.Bytes() - // replace the expected snapshot contents with the current presenter contents + // replace the expected snapshot contents with the current encoder contents if updateSnapshot { testutils.UpdateGoldenFileContents(t, actual) } diff --git a/internal/formats/cyclonedx13json/encoder_test.go b/internal/formats/cyclonedx13json/encoder_test.go index 880b3d02171..b8c8f9edb79 100644 --- a/internal/formats/cyclonedx13json/encoder_test.go +++ b/internal/formats/cyclonedx13json/encoder_test.go @@ -8,20 +8,22 @@ import ( "github.com/anchore/syft/internal/formats/common/testutils" ) -var updateCycloneDx = flag.Bool("update-cyclonedx", false, "update the *.golden files for cyclone-dx presenters") +var updateCycloneDx = flag.Bool("update-cyclonedx", false, "update the *.golden files for cyclone-dx encoders") -func TestCycloneDxDirectoryPresenter(t *testing.T) { - testutils.AssertPresenterAgainstGoldenSnapshot(t, - Format().Presenter(testutils.DirectoryInput(t)), +func TestCycloneDxDirectoryEncoder(t *testing.T) { + testutils.AssertEncoderAgainstGoldenSnapshot(t, + Format(), + testutils.DirectoryInput(t), *updateCycloneDx, cycloneDxRedactor, ) } -func TestCycloneDxImagePresenter(t *testing.T) { +func TestCycloneDxImageEncoder(t *testing.T) { testImage := "image-simple" - testutils.AssertPresenterAgainstGoldenImageSnapshot(t, - Format().Presenter(testutils.ImageInput(t, testImage)), + testutils.AssertEncoderAgainstGoldenImageSnapshot(t, + Format(), + testutils.ImageInput(t, testImage), testImage, *updateCycloneDx, cycloneDxRedactor, diff --git a/internal/formats/cyclonedx13json/test-fixtures/snapshot/TestCycloneDxDirectoryPresenter.golden b/internal/formats/cyclonedx13json/test-fixtures/snapshot/TestCycloneDxDirectoryEncoder.golden similarity index 100% rename from internal/formats/cyclonedx13json/test-fixtures/snapshot/TestCycloneDxDirectoryPresenter.golden rename to internal/formats/cyclonedx13json/test-fixtures/snapshot/TestCycloneDxDirectoryEncoder.golden diff --git a/internal/formats/cyclonedx13json/test-fixtures/snapshot/TestCycloneDxImagePresenter.golden b/internal/formats/cyclonedx13json/test-fixtures/snapshot/TestCycloneDxImageEncoder.golden similarity index 100% rename from internal/formats/cyclonedx13json/test-fixtures/snapshot/TestCycloneDxImagePresenter.golden rename to internal/formats/cyclonedx13json/test-fixtures/snapshot/TestCycloneDxImageEncoder.golden diff --git a/internal/formats/cyclonedx13xml/encoder_test.go b/internal/formats/cyclonedx13xml/encoder_test.go index a9b61e9661b..0635eaeb0ef 100644 --- a/internal/formats/cyclonedx13xml/encoder_test.go +++ b/internal/formats/cyclonedx13xml/encoder_test.go @@ -8,20 +8,22 @@ import ( "github.com/anchore/syft/internal/formats/common/testutils" ) -var updateCycloneDx = flag.Bool("update-cyclonedx", false, "update the *.golden files for cyclone-dx presenters") +var updateCycloneDx = flag.Bool("update-cyclonedx", false, "update the *.golden files for cyclone-dx encoders") -func TestCycloneDxDirectoryPresenter(t *testing.T) { - testutils.AssertPresenterAgainstGoldenSnapshot(t, - Format().Presenter(testutils.DirectoryInput(t)), +func TestCycloneDxDirectoryEncoder(t *testing.T) { + testutils.AssertEncoderAgainstGoldenSnapshot(t, + Format(), + testutils.DirectoryInput(t), *updateCycloneDx, cycloneDxRedactor, ) } -func TestCycloneDxImagePresenter(t *testing.T) { +func TestCycloneDxImageEncoder(t *testing.T) { testImage := "image-simple" - testutils.AssertPresenterAgainstGoldenImageSnapshot(t, - Format().Presenter(testutils.ImageInput(t, testImage)), + testutils.AssertEncoderAgainstGoldenImageSnapshot(t, + Format(), + testutils.ImageInput(t, testImage), testImage, *updateCycloneDx, cycloneDxRedactor, diff --git a/internal/formats/cyclonedx13xml/test-fixtures/snapshot/TestCycloneDxDirectoryPresenter.golden b/internal/formats/cyclonedx13xml/test-fixtures/snapshot/TestCycloneDxDirectoryEncoder.golden similarity index 100% rename from internal/formats/cyclonedx13xml/test-fixtures/snapshot/TestCycloneDxDirectoryPresenter.golden rename to internal/formats/cyclonedx13xml/test-fixtures/snapshot/TestCycloneDxDirectoryEncoder.golden diff --git a/internal/formats/cyclonedx13xml/test-fixtures/snapshot/TestCycloneDxImagePresenter.golden b/internal/formats/cyclonedx13xml/test-fixtures/snapshot/TestCycloneDxImageEncoder.golden similarity index 100% rename from internal/formats/cyclonedx13xml/test-fixtures/snapshot/TestCycloneDxImagePresenter.golden rename to internal/formats/cyclonedx13xml/test-fixtures/snapshot/TestCycloneDxImageEncoder.golden diff --git a/internal/formats/spdx22json/encoder_test.go b/internal/formats/spdx22json/encoder_test.go index 59bb5eccdb6..ef5ccfce96e 100644 --- a/internal/formats/spdx22json/encoder_test.go +++ b/internal/formats/spdx22json/encoder_test.go @@ -8,20 +8,22 @@ import ( "github.com/anchore/syft/internal/formats/common/testutils" ) -var updateSpdxJson = flag.Bool("update-spdx-json", false, "update the *.golden files for spdx-json presenters") +var updateSpdxJson = flag.Bool("update-spdx-json", false, "update the *.golden files for spdx-json encoders") -func TestSPDXJSONDirectoryPresenter(t *testing.T) { - testutils.AssertPresenterAgainstGoldenSnapshot(t, - Format().Presenter(testutils.DirectoryInput(t)), +func TestSPDXJSONDirectoryEncoder(t *testing.T) { + testutils.AssertEncoderAgainstGoldenSnapshot(t, + Format(), + testutils.DirectoryInput(t), *updateSpdxJson, spdxJsonRedactor, ) } -func TestSPDXJSONImagePresenter(t *testing.T) { +func TestSPDXJSONImageEncoder(t *testing.T) { testImage := "image-simple" - testutils.AssertPresenterAgainstGoldenImageSnapshot(t, - Format().Presenter(testutils.ImageInput(t, testImage, testutils.FromSnapshot())), + testutils.AssertEncoderAgainstGoldenImageSnapshot(t, + Format(), + testutils.ImageInput(t, testImage, testutils.FromSnapshot()), testImage, *updateSpdxJson, spdxJsonRedactor, diff --git a/internal/formats/spdx22json/test-fixtures/snapshot/TestSPDXJSONDirectoryPresenter.golden b/internal/formats/spdx22json/test-fixtures/snapshot/TestSPDXJSONDirectoryEncoder.golden similarity index 100% rename from internal/formats/spdx22json/test-fixtures/snapshot/TestSPDXJSONDirectoryPresenter.golden rename to internal/formats/spdx22json/test-fixtures/snapshot/TestSPDXJSONDirectoryEncoder.golden diff --git a/internal/formats/spdx22json/test-fixtures/snapshot/TestSPDXJSONImagePresenter.golden b/internal/formats/spdx22json/test-fixtures/snapshot/TestSPDXJSONImageEncoder.golden similarity index 100% rename from internal/formats/spdx22json/test-fixtures/snapshot/TestSPDXJSONImagePresenter.golden rename to internal/formats/spdx22json/test-fixtures/snapshot/TestSPDXJSONImageEncoder.golden diff --git a/internal/formats/spdx22tagvalue/encoder_test.go b/internal/formats/spdx22tagvalue/encoder_test.go index 2a3ab3538af..af8dd500a75 100644 --- a/internal/formats/spdx22tagvalue/encoder_test.go +++ b/internal/formats/spdx22tagvalue/encoder_test.go @@ -8,21 +8,23 @@ import ( "github.com/anchore/syft/internal/formats/common/testutils" ) -var updateSpdxTagValue = flag.Bool("update-spdx-tv", false, "update the *.golden files for spdx-tv presenters") +var updateSpdxTagValue = flag.Bool("update-spdx-tv", false, "update the *.golden files for spdx-tv encoders") -func TestSPDXTagValueDirectoryPresenter(t *testing.T) { +func TestSPDXTagValueDirectoryEncoder(t *testing.T) { - testutils.AssertPresenterAgainstGoldenSnapshot(t, - Format().Presenter(testutils.DirectoryInput(t)), + testutils.AssertEncoderAgainstGoldenSnapshot(t, + Format(), + testutils.DirectoryInput(t), *updateSpdxTagValue, spdxTagValueRedactor, ) } -func TestSPDXTagValueImagePresenter(t *testing.T) { +func TestSPDXTagValueImageEncoder(t *testing.T) { testImage := "image-simple" - testutils.AssertPresenterAgainstGoldenImageSnapshot(t, - Format().Presenter(testutils.ImageInput(t, testImage, testutils.FromSnapshot())), + testutils.AssertEncoderAgainstGoldenImageSnapshot(t, + Format(), + testutils.ImageInput(t, testImage, testutils.FromSnapshot()), testImage, *updateSpdxTagValue, spdxTagValueRedactor, diff --git a/internal/formats/spdx22tagvalue/test-fixtures/snapshot/TestSPDXTagValueDirectoryPresenter.golden b/internal/formats/spdx22tagvalue/test-fixtures/snapshot/TestSPDXTagValueDirectoryEncoder.golden similarity index 100% rename from internal/formats/spdx22tagvalue/test-fixtures/snapshot/TestSPDXTagValueDirectoryPresenter.golden rename to internal/formats/spdx22tagvalue/test-fixtures/snapshot/TestSPDXTagValueDirectoryEncoder.golden diff --git a/internal/formats/spdx22tagvalue/test-fixtures/snapshot/TestSPDXTagValueImagePresenter.golden b/internal/formats/spdx22tagvalue/test-fixtures/snapshot/TestSPDXTagValueImageEncoder.golden similarity index 100% rename from internal/formats/spdx22tagvalue/test-fixtures/snapshot/TestSPDXTagValueImagePresenter.golden rename to internal/formats/spdx22tagvalue/test-fixtures/snapshot/TestSPDXTagValueImageEncoder.golden diff --git a/internal/formats/syftjson/encoder_test.go b/internal/formats/syftjson/encoder_test.go index 5a9f30f5c47..2544c32750a 100644 --- a/internal/formats/syftjson/encoder_test.go +++ b/internal/formats/syftjson/encoder_test.go @@ -16,19 +16,21 @@ import ( "github.com/anchore/syft/internal/formats/common/testutils" ) -var updateJson = flag.Bool("update-json", false, "update the *.golden files for json presenters") +var updateJson = flag.Bool("update-json", false, "update the *.golden files for json encoders") -func TestDirectoryPresenter(t *testing.T) { - testutils.AssertPresenterAgainstGoldenSnapshot(t, - Format().Presenter(testutils.DirectoryInput(t)), +func TestDirectoryEncoder(t *testing.T) { + testutils.AssertEncoderAgainstGoldenSnapshot(t, + Format(), + testutils.DirectoryInput(t), *updateJson, ) } -func TestImagePresenter(t *testing.T) { +func TestImageEncoder(t *testing.T) { testImage := "image-simple" - testutils.AssertPresenterAgainstGoldenImageSnapshot(t, - Format().Presenter(testutils.ImageInput(t, testImage, testutils.FromSnapshot())), + testutils.AssertEncoderAgainstGoldenImageSnapshot(t, + Format(), + testutils.ImageInput(t, testImage, testutils.FromSnapshot()), testImage, *updateJson, ) @@ -192,8 +194,9 @@ func TestEncodeFullJSONDocument(t *testing.T) { }, } - testutils.AssertPresenterAgainstGoldenSnapshot(t, - Format().Presenter(s), + testutils.AssertEncoderAgainstGoldenSnapshot(t, + Format(), + s, *updateJson, ) } diff --git a/internal/formats/syftjson/test-fixtures/snapshot/TestDirectoryPresenter.golden b/internal/formats/syftjson/test-fixtures/snapshot/TestDirectoryEncoder.golden similarity index 100% rename from internal/formats/syftjson/test-fixtures/snapshot/TestDirectoryPresenter.golden rename to internal/formats/syftjson/test-fixtures/snapshot/TestDirectoryEncoder.golden diff --git a/internal/formats/syftjson/test-fixtures/snapshot/TestImagePresenter.golden b/internal/formats/syftjson/test-fixtures/snapshot/TestImageEncoder.golden similarity index 100% rename from internal/formats/syftjson/test-fixtures/snapshot/TestImagePresenter.golden rename to internal/formats/syftjson/test-fixtures/snapshot/TestImageEncoder.golden diff --git a/internal/formats/table/encoder_test.go b/internal/formats/table/encoder_test.go index 7bcd62b4ef5..b2ba0236889 100644 --- a/internal/formats/table/encoder_test.go +++ b/internal/formats/table/encoder_test.go @@ -10,9 +10,10 @@ import ( var updateTableGoldenFiles = flag.Bool("update-table", false, "update the *.golden files for table format") -func TestTablePresenter(t *testing.T) { - testutils.AssertPresenterAgainstGoldenSnapshot(t, - Format().Presenter(testutils.DirectoryInput(t)), +func TestTableEncoder(t *testing.T) { + testutils.AssertEncoderAgainstGoldenSnapshot(t, + Format(), + testutils.DirectoryInput(t), *updateTableGoldenFiles, ) } diff --git a/internal/formats/table/test-fixtures/snapshot/TestTablePresenter.golden b/internal/formats/table/test-fixtures/snapshot/TestTableEncoder.golden similarity index 100% rename from internal/formats/table/test-fixtures/snapshot/TestTablePresenter.golden rename to internal/formats/table/test-fixtures/snapshot/TestTableEncoder.golden diff --git a/internal/formats/text/encoder_test.go b/internal/formats/text/encoder_test.go index 3ae7e87e45c..7d4a4cd7094 100644 --- a/internal/formats/text/encoder_test.go +++ b/internal/formats/text/encoder_test.go @@ -7,20 +7,22 @@ import ( "github.com/anchore/syft/internal/formats/common/testutils" ) -var updateTextPresenterGoldenFiles = flag.Bool("update-text", false, "update the *.golden files for text presenters") +var updateTextEncoderGoldenFiles = flag.Bool("update-text", false, "update the *.golden files for text encoder") -func TestTextDirectoryPresenter(t *testing.T) { - testutils.AssertPresenterAgainstGoldenSnapshot(t, - Format().Presenter(testutils.DirectoryInput(t)), - *updateTextPresenterGoldenFiles, +func TestTextDirectoryEncoder(t *testing.T) { + testutils.AssertEncoderAgainstGoldenSnapshot(t, + Format(), + testutils.DirectoryInput(t), + *updateTextEncoderGoldenFiles, ) } -func TestTextImagePresenter(t *testing.T) { +func TestTextImageEncoder(t *testing.T) { testImage := "image-simple" - testutils.AssertPresenterAgainstGoldenImageSnapshot(t, - Format().Presenter(testutils.ImageInput(t, testImage, testutils.FromSnapshot())), + testutils.AssertEncoderAgainstGoldenImageSnapshot(t, + Format(), + testutils.ImageInput(t, testImage, testutils.FromSnapshot()), testImage, - *updateTextPresenterGoldenFiles, + *updateTextEncoderGoldenFiles, ) } diff --git a/internal/formats/text/test-fixtures/snapshot/TestTextDirectoryPresenter.golden b/internal/formats/text/test-fixtures/snapshot/TestTextDirectoryEncoder.golden similarity index 100% rename from internal/formats/text/test-fixtures/snapshot/TestTextDirectoryPresenter.golden rename to internal/formats/text/test-fixtures/snapshot/TestTextDirectoryEncoder.golden diff --git a/internal/formats/text/test-fixtures/snapshot/TestTextImagePresenter.golden b/internal/formats/text/test-fixtures/snapshot/TestTextImageEncoder.golden similarity index 100% rename from internal/formats/text/test-fixtures/snapshot/TestTextImagePresenter.golden rename to internal/formats/text/test-fixtures/snapshot/TestTextImageEncoder.golden diff --git a/internal/output/writer.go b/internal/output/writer.go new file mode 100644 index 00000000000..a41f0745e55 --- /dev/null +++ b/internal/output/writer.go @@ -0,0 +1,116 @@ +package output + +import ( + "fmt" + "io" + "os" + "path" + + "github.com/anchore/syft/syft/format" + "github.com/anchore/syft/syft/sbom" + "github.com/hashicorp/go-multierror" +) + +// streamWriter implements sbom.Writer for a given format and io.Writer, also providing a close function for cleanup +type streamWriter struct { + format format.Format + out io.Writer + close func() error +} + +// Write the provided SBOM to the data stream +func (w *streamWriter) Write(s sbom.SBOM) error { + return w.format.Encode(w.out, s) +} + +// Close any resources, such as open files +func (w *streamWriter) Close() error { + if w.close != nil { + return w.close() + } + return nil +} + +// multiWriter holds a list of child sbom.Writers to apply all Write and Close operations to +type multiWriter struct { + writers []sbom.Writer +} + +// Write writes the SBOM to all writers +func (m *multiWriter) Write(s sbom.SBOM) (errs error) { + for _, w := range m.writers { + err := w.Write(s) + if err != nil { + errs = multierror.Append(errs, err) + } + } + return errs +} + +// Close closes all writers +func (m *multiWriter) Close() (errs error) { + for _, w := range m.writers { + err := w.Close() + if err != nil { + errs = multierror.Append(errs, err) + } + } + return errs +} + +// WriterOption Format and path strings used to create sbom.Writer +type WriterOption struct { + Format format.Format + Path string +} + +// MakeWriter create all report writers from input options; if a file is not specified, os.Stdout is used +func MakeWriter(options ...WriterOption) (_ sbom.Writer, errs error) { + if len(options) == 0 { + return nil, fmt.Errorf("no output options provided") + } + + out := &multiWriter{} + + defer func() { + if errs != nil { + // close any previously opened files; we can't really recover from any errors + _ = out.Close() + } + }() + + for _, option := range options { + switch len(option.Path) { + case 0: + out.writers = append(out.writers, &streamWriter{ + format: option.Format, + out: os.Stdout, + }) + default: + // create any missing subdirectories + dir := path.Dir(option.Path) + if dir != "" { + s, err := os.Stat(dir) + if err != nil { + err = os.MkdirAll(dir, 0755) // maybe should be os.ModePerm ? + if err != nil { + return nil, err + } + } else if !s.IsDir() { + return nil, fmt.Errorf("output path does not contain a valid directory: %s", option.Path) + } + } + fileOut, err := os.OpenFile(option.Path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return nil, fmt.Errorf("unable to create report file: %w", err) + } + out.writers = append(out.writers, &streamWriter{ + format: option.Format, + out: fileOut, + close: fileOut.Close, + }) + } + } + + return out, nil +} diff --git a/internal/output/writer_test.go b/internal/output/writer_test.go new file mode 100644 index 00000000000..8bc9d285f66 --- /dev/null +++ b/internal/output/writer_test.go @@ -0,0 +1,168 @@ +package output + +import ( + "strings" + "testing" + + "github.com/anchore/syft/internal/formats/spdx22json" + "github.com/anchore/syft/internal/formats/syftjson" + "github.com/anchore/syft/internal/formats/table" + "github.com/anchore/syft/internal/formats/text" + + "github.com/stretchr/testify/assert" +) + +type writerConfig struct { + format string + file string +} + +func TestOutputWriter(t *testing.T) { + tmp := t.TempDir() + + testName := func(options []WriterOption, err bool) string { + var out []string + for _, opt := range options { + out = append(out, string(opt.Format.Option)+"="+opt.Path) + } + errs := "" + if err { + errs = "(err)" + } + return strings.Join(out, ", ") + errs + } + + tests := []struct { + outputs []WriterOption + err bool + expected []writerConfig + }{ + { + outputs: []WriterOption{}, + err: true, + }, + { + outputs: []WriterOption{ + { + Format: table.Format(), + Path: "", + }, + }, + expected: []writerConfig{ + { + format: "table", + }, + }, + }, + { + outputs: []WriterOption{ + { + Format: syftjson.Format(), + }, + }, + expected: []writerConfig{ + { + format: "json", + }, + }, + }, + { + outputs: []WriterOption{ + { + Format: syftjson.Format(), + Path: "test-2.json", + }, + }, + expected: []writerConfig{ + { + format: "json", + file: "test-2.json", + }, + }, + }, + { + outputs: []WriterOption{ + { + Format: syftjson.Format(), + Path: "test-3/1.json", + }, + { + Format: spdx22json.Format(), + Path: "test-3/2.json", + }, + }, + expected: []writerConfig{ + { + format: "json", + file: "test-3/1.json", + }, + { + format: "spdx-json", + file: "test-3/2.json", + }, + }, + }, + { + outputs: []WriterOption{ + { + Format: text.Format(), + }, + { + Format: spdx22json.Format(), + Path: "test-4.json", + }, + }, + expected: []writerConfig{ + { + format: "text", + }, + { + format: "spdx-json", + file: "test-4.json", + }, + }, + }, + } + + for _, test := range tests { + t.Run(testName(test.outputs, test.err), func(t *testing.T) { + outputs := test.outputs + for i := range outputs { + if outputs[i].Path != "" { + outputs[i].Path = tmp + outputs[i].Path + } + } + + writer, err := MakeWriter(outputs...) + + if test.err { + assert.Error(t, err) + return + } else { + assert.NoError(t, err) + } + + mw := writer.(*multiWriter) + + assert.Len(t, mw.writers, len(test.expected)) + + for i, e := range test.expected { + w := mw.writers[i].(*streamWriter) + + assert.Equal(t, string(w.format.Option), e.format) + + if e.file != "" { + assert.FileExists(t, tmp+e.file) + } + + if e.file != "" { + assert.NotNil(t, w.out) + assert.NotNil(t, w.close) + } else { + assert.NotNil(t, w.out) + assert.Nil(t, w.close) + } + } + }) + } +} diff --git a/internal/ui/common_event_handlers.go b/internal/ui/common_event_handlers.go index 7617e0b9882..39b131e8087 100644 --- a/internal/ui/common_event_handlers.go +++ b/internal/ui/common_event_handlers.go @@ -2,23 +2,22 @@ package ui import ( "fmt" - "io" syftEventParsers "github.com/anchore/syft/syft/event/parsers" "github.com/wagoodman/go-partybus" ) -// handleCatalogerPresenterReady is a UI function for processing the CatalogerFinished bus event, displaying the catalog -// via the given presenter to stdout. -func handleCatalogerPresenterReady(event partybus.Event, reportOutput io.Writer) error { +// handleExit is a UI function for processing the Exit bus event, +// and calling the given function to output the contents. +func handleExit(event partybus.Event) error { // show the report to stdout - pres, err := syftEventParsers.ParsePresenterReady(event) + fn, err := syftEventParsers.ParseExit(event) if err != nil { return fmt.Errorf("bad CatalogerFinished event: %w", err) } - if err := pres.Present(reportOutput); err != nil { - return fmt.Errorf("unable to show package catalog report: %w", err) + if err := fn(); err != nil { + return fmt.Errorf("unable to show package catalog report: %v", err) } return nil } diff --git a/internal/ui/ephemeral_terminal_ui.go b/internal/ui/ephemeral_terminal_ui.go index 964a5947989..7cf7ad329ea 100644 --- a/internal/ui/ephemeral_terminal_ui.go +++ b/internal/ui/ephemeral_terminal_ui.go @@ -36,22 +36,20 @@ import ( // or in the shared ui package as a function on the main handler object. All handler functions should be completed // processing an event before the ETUI exits (coordinated with a sync.WaitGroup) type ephemeralTerminalUI struct { - unsubscribe func() error - handler *ui.Handler - waitGroup *sync.WaitGroup - frame *frame.Frame - logBuffer *bytes.Buffer - uiOutput *os.File - reportOutput io.Writer + unsubscribe func() error + handler *ui.Handler + waitGroup *sync.WaitGroup + frame *frame.Frame + logBuffer *bytes.Buffer + uiOutput *os.File } // NewEphemeralTerminalUI writes all events to a TUI and writes the final report to the given writer. -func NewEphemeralTerminalUI(reportWriter io.Writer) UI { +func NewEphemeralTerminalUI() UI { return &ephemeralTerminalUI{ - handler: ui.NewHandler(), - waitGroup: &sync.WaitGroup{}, - uiOutput: os.Stderr, - reportOutput: reportWriter, + handler: ui.NewHandler(), + waitGroup: &sync.WaitGroup{}, + uiOutput: os.Stderr, } } @@ -82,12 +80,12 @@ func (h *ephemeralTerminalUI) Handle(event partybus.Event) error { log.Errorf("unable to show %s event: %+v", event.Type, err) } - case event.Type == syftEvent.PresenterReady: - // we need to close the screen now since signaling the the presenter is ready means that we + case event.Type == syftEvent.Exit: + // we need to close the screen now since signaling the sbom is ready means that we // are about to write bytes to stdout, so we should reset the terminal state first h.closeScreen(false) - if err := handleCatalogerPresenterReady(event, h.reportOutput); err != nil { + if err := handleExit(event); err != nil { log.Errorf("unable to show %s event: %+v", event.Type, err) } diff --git a/internal/ui/logger_ui.go b/internal/ui/logger_ui.go index 49a8431ad4b..53cd9f7a952 100644 --- a/internal/ui/logger_ui.go +++ b/internal/ui/logger_ui.go @@ -1,23 +1,18 @@ package ui import ( - "io" - "github.com/anchore/syft/internal/log" syftEvent "github.com/anchore/syft/syft/event" "github.com/wagoodman/go-partybus" ) type loggerUI struct { - unsubscribe func() error - reportOutput io.Writer + unsubscribe func() error } // NewLoggerUI writes all events to the common application logger and writes the final report to the given writer. -func NewLoggerUI(reportWriter io.Writer) UI { - return &loggerUI{ - reportOutput: reportWriter, - } +func NewLoggerUI() UI { + return &loggerUI{} } func (l *loggerUI) Setup(unsubscribe func() error) error { @@ -27,11 +22,11 @@ func (l *loggerUI) Setup(unsubscribe func() error) error { func (l loggerUI) Handle(event partybus.Event) error { // ignore all events except for the final event - if event.Type != syftEvent.PresenterReady { + if event.Type != syftEvent.Exit { return nil } - if err := handleCatalogerPresenterReady(event, l.reportOutput); err != nil { + if err := handleExit(event); err != nil { log.Warnf("unable to show catalog image finished event: %+v", err) } diff --git a/internal/ui/select.go b/internal/ui/select.go index 52ae0ef6a8f..b8b3adf193f 100644 --- a/internal/ui/select.go +++ b/internal/ui/select.go @@ -4,7 +4,6 @@ package ui import ( - "io" "os" "runtime" @@ -16,16 +15,16 @@ import ( // is intended to be used and the UIs that follow are meant to be attempted only in a fallback posture when there // are environmental problems (e.g. cannot write to the terminal). A writer is provided to capture the output of // the final SBOM report. -func Select(verbose, quiet bool, reportWriter io.Writer) (uis []UI) { +func Select(verbose, quiet bool) (uis []UI) { isStdoutATty := term.IsTerminal(int(os.Stdout.Fd())) isStderrATty := term.IsTerminal(int(os.Stderr.Fd())) notATerminal := !isStderrATty && !isStdoutATty switch { case runtime.GOOS == "windows" || verbose || quiet || notATerminal || !isStderrATty: - uis = append(uis, NewLoggerUI(reportWriter)) + uis = append(uis, NewLoggerUI()) default: - uis = append(uis, NewEphemeralTerminalUI(reportWriter), NewLoggerUI(reportWriter)) + uis = append(uis, NewEphemeralTerminalUI()) } return uis diff --git a/internal/ui/select_windows.go b/internal/ui/select_windows.go index 20e7e58f9a6..cd8c79839ec 100644 --- a/internal/ui/select_windows.go +++ b/internal/ui/select_windows.go @@ -3,15 +3,11 @@ package ui -import ( - "io" -) - // Select is responsible for determining the specific UI function given select user option, the current platform // config values, and environment status (such as a TTY being present). The first UI in the returned slice of UIs // is intended to be used and the UIs that follow are meant to be attempted only in a fallback posture when there // are environmental problems (e.g. cannot write to the terminal). A writer is provided to capture the output of // the final SBOM report. -func Select(verbose, quiet bool, reportWriter io.Writer) (uis []UI) { - return append(uis, NewLoggerUI(reportWriter)) +func Select(verbose, quiet bool) (uis []UI) { + return append(uis, NewLoggerUI()) } diff --git a/syft/event/event.go b/syft/event/event.go index 4b0e6fd5cab..c5ff81d314e 100644 --- a/syft/event/event.go +++ b/syft/event/event.go @@ -26,8 +26,8 @@ const ( // FileIndexingStarted is a partybus event that occurs when the directory resolver begins indexing a filesystem FileIndexingStarted partybus.EventType = "syft-file-indexing-started-event" - // PresenterReady is a partybus event that occurs when an analysis result is ready for final presentation - PresenterReady partybus.EventType = "syft-presenter-ready-event" + // Exit is a partybus event that occurs when an analysis result is ready for final presentation + Exit partybus.EventType = "syft-exit-event" // ImportStarted is a partybus event that occurs when an SBOM upload process has begun ImportStarted partybus.EventType = "syft-import-started-event" diff --git a/syft/event/parsers/parsers.go b/syft/event/parsers/parsers.go index b91aad1479b..6abcd28caae 100644 --- a/syft/event/parsers/parsers.go +++ b/syft/event/parsers/parsers.go @@ -6,7 +6,6 @@ package parsers import ( "fmt" - "github.com/anchore/go-presenter" "github.com/anchore/syft/syft/event" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/pkg/cataloger" @@ -109,17 +108,17 @@ func ParseFileIndexingStarted(e partybus.Event) (string, progress.StagedProgress return path, prog, nil } -func ParsePresenterReady(e partybus.Event) (presenter.Presenter, error) { - if err := checkEventType(e.Type, event.PresenterReady); err != nil { +func ParseExit(e partybus.Event) (func() error, error) { + if err := checkEventType(e.Type, event.Exit); err != nil { return nil, err } - pres, ok := e.Value.(presenter.Presenter) + fn, ok := e.Value.(func() error) if !ok { return nil, newPayloadErr(e.Type, "Value", e.Value) } - return pres, nil + return fn, nil } func ParseAppUpdateAvailable(e partybus.Event) (string, error) { diff --git a/syft/format/format.go b/syft/format/format.go index a7dacb15a2d..cc66b06324d 100644 --- a/syft/format/format.go +++ b/syft/format/format.go @@ -50,10 +50,3 @@ func (f Format) Validate(reader io.Reader) error { return f.validator(reader) } - -func (f Format) Presenter(s sbom.SBOM) *Presenter { - if f.encoder == nil { - return nil - } - return NewPresenter(f.encoder, s) -} diff --git a/syft/format/presenter.go b/syft/format/presenter.go deleted file mode 100644 index 1ec0189912f..00000000000 --- a/syft/format/presenter.go +++ /dev/null @@ -1,23 +0,0 @@ -package format - -import ( - "io" - - "github.com/anchore/syft/syft/sbom" -) - -type Presenter struct { - sbom sbom.SBOM - encoder Encoder -} - -func NewPresenter(encoder Encoder, s sbom.SBOM) *Presenter { - return &Presenter{ - sbom: s, - encoder: encoder, - } -} - -func (pres *Presenter) Present(output io.Writer) error { - return pres.encoder(output, pres.sbom) -} diff --git a/syft/lib.go b/syft/lib.go index 244312b03e1..4e965b627e5 100644 --- a/syft/lib.go +++ b/syft/lib.go @@ -5,7 +5,7 @@ Here is what the main execution path for syft does: 1. Parse a user image string to get a stereoscope image.Source object 2. Invoke all catalogers to catalog the image, adding discovered packages to a single catalog object - 3. Invoke a single presenter to show the contents of the catalog + 3. Invoke one or more encoders to output contents of the catalog A Source object encapsulates the image object to be cataloged and the user options (catalog all layers vs. squashed layer), providing a way to inspect paths and file content within the image. The Source object, not the image object, is used diff --git a/syft/sbom/writer.go b/syft/sbom/writer.go new file mode 100644 index 00000000000..824e2fd161d --- /dev/null +++ b/syft/sbom/writer.go @@ -0,0 +1,13 @@ +package sbom + +import "io" + +// Writer an interface to write SBOMs +type Writer interface { + // Write writes the provided SBOM + Write(SBOM) error + + // Closer a resource cleanup hook which will be called after SBOM + // is written or if an error occurs before Write is called + io.Closer +} diff --git a/test/cli/packages_cmd_test.go b/test/cli/packages_cmd_test.go index 30fe8700541..93bba2f8ef9 100644 --- a/test/cli/packages_cmd_test.go +++ b/test/cli/packages_cmd_test.go @@ -8,6 +8,7 @@ import ( func TestPackagesCmdFlags(t *testing.T) { coverageImage := "docker-archive:" + getFixtureImage(t, "image-pkg-coverage") //badBinariesImage := "docker-archive:" + getFixtureImage(t, "image-bad-binaries") + tmp := t.TempDir() + "/" tests := []struct { name string @@ -32,6 +33,15 @@ func TestPackagesCmdFlags(t *testing.T) { assertSuccessfulReturnCode, }, }, + { + name: "multiple-output-flags", + args: []string{"packages", "-o", "table", "-o", "json=" + tmp + ".tmp/multiple-output-flag-test.json", coverageImage}, + assertions: []traitAssertion{ + assertTableReport, + assertFileExists(tmp + ".tmp/multiple-output-flag-test.json"), + assertSuccessfulReturnCode, + }, + }, // I haven't been able to reproduce locally yet, but in CI this has proven to be unstable: // For the same commit: // pass: https://github.com/anchore/syft/runs/4611344142?check_suite_focus=true diff --git a/test/cli/trait_assertions_test.go b/test/cli/trait_assertions_test.go index d19f14a4c12..8794e8b6d56 100644 --- a/test/cli/trait_assertions_test.go +++ b/test/cli/trait_assertions_test.go @@ -2,6 +2,7 @@ package cli import ( "encoding/json" + "os" "regexp" "strings" "testing" @@ -114,3 +115,12 @@ func assertSuccessfulReturnCode(tb testing.TB, _, _ string, rc int) { tb.Errorf("expected no failure but got rc=%d", rc) } } + +func assertFileExists(file string) traitAssertion { + return func(tb testing.TB, _, _ string, _ int) { + tb.Helper() + if _, err := os.Stat(file); err != nil { + tb.Errorf("expected file to exist %s", file) + } + } +} diff --git a/test/integration/package_ownership_relationship_test.go b/test/integration/package_ownership_relationship_test.go index 2166bd9b7f0..587b15459c5 100644 --- a/test/integration/package_ownership_relationship_test.go +++ b/test/integration/package_ownership_relationship_test.go @@ -11,7 +11,7 @@ import ( func TestPackageOwnershipRelationships(t *testing.T) { - // ensure that the json presenter is applying artifact ownership with an image that has expected ownership relationships + // ensure that the json encoder is applying artifact ownership with an image that has expected ownership relationships tests := []struct { fixture string }{ @@ -24,13 +24,8 @@ func TestPackageOwnershipRelationships(t *testing.T) { t.Run(test.fixture, func(t *testing.T) { sbom, _ := catalogFixtureImage(t, test.fixture) - p := syftjson.Format().Presenter(sbom) - if p == nil { - t.Fatal("unable to get presenter") - } - output := bytes.NewBufferString("") - err := p.Present(output) + err := syftjson.Format().Encode(output, sbom) if err != nil { t.Fatalf("unable to present: %+v", err) }