diff --git a/cmd/syft/cli/attest/attest.go b/cmd/syft/cli/attest/attest.go index f6bd79d83..a4d18236a 100644 --- a/cmd/syft/cli/attest/attest.go +++ b/cmd/syft/cli/attest/attest.go @@ -22,7 +22,6 @@ import ( "github.com/anchore/syft/syft/event/monitor" "github.com/anchore/syft/syft/formats/syftjson" "github.com/anchore/syft/syft/formats/table" - "github.com/anchore/syft/syft/formats/template" "github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/source" ) @@ -176,16 +175,9 @@ func execWorker(app *config.Application, si source.Input, writer sbom.Writer) <- } func ValidateOutputOptions(app *config.Application) error { - var usesTemplateOutput bool - for _, o := range app.Outputs { - if o == template.ID.String() { - usesTemplateOutput = true - break - } - } - - if usesTemplateOutput && app.OutputTemplatePath == "" { - return fmt.Errorf(`must specify path to template file when using "template" output format`) + err := packages.ValidateOutputOptions(app) + if err != nil { + return err } if len(app.Outputs) > 1 { diff --git a/cmd/syft/cli/convert/convert.go b/cmd/syft/cli/convert/convert.go index d0c23725e..208170337 100644 --- a/cmd/syft/cli/convert/convert.go +++ b/cmd/syft/cli/convert/convert.go @@ -8,7 +8,7 @@ import ( "github.com/anchore/syft/cmd/syft/cli/options" "github.com/anchore/syft/internal/config" "github.com/anchore/syft/internal/log" - "github.com/anchore/syft/syft" + "github.com/anchore/syft/syft/formats" ) func Run(_ context.Context, app *config.Application, args []string) error { @@ -34,7 +34,7 @@ func Run(_ context.Context, app *config.Application, args []string) error { _ = f.Close() }() - sbom, _, err := syft.Decode(f) + sbom, _, err := formats.Decode(f) if err != nil { return fmt.Errorf("failed to decode SBOM: %w", err) } diff --git a/cmd/syft/cli/options/format.go b/cmd/syft/cli/options/format.go deleted file mode 100644 index e8fabb914..000000000 --- a/cmd/syft/cli/options/format.go +++ /dev/null @@ -1,39 +0,0 @@ -package options - -import ( - "github.com/anchore/syft/syft/formats/cyclonedxjson" - "github.com/anchore/syft/syft/formats/cyclonedxxml" - "github.com/anchore/syft/syft/formats/github" - "github.com/anchore/syft/syft/formats/spdxjson" - "github.com/anchore/syft/syft/formats/spdxtagvalue" - "github.com/anchore/syft/syft/formats/syftjson" - "github.com/anchore/syft/syft/formats/table" - "github.com/anchore/syft/syft/formats/text" - "github.com/anchore/syft/syft/sbom" -) - -func FormatAliases(ids ...sbom.FormatID) (aliases []string) { - for _, id := range ids { - switch id { - case syftjson.ID: - aliases = append(aliases, "syft-json") - case text.ID: - aliases = append(aliases, "text") - case table.ID: - aliases = append(aliases, "table") - case spdxjson.ID: - aliases = append(aliases, "spdx-json") - case spdxtagvalue.ID: - aliases = append(aliases, "spdx-tag-value") - case cyclonedxxml.ID: - aliases = append(aliases, "cyclonedx-xml") - case cyclonedxjson.ID: - aliases = append(aliases, "cyclonedx-json") - case github.ID: - aliases = append(aliases, "github", "github-json") - default: - aliases = append(aliases, string(id)) - } - } - return aliases -} diff --git a/cmd/syft/cli/options/packages.go b/cmd/syft/cli/options/packages.go index 0acdcdc2d..ec0331e4f 100644 --- a/cmd/syft/cli/options/packages.go +++ b/cmd/syft/cli/options/packages.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/pflag" "github.com/spf13/viper" - "github.com/anchore/syft/syft" + "github.com/anchore/syft/syft/formats" "github.com/anchore/syft/syft/formats/table" "github.com/anchore/syft/syft/pkg/cataloger" "github.com/anchore/syft/syft/source" @@ -30,8 +30,8 @@ func (o *PackagesOptions) AddFlags(cmd *cobra.Command, v *viper.Viper) error { cmd.Flags().StringVarP(&o.Scope, "scope", "s", cataloger.DefaultSearchConfig().Scope.String(), fmt.Sprintf("selection of layers to catalog, options=%v", source.AllScopes)) - cmd.Flags().StringArrayVarP(&o.Output, "output", "o", FormatAliases(table.ID), - fmt.Sprintf("report output format, options=%v", FormatAliases(syft.FormatIDs()...))) + cmd.Flags().StringArrayVarP(&o.Output, "output", "o", []string{string(table.ID)}, + fmt.Sprintf("report output format, options=%v", formats.AllIDs())) cmd.Flags().StringVarP(&o.File, "file", "", "", "file to write the default report output to (default is STDOUT)") diff --git a/cmd/syft/cli/options/writer.go b/cmd/syft/cli/options/writer.go index d93e1db03..a2e0e37e9 100644 --- a/cmd/syft/cli/options/writer.go +++ b/cmd/syft/cli/options/writer.go @@ -6,7 +6,7 @@ import ( "github.com/hashicorp/go-multierror" - "github.com/anchore/syft/syft" + "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" @@ -32,7 +32,7 @@ func MakeWriter(outputs []string, defaultFile, templateFilePath string) (sbom.Wr func parseOutputs(outputs []string, defaultFile, templateFilePath string) (out []sbom.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(table.ID)) + outputs = append(outputs, table.ID.String()) } for _, name := range outputs { @@ -52,9 +52,9 @@ func parseOutputs(outputs []string, defaultFile, templateFilePath string) (out [ file = parts[1] } - format := syft.FormatByName(name) + format := formats.ByName(name) if format == nil { - errs = multierror.Append(errs, fmt.Errorf(`unsupported output format "%s", supported formats are: %+v`, name, FormatAliases(syft.FormatIDs()...))) + errs = multierror.Append(errs, fmt.Errorf(`unsupported output format "%s", supported formats are: %+v`, name, formats.AllIDs())) continue } diff --git a/cmd/syft/cli/poweruser/poweruser.go b/cmd/syft/cli/poweruser/poweruser.go index d989857b5..82bc6ff39 100644 --- a/cmd/syft/cli/poweruser/poweruser.go +++ b/cmd/syft/cli/poweruser/poweruser.go @@ -26,9 +26,10 @@ import ( "github.com/anchore/syft/syft/source" ) -func Run(ctx context.Context, app *config.Application, args []string) error { +func Run(_ context.Context, app *config.Application, args []string) error { + f := syftjson.Format() writer, err := sbom.NewWriter(sbom.WriterOption{ - Format: syftjson.Format(), + Format: f, Path: app.File, }) if err != nil { diff --git a/syft/formats.go b/syft/formats.go index e3cd5d208..71dc8ebfa 100644 --- a/syft/formats.go +++ b/syft/formats.go @@ -30,12 +30,12 @@ const ( // TODO: deprecated, moved to syft/formats/formats.go. will be removed in v1.0.0 func FormatIDs() (ids []sbom.FormatID) { - return formats.IDs() + return formats.AllIDs() } // TODO: deprecated, moved to syft/formats/formats.go. will be removed in v1.0.0 func FormatByID(id sbom.FormatID) sbom.Format { - return formats.ByID(id) + return formats.ByNameAndVersion(string(id), "") } // TODO: deprecated, moved to syft/formats/formats.go. will be removed in v1.0.0 diff --git a/syft/formats/cyclonedxjson/format.go b/syft/formats/cyclonedxjson/format.go index 14b3005fd..8c1e2e016 100644 --- a/syft/formats/cyclonedxjson/format.go +++ b/syft/formats/cyclonedxjson/format.go @@ -7,13 +7,14 @@ import ( "github.com/anchore/syft/syft/sbom" ) -const ID sbom.FormatID = "cyclonedx-1-json" +const ID sbom.FormatID = "cyclonedx-json" func Format() sbom.Format { return sbom.NewFormat( - ID, + sbom.AnyVersion, encoder, cyclonedxhelpers.GetDecoder(cyclonedx.BOMFileFormatJSON), cyclonedxhelpers.GetValidator(cyclonedx.BOMFileFormatJSON), + ID, ) } diff --git a/syft/formats/cyclonedxxml/format.go b/syft/formats/cyclonedxxml/format.go index de1e57cb0..7fe53c4f7 100644 --- a/syft/formats/cyclonedxxml/format.go +++ b/syft/formats/cyclonedxxml/format.go @@ -7,13 +7,14 @@ import ( "github.com/anchore/syft/syft/sbom" ) -const ID sbom.FormatID = "cyclonedx-1-xml" +const ID sbom.FormatID = "cyclonedx-xml" func Format() sbom.Format { return sbom.NewFormat( - ID, + sbom.AnyVersion, encoder, cyclonedxhelpers.GetDecoder(cyclonedx.BOMFileFormatXML), cyclonedxhelpers.GetValidator(cyclonedx.BOMFileFormatXML), + ID, "cyclonedx", "cyclone", ) } diff --git a/syft/formats/formats.go b/syft/formats/formats.go index 7275e54a6..900db713c 100644 --- a/syft/formats/formats.go +++ b/syft/formats/formats.go @@ -5,8 +5,11 @@ import ( "errors" "fmt" "io" + "regexp" "strings" + "golang.org/x/exp/slices" + "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/formats/cyclonedxjson" "github.com/anchore/syft/syft/formats/cyclonedxxml" @@ -26,8 +29,11 @@ func Formats() []sbom.Format { cyclonedxxml.Format(), cyclonedxjson.Format(), github.Format(), - spdxtagvalue.Format(), - spdxjson.Format(), + spdxtagvalue.Format2_1(), + spdxtagvalue.Format2_2(), + spdxtagvalue.Format2_3(), + spdxjson.Format2_2(), + spdxjson.Format2_3(), table.Format(), text.Format(), template.Format(), @@ -47,53 +53,51 @@ func Identify(by []byte) sbom.Format { return nil } +// ByName accepts a name@version string, such as: +// +// spdx-json@2.1 or cyclonedx@2 func ByName(name string) sbom.Format { - cleanName := cleanFormatName(name) - for _, f := range Formats() { - if cleanFormatName(string(f.ID())) == cleanName { - return f - } + parts := strings.SplitN(name, "@", 2) + version := sbom.AnyVersion + if len(parts) > 1 { + version = parts[1] } - - // handle any aliases for any supported format - switch cleanName { - case "json", "syftjson": - return ByID(syftjson.ID) - case "cyclonedx", "cyclone", "cyclonedxxml": - return ByID(cyclonedxxml.ID) - case "cyclonedxjson": - return ByID(cyclonedxjson.ID) - case "github", "githubjson": - return ByID(github.ID) - case "spdx", "spdxtv", "spdxtagvalue": - return ByID(spdxtagvalue.ID) - case "spdxjson": - return ByID(spdxjson.ID) - case "table": - return ByID(table.ID) - case "text": - return ByID(text.ID) - case "template": - ByID(template.ID) - } - - return nil + return ByNameAndVersion(parts[0], version) } -func IDs() (ids []sbom.FormatID) { +func ByNameAndVersion(name string, version string) sbom.Format { + name = cleanFormatName(name) + var mostRecentFormat sbom.Format for _, f := range Formats() { - ids = append(ids, f.ID()) - } - return ids -} - -func ByID(id sbom.FormatID) sbom.Format { - for _, f := range Formats() { - if f.ID() == id { - return f + for _, n := range f.IDs() { + if cleanFormatName(string(n)) == name && versionMatches(f.Version(), version) { + if mostRecentFormat == nil || f.Version() > mostRecentFormat.Version() { + mostRecentFormat = f + } + } } } - return nil + return mostRecentFormat +} + +func versionMatches(version string, match string) bool { + if version == sbom.AnyVersion || match == sbom.AnyVersion { + return true + } + + dots := strings.Count(match, ".") + if dots == 0 { + match += ".*" + } + match = strings.ReplaceAll(match, ".", "\\.") + match = strings.ReplaceAll(match, "*", ".*") + match = strings.ReplaceAll(match, "\\..*", "(\\..*)*") + match = fmt.Sprintf("^%s$", match) + matcher, err := regexp.Compile(match) + if err != nil { + return false + } + return matcher.MatchString(version) } func cleanFormatName(name string) string { @@ -127,3 +131,13 @@ func Decode(reader io.Reader) (*sbom.SBOM, sbom.Format, error) { s, err := f.Decode(bytes.NewReader(by)) return s, f, err } + +func AllIDs() (ids []sbom.FormatID) { + for _, f := range Formats() { + if slices.Contains(ids, f.ID()) { + continue + } + ids = append(ids, f.ID()) + } + return ids +} diff --git a/syft/formats/formats_test.go b/syft/formats/formats_test.go index c2b847ae8..3d591b4cf 100644 --- a/syft/formats/formats_test.go +++ b/syft/formats/formats_test.go @@ -92,18 +92,6 @@ func TestByName(t *testing.T) { name: "spdxtv", // clean variant want: spdxtagvalue.ID, }, - { - name: "spdx-2-tag-value", // clean variant - want: spdxtagvalue.ID, - }, - { - name: "spdx-2-tagvalue", // clean variant - want: spdxtagvalue.ID, - }, - { - name: "spdx2-tagvalue", // clean variant - want: spdxtagvalue.ID, - }, // SPDX JSON { @@ -111,7 +99,7 @@ func TestByName(t *testing.T) { want: spdxjson.ID, }, { - name: "spdx-2-json", + name: "spdxjson", // clean variant want: spdxjson.ID, }, @@ -121,7 +109,7 @@ func TestByName(t *testing.T) { want: cyclonedxjson.ID, }, { - name: "cyclonedx-1-json", + name: "cyclonedxjson", // clean variant want: cyclonedxjson.ID, }, @@ -135,7 +123,7 @@ func TestByName(t *testing.T) { want: cyclonedxxml.ID, }, { - name: "cyclonedx-1-xml", + name: "cyclonedxxml", // clean variant want: cyclonedxxml.ID, }, @@ -144,7 +132,6 @@ func TestByName(t *testing.T) { name: "table", want: table.ID, }, - { name: "syft-table", want: table.ID, @@ -155,7 +142,6 @@ func TestByName(t *testing.T) { name: "text", want: text.ID, }, - { name: "syft-text", want: text.ID, @@ -166,23 +152,26 @@ func TestByName(t *testing.T) { name: "json", want: syftjson.ID, }, - { name: "syft-json", want: syftjson.ID, }, + { + name: "syftjson", // clean variant + want: syftjson.ID, + }, // GitHub JSON { name: "github", want: github.ID, }, - { name: "github-json", want: github.ID, }, + // Syft template { name: "template", want: template.ID, @@ -200,3 +189,56 @@ func TestByName(t *testing.T) { }) } } + +func Test_versionMatches(t *testing.T) { + tests := []struct { + name string + version string + match string + matches bool + }{ + { + name: "any version matches number", + version: string(sbom.AnyVersion), + match: "6", + matches: true, + }, + { + name: "number matches any version", + version: "6", + match: string(sbom.AnyVersion), + matches: true, + }, + { + name: "same number matches", + version: "3", + match: "3", + matches: true, + }, + { + name: "same major number matches", + version: "3.1", + match: "3", + matches: true, + }, + { + name: "same minor number matches", + version: "3.1", + match: "3.1", + matches: true, + }, + { + name: "different number does not match", + version: "3", + match: "4", + matches: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + matches := versionMatches(test.version, test.match) + assert.Equal(t, test.matches, matches) + }) + } +} diff --git a/syft/formats/github/format.go b/syft/formats/github/format.go index fad5dbff1..7fdd655b3 100644 --- a/syft/formats/github/format.go +++ b/syft/formats/github/format.go @@ -7,11 +7,11 @@ import ( "github.com/anchore/syft/syft/sbom" ) -const ID sbom.FormatID = "github-0-json" +const ID sbom.FormatID = "github-json" func Format() sbom.Format { return sbom.NewFormat( - ID, + sbom.AnyVersion, func(writer io.Writer, sbom sbom.SBOM) error { bom := toGithubModel(&sbom) @@ -25,5 +25,6 @@ func Format() sbom.Format { }, nil, nil, + ID, "github", ) } diff --git a/syft/formats/spdxjson/encoder.go b/syft/formats/spdxjson/encoder.go index a8ca26648..8a2c89f72 100644 --- a/syft/formats/spdxjson/encoder.go +++ b/syft/formats/spdxjson/encoder.go @@ -4,13 +4,31 @@ import ( "encoding/json" "io" + "github.com/spdx/tools-golang/convert" + "github.com/spdx/tools-golang/spdx/v2/v2_2" + "github.com/anchore/syft/syft/formats/common/spdxhelpers" "github.com/anchore/syft/syft/sbom" ) -func encoder(output io.Writer, s sbom.SBOM) error { +func encoder2_3(output io.Writer, s sbom.SBOM) error { + doc := spdxhelpers.ToFormatModel(s) + return encodeJSON(output, doc) +} + +func encoder2_2(output io.Writer, s sbom.SBOM) error { doc := spdxhelpers.ToFormatModel(s) + var out v2_2.Document + err := convert.Document(doc, &out) + if err != nil { + return err + } + + return encodeJSON(output, out) +} + +func encodeJSON(output io.Writer, doc interface{}) error { enc := json.NewEncoder(output) // prevent > and < from being escaped in the payload enc.SetEscapeHTML(false) diff --git a/syft/formats/spdxjson/format.go b/syft/formats/spdxjson/format.go index 6fc2ad782..26d543e45 100644 --- a/syft/formats/spdxjson/format.go +++ b/syft/formats/spdxjson/format.go @@ -4,14 +4,30 @@ import ( "github.com/anchore/syft/syft/sbom" ) -const ID sbom.FormatID = "spdx-2-json" +const ID sbom.FormatID = "spdx-json" + +var IDs = []sbom.FormatID{ID} // note: this format is LOSSY relative to the syftjson format -func Format() sbom.Format { + +func Format2_2() sbom.Format { return sbom.NewFormat( - ID, - encoder, + "2.2", + encoder2_2, decoder, validator, + IDs..., ) } + +func Format2_3() sbom.Format { + return sbom.NewFormat( + "2.3", + encoder2_3, + decoder, + validator, + IDs..., + ) +} + +var Format = Format2_3 diff --git a/syft/formats/spdxtagvalue/encoder.go b/syft/formats/spdxtagvalue/encoder.go index fbfd04471..5949bfc8d 100644 --- a/syft/formats/spdxtagvalue/encoder.go +++ b/syft/formats/spdxtagvalue/encoder.go @@ -3,13 +3,36 @@ package spdxtagvalue import ( "io" + "github.com/spdx/tools-golang/convert" + "github.com/spdx/tools-golang/spdx/v2/v2_1" + "github.com/spdx/tools-golang/spdx/v2/v2_2" "github.com/spdx/tools-golang/tagvalue" "github.com/anchore/syft/syft/formats/common/spdxhelpers" "github.com/anchore/syft/syft/sbom" ) -func encoder(output io.Writer, s sbom.SBOM) error { +func encoder2_3(output io.Writer, s sbom.SBOM) error { model := spdxhelpers.ToFormatModel(s) return tagvalue.Write(model, output) } + +func encoder2_2(output io.Writer, s sbom.SBOM) error { + model := spdxhelpers.ToFormatModel(s) + var out v2_2.Document + err := convert.Document(model, &out) + if err != nil { + return err + } + return tagvalue.Write(out, output) +} + +func encoder2_1(output io.Writer, s sbom.SBOM) error { + model := spdxhelpers.ToFormatModel(s) + var out v2_1.Document + err := convert.Document(model, &out) + if err != nil { + return err + } + return tagvalue.Write(out, output) +} diff --git a/syft/formats/spdxtagvalue/format.go b/syft/formats/spdxtagvalue/format.go index 4f17561e6..46db72d6f 100644 --- a/syft/formats/spdxtagvalue/format.go +++ b/syft/formats/spdxtagvalue/format.go @@ -4,14 +4,39 @@ import ( "github.com/anchore/syft/syft/sbom" ) -const ID sbom.FormatID = "spdx-2-tag-value" +const ID sbom.FormatID = "spdx-tag-value" -// note: this format is LOSSY relative to the syftjson formation, which means that decoding and validation is not supported at this time -func Format() sbom.Format { +var IDs = []sbom.FormatID{ID, "spdx", "spdx-tv"} + +// note: this format is LOSSY relative to the syftjson format +func Format2_1() sbom.Format { return sbom.NewFormat( - ID, - encoder, + "2.1", + encoder2_1, decoder, validator, + IDs..., ) } + +func Format2_2() sbom.Format { + return sbom.NewFormat( + "2.2", + encoder2_2, + decoder, + validator, + IDs..., + ) +} + +func Format2_3() sbom.Format { + return sbom.NewFormat( + "2.3", + encoder2_3, + decoder, + validator, + IDs..., + ) +} + +var Format = Format2_3 diff --git a/syft/formats/syftjson/format.go b/syft/formats/syftjson/format.go index af884e14e..fe5ffca21 100644 --- a/syft/formats/syftjson/format.go +++ b/syft/formats/syftjson/format.go @@ -4,13 +4,14 @@ import ( "github.com/anchore/syft/syft/sbom" ) -const ID sbom.FormatID = "syft-6-json" +const ID sbom.FormatID = "syft-json" func Format() sbom.Format { return sbom.NewFormat( - ID, + "6", encoder, decoder, validator, + ID, "json", "syft", ) } diff --git a/syft/formats/syftjson/to_format_model_test.go b/syft/formats/syftjson/to_format_model_test.go index e44db8e77..f28dc6404 100644 --- a/syft/formats/syftjson/to_format_model_test.go +++ b/syft/formats/syftjson/to_format_model_test.go @@ -15,8 +15,7 @@ import ( func Test_SyftJsonID_Compatibility(t *testing.T) { jsonMajorVersion := strings.Split(internal.JSONSchemaVersion, ".")[0] - syftJsonIDVersion := strings.Split(string(ID), "-")[1] - assert.Equal(t, jsonMajorVersion, syftJsonIDVersion) + assert.Equal(t, jsonMajorVersion, string(Format().Version())) } func Test_toSourceModel(t *testing.T) { diff --git a/syft/formats/table/format.go b/syft/formats/table/format.go index 66e60e0ec..7d9623726 100644 --- a/syft/formats/table/format.go +++ b/syft/formats/table/format.go @@ -8,9 +8,10 @@ const ID sbom.FormatID = "syft-table" func Format() sbom.Format { return sbom.NewFormat( - ID, + sbom.AnyVersion, encoder, nil, nil, + ID, "table", ) } diff --git a/syft/formats/template/format.go b/syft/formats/template/format.go index 9fe7ad20a..4d6fb0ab2 100644 --- a/syft/formats/template/format.go +++ b/syft/formats/template/format.go @@ -1,6 +1,7 @@ package template import ( + "fmt" "io" "github.com/anchore/syft/syft/formats/syftjson" @@ -23,7 +24,19 @@ func (f OutputFormat) ID() sbom.FormatID { return ID } -func (f OutputFormat) Decode(reader io.Reader) (*sbom.SBOM, error) { +func (f OutputFormat) IDs() []sbom.FormatID { + return []sbom.FormatID{ID} +} + +func (f OutputFormat) Version() string { + return sbom.AnyVersion +} + +func (f OutputFormat) String() string { + return fmt.Sprintf("template: " + f.templateFilePath) +} + +func (f OutputFormat) Decode(_ io.Reader) (*sbom.SBOM, error) { return nil, sbom.ErrDecodingNotSupported } @@ -37,7 +50,7 @@ func (f OutputFormat) Encode(output io.Writer, s sbom.SBOM) error { return tmpl.Execute(output, doc) } -func (f OutputFormat) Validate(reader io.Reader) error { +func (f OutputFormat) Validate(_ io.Reader) error { return sbom.ErrValidationNotSupported } @@ -45,3 +58,5 @@ func (f OutputFormat) Validate(reader io.Reader) error { func (f *OutputFormat) SetTemplatePath(filePath string) { f.templateFilePath = filePath } + +var _ sbom.Format = (*OutputFormat)(nil) diff --git a/syft/formats/text/format.go b/syft/formats/text/format.go index e8e5e98b5..387bbd52a 100644 --- a/syft/formats/text/format.go +++ b/syft/formats/text/format.go @@ -8,9 +8,10 @@ const ID sbom.FormatID = "syft-text" func Format() sbom.Format { return sbom.NewFormat( - ID, + sbom.AnyVersion, encoder, nil, nil, + ID, "text", ) } diff --git a/syft/sbom/format.go b/syft/sbom/format.go index 13cfa7848..5247845b3 100644 --- a/syft/sbom/format.go +++ b/syft/sbom/format.go @@ -2,6 +2,7 @@ package sbom import ( "errors" + "fmt" "io" ) @@ -18,20 +19,41 @@ func (f FormatID) String() string { return string(f) } +const AnyVersion = "" + type Format interface { ID() FormatID + IDs() []FormatID + Version() string Encode(io.Writer, SBOM) error Decode(io.Reader) (*SBOM, error) Validate(io.Reader) error + fmt.Stringer } type format struct { - id FormatID + ids []FormatID + version string encoder Encoder decoder Decoder validator Validator } +func (f format) IDs() []FormatID { + return f.ids +} + +func (f format) Version() string { + return f.version +} + +func (f format) String() string { + if f.version == AnyVersion { + return f.ID().String() + } + return fmt.Sprintf("%s@%s", f.ID(), f.version) +} + // 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) @@ -47,9 +69,10 @@ type Encoder func(io.Writer, SBOM) error // 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, +func NewFormat(version string, encoder Encoder, decoder Decoder, validator Validator, ids ...FormatID) Format { + return format{ + ids: ids, + version: version, encoder: encoder, decoder: decoder, validator: validator, @@ -57,7 +80,7 @@ func NewFormat(id FormatID, encoder Encoder, decoder Decoder, validator Validato } func (f format) ID() FormatID { - return f.id + return f.ids[0] } func (f format) Encode(output io.Writer, s SBOM) error { @@ -81,3 +104,5 @@ func (f format) Validate(reader io.Reader) error { return f.validator(reader) } + +var _ Format = (*format)(nil) diff --git a/syft/sbom/multi_writer_test.go b/syft/sbom/multi_writer_test.go index be61c26f7..b46e852fc 100644 --- a/syft/sbom/multi_writer_test.go +++ b/syft/sbom/multi_writer_test.go @@ -15,7 +15,7 @@ func dummyEncoder(io.Writer, SBOM) error { } func dummyFormat(name string) Format { - return NewFormat(FormatID(name), dummyEncoder, nil, nil) + return NewFormat(AnyVersion, dummyEncoder, nil, nil, FormatID(name)) } type writerConfig struct { diff --git a/test/cli/all_formats_expressible_test.go b/test/cli/all_formats_expressible_test.go index 7a70da16c..1cc8e9e88 100644 --- a/test/cli/all_formats_expressible_test.go +++ b/test/cli/all_formats_expressible_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/anchore/syft/syft" + "github.com/anchore/syft/syft/formats" "github.com/anchore/syft/syft/formats/template" ) @@ -21,9 +21,9 @@ func TestAllFormatsExpressible(t *testing.T) { }, assertSuccessfulReturnCode, } - formats := syft.FormatIDs() - require.NotEmpty(t, formats) - for _, o := range formats { + formatNames := formats.AllIDs() + require.NotEmpty(t, formatNames) + for _, o := range formatNames { t.Run(fmt.Sprintf("format:%s", o), func(t *testing.T) { args := []string{"dir:./test-fixtures/image-pkg-coverage", "-o", string(o)} if o == template.ID { diff --git a/test/integration/convert_test.go b/test/integration/convert_test.go index 00f3f6e6a..d20cab27b 100644 --- a/test/integration/convert_test.go +++ b/test/integration/convert_test.go @@ -2,7 +2,7 @@ package integration import ( "context" - "io/ioutil" + "fmt" "os" "testing" @@ -10,7 +10,7 @@ import ( "github.com/anchore/syft/cmd/syft/cli/convert" "github.com/anchore/syft/internal/config" - "github.com/anchore/syft/syft" + "github.com/anchore/syft/syft/formats" "github.com/anchore/syft/syft/formats/cyclonedxjson" "github.com/anchore/syft/syft/formats/cyclonedxxml" "github.com/anchore/syft/syft/formats/spdxjson" @@ -21,36 +21,61 @@ import ( "github.com/anchore/syft/syft/source" ) -var convertibleFormats = []sbom.Format{ - syftjson.Format(), - spdxjson.Format(), - spdxtagvalue.Format(), - cyclonedxjson.Format(), - cyclonedxxml.Format(), -} - // TestConvertCmd tests if the converted SBOM is a valid document according // to spec. // TODO: This test can, but currently does not, check the converted SBOM content. It // might be useful to do that in the future, once we gather a better understanding of // what users expect from the convert command. func TestConvertCmd(t *testing.T) { - for _, format := range convertibleFormats { - t.Run(format.ID().String(), func(t *testing.T) { - sbom, _ := catalogFixtureImage(t, "image-pkg-coverage", source.SquashedScope, nil) - format := syft.FormatByID(syftjson.ID) + tests := []struct { + name string + format sbom.Format + }{ + { + name: "syft-json", + format: syftjson.Format(), + }, + { + name: "spdx-json", + format: spdxjson.Format(), + }, + { + name: "spdx-tag-value", + format: spdxtagvalue.Format(), + }, + { + name: "cyclonedx-json", + format: cyclonedxjson.Format(), + }, + { + name: "cyclonedx-xml", + format: cyclonedxxml.Format(), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + syftSbom, _ := catalogFixtureImage(t, "image-pkg-coverage", source.SquashedScope, nil) + syftFormat := syftjson.Format() - f, err := ioutil.TempFile("", "test-convert-sbom-") + syftFile, err := os.CreateTemp("", "test-convert-sbom-") require.NoError(t, err) defer func() { - os.Remove(f.Name()) + _ = os.Remove(syftFile.Name()) }() - err = format.Encode(f, sbom) + err = syftFormat.Encode(syftFile, syftSbom) require.NoError(t, err) + formatFile, err := os.CreateTemp("", "test-convert-sbom-") + require.NoError(t, err) + defer func() { + _ = os.Remove(syftFile.Name()) + }() + ctx := context.Background() - app := &config.Application{Outputs: []string{format.ID().String()}} + app := &config.Application{ + Outputs: []string{fmt.Sprintf("%s=%s", test.format.ID().String(), formatFile.Name())}, + } // stdout reduction of test noise rescue := os.Stdout // keep backup of the real stdout @@ -59,17 +84,17 @@ func TestConvertCmd(t *testing.T) { os.Stdout = rescue }() - err = convert.Run(ctx, app, []string{f.Name()}) + err = convert.Run(ctx, app, []string{syftFile.Name()}) require.NoError(t, err) - file, err := ioutil.ReadFile(f.Name()) + contents, err := os.ReadFile(formatFile.Name()) require.NoError(t, err) - formatFound := syft.IdentifyFormat(file) - if format.ID() == table.ID { + formatFound := formats.Identify(contents) + if test.format.ID() == table.ID { require.Nil(t, formatFound) return } - require.Equal(t, format.ID(), formatFound.ID()) + require.Equal(t, test.format.ID(), formatFound.ID()) }) } } diff --git a/test/integration/encode_decode_cycle_test.go b/test/integration/encode_decode_cycle_test.go index 8f47e10ce..54304b8ac 100644 --- a/test/integration/encode_decode_cycle_test.go +++ b/test/integration/encode_decode_cycle_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/anchore/syft/syft" + "github.com/anchore/syft/syft/formats" "github.com/anchore/syft/syft/formats/cyclonedxjson" "github.com/anchore/syft/syft/formats/cyclonedxxml" "github.com/anchore/syft/syft/formats/syftjson" @@ -68,17 +68,17 @@ func TestEncodeDecodeEncodeCycleComparison(t *testing.T) { for _, image := range images { originalSBOM, _ := catalogFixtureImage(t, image, source.SquashedScope, nil) - format := syft.FormatByID(test.formatOption) + format := formats.ByName(string(test.formatOption)) require.NotNil(t, format) - by1, err := syft.Encode(originalSBOM, format) + by1, err := formats.Encode(originalSBOM, format) assert.NoError(t, err) - newSBOM, newFormat, err := syft.Decode(bytes.NewReader(by1)) + newSBOM, newFormat, err := formats.Decode(bytes.NewReader(by1)) assert.NoError(t, err) assert.Equal(t, format.ID(), newFormat.ID()) - by2, err := syft.Encode(*newSBOM, format) + by2, err := formats.Encode(*newSBOM, format) assert.NoError(t, err) if test.redactor != nil {