From 25ce245c035c4f80598e2baa70d0dd1db99e5c1d Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Fri, 23 Jun 2023 11:21:22 -0400 Subject: [PATCH] Simplify the SBOM writer interface (#1892) * remove sbom.writer bytes call and consolidate helpers to options pkg Signed-off-by: Alex Goodman * dont close stdout Signed-off-by: Alex Goodman * remove close operation from multiwriter Signed-off-by: Alex Goodman --------- Signed-off-by: Alex Goodman --- cmd/syft/cli/attest/attest.go | 228 +++++++++++++--------------- cmd/syft/cli/convert/convert.go | 8 +- cmd/syft/cli/options/writer.go | 142 ++++++++++++++++- cmd/syft/cli/options/writer_test.go | 201 +++++++++++++++++++++++- cmd/syft/cli/packages/packages.go | 9 +- cmd/syft/cli/poweruser/poweruser.go | 11 +- syft/lib.go | 6 +- syft/sbom/multi_writer.go | 123 --------------- syft/sbom/multi_writer_test.go | 204 ------------------------- syft/sbom/stream_writer.go | 36 ----- syft/sbom/writer.go | 12 +- 11 files changed, 443 insertions(+), 537 deletions(-) delete mode 100644 syft/sbom/multi_writer.go delete mode 100644 syft/sbom/multi_writer_test.go delete mode 100644 syft/sbom/stream_writer.go diff --git a/cmd/syft/cli/attest/attest.go b/cmd/syft/cli/attest/attest.go index 997f3307d..9264c8e8b 100644 --- a/cmd/syft/cli/attest/attest.go +++ b/cmd/syft/cli/attest/attest.go @@ -17,6 +17,7 @@ import ( "github.com/anchore/syft/cmd/syft/cli/packages" "github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/config" + "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/ui" "github.com/anchore/syft/syft" "github.com/anchore/syft/syft/event" @@ -33,17 +34,6 @@ func Run(_ context.Context, app *config.Application, args []string) error { return err } - writer, err := options.MakeWriter(app.Outputs, app.File, app.OutputTemplatePath) - if err != nil { - return fmt.Errorf("unable to write to report destination: %w", err) - } - - defer func() { - if err := writer.Close(); err != nil { - fmt.Printf("unable to close report destination: %+v", err) - } - }() - // could be an image or a directory, with or without a scheme // TODO: validate that source is image userInput := args[0] @@ -62,7 +52,7 @@ func Run(_ context.Context, app *config.Application, args []string) error { subscription := eventBus.Subscribe() return eventloop.EventLoop( - execWorker(app, *si, writer), + execWorker(app, *si), eventloop.SetupSignals(), subscription, stereoscope.Cleanup, @@ -70,7 +60,7 @@ func Run(_ context.Context, app *config.Application, args []string) error { ) } -func buildSBOM(app *config.Application, si source.Input, writer sbom.Writer, errs chan error) ([]byte, error) { +func buildSBOM(app *config.Application, si source.Input, errs chan error) (*sbom.SBOM, error) { src, cleanup, err := source.New(si, app.Registry.ToOptions(), app.Exclusions) if cleanup != nil { defer cleanup() @@ -88,103 +78,120 @@ func buildSBOM(app *config.Application, si source.Input, writer sbom.Writer, err return nil, fmt.Errorf("no SBOM produced for %q", si.UserInput) } - // note: only works for single format no multi writer support - sBytes, err := writer.Bytes(*s) - if err != nil { - return nil, fmt.Errorf("unable to build SBOM bytes: %w", err) - } - - return sBytes, nil + return s, nil } //nolint:funlen -func execWorker(app *config.Application, si source.Input, writer sbom.Writer) <-chan error { +func execWorker(app *config.Application, si source.Input) <-chan error { errs := make(chan error) go func() { defer close(errs) - sBytes, err := buildSBOM(app, si, writer, errs) + defer bus.Publish(partybus.Event{Type: event.Exit}) + + s, err := buildSBOM(app, si, errs) if err != nil { errs <- fmt.Errorf("unable to build SBOM: %w", err) return } - // TODO: add multi writer support - for _, o := range app.Outputs { - f, err := os.CreateTemp("", o) - if err != nil { - errs <- fmt.Errorf("unable to create temp file: %w", err) - return - } + // note: ValidateOutputOptions ensures that there is no more than one output type + o := app.Outputs[0] - defer f.Close() - defer os.Remove(f.Name()) + f, err := os.CreateTemp("", o) + if err != nil { + errs <- fmt.Errorf("unable to create temp file: %w", err) + return + } + defer os.Remove(f.Name()) - if _, err := f.Write(sBytes); err != nil { - errs <- fmt.Errorf("unable to write SBOM to temp file: %w", err) - return - } - - // TODO: what other validation here besides binary name? - cmd := "cosign" - if !commandExists(cmd) { - errs <- fmt.Errorf("unable to find cosign in PATH; make sure you have it installed") - return - } - - // Select Cosign predicate type based on defined output type - // As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go - var predicateType string - switch strings.ToLower(o) { - case "cyclonedx-json": - predicateType = "cyclonedx" - case "spdx-tag-value": - predicateType = "spdx" - case "spdx-json": - predicateType = "spdxjson" - default: - predicateType = "custom" - } - - args := []string{"attest", si.UserInput, "--predicate", f.Name(), "--type", predicateType} - if app.Attest.Key != "" { - args = append(args, "--key", app.Attest.Key) - } - - execCmd := exec.Command(cmd, args...) - execCmd.Env = os.Environ() - if app.Attest.Key != "" { - execCmd.Env = append(execCmd.Env, fmt.Sprintf("COSIGN_PASSWORD=%s", app.Attest.Password)) - } else { - // no key provided, use cosign's keyless mode - execCmd.Env = append(execCmd.Env, "COSIGN_EXPERIMENTAL=1") - } - - // bus adapter for ui to hook into stdout via an os pipe - r, w, err := os.Pipe() - if err != nil { - errs <- fmt.Errorf("unable to create os pipe: %w", err) - return - } - defer w.Close() - - b := &busWriter{r: r, w: w, mon: progress.NewManual(-1)} - execCmd.Stdout = b - execCmd.Stderr = b - defer b.mon.SetCompleted() - - // attest the SBOM - err = execCmd.Run() - if err != nil { - b.mon.SetError(err) - errs <- fmt.Errorf("unable to attest SBOM: %w", err) - return - } + writer, err := options.MakeSBOMWriter(app.Outputs, f.Name(), app.OutputTemplatePath) + if err != nil { + errs <- fmt.Errorf("unable to create SBOM writer: %w", err) + return } - bus.Publish(partybus.Event{ - Type: event.Exit, - Value: func() error { return nil }, - }) + if err := writer.Write(*s); err != nil { + errs <- fmt.Errorf("unable to write SBOM to temp file: %w", err) + return + } + + // TODO: what other validation here besides binary name? + cmd := "cosign" + if !commandExists(cmd) { + errs <- fmt.Errorf("unable to find cosign in PATH; make sure you have it installed") + return + } + + // Select Cosign predicate type based on defined output type + // As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go + var predicateType string + switch strings.ToLower(o) { + case "cyclonedx-json": + predicateType = "cyclonedx" + case "spdx-tag-value", "spdx-tv": + predicateType = "spdx" + case "spdx-json", "json": + predicateType = "spdxjson" + default: + predicateType = "custom" + } + + args := []string{"attest", si.UserInput, "--predicate", f.Name(), "--type", predicateType} + if app.Attest.Key != "" { + args = append(args, "--key", app.Attest.Key) + } + + execCmd := exec.Command(cmd, args...) + execCmd.Env = os.Environ() + if app.Attest.Key != "" { + execCmd.Env = append(execCmd.Env, fmt.Sprintf("COSIGN_PASSWORD=%s", app.Attest.Password)) + } else { + // no key provided, use cosign's keyless mode + execCmd.Env = append(execCmd.Env, "COSIGN_EXPERIMENTAL=1") + } + + log.WithFields("cmd", strings.Join(execCmd.Args, " ")).Trace("creating attestation") + + // bus adapter for ui to hook into stdout via an os pipe + r, w, err := os.Pipe() + if err != nil { + errs <- fmt.Errorf("unable to create os pipe: %w", err) + return + } + defer w.Close() + + mon := progress.NewManual(-1) + + bus.Publish( + partybus.Event{ + Type: event.AttestationStarted, + Source: monitor.GenericTask{ + Title: monitor.Title{ + Default: "Create attestation", + WhileRunning: "Creating attestation", + OnSuccess: "Created attestation", + }, + Context: "cosign", + }, + Value: &monitor.ShellProgress{ + Reader: r, + Manual: mon, + }, + }, + ) + + execCmd.Stdout = w + execCmd.Stderr = w + + // attest the SBOM + err = execCmd.Run() + if err != nil { + mon.SetError(err) + errs <- fmt.Errorf("unable to attest SBOM: %w", err) + return + } + + mon.SetCompleted() }() return errs } @@ -207,37 +214,6 @@ func ValidateOutputOptions(app *config.Application) error { return nil } -type busWriter struct { - w *os.File - r *os.File - hasWritten bool - mon *progress.Manual -} - -func (b *busWriter) Write(p []byte) (n int, err error) { - if !b.hasWritten { - b.hasWritten = true - bus.Publish( - partybus.Event{ - Type: event.AttestationStarted, - Source: monitor.GenericTask{ - Title: monitor.Title{ - Default: "Create attestation", - WhileRunning: "Creating attestation", - OnSuccess: "Created attestation", - }, - Context: "cosign", - }, - Value: &monitor.ShellProgress{ - Reader: b.r, - Manual: b.mon, - }, - }, - ) - } - return b.w.Write(p) -} - func commandExists(cmd string) bool { _, err := exec.LookPath(cmd) return err == nil diff --git a/cmd/syft/cli/convert/convert.go b/cmd/syft/cli/convert/convert.go index 50e9d4d80..a646bded3 100644 --- a/cmd/syft/cli/convert/convert.go +++ b/cmd/syft/cli/convert/convert.go @@ -14,17 +14,11 @@ import ( func Run(_ context.Context, app *config.Application, args []string) error { log.Warn("convert is an experimental feature, run `syft convert -h` for help") - writer, err := options.MakeWriter(app.Outputs, app.File, app.OutputTemplatePath) + writer, err := options.MakeSBOMWriter(app.Outputs, app.File, app.OutputTemplatePath) if err != nil { return err } - defer func() { - if err := writer.Close(); err != nil { - log.Warnf("unable to write to report destination: %w", err) - } - }() - // this can only be a SBOM file userInput := args[0] diff --git a/cmd/syft/cli/options/writer.go b/cmd/syft/cli/options/writer.go index a2e0e37e9..40c8a2675 100644 --- a/cmd/syft/cli/options/writer.go +++ b/cmd/syft/cli/options/writer.go @@ -2,25 +2,37 @@ package options import ( "fmt" + "io" + "os" + "path" "strings" "github.com/hashicorp/go-multierror" + "github.com/mitchellh/go-homedir" + "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/formats" "github.com/anchore/syft/syft/formats/table" "github.com/anchore/syft/syft/formats/template" "github.com/anchore/syft/syft/sbom" ) -// makeWriter creates a sbom.Writer for output or returns an error. this will either return a valid writer +var _ sbom.Writer = (*sbomMultiWriter)(nil) + +var _ interface { + io.Closer + sbom.Writer +} = (*sbomStreamWriter)(nil) + +// MakeSBOMWriter 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, templateFilePath string) (sbom.Writer, error) { - outputOptions, err := parseOutputs(outputs, defaultFile, templateFilePath) +func MakeSBOMWriter(outputs []string, defaultFile, templateFilePath string) (sbom.Writer, error) { + outputOptions, err := parseSBOMOutputFlags(outputs, defaultFile, templateFilePath) if err != nil { return nil, err } - writer, err := sbom.NewWriter(outputOptions...) + writer, err := newSBOMMultiWriter(outputOptions...) if err != nil { return nil, err } @@ -28,8 +40,18 @@ func MakeWriter(outputs []string, defaultFile, templateFilePath string) (sbom.Wr return writer, nil } -// parseOptions utility to parse command-line option strings and retain the existing behavior of default format and file -func parseOutputs(outputs []string, defaultFile, templateFilePath string) (out []sbom.WriterOption, errs error) { +// MakeSBOMWriterForFormat creates a sbom.Writer for for the given format or returns an error. +func MakeSBOMWriterForFormat(format sbom.Format, path string) (sbom.Writer, error) { + writer, err := newSBOMMultiWriter(newSBOMWriterDescription(format, path)) + if err != nil { + return nil, err + } + + return writer, nil +} + +// parseSBOMOutputFlags utility to parse command-line option strings and retain the existing behavior of default format and file +func parseSBOMOutputFlags(outputs []string, defaultFile, templateFilePath string) (out []sbomWriterDescription, 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, table.ID.String()) @@ -63,7 +85,113 @@ func parseOutputs(outputs []string, defaultFile, templateFilePath string) (out [ format = tmpl } - out = append(out, sbom.NewWriterOption(format, file)) + out = append(out, newSBOMWriterDescription(format, file)) } return out, errs } + +// sbomWriterDescription Format and path strings used to create sbom.Writer +type sbomWriterDescription struct { + Format sbom.Format + Path string +} + +func newSBOMWriterDescription(f sbom.Format, p string) sbomWriterDescription { + expandedPath, err := homedir.Expand(p) + if err != nil { + log.Warnf("could not expand given writer output path=%q: %w", p, err) + // ignore errors + expandedPath = p + } + return sbomWriterDescription{ + Format: f, + Path: expandedPath, + } +} + +// sbomMultiWriter holds a list of child sbom.Writers to apply all Write and Close operations to +type sbomMultiWriter struct { + writers []sbom.Writer +} + +type nopWriteCloser struct { + io.Writer +} + +func (n nopWriteCloser) Close() error { + return nil +} + +// newSBOMMultiWriter create all report writers from input options; if a file is not specified the given defaultWriter is used +func newSBOMMultiWriter(options ...sbomWriterDescription) (_ *sbomMultiWriter, err error) { + if len(options) == 0 { + return nil, fmt.Errorf("no output options provided") + } + + out := &sbomMultiWriter{} + + for _, option := range options { + switch len(option.Path) { + case 0: + out.writers = append(out.writers, &sbomStreamWriter{ + format: option.Format, + out: nopWriteCloser{Writer: 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, &sbomStreamWriter{ + format: option.Format, + out: fileOut, + }) + } + } + + return out, nil +} + +// Write writes the SBOM to all writers +func (m *sbomMultiWriter) Write(s sbom.SBOM) (errs error) { + for _, w := range m.writers { + err := w.Write(s) + if err != nil { + errs = multierror.Append(errs, fmt.Errorf("unable to write SBOM: %w", err)) + } + } + return errs +} + +// sbomStreamWriter implements sbom.Writer for a given format and io.Writer, also providing a close function for cleanup +type sbomStreamWriter struct { + format sbom.Format + out io.Writer +} + +// Write the provided SBOM to the data stream +func (w *sbomStreamWriter) Write(s sbom.SBOM) error { + defer w.Close() + return w.format.Encode(w.out, s) +} + +// Close any resources, such as open files +func (w *sbomStreamWriter) Close() error { + if closer, ok := w.out.(io.Closer); ok { + return closer.Close() + } + return nil +} diff --git a/cmd/syft/cli/options/writer_test.go b/cmd/syft/cli/options/writer_test.go index 25a01fde9..2e251234e 100644 --- a/cmd/syft/cli/options/writer_test.go +++ b/cmd/syft/cli/options/writer_test.go @@ -1,12 +1,18 @@ package options import ( + "io" + "path/filepath" + "strings" "testing" + "github.com/docker/docker/pkg/homedir" "github.com/stretchr/testify/assert" + + "github.com/anchore/syft/syft/sbom" ) -func TestIsSupportedFormat(t *testing.T) { +func Test_MakeSBOMWriter(t *testing.T) { tests := []struct { outputs []string wantErr assert.ErrorAssertionFunc @@ -28,7 +34,198 @@ func TestIsSupportedFormat(t *testing.T) { } for _, tt := range tests { - _, err := MakeWriter(tt.outputs, "", "") + _, err := MakeSBOMWriter(tt.outputs, "", "") tt.wantErr(t, err) } } + +func dummyEncoder(io.Writer, sbom.SBOM) error { + return nil +} + +func dummyFormat(name string) sbom.Format { + return sbom.NewFormat(sbom.AnyVersion, dummyEncoder, nil, nil, sbom.FormatID(name)) +} + +func Test_newSBOMMultiWriter(t *testing.T) { + type writerConfig struct { + format string + file string + } + + tmp := t.TempDir() + + testName := func(options []sbomWriterDescription, err bool) string { + var out []string + for _, opt := range options { + out = append(out, string(opt.Format.ID())+"="+opt.Path) + } + errs := "" + if err { + errs = "(err)" + } + return strings.Join(out, ", ") + errs + } + + tests := []struct { + outputs []sbomWriterDescription + err bool + expected []writerConfig + }{ + { + outputs: []sbomWriterDescription{}, + err: true, + }, + { + outputs: []sbomWriterDescription{ + { + Format: dummyFormat("table"), + Path: "", + }, + }, + expected: []writerConfig{ + { + format: "table", + }, + }, + }, + { + outputs: []sbomWriterDescription{ + { + Format: dummyFormat("json"), + }, + }, + expected: []writerConfig{ + { + format: "json", + }, + }, + }, + { + outputs: []sbomWriterDescription{ + { + Format: dummyFormat("json"), + Path: "test-2.json", + }, + }, + expected: []writerConfig{ + { + format: "json", + file: "test-2.json", + }, + }, + }, + { + outputs: []sbomWriterDescription{ + { + Format: dummyFormat("json"), + Path: "test-3/1.json", + }, + { + Format: dummyFormat("spdx-json"), + Path: "test-3/2.json", + }, + }, + expected: []writerConfig{ + { + format: "json", + file: "test-3/1.json", + }, + { + format: "spdx-json", + file: "test-3/2.json", + }, + }, + }, + { + outputs: []sbomWriterDescription{ + { + Format: dummyFormat("text"), + }, + { + Format: dummyFormat("spdx-json"), + 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 + } + } + + mw, err := newSBOMMultiWriter(outputs...) + + if test.err { + assert.Error(t, err) + return + } else { + assert.NoError(t, err) + } + + assert.Len(t, mw.writers, len(test.expected)) + + for i, e := range test.expected { + switch w := mw.writers[i].(type) { + case *sbomStreamWriter: + assert.Equal(t, string(w.format.ID()), e.format) + if e.file != "" { + assert.NotNil(t, w.out) + } else { + assert.NotNil(t, w.out) + } + if e.file != "" { + assert.FileExists(t, tmp+e.file) + } + default: + t.Fatalf("unknown writer type: %T", w) + } + + } + }) + } +} + +func Test_newSBOMWriterDescription(t *testing.T) { + tests := []struct { + name string + path string + expected string + }{ + { + name: "expand home dir", + path: "~/place.txt", + expected: filepath.Join(homedir.Get(), "place.txt"), + }, + { + name: "passthrough other paths", + path: "/other/place.txt", + expected: "/other/place.txt", + }, + { + name: "no path", + path: "", + expected: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := newSBOMWriterDescription(dummyFormat("table"), tt.path) + assert.Equal(t, tt.expected, o.Path) + }) + } +} diff --git a/cmd/syft/cli/packages/packages.go b/cmd/syft/cli/packages/packages.go index 12695e4f0..544a1b502 100644 --- a/cmd/syft/cli/packages/packages.go +++ b/cmd/syft/cli/packages/packages.go @@ -12,7 +12,6 @@ import ( "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/config" - "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/ui" "github.com/anchore/syft/internal/version" "github.com/anchore/syft/syft" @@ -29,17 +28,11 @@ func Run(_ context.Context, app *config.Application, args []string) error { return err } - writer, err := options.MakeWriter(app.Outputs, app.File, app.OutputTemplatePath) + writer, err := options.MakeSBOMWriter(app.Outputs, app.File, app.OutputTemplatePath) if err != nil { 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] si, err := source.ParseInputWithNameVersion(userInput, app.Platform, app.SourceName, app.SourceVersion, app.DefaultImagePullSource) diff --git a/cmd/syft/cli/poweruser/poweruser.go b/cmd/syft/cli/poweruser/poweruser.go index b4e524fea..724f9a81f 100644 --- a/cmd/syft/cli/poweruser/poweruser.go +++ b/cmd/syft/cli/poweruser/poweruser.go @@ -15,7 +15,6 @@ import ( "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/config" - "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/ui" "github.com/anchore/syft/internal/version" "github.com/anchore/syft/syft" @@ -28,19 +27,11 @@ import ( func Run(_ context.Context, app *config.Application, args []string) error { f := syftjson.Format() - writer, err := sbom.NewWriter(sbom.WriterOption{ - Format: f, - Path: app.File, - }) + writer, err := options.MakeSBOMWriterForFormat(f, app.File) if err != nil { return err } - defer func() { - if err := writer.Close(); err != nil { - log.Warnf("unable to write to report destination: %+v", err) - } - // inform user at end of run that command will be removed deprecated := color.Style{color.Red, color.OpBold}.Sprint("DEPRECATED: This command will be removed in v1.0.0") fmt.Fprintln(os.Stderr, deprecated) diff --git a/syft/lib.go b/syft/lib.go index 7f3701301..ea2869006 100644 --- a/syft/lib.go +++ b/syft/lib.go @@ -56,13 +56,13 @@ func CatalogPackages(src *source.Source, cfg cataloger.Config) (*pkg.Collection, // otherwise conditionally use the correct set of loggers based on the input type (container image or directory) switch src.Metadata.Scheme { case source.ImageScheme: - log.Info("cataloging image") + log.Info("cataloging an image") catalogers = cataloger.ImageCatalogers(cfg) case source.FileScheme: - log.Info("cataloging file") + log.Info("cataloging a file") catalogers = cataloger.AllCatalogers(cfg) case source.DirectoryScheme: - log.Info("cataloging directory") + log.Info("cataloging a directory") catalogers = cataloger.DirectoryCatalogers(cfg) default: return nil, nil, nil, fmt.Errorf("unable to determine cataloger set from scheme=%+v", src.Metadata.Scheme) diff --git a/syft/sbom/multi_writer.go b/syft/sbom/multi_writer.go deleted file mode 100644 index 6eb85ef06..000000000 --- a/syft/sbom/multi_writer.go +++ /dev/null @@ -1,123 +0,0 @@ -package sbom - -import ( - "fmt" - "os" - "path" - - "github.com/hashicorp/go-multierror" - "github.com/mitchellh/go-homedir" - - "github.com/anchore/syft/internal/log" -) - -// multiWriter holds a list of child sbom.Writers to apply all Write and Close operations to -type multiWriter struct { - writers []Writer -} - -// WriterOption Format and path strings used to create sbom.Writer -type WriterOption struct { - Format Format - Path string -} - -func NewWriterOption(f Format, p string) WriterOption { - expandedPath, err := homedir.Expand(p) - if err != nil { - log.Warnf("could not expand given writer output path=%q: %w", p, err) - // ignore errors - expandedPath = p - } - return WriterOption{ - Format: f, - Path: expandedPath, - } -} - -// NewWriter create all report writers from input options; if a file is not specified, os.Stdout is used -func NewWriter(options ...WriterOption) (_ Writer, err error) { - if len(options) == 0 { - return nil, fmt.Errorf("no output options provided") - } - - out := &multiWriter{} - - defer func() { - if err != nil { - // close any previously opened files; we can't really recover from any errors - if err := out.Close(); err != nil { - log.Warnf("unable to close sbom writers: %+v", err) - } - } - }() - - 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 -} - -// Write writes the SBOM to all writers -func (m *multiWriter) Write(s SBOM) (errs error) { - for _, w := range m.writers { - err := w.Write(s) - if err != nil { - errs = multierror.Append(errs, err) - } - } - return errs -} - -// Bytes returns the bytes of the SBOM that would be written -func (m *multiWriter) Bytes(s SBOM) (bytes []byte, err error) { - for _, w := range m.writers { - b, err := w.Bytes(s) - if err != nil { - return nil, err - } - bytes = append(bytes, b...) - } - return bytes, nil -} - -// 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 -} diff --git a/syft/sbom/multi_writer_test.go b/syft/sbom/multi_writer_test.go deleted file mode 100644 index b46e852fc..000000000 --- a/syft/sbom/multi_writer_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package sbom - -import ( - "io" - "path/filepath" - "strings" - "testing" - - "github.com/docker/docker/pkg/homedir" - "github.com/stretchr/testify/assert" -) - -func dummyEncoder(io.Writer, SBOM) error { - return nil -} - -func dummyFormat(name string) Format { - return NewFormat(AnyVersion, dummyEncoder, nil, nil, FormatID(name)) -} - -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.ID())+"="+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: dummyFormat("table"), - Path: "", - }, - }, - expected: []writerConfig{ - { - format: "table", - }, - }, - }, - { - outputs: []WriterOption{ - { - Format: dummyFormat("json"), - }, - }, - expected: []writerConfig{ - { - format: "json", - }, - }, - }, - { - outputs: []WriterOption{ - { - Format: dummyFormat("json"), - Path: "test-2.json", - }, - }, - expected: []writerConfig{ - { - format: "json", - file: "test-2.json", - }, - }, - }, - { - outputs: []WriterOption{ - { - Format: dummyFormat("json"), - Path: "test-3/1.json", - }, - { - Format: dummyFormat("spdx-json"), - 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: dummyFormat("text"), - }, - { - Format: dummyFormat("spdx-json"), - 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 := NewWriter(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.ID()), 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) - } - } - }) - } -} - -func TestNewWriterOption(t *testing.T) { - tests := []struct { - name string - path string - expected string - }{ - { - name: "expand home dir", - path: "~/place.txt", - expected: filepath.Join(homedir.Get(), "place.txt"), - }, - { - name: "passthrough other paths", - path: "/other/place.txt", - expected: "/other/place.txt", - }, - { - name: "no path", - path: "", - expected: "", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - o := NewWriterOption(dummyFormat("table"), tt.path) - assert.Equal(t, tt.expected, o.Path) - }) - } -} diff --git a/syft/sbom/stream_writer.go b/syft/sbom/stream_writer.go deleted file mode 100644 index 85a91bdab..000000000 --- a/syft/sbom/stream_writer.go +++ /dev/null @@ -1,36 +0,0 @@ -package sbom - -import ( - "bytes" - "io" -) - -// streamWriter implements sbom.Writer for a given format and io.Writer, also providing a close function for cleanup -type streamWriter struct { - format Format - out io.Writer - close func() error -} - -// Write the provided SBOM to the data stream -func (w *streamWriter) Write(s SBOM) error { - return w.format.Encode(w.out, s) -} - -// Bytes returns the bytes of the SBOM that would be written -func (w *streamWriter) Bytes(s SBOM) ([]byte, error) { - var buffer bytes.Buffer - err := w.format.Encode(&buffer, s) - if err != nil { - return nil, err - } - return buffer.Bytes(), nil -} - -// Close any resources, such as open files -func (w *streamWriter) Close() error { - if w.close != nil { - return w.close() - } - return nil -} diff --git a/syft/sbom/writer.go b/syft/sbom/writer.go index 4766714ca..272ed4019 100644 --- a/syft/sbom/writer.go +++ b/syft/sbom/writer.go @@ -1,16 +1,6 @@ package sbom -import "io" - -// Writer an interface to write SBOMs +// Writer an interface to write SBOMs to a destination type Writer interface { - // Write writes the provided SBOM Write(SBOM) error - - // Bytes returns the bytes of the SBOM that would be written - Bytes(SBOM) ([]byte, 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 }