Simplify the SBOM writer interface (#1892)

* remove sbom.writer bytes call and consolidate helpers to options pkg

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>

* dont close stdout

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>

* remove close operation from multiwriter

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>

---------

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
This commit is contained in:
Alex Goodman 2023-06-23 11:21:22 -04:00 committed by GitHub
parent 7de7a7990a
commit 25ce245c03
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 443 additions and 537 deletions

View File

@ -17,6 +17,7 @@ import (
"github.com/anchore/syft/cmd/syft/cli/packages" "github.com/anchore/syft/cmd/syft/cli/packages"
"github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/config" "github.com/anchore/syft/internal/config"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/ui" "github.com/anchore/syft/internal/ui"
"github.com/anchore/syft/syft" "github.com/anchore/syft/syft"
"github.com/anchore/syft/syft/event" "github.com/anchore/syft/syft/event"
@ -33,17 +34,6 @@ func Run(_ context.Context, app *config.Application, args []string) error {
return err 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 // could be an image or a directory, with or without a scheme
// TODO: validate that source is image // TODO: validate that source is image
userInput := args[0] userInput := args[0]
@ -62,7 +52,7 @@ func Run(_ context.Context, app *config.Application, args []string) error {
subscription := eventBus.Subscribe() subscription := eventBus.Subscribe()
return eventloop.EventLoop( return eventloop.EventLoop(
execWorker(app, *si, writer), execWorker(app, *si),
eventloop.SetupSignals(), eventloop.SetupSignals(),
subscription, subscription,
stereoscope.Cleanup, 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) src, cleanup, err := source.New(si, app.Registry.ToOptions(), app.Exclusions)
if cleanup != nil { if cleanup != nil {
defer cleanup() 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) return nil, fmt.Errorf("no SBOM produced for %q", si.UserInput)
} }
// note: only works for single format no multi writer support return s, nil
sBytes, err := writer.Bytes(*s)
if err != nil {
return nil, fmt.Errorf("unable to build SBOM bytes: %w", err)
}
return sBytes, nil
} }
//nolint:funlen //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) errs := make(chan error)
go func() { go func() {
defer close(errs) 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 { if err != nil {
errs <- fmt.Errorf("unable to build SBOM: %w", err) errs <- fmt.Errorf("unable to build SBOM: %w", err)
return return
} }
// TODO: add multi writer support // note: ValidateOutputOptions ensures that there is no more than one output type
for _, o := range app.Outputs { o := app.Outputs[0]
f, err := os.CreateTemp("", o)
if err != nil {
errs <- fmt.Errorf("unable to create temp file: %w", err)
return
}
defer f.Close() f, err := os.CreateTemp("", o)
defer os.Remove(f.Name()) 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 { writer, err := options.MakeSBOMWriter(app.Outputs, f.Name(), app.OutputTemplatePath)
errs <- fmt.Errorf("unable to write SBOM to temp file: %w", err) if err != nil {
return errs <- fmt.Errorf("unable to create SBOM writer: %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
}
} }
bus.Publish(partybus.Event{ if err := writer.Write(*s); err != nil {
Type: event.Exit, errs <- fmt.Errorf("unable to write SBOM to temp file: %w", err)
Value: func() error { return nil }, 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 return errs
} }
@ -207,37 +214,6 @@ func ValidateOutputOptions(app *config.Application) error {
return nil 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 { func commandExists(cmd string) bool {
_, err := exec.LookPath(cmd) _, err := exec.LookPath(cmd)
return err == nil return err == nil

View File

@ -14,17 +14,11 @@ import (
func Run(_ context.Context, app *config.Application, args []string) error { func Run(_ context.Context, app *config.Application, args []string) error {
log.Warn("convert is an experimental feature, run `syft convert -h` for help") 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 { 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)
}
}()
// this can only be a SBOM file // this can only be a SBOM file
userInput := args[0] userInput := args[0]

View File

@ -2,25 +2,37 @@ package options
import ( import (
"fmt" "fmt"
"io"
"os"
"path"
"strings" "strings"
"github.com/hashicorp/go-multierror" "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"
"github.com/anchore/syft/syft/formats/table" "github.com/anchore/syft/syft/formats/table"
"github.com/anchore/syft/syft/formats/template" "github.com/anchore/syft/syft/formats/template"
"github.com/anchore/syft/syft/sbom" "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 // 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) { func MakeSBOMWriter(outputs []string, defaultFile, templateFilePath string) (sbom.Writer, error) {
outputOptions, err := parseOutputs(outputs, defaultFile, templateFilePath) outputOptions, err := parseSBOMOutputFlags(outputs, defaultFile, templateFilePath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
writer, err := sbom.NewWriter(outputOptions...) writer, err := newSBOMMultiWriter(outputOptions...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -28,8 +40,18 @@ func MakeWriter(outputs []string, defaultFile, templateFilePath string) (sbom.Wr
return writer, nil return writer, nil
} }
// parseOptions utility to parse command-line option strings and retain the existing behavior of default format and file // MakeSBOMWriterForFormat creates a sbom.Writer for for the given format or returns an error.
func parseOutputs(outputs []string, defaultFile, templateFilePath string) (out []sbom.WriterOption, errs 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 // always should have one option -- we generally get the default of "table", but just make sure
if len(outputs) == 0 { if len(outputs) == 0 {
outputs = append(outputs, table.ID.String()) outputs = append(outputs, table.ID.String())
@ -63,7 +85,113 @@ func parseOutputs(outputs []string, defaultFile, templateFilePath string) (out [
format = tmpl format = tmpl
} }
out = append(out, sbom.NewWriterOption(format, file)) out = append(out, newSBOMWriterDescription(format, file))
} }
return out, errs 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
}

View File

@ -1,12 +1,18 @@
package options package options
import ( import (
"io"
"path/filepath"
"strings"
"testing" "testing"
"github.com/docker/docker/pkg/homedir"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/anchore/syft/syft/sbom"
) )
func TestIsSupportedFormat(t *testing.T) { func Test_MakeSBOMWriter(t *testing.T) {
tests := []struct { tests := []struct {
outputs []string outputs []string
wantErr assert.ErrorAssertionFunc wantErr assert.ErrorAssertionFunc
@ -28,7 +34,198 @@ func TestIsSupportedFormat(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
_, err := MakeWriter(tt.outputs, "", "") _, err := MakeSBOMWriter(tt.outputs, "", "")
tt.wantErr(t, err) 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)
})
}
}

View File

@ -12,7 +12,6 @@ import (
"github.com/anchore/syft/internal" "github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/config" "github.com/anchore/syft/internal/config"
"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"
"github.com/anchore/syft/syft" "github.com/anchore/syft/syft"
@ -29,17 +28,11 @@ func Run(_ context.Context, app *config.Application, args []string) error {
return err 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 { 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 // could be an image or a directory, with or without a scheme
userInput := args[0] userInput := args[0]
si, err := source.ParseInputWithNameVersion(userInput, app.Platform, app.SourceName, app.SourceVersion, app.DefaultImagePullSource) si, err := source.ParseInputWithNameVersion(userInput, app.Platform, app.SourceName, app.SourceVersion, app.DefaultImagePullSource)

View File

@ -15,7 +15,6 @@ import (
"github.com/anchore/syft/internal" "github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/config" "github.com/anchore/syft/internal/config"
"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"
"github.com/anchore/syft/syft" "github.com/anchore/syft/syft"
@ -28,19 +27,11 @@ import (
func Run(_ context.Context, app *config.Application, args []string) error { func Run(_ context.Context, app *config.Application, args []string) error {
f := syftjson.Format() f := syftjson.Format()
writer, err := sbom.NewWriter(sbom.WriterOption{ writer, err := options.MakeSBOMWriterForFormat(f, app.File)
Format: f,
Path: app.File,
})
if err != nil { if err != nil {
return err return err
} }
defer func() { 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 // 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") deprecated := color.Style{color.Red, color.OpBold}.Sprint("DEPRECATED: This command will be removed in v1.0.0")
fmt.Fprintln(os.Stderr, deprecated) fmt.Fprintln(os.Stderr, deprecated)

View File

@ -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) // otherwise conditionally use the correct set of loggers based on the input type (container image or directory)
switch src.Metadata.Scheme { switch src.Metadata.Scheme {
case source.ImageScheme: case source.ImageScheme:
log.Info("cataloging image") log.Info("cataloging an image")
catalogers = cataloger.ImageCatalogers(cfg) catalogers = cataloger.ImageCatalogers(cfg)
case source.FileScheme: case source.FileScheme:
log.Info("cataloging file") log.Info("cataloging a file")
catalogers = cataloger.AllCatalogers(cfg) catalogers = cataloger.AllCatalogers(cfg)
case source.DirectoryScheme: case source.DirectoryScheme:
log.Info("cataloging directory") log.Info("cataloging a directory")
catalogers = cataloger.DirectoryCatalogers(cfg) catalogers = cataloger.DirectoryCatalogers(cfg)
default: default:
return nil, nil, nil, fmt.Errorf("unable to determine cataloger set from scheme=%+v", src.Metadata.Scheme) return nil, nil, nil, fmt.Errorf("unable to determine cataloger set from scheme=%+v", src.Metadata.Scheme)

View File

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

View File

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

View File

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

View File

@ -1,16 +1,6 @@
package sbom package sbom
import "io" // Writer an interface to write SBOMs to a destination
// Writer an interface to write SBOMs
type Writer interface { type Writer interface {
// Write writes the provided SBOM
Write(SBOM) error 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
} }