Add support for multiple output files in different formats (#732)

This commit is contained in:
Keith Zantow 2022-01-06 17:52:20 -05:00 committed by GitHub
parent 38c4b17847
commit 5e5312c72d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 642 additions and 243 deletions

View File

@ -112,7 +112,8 @@ may attempt to expand wildcards, so put those parameters in single quotes, like:
### Output formats ### 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 <image> -o <format> syft packages <image> -o <format>
@ -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). - `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). - `table`: A columnar summary (default).
#### Multiple outputs
Syft can also output _multiple_ files in differing formats by appending
`=<file>` to the option, for example to output Syft JSON and SPDX JSON:
```shell
syft packages <image> -o json=sbom.syft.json -o spdx-json=sbom.spdx.json
```
## Private Registry Authentication ## Private Registry Authentication
### Local Docker Credentials ### Local Docker Credentials
@ -221,8 +231,12 @@ Configuration search paths:
Configuration options (example values are the default): Configuration options (example values are the default):
```yaml ```yaml
# the output format of the SBOM report (options: table, text, json) # the output format(s) of the SBOM report (options: table, text, json, spdx, ...)
# same as -o ; SYFT_OUTPUT env var # same as -o, --output, and SYFT_OUTPUT env var
# to specify multiple output files in differing formats, use a list:
# output:
# - "json=<syft-json-output-file>"
# - "spdx-json=<spdx-json-output-file>"
output: "table" output: "table"
# suppress all output (except for the SBOM report) # 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: # a list of globs to exclude from scanning. same as --exclude ; for example:
# exclude: # exclude:
# - '/etc/**' # - "/etc/**"
# - './out/**/*.json' # - "./out/**/*.json"
exclude: exclude:
# cataloging packages is exposed through the packages and power-user subcommands # cataloging packages is exposed through the packages and power-user subcommands

View File

@ -50,7 +50,7 @@ func Test_eventLoop_gracefulExit(t *testing.T) {
t.Cleanup(testBus.Close) t.Cleanup(testBus.Close)
finalEvent := partybus.Event{ finalEvent := partybus.Event{
Type: event.PresenterReady, Type: event.Exit,
} }
worker := func() <-chan error { worker := func() <-chan error {
@ -182,7 +182,7 @@ func Test_eventLoop_unsubscribeError(t *testing.T) {
t.Cleanup(testBus.Close) t.Cleanup(testBus.Close)
finalEvent := partybus.Event{ finalEvent := partybus.Event{
Type: event.PresenterReady, Type: event.Exit,
} }
worker := func() <-chan error { worker := func() <-chan error {
@ -251,8 +251,8 @@ func Test_eventLoop_handlerError(t *testing.T) {
t.Cleanup(testBus.Close) t.Cleanup(testBus.Close)
finalEvent := partybus.Event{ finalEvent := partybus.Event{
Type: event.PresenterReady, Type: event.Exit,
Error: fmt.Errorf("unable to create presenter"), Error: fmt.Errorf("an exit error occured"),
} }
worker := func() <-chan error { worker := func() <-chan error {
@ -376,7 +376,7 @@ func Test_eventLoop_uiTeardownError(t *testing.T) {
t.Cleanup(testBus.Close) t.Cleanup(testBus.Close)
finalEvent := partybus.Event{ finalEvent := partybus.Event{
Type: event.PresenterReady, Type: event.Exit,
} }
worker := func() <-chan error { worker := func() <-chan error {

72
cmd/output_writer.go Normal file
View File

@ -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 <format>=<file>
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
}

78
cmd/output_writer_test.go Normal file
View File

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

View File

@ -10,7 +10,6 @@ import (
"github.com/anchore/syft/internal" "github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/anchore" "github.com/anchore/syft/internal/anchore"
"github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/formats"
"github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/ui" "github.com/anchore/syft/internal/ui"
"github.com/anchore/syft/internal/version" "github.com/anchore/syft/internal/version"
@ -51,7 +50,6 @@ const (
) )
var ( var (
packagesPresenterOpt format.Option
packagesCmd = &cobra.Command{ packagesCmd = &cobra.Command{
Use: "packages [SOURCE]", Use: "packages [SOURCE]",
Short: "Generate a package SBOM", Short: "Generate a package SBOM",
@ -63,14 +61,7 @@ var (
Args: validateInputArgs, Args: validateInputArgs,
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true, SilenceErrors: true,
PreRunE: func(cmd *cobra.Command, args []string) error { PreRunE: func(cmd *cobra.Command, args []string) (err error) {
// set the presenter
presenterOption := format.ParseOption(appConfig.Output)
if presenterOption == format.UnknownFormatOption {
return fmt.Errorf("bad --output value '%s'", appConfig.Output)
}
packagesPresenterOpt = presenterOption
if appConfig.Dev.ProfileCPU && appConfig.Dev.ProfileMem { if appConfig.Dev.ProfileCPU && appConfig.Dev.ProfileMem {
return fmt.Errorf("cannot profile CPU and memory simultaneously") return fmt.Errorf("cannot profile CPU and memory simultaneously")
} }
@ -102,14 +93,14 @@ func setPackageFlags(flags *pflag.FlagSet) {
"scope", "s", cataloger.DefaultSearchConfig().Scope.String(), "scope", "s", cataloger.DefaultSearchConfig().Scope.String(),
fmt.Sprintf("selection of layers to catalog, options=%v", source.AllScopes)) fmt.Sprintf("selection of layers to catalog, options=%v", source.AllScopes))
flags.StringP( flags.StringArrayP(
"output", "o", string(format.TableOption), "output", "o", []string{string(format.TableOption)},
fmt.Sprintf("report output formatter, options=%v", format.AllOptions), fmt.Sprintf("report output format, options=%v", format.AllOptions),
) )
flags.StringP( flags.StringP(
"file", "", "", "file", "", "",
"file to write the report output to (default is STDOUT)", "file to write the default report output to (default is STDOUT)",
) )
// Upload options ////////////////////////////////////////////////////////// // Upload options //////////////////////////////////////////////////////////
@ -210,26 +201,26 @@ func validateInputArgs(cmd *cobra.Command, args []string) error {
} }
func packagesExec(_ *cobra.Command, args []string) error { func packagesExec(_ *cobra.Command, args []string) error {
// could be an image or a directory, with or without a scheme writer, err := makeWriter(appConfig.Output, appConfig.File)
userInput := args[0]
reporter, closer, err := reportWriter()
defer func() {
if err := closer(); err != nil {
log.Warnf("unable to write to report destination: %+v", err)
}
}()
if err != nil { if err != nil {
return err return err
} }
defer func() {
if err := writer.Close(); err != nil {
log.Warnf("unable to write to report destination: %w", err)
}
}()
// could be an image or a directory, with or without a scheme
userInput := args[0]
return eventLoop( return eventLoop(
packagesExecWorker(userInput), packagesExecWorker(userInput, writer),
setupSignals(), setupSignals(),
eventSubscription, eventSubscription,
stereoscope.Cleanup, 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 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) errs := make(chan error)
go func() { go func() {
defer close(errs) defer close(errs)
@ -255,12 +246,6 @@ func packagesExecWorker(userInput string) <-chan error {
return 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) src, cleanup, err := source.New(userInput, appConfig.Registry.ToOptions(), appConfig.Exclusions)
if err != nil { if err != nil {
errs <- fmt.Errorf("failed to construct source from user input %q: %w", userInput, err) 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{ bus.Publish(partybus.Event{
Type: event.PresenterReady, Type: event.Exit,
Value: f.Presenter(s), Value: func() error { return writer.Write(s) },
}) })
}() }()
return errs return errs

View File

@ -9,6 +9,7 @@ import (
"github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/formats/syftjson" "github.com/anchore/syft/internal/formats/syftjson"
"github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/output"
"github.com/anchore/syft/internal/ui" "github.com/anchore/syft/internal/ui"
"github.com/anchore/syft/internal/version" "github.com/anchore/syft/internal/version"
"github.com/anchore/syft/syft/artifact" "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 // could be an image or a directory, with or without a scheme
userInput := args[0] 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() { defer func() {
if err := closer(); err != nil { if err := writer.Close(); err != nil {
log.Warnf("unable to write to report destination: %+v", err) 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) fmt.Fprintln(os.Stderr, deprecated)
}() }()
if err != nil {
return err
}
return eventLoop( return eventLoop(
powerUserExecWorker(userInput), powerUserExecWorker(userInput, writer),
setupSignals(), setupSignals(),
eventSubscription, eventSubscription,
stereoscope.Cleanup, 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) errs := make(chan error)
go func() { go func() {
defer close(errs) defer close(errs)
@ -140,8 +144,8 @@ func powerUserExecWorker(userInput string) <-chan error {
s.Relationships = append(s.Relationships, mergeRelationships(relationships...)...) s.Relationships = append(s.Relationships, mergeRelationships(relationships...)...)
bus.Publish(partybus.Event{ bus.Publish(partybus.Event{
Type: event.PresenterReady, Type: event.Exit,
Value: syftjson.Format().Presenter(s), Value: func() error { return writer.Write(s) },
}) })
}() }()

View File

@ -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
}
}

1
go.mod
View File

@ -8,7 +8,6 @@ require (
github.com/adrg/xdg v0.2.1 github.com/adrg/xdg v0.2.1
github.com/alecthomas/jsonschema v0.0.0-20210301060011-54c507b6f074 github.com/alecthomas/jsonschema v0.0.0-20210301060011-54c507b6f074
github.com/anchore/client-go v0.0.0-20210222170800-9c70f9b80bcf 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-rpmdb v0.0.0-20210914181456-a9c52348da63
github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04 github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04
github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b

2
go.sum
View File

@ -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/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 h1:DYssiUV1pBmKqzKsm4mqXx8artqC0Q8HgZsVI3lMsAg=
github.com/anchore/client-go v0.0.0-20210222170800-9c70f9b80bcf/go.mod h1:FaODhIA06mxO1E6R32JE0TL1JWZZkmjRIAd4ULvHUKk= 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 h1:C9W/LAydEz/qdUhx1MdjO9l8NEcFKYknkxDVyo9LAoM=
github.com/anchore/go-rpmdb v0.0.0-20210914181456-a9c52348da63/go.mod h1:6qH8c6U/3CBVvDDDBZnPSTbTINq3cIdADUYTaVf75EM= 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= github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04 h1:VzprUTpc0vW0nnNKJfJieyH/TZ9UYAnTZs5/gHTdAe8=

View File

@ -25,7 +25,7 @@ type packageSBOMImportAPI interface {
func packageSbomModel(s sbom.SBOM) (*external.ImagePackageManifest, error) { func packageSbomModel(s sbom.SBOM) (*external.ImagePackageManifest, error) {
var buf bytes.Buffer var buf bytes.Buffer
err := syftjson.Format().Presenter(s).Present(&buf) err := syftjson.Format().Encode(&buf, s)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to serialize results: %w", err) 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. // 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 var model external.ImagePackageManifest
if err = json.Unmarshal(buf.Bytes(), &model); err != nil { 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 return &model, nil

View File

@ -105,8 +105,7 @@ func TestPackageSbomToModel(t *testing.T) {
} }
var buf bytes.Buffer var buf bytes.Buffer
pres := syftjson.Format().Presenter(s) if err := syftjson.Format().Encode(&buf, s); err != nil {
if err := pres.Present(&buf); err != nil {
t.Fatalf("unable to get expected json: %+v", err) t.Fatalf("unable to get expected json: %+v", err)
} }

View File

@ -30,7 +30,7 @@ type parser interface {
// Application is the main syft application configuration. // Application is the main syft application configuration.
type Application struct { 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) 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 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) 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 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

View File

@ -2,9 +2,10 @@ package config
import ( import (
"fmt" "fmt"
"github.com/anchore/stereoscope/pkg/image"
"testing" "testing"
"github.com/anchore/stereoscope/pkg/image"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )

View File

@ -4,7 +4,7 @@ const (
// ApplicationName is the non-capitalized name of the application (do not change this) // ApplicationName is the non-capitalized name of the application (do not change this)
ApplicationName = "syft" 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. // 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" JSONSchemaVersion = "2.0.2"
) )

View File

@ -5,12 +5,12 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/anchore/go-presenter"
"github.com/anchore/go-testutils" "github.com/anchore/go-testutils"
"github.com/anchore/stereoscope/pkg/filetree" "github.com/anchore/stereoscope/pkg/filetree"
"github.com/anchore/stereoscope/pkg/image" "github.com/anchore/stereoscope/pkg/image"
"github.com/anchore/stereoscope/pkg/imagetest" "github.com/anchore/stereoscope/pkg/imagetest"
"github.com/anchore/syft/syft/distro" "github.com/anchore/syft/syft/distro"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source" "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 var buffer bytes.Buffer
// grab the latest image contents and persist // grab the latest image contents and persist
@ -40,11 +40,11 @@ func AssertPresenterAgainstGoldenImageSnapshot(t *testing.T, pres presenter.Pres
imagetest.UpdateGoldenFixtureImage(t, testImage) imagetest.UpdateGoldenFixtureImage(t, testImage)
} }
err := pres.Present(&buffer) err := format.Encode(&buffer, sbom)
assert.NoError(t, err) assert.NoError(t, err)
actual := buffer.Bytes() 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 { if updateSnapshot {
testutils.UpdateGoldenFileContents(t, actual) 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 var buffer bytes.Buffer
err := pres.Present(&buffer) err := format.Encode(&buffer, sbom)
assert.NoError(t, err) assert.NoError(t, err)
actual := buffer.Bytes() 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 { if updateSnapshot {
testutils.UpdateGoldenFileContents(t, actual) testutils.UpdateGoldenFileContents(t, actual)
} }

View File

@ -8,20 +8,22 @@ import (
"github.com/anchore/syft/internal/formats/common/testutils" "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) { func TestCycloneDxDirectoryEncoder(t *testing.T) {
testutils.AssertPresenterAgainstGoldenSnapshot(t, testutils.AssertEncoderAgainstGoldenSnapshot(t,
Format().Presenter(testutils.DirectoryInput(t)), Format(),
testutils.DirectoryInput(t),
*updateCycloneDx, *updateCycloneDx,
cycloneDxRedactor, cycloneDxRedactor,
) )
} }
func TestCycloneDxImagePresenter(t *testing.T) { func TestCycloneDxImageEncoder(t *testing.T) {
testImage := "image-simple" testImage := "image-simple"
testutils.AssertPresenterAgainstGoldenImageSnapshot(t, testutils.AssertEncoderAgainstGoldenImageSnapshot(t,
Format().Presenter(testutils.ImageInput(t, testImage)), Format(),
testutils.ImageInput(t, testImage),
testImage, testImage,
*updateCycloneDx, *updateCycloneDx,
cycloneDxRedactor, cycloneDxRedactor,

View File

@ -8,20 +8,22 @@ import (
"github.com/anchore/syft/internal/formats/common/testutils" "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) { func TestCycloneDxDirectoryEncoder(t *testing.T) {
testutils.AssertPresenterAgainstGoldenSnapshot(t, testutils.AssertEncoderAgainstGoldenSnapshot(t,
Format().Presenter(testutils.DirectoryInput(t)), Format(),
testutils.DirectoryInput(t),
*updateCycloneDx, *updateCycloneDx,
cycloneDxRedactor, cycloneDxRedactor,
) )
} }
func TestCycloneDxImagePresenter(t *testing.T) { func TestCycloneDxImageEncoder(t *testing.T) {
testImage := "image-simple" testImage := "image-simple"
testutils.AssertPresenterAgainstGoldenImageSnapshot(t, testutils.AssertEncoderAgainstGoldenImageSnapshot(t,
Format().Presenter(testutils.ImageInput(t, testImage)), Format(),
testutils.ImageInput(t, testImage),
testImage, testImage,
*updateCycloneDx, *updateCycloneDx,
cycloneDxRedactor, cycloneDxRedactor,

View File

@ -8,20 +8,22 @@ import (
"github.com/anchore/syft/internal/formats/common/testutils" "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) { func TestSPDXJSONDirectoryEncoder(t *testing.T) {
testutils.AssertPresenterAgainstGoldenSnapshot(t, testutils.AssertEncoderAgainstGoldenSnapshot(t,
Format().Presenter(testutils.DirectoryInput(t)), Format(),
testutils.DirectoryInput(t),
*updateSpdxJson, *updateSpdxJson,
spdxJsonRedactor, spdxJsonRedactor,
) )
} }
func TestSPDXJSONImagePresenter(t *testing.T) { func TestSPDXJSONImageEncoder(t *testing.T) {
testImage := "image-simple" testImage := "image-simple"
testutils.AssertPresenterAgainstGoldenImageSnapshot(t, testutils.AssertEncoderAgainstGoldenImageSnapshot(t,
Format().Presenter(testutils.ImageInput(t, testImage, testutils.FromSnapshot())), Format(),
testutils.ImageInput(t, testImage, testutils.FromSnapshot()),
testImage, testImage,
*updateSpdxJson, *updateSpdxJson,
spdxJsonRedactor, spdxJsonRedactor,

View File

@ -8,21 +8,23 @@ import (
"github.com/anchore/syft/internal/formats/common/testutils" "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, testutils.AssertEncoderAgainstGoldenSnapshot(t,
Format().Presenter(testutils.DirectoryInput(t)), Format(),
testutils.DirectoryInput(t),
*updateSpdxTagValue, *updateSpdxTagValue,
spdxTagValueRedactor, spdxTagValueRedactor,
) )
} }
func TestSPDXTagValueImagePresenter(t *testing.T) { func TestSPDXTagValueImageEncoder(t *testing.T) {
testImage := "image-simple" testImage := "image-simple"
testutils.AssertPresenterAgainstGoldenImageSnapshot(t, testutils.AssertEncoderAgainstGoldenImageSnapshot(t,
Format().Presenter(testutils.ImageInput(t, testImage, testutils.FromSnapshot())), Format(),
testutils.ImageInput(t, testImage, testutils.FromSnapshot()),
testImage, testImage,
*updateSpdxTagValue, *updateSpdxTagValue,
spdxTagValueRedactor, spdxTagValueRedactor,

View File

@ -16,19 +16,21 @@ import (
"github.com/anchore/syft/internal/formats/common/testutils" "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) { func TestDirectoryEncoder(t *testing.T) {
testutils.AssertPresenterAgainstGoldenSnapshot(t, testutils.AssertEncoderAgainstGoldenSnapshot(t,
Format().Presenter(testutils.DirectoryInput(t)), Format(),
testutils.DirectoryInput(t),
*updateJson, *updateJson,
) )
} }
func TestImagePresenter(t *testing.T) { func TestImageEncoder(t *testing.T) {
testImage := "image-simple" testImage := "image-simple"
testutils.AssertPresenterAgainstGoldenImageSnapshot(t, testutils.AssertEncoderAgainstGoldenImageSnapshot(t,
Format().Presenter(testutils.ImageInput(t, testImage, testutils.FromSnapshot())), Format(),
testutils.ImageInput(t, testImage, testutils.FromSnapshot()),
testImage, testImage,
*updateJson, *updateJson,
) )
@ -192,8 +194,9 @@ func TestEncodeFullJSONDocument(t *testing.T) {
}, },
} }
testutils.AssertPresenterAgainstGoldenSnapshot(t, testutils.AssertEncoderAgainstGoldenSnapshot(t,
Format().Presenter(s), Format(),
s,
*updateJson, *updateJson,
) )
} }

View File

@ -10,9 +10,10 @@ import (
var updateTableGoldenFiles = flag.Bool("update-table", false, "update the *.golden files for table format") var updateTableGoldenFiles = flag.Bool("update-table", false, "update the *.golden files for table format")
func TestTablePresenter(t *testing.T) { func TestTableEncoder(t *testing.T) {
testutils.AssertPresenterAgainstGoldenSnapshot(t, testutils.AssertEncoderAgainstGoldenSnapshot(t,
Format().Presenter(testutils.DirectoryInput(t)), Format(),
testutils.DirectoryInput(t),
*updateTableGoldenFiles, *updateTableGoldenFiles,
) )
} }

View File

@ -7,20 +7,22 @@ import (
"github.com/anchore/syft/internal/formats/common/testutils" "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) { func TestTextDirectoryEncoder(t *testing.T) {
testutils.AssertPresenterAgainstGoldenSnapshot(t, testutils.AssertEncoderAgainstGoldenSnapshot(t,
Format().Presenter(testutils.DirectoryInput(t)), Format(),
*updateTextPresenterGoldenFiles, testutils.DirectoryInput(t),
*updateTextEncoderGoldenFiles,
) )
} }
func TestTextImagePresenter(t *testing.T) { func TestTextImageEncoder(t *testing.T) {
testImage := "image-simple" testImage := "image-simple"
testutils.AssertPresenterAgainstGoldenImageSnapshot(t, testutils.AssertEncoderAgainstGoldenImageSnapshot(t,
Format().Presenter(testutils.ImageInput(t, testImage, testutils.FromSnapshot())), Format(),
testutils.ImageInput(t, testImage, testutils.FromSnapshot()),
testImage, testImage,
*updateTextPresenterGoldenFiles, *updateTextEncoderGoldenFiles,
) )
} }

116
internal/output/writer.go Normal file
View File

@ -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
}

View File

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

View File

@ -2,23 +2,22 @@ package ui
import ( import (
"fmt" "fmt"
"io"
syftEventParsers "github.com/anchore/syft/syft/event/parsers" syftEventParsers "github.com/anchore/syft/syft/event/parsers"
"github.com/wagoodman/go-partybus" "github.com/wagoodman/go-partybus"
) )
// handleCatalogerPresenterReady is a UI function for processing the CatalogerFinished bus event, displaying the catalog // handleExit is a UI function for processing the Exit bus event,
// via the given presenter to stdout. // and calling the given function to output the contents.
func handleCatalogerPresenterReady(event partybus.Event, reportOutput io.Writer) error { func handleExit(event partybus.Event) error {
// show the report to stdout // show the report to stdout
pres, err := syftEventParsers.ParsePresenterReady(event) fn, err := syftEventParsers.ParseExit(event)
if err != nil { if err != nil {
return fmt.Errorf("bad CatalogerFinished event: %w", err) return fmt.Errorf("bad CatalogerFinished event: %w", err)
} }
if err := pres.Present(reportOutput); err != nil { if err := fn(); err != nil {
return fmt.Errorf("unable to show package catalog report: %w", err) return fmt.Errorf("unable to show package catalog report: %v", err)
} }
return nil return nil
} }

View File

@ -42,16 +42,14 @@ type ephemeralTerminalUI struct {
frame *frame.Frame frame *frame.Frame
logBuffer *bytes.Buffer logBuffer *bytes.Buffer
uiOutput *os.File uiOutput *os.File
reportOutput io.Writer
} }
// NewEphemeralTerminalUI writes all events to a TUI and writes the final report to the given writer. // 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{ return &ephemeralTerminalUI{
handler: ui.NewHandler(), handler: ui.NewHandler(),
waitGroup: &sync.WaitGroup{}, waitGroup: &sync.WaitGroup{},
uiOutput: os.Stderr, uiOutput: os.Stderr,
reportOutput: reportWriter,
} }
} }
@ -82,12 +80,12 @@ func (h *ephemeralTerminalUI) Handle(event partybus.Event) error {
log.Errorf("unable to show %s event: %+v", event.Type, err) log.Errorf("unable to show %s event: %+v", event.Type, err)
} }
case event.Type == syftEvent.PresenterReady: case event.Type == syftEvent.Exit:
// we need to close the screen now since signaling the the presenter is ready means that we // 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 // are about to write bytes to stdout, so we should reset the terminal state first
h.closeScreen(false) 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) log.Errorf("unable to show %s event: %+v", event.Type, err)
} }

View File

@ -1,8 +1,6 @@
package ui package ui
import ( import (
"io"
"github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/log"
syftEvent "github.com/anchore/syft/syft/event" syftEvent "github.com/anchore/syft/syft/event"
"github.com/wagoodman/go-partybus" "github.com/wagoodman/go-partybus"
@ -10,14 +8,11 @@ import (
type loggerUI struct { type loggerUI struct {
unsubscribe func() error unsubscribe func() error
reportOutput io.Writer
} }
// NewLoggerUI writes all events to the common application logger and writes the final report to the given writer. // NewLoggerUI writes all events to the common application logger and writes the final report to the given writer.
func NewLoggerUI(reportWriter io.Writer) UI { func NewLoggerUI() UI {
return &loggerUI{ return &loggerUI{}
reportOutput: reportWriter,
}
} }
func (l *loggerUI) Setup(unsubscribe func() error) error { 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 { func (l loggerUI) Handle(event partybus.Event) error {
// ignore all events except for the final event // ignore all events except for the final event
if event.Type != syftEvent.PresenterReady { if event.Type != syftEvent.Exit {
return nil 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) log.Warnf("unable to show catalog image finished event: %+v", err)
} }

View File

@ -4,7 +4,6 @@
package ui package ui
import ( import (
"io"
"os" "os"
"runtime" "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 // 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 // are environmental problems (e.g. cannot write to the terminal). A writer is provided to capture the output of
// the final SBOM report. // 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())) isStdoutATty := term.IsTerminal(int(os.Stdout.Fd()))
isStderrATty := term.IsTerminal(int(os.Stderr.Fd())) isStderrATty := term.IsTerminal(int(os.Stderr.Fd()))
notATerminal := !isStderrATty && !isStdoutATty notATerminal := !isStderrATty && !isStdoutATty
switch { switch {
case runtime.GOOS == "windows" || verbose || quiet || notATerminal || !isStderrATty: case runtime.GOOS == "windows" || verbose || quiet || notATerminal || !isStderrATty:
uis = append(uis, NewLoggerUI(reportWriter)) uis = append(uis, NewLoggerUI())
default: default:
uis = append(uis, NewEphemeralTerminalUI(reportWriter), NewLoggerUI(reportWriter)) uis = append(uis, NewEphemeralTerminalUI())
} }
return uis return uis

View File

@ -3,15 +3,11 @@
package ui package ui
import (
"io"
)
// Select is responsible for determining the specific UI function given select user option, the current platform // 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 // 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 // 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 // are environmental problems (e.g. cannot write to the terminal). A writer is provided to capture the output of
// the final SBOM report. // the final SBOM report.
func Select(verbose, quiet bool, reportWriter io.Writer) (uis []UI) { func Select(verbose, quiet bool) (uis []UI) {
return append(uis, NewLoggerUI(reportWriter)) return append(uis, NewLoggerUI())
} }

View File

@ -26,8 +26,8 @@ const (
// FileIndexingStarted is a partybus event that occurs when the directory resolver begins indexing a filesystem // FileIndexingStarted is a partybus event that occurs when the directory resolver begins indexing a filesystem
FileIndexingStarted partybus.EventType = "syft-file-indexing-started-event" FileIndexingStarted partybus.EventType = "syft-file-indexing-started-event"
// PresenterReady is a partybus event that occurs when an analysis result is ready for final presentation // Exit is a partybus event that occurs when an analysis result is ready for final presentation
PresenterReady partybus.EventType = "syft-presenter-ready-event" Exit partybus.EventType = "syft-exit-event"
// ImportStarted is a partybus event that occurs when an SBOM upload process has begun // ImportStarted is a partybus event that occurs when an SBOM upload process has begun
ImportStarted partybus.EventType = "syft-import-started-event" ImportStarted partybus.EventType = "syft-import-started-event"

View File

@ -6,7 +6,6 @@ package parsers
import ( import (
"fmt" "fmt"
"github.com/anchore/go-presenter"
"github.com/anchore/syft/syft/event" "github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg/cataloger" "github.com/anchore/syft/syft/pkg/cataloger"
@ -109,17 +108,17 @@ func ParseFileIndexingStarted(e partybus.Event) (string, progress.StagedProgress
return path, prog, nil return path, prog, nil
} }
func ParsePresenterReady(e partybus.Event) (presenter.Presenter, error) { func ParseExit(e partybus.Event) (func() error, error) {
if err := checkEventType(e.Type, event.PresenterReady); err != nil { if err := checkEventType(e.Type, event.Exit); err != nil {
return nil, err return nil, err
} }
pres, ok := e.Value.(presenter.Presenter) fn, ok := e.Value.(func() error)
if !ok { if !ok {
return nil, newPayloadErr(e.Type, "Value", e.Value) return nil, newPayloadErr(e.Type, "Value", e.Value)
} }
return pres, nil return fn, nil
} }
func ParseAppUpdateAvailable(e partybus.Event) (string, error) { func ParseAppUpdateAvailable(e partybus.Event) (string, error) {

View File

@ -50,10 +50,3 @@ func (f Format) Validate(reader io.Reader) error {
return f.validator(reader) return f.validator(reader)
} }
func (f Format) Presenter(s sbom.SBOM) *Presenter {
if f.encoder == nil {
return nil
}
return NewPresenter(f.encoder, s)
}

View File

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

View File

@ -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 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 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), 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 providing a way to inspect paths and file content within the image. The Source object, not the image object, is used

13
syft/sbom/writer.go Normal file
View File

@ -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
}

View File

@ -8,6 +8,7 @@ import (
func TestPackagesCmdFlags(t *testing.T) { func TestPackagesCmdFlags(t *testing.T) {
coverageImage := "docker-archive:" + getFixtureImage(t, "image-pkg-coverage") coverageImage := "docker-archive:" + getFixtureImage(t, "image-pkg-coverage")
//badBinariesImage := "docker-archive:" + getFixtureImage(t, "image-bad-binaries") //badBinariesImage := "docker-archive:" + getFixtureImage(t, "image-bad-binaries")
tmp := t.TempDir() + "/"
tests := []struct { tests := []struct {
name string name string
@ -32,6 +33,15 @@ func TestPackagesCmdFlags(t *testing.T) {
assertSuccessfulReturnCode, 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: // I haven't been able to reproduce locally yet, but in CI this has proven to be unstable:
// For the same commit: // For the same commit:
// pass: https://github.com/anchore/syft/runs/4611344142?check_suite_focus=true // pass: https://github.com/anchore/syft/runs/4611344142?check_suite_focus=true

View File

@ -2,6 +2,7 @@ package cli
import ( import (
"encoding/json" "encoding/json"
"os"
"regexp" "regexp"
"strings" "strings"
"testing" "testing"
@ -114,3 +115,12 @@ func assertSuccessfulReturnCode(tb testing.TB, _, _ string, rc int) {
tb.Errorf("expected no failure but got rc=%d", rc) 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)
}
}
}

View File

@ -11,7 +11,7 @@ import (
func TestPackageOwnershipRelationships(t *testing.T) { 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 { tests := []struct {
fixture string fixture string
}{ }{
@ -24,13 +24,8 @@ func TestPackageOwnershipRelationships(t *testing.T) {
t.Run(test.fixture, func(t *testing.T) { t.Run(test.fixture, func(t *testing.T) {
sbom, _ := catalogFixtureImage(t, test.fixture) sbom, _ := catalogFixtureImage(t, test.fixture)
p := syftjson.Format().Presenter(sbom)
if p == nil {
t.Fatal("unable to get presenter")
}
output := bytes.NewBufferString("") output := bytes.NewBufferString("")
err := p.Present(output) err := syftjson.Format().Encode(output, sbom)
if err != nil { if err != nil {
t.Fatalf("unable to present: %+v", err) t.Fatalf("unable to present: %+v", err)
} }