Migrate format definitions to sbom package (#864)

This commit is contained in:
Alex Goodman
2022-03-04 17:22:40 -05:00
committed by GitHub
parent 640099ce2e
commit 4af32c5bee
36 changed files with 580 additions and 401 deletions
+78
View File
@@ -0,0 +1,78 @@
package sbom
import (
"errors"
"io"
)
var (
ErrEncodingNotSupported = errors.New("encoding not supported")
ErrDecodingNotSupported = errors.New("decoding not supported")
ErrValidationNotSupported = errors.New("validation not supported")
)
type FormatID string
type Format interface {
ID() FormatID
Encode(io.Writer, SBOM) error
Decode(io.Reader) (*SBOM, error)
Validate(io.Reader) error
}
type format struct {
id FormatID
encoder Encoder
decoder Decoder
validator Validator
}
// Decoder is a function that can convert an SBOM document of a specific format from a reader into Syft native objects.
type Decoder func(reader io.Reader) (*SBOM, error)
// Encoder is a function that can transform Syft native objects into an SBOM document of a specific format written to the given writer.
type Encoder func(io.Writer, SBOM) error
// Validator reads the SBOM from the given reader and assesses whether the document conforms to the specific SBOM format.
// The validator should positively confirm if the SBOM is not only the format but also has the minimal set of values
// that the format requires. For example, all syftjson formatted documents have a schema section which should have
// "anchore/syft" within the version --if this isn't found then the validator should raise an error. These active
// assertions protect against "simple" format decoding validations that may lead to false positives (e.g. I decoded
// json successfully therefore this must be the target format, however, all values are their default zero-value and
// really represent a different format that also uses json)
type Validator func(reader io.Reader) error
func NewFormat(id FormatID, encoder Encoder, decoder Decoder, validator Validator) Format {
return &format{
id: id,
encoder: encoder,
decoder: decoder,
validator: validator,
}
}
func (f format) ID() FormatID {
return f.id
}
func (f format) Encode(output io.Writer, s SBOM) error {
if f.encoder == nil {
return ErrEncodingNotSupported
}
return f.encoder(output, s)
}
func (f format) Decode(reader io.Reader) (*SBOM, error) {
if f.decoder == nil {
return nil, ErrDecodingNotSupported
}
return f.decoder(reader)
}
func (f format) Validate(reader io.Reader) error {
if f.validator == nil {
return ErrValidationNotSupported
}
return f.validator(reader)
}
+95
View File
@@ -0,0 +1,95 @@
package sbom
import (
"fmt"
"os"
"path"
"github.com/anchore/syft/internal/log"
"github.com/hashicorp/go-multierror"
)
// 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
}
// NewWriter create all report writers from input options; if a file is not specified, os.Stdout is used
func NewWriter(options ...WriterOption) (Writer, error) {
if len(options) == 0 {
return nil, fmt.Errorf("no output options provided")
}
out := &multiWriter{}
defer func() {
// close any previously opened files; we can't really recover from any errors
if err := out.Close(); err != nil {
log.Warnf("unable to close sbom writers: %+v", err)
}
}()
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
}
// 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
}
+172
View File
@@ -0,0 +1,172 @@
package sbom
import (
"io"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func dummyEncoder(io.Writer, SBOM) error {
return nil
}
func dummyFormat(name string) Format {
return NewFormat(FormatID(name), dummyEncoder, nil, nil)
}
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)
}
}
})
}
}
+25
View File
@@ -0,0 +1,25 @@
package sbom
import (
"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)
}
// Close any resources, such as open files
func (w *streamWriter) Close() error {
if w.close != nil {
return w.close()
}
return nil
}