mirror of
https://github.com/anchore/syft.git
synced 2026-11-01 15:06:36 +01:00
Split the sbom.Format interface by encode and decode use cases (#2186)
* split up sbom.Format into encode and decode ops Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * update cmd pkg to inject format configs Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * bump cyclonedx schema to 1.5 Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * redact image metadata from github encoder tests Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * add more testing around format decoder identify Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * add test case for format version options Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix cli tests Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix CLI test Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * [wip] - review comments Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * keep encoder creation out of post load function Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * keep decider and identify functions Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * add a few more doc comments Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * remove format encoder default function helpers Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * address PR feedback Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * move back to streaming based decode functions Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * with common convention for encoder constructors Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix tests and allow for encoders to be created from cli options Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix cli tests Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix linting Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * buffer reads from stdin to support seeking Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> --------- Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
@@ -19,12 +19,10 @@ import (
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/event"
|
||||
"github.com/anchore/syft/syft/event/monitor"
|
||||
"github.com/anchore/syft/syft/formats"
|
||||
"github.com/anchore/syft/syft/formats/github"
|
||||
"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/formats/text"
|
||||
"github.com/anchore/syft/syft/format/cyclonedxjson"
|
||||
"github.com/anchore/syft/syft/format/spdxjson"
|
||||
"github.com/anchore/syft/syft/format/spdxtagvalue"
|
||||
"github.com/anchore/syft/syft/format/syftjson"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
@@ -37,30 +35,33 @@ const (
|
||||
)
|
||||
|
||||
type attestOptions struct {
|
||||
options.Config `yaml:",inline" mapstructure:",squash"`
|
||||
options.SingleOutput `yaml:",inline" mapstructure:",squash"`
|
||||
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
|
||||
options.Catalog `yaml:",inline" mapstructure:",squash"`
|
||||
options.Attest `yaml:",inline" mapstructure:",squash"`
|
||||
options.Config `yaml:",inline" mapstructure:",squash"`
|
||||
options.Output `yaml:",inline" mapstructure:",squash"`
|
||||
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
|
||||
options.Catalog `yaml:",inline" mapstructure:",squash"`
|
||||
options.Attest `yaml:",inline" mapstructure:",squash"`
|
||||
}
|
||||
|
||||
func Attest(app clio.Application) *cobra.Command {
|
||||
id := app.ID()
|
||||
|
||||
var allowableOutputs []string
|
||||
for _, f := range formats.AllIDs() {
|
||||
switch f {
|
||||
case table.ID, text.ID, github.ID, template.ID:
|
||||
continue
|
||||
}
|
||||
allowableOutputs = append(allowableOutputs, f.String())
|
||||
}
|
||||
|
||||
opts := &attestOptions{
|
||||
UpdateCheck: options.DefaultUpdateCheck(),
|
||||
SingleOutput: options.SingleOutput{
|
||||
AllowableOptions: allowableOutputs,
|
||||
Output: syftjson.ID.String(),
|
||||
Output: options.Output{
|
||||
AllowMultipleOutputs: false,
|
||||
AllowableOptions: []string{
|
||||
string(syftjson.ID),
|
||||
string(cyclonedxjson.ID),
|
||||
string(spdxjson.ID),
|
||||
string(spdxtagvalue.ID),
|
||||
},
|
||||
Outputs: []string{syftjson.ID.String()},
|
||||
OutputFile: options.OutputFile{ // nolint:staticcheck
|
||||
Enabled: false, // explicitly not allowed
|
||||
},
|
||||
OutputTemplate: options.OutputTemplate{
|
||||
Enabled: false, // explicitly not allowed
|
||||
},
|
||||
},
|
||||
Catalog: options.DefaultCatalog(),
|
||||
}
|
||||
@@ -95,15 +96,13 @@ func runAttest(id clio.Identification, opts *attestOptions, userInput string) er
|
||||
return fmt.Errorf("unable to build SBOM: %w", err)
|
||||
}
|
||||
|
||||
o := opts.Output
|
||||
|
||||
f, err := os.CreateTemp("", o)
|
||||
f, err := os.CreateTemp("", "syft-attest-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
writer, err := opts.SBOMWriter(f.Name())
|
||||
writer, err := opts.SBOMWriter()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create SBOM writer: %w", err)
|
||||
}
|
||||
@@ -118,10 +117,21 @@ func runAttest(id clio.Identification, opts *attestOptions, userInput string) er
|
||||
return fmt.Errorf("unable to find cosign in PATH; make sure you have it installed")
|
||||
}
|
||||
|
||||
outputNames := opts.OutputNameSet()
|
||||
var outputName string
|
||||
switch outputNames.Size() {
|
||||
case 0:
|
||||
return fmt.Errorf("no output format specified")
|
||||
case 1:
|
||||
outputName = outputNames.List()[0]
|
||||
default:
|
||||
return fmt.Errorf("multiple output formats specified: %s", strings.Join(outputNames.List(), ", "))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
switch strings.ToLower(outputName) {
|
||||
case "cyclonedx-json":
|
||||
predicateType = "cyclonedx"
|
||||
case "spdx-tag-value", "spdx-tv":
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/anchore/syft/cmd/syft/cli/options"
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/formats"
|
||||
"github.com/anchore/syft/syft/format"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -23,7 +23,7 @@ const (
|
||||
|
||||
type ConvertOptions struct {
|
||||
options.Config `yaml:",inline" mapstructure:",squash"`
|
||||
options.MultiOutput `yaml:",inline" mapstructure:",squash"`
|
||||
options.Output `yaml:",inline" mapstructure:",squash"`
|
||||
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ func Convert(app clio.Application) *cobra.Command {
|
||||
|
||||
opts := &ConvertOptions{
|
||||
UpdateCheck: options.DefaultUpdateCheck(),
|
||||
Output: options.DefaultOutput(),
|
||||
}
|
||||
|
||||
return app.SetupCommand(&cobra.Command{
|
||||
@@ -63,10 +64,13 @@ func RunConvert(opts *ConvertOptions, userInput string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
var reader io.ReadCloser
|
||||
var reader io.ReadSeekCloser
|
||||
|
||||
if userInput == "-" {
|
||||
reader = os.Stdin
|
||||
// though os.Stdin is an os.File, it does not support seeking
|
||||
// you will get errors such as "seek /dev/stdin: illegal seek".
|
||||
// We need to buffer what we read.
|
||||
reader = internal.NewBufferedSeeker(os.Stdin)
|
||||
} else {
|
||||
f, err := os.Open(userInput)
|
||||
if err != nil {
|
||||
@@ -78,7 +82,7 @@ func RunConvert(opts *ConvertOptions, userInput string) error {
|
||||
reader = f
|
||||
}
|
||||
|
||||
s, _, err := formats.Decode(reader)
|
||||
s, _, _, err := format.Decode(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode SBOM: %w", err)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/anchore/syft/internal/file"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/formats/template"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
@@ -55,14 +54,14 @@ const (
|
||||
|
||||
type packagesOptions struct {
|
||||
options.Config `yaml:",inline" mapstructure:",squash"`
|
||||
options.MultiOutput `yaml:",inline" mapstructure:",squash"`
|
||||
options.Output `yaml:",inline" mapstructure:",squash"`
|
||||
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
|
||||
options.Catalog `yaml:",inline" mapstructure:",squash"`
|
||||
}
|
||||
|
||||
func defaultPackagesOptions() *packagesOptions {
|
||||
return &packagesOptions{
|
||||
MultiOutput: options.DefaultOutput(),
|
||||
Output: options.DefaultOutput(),
|
||||
UpdateCheck: options.DefaultUpdateCheck(),
|
||||
Catalog: options.DefaultCatalog(),
|
||||
}
|
||||
@@ -108,11 +107,6 @@ func validateArgs(cmd *cobra.Command, args []string, error string) error {
|
||||
|
||||
// nolint:funlen
|
||||
func runPackages(id clio.Identification, opts *packagesOptions, userInput string) error {
|
||||
err := validatePackageOutputOptions(&opts.MultiOutput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writer, err := opts.SBOMWriter()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -235,19 +229,3 @@ func mergeRelationships(cs ...<-chan artifact.Relationship) (relationships []art
|
||||
|
||||
return relationships
|
||||
}
|
||||
|
||||
func validatePackageOutputOptions(cfg *options.MultiOutput) error {
|
||||
var usesTemplateOutput bool
|
||||
for _, o := range cfg.Outputs {
|
||||
if o == template.ID.String() {
|
||||
usesTemplateOutput = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if usesTemplateOutput && cfg.OutputTemplatePath == "" {
|
||||
return fmt.Errorf(`must specify path to template file when using "template" output format`)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/anchore/syft/cmd/syft/cli/options"
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/formats/syftjson"
|
||||
"github.com/anchore/syft/syft/format/syftjson"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
@@ -42,6 +42,9 @@ func PowerUser(app clio.Application) *cobra.Command {
|
||||
pkgs.FileClassification.Cataloger.Enabled = true
|
||||
opts := &powerUserOptions{
|
||||
Catalog: pkgs,
|
||||
OutputFile: options.OutputFile{ // nolint:staticcheck
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
return app.SetupCommand(&cobra.Command{
|
||||
@@ -62,7 +65,7 @@ func PowerUser(app clio.Application) *cobra.Command {
|
||||
|
||||
//nolint:funlen
|
||||
func runPowerUser(id clio.Identification, opts *powerUserOptions, userInput string) error {
|
||||
writer, err := opts.SBOMWriter(syftjson.Format())
|
||||
writer, err := opts.SBOMWriter(syftjson.NewFormatEncoder())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+210
-77
@@ -2,99 +2,232 @@ package options
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/scylladb/go-set/strset"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/fangs"
|
||||
"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/format/cyclonedxjson"
|
||||
"github.com/anchore/syft/syft/format/cyclonedxxml"
|
||||
"github.com/anchore/syft/syft/format/github"
|
||||
"github.com/anchore/syft/syft/format/spdxjson"
|
||||
"github.com/anchore/syft/syft/format/spdxtagvalue"
|
||||
"github.com/anchore/syft/syft/format/syftjson"
|
||||
"github.com/anchore/syft/syft/format/table"
|
||||
"github.com/anchore/syft/syft/format/template"
|
||||
"github.com/anchore/syft/syft/format/text"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
)
|
||||
|
||||
// MultiOutput has the standard output options syft accepts: multiple -o, --file, --template
|
||||
type MultiOutput struct {
|
||||
Outputs []string `yaml:"output" json:"output" mapstructure:"output"` // -o, the format to use for output
|
||||
OutputFile `yaml:",inline" json:"" mapstructure:",squash"`
|
||||
OutputTemplatePath string `yaml:"output-template-path" json:"output-template-path" mapstructure:"output-template-path"` // -t template file to use for output
|
||||
}
|
||||
|
||||
var _ interface {
|
||||
clio.FlagAdder
|
||||
} = (*MultiOutput)(nil)
|
||||
|
||||
func DefaultOutput() MultiOutput {
|
||||
return MultiOutput{
|
||||
Outputs: []string{string(table.ID)},
|
||||
}
|
||||
}
|
||||
|
||||
func (o *MultiOutput) AddFlags(flags clio.FlagSet) {
|
||||
flags.StringArrayVarP(&o.Outputs, "output", "o",
|
||||
fmt.Sprintf("report output format (<format>=<file> to output to a file), formats=%v", formats.AllIDs()))
|
||||
|
||||
flags.StringVarP(&o.OutputTemplatePath, "template", "t",
|
||||
"specify the path to a Go template file")
|
||||
}
|
||||
|
||||
func (o *MultiOutput) SBOMWriter() (sbom.Writer, error) {
|
||||
return makeSBOMWriter(o.Outputs, o.File, o.OutputTemplatePath)
|
||||
}
|
||||
|
||||
// SingleOutput allows only 1 output to be specified, with a user able to set what options are allowed by setting AllowableOptions
|
||||
type SingleOutput struct {
|
||||
AllowableOptions []string `yaml:"-" json:"-" mapstructure:"-"`
|
||||
Output string `yaml:"output" json:"output" mapstructure:"output"`
|
||||
OutputTemplatePath string `yaml:"output-template-path" json:"output-template-path" mapstructure:"output-template-path"` // -t template file to use for output
|
||||
}
|
||||
|
||||
var _ clio.FlagAdder = (*SingleOutput)(nil)
|
||||
|
||||
func (o *SingleOutput) AddFlags(flags clio.FlagSet) {
|
||||
flags.StringVarP(&o.Output, "output", "o",
|
||||
fmt.Sprintf("report output format, options=%v", o.AllowableOptions))
|
||||
|
||||
if slices.Contains(o.AllowableOptions, template.ID.String()) {
|
||||
flags.StringVarP(&o.OutputTemplatePath, "template", "t",
|
||||
"specify the path to a Go template file")
|
||||
}
|
||||
}
|
||||
|
||||
func (o *SingleOutput) SBOMWriter(file string) (sbom.Writer, error) {
|
||||
return makeSBOMWriter([]string{o.Output}, file, o.OutputTemplatePath)
|
||||
}
|
||||
|
||||
// Deprecated: OutputFile is only the --file argument
|
||||
type OutputFile struct {
|
||||
File string `yaml:"file" json:"file" mapstructure:"file"` // --file, the file to write report output to
|
||||
}
|
||||
|
||||
var _ interface {
|
||||
clio.FlagAdder
|
||||
clio.PostLoader
|
||||
} = (*OutputFile)(nil)
|
||||
} = (*Output)(nil)
|
||||
|
||||
func (o *OutputFile) AddFlags(flags clio.FlagSet) {
|
||||
flags.StringVarP(&o.File, "file", "",
|
||||
"file to write the default report output to (default is STDOUT)")
|
||||
// Output has the standard output options syft accepts: multiple -o, --file, --template
|
||||
type Output struct {
|
||||
AllowableOptions []string `yaml:"-" json:"-" mapstructure:"-"`
|
||||
AllowMultipleOutputs bool `yaml:"-" json:"-" mapstructure:"-"`
|
||||
Outputs []string `yaml:"output" json:"output" mapstructure:"output"` // -o, the format to use for output
|
||||
OutputFile `yaml:",inline" json:"" mapstructure:",squash"`
|
||||
OutputTemplate `yaml:"template" json:"template" mapstructure:"template"`
|
||||
}
|
||||
|
||||
if pfp, ok := flags.(fangs.PFlagSetProvider); ok {
|
||||
flagSet := pfp.PFlagSet()
|
||||
flagSet.Lookup("file").Deprecated = "use: output"
|
||||
func DefaultOutput() Output {
|
||||
return Output{
|
||||
AllowMultipleOutputs: true,
|
||||
Outputs: []string{string(table.ID)},
|
||||
OutputFile: OutputFile{
|
||||
Enabled: true,
|
||||
},
|
||||
OutputTemplate: OutputTemplate{
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (o *OutputFile) PostLoad() error {
|
||||
if o.File != "" {
|
||||
file, err := expandFilePath(o.File)
|
||||
func (o *Output) AddFlags(flags clio.FlagSet) {
|
||||
var names []string
|
||||
for _, id := range supportedIDs() {
|
||||
names = append(names, id.String())
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
flags.StringArrayVarP(&o.Outputs, "output", "o",
|
||||
fmt.Sprintf("report output format (<format>=<file> to output to a file), formats=%v", names))
|
||||
}
|
||||
|
||||
func (o Output) SBOMWriter() (sbom.Writer, error) {
|
||||
names := o.OutputNameSet()
|
||||
|
||||
if len(o.Outputs) > 1 && !o.AllowMultipleOutputs {
|
||||
return nil, fmt.Errorf("only one output format is allowed (given %d: %s)", len(o.Outputs), names)
|
||||
}
|
||||
|
||||
usesTemplateOutput := names.Has(string(template.ID))
|
||||
|
||||
if usesTemplateOutput && o.OutputTemplate.Path == "" {
|
||||
return nil, fmt.Errorf(`must specify path to template file when using "template" output format`)
|
||||
}
|
||||
|
||||
encoders, err := o.Encoders()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return makeSBOMWriter(o.Outputs, o.File, encoders)
|
||||
}
|
||||
|
||||
func (o *Output) Encoders() ([]sbom.FormatEncoder, error) {
|
||||
// setup all encoders based on the configuration
|
||||
var list encoderList
|
||||
|
||||
// in the future there will be application configuration options that can be used to set the default output format
|
||||
list.addWithErr(template.ID)(o.OutputTemplate.formatEncoders())
|
||||
list.add(syftjson.ID)(syftjson.NewFormatEncoder())
|
||||
list.add(table.ID)(table.NewFormatEncoder())
|
||||
list.add(text.ID)(text.NewFormatEncoder())
|
||||
list.add(github.ID)(github.NewFormatEncoder())
|
||||
list.addWithErr(cyclonedxxml.ID)(cycloneDxXMLEncoders())
|
||||
list.addWithErr(cyclonedxjson.ID)(cycloneDxJSONEncoders())
|
||||
list.addWithErr(spdxjson.ID)(spdxJSONEncoders())
|
||||
list.addWithErr(spdxtagvalue.ID)(spdxTagValueEncoders())
|
||||
|
||||
return list.encoders, list.err
|
||||
}
|
||||
|
||||
func (o Output) OutputNameSet() *strset.Set {
|
||||
names := strset.New()
|
||||
for _, output := range o.Outputs {
|
||||
fields := strings.Split(output, "=")
|
||||
names.Add(fields[0])
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
type encoderList struct {
|
||||
encoders []sbom.FormatEncoder
|
||||
err error
|
||||
}
|
||||
|
||||
func (l *encoderList) addWithErr(name sbom.FormatID) func([]sbom.FormatEncoder, error) {
|
||||
return func(encs []sbom.FormatEncoder, err error) {
|
||||
if err != nil {
|
||||
return err
|
||||
l.err = multierror.Append(l.err, fmt.Errorf("unable to configure %q format encoder: %w", name, err))
|
||||
return
|
||||
}
|
||||
for _, enc := range encs {
|
||||
if enc == nil {
|
||||
l.err = multierror.Append(l.err, fmt.Errorf("unable to configure %q format encoder: nil encoder returned", name))
|
||||
continue
|
||||
}
|
||||
l.encoders = append(l.encoders, enc)
|
||||
}
|
||||
o.File = file
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *OutputFile) SBOMWriter(f sbom.Format) (sbom.Writer, error) {
|
||||
return makeSBOMWriterForFormat(f, o.File)
|
||||
func (l *encoderList) add(name sbom.FormatID) func(...sbom.FormatEncoder) {
|
||||
return func(encs ...sbom.FormatEncoder) {
|
||||
for _, enc := range encs {
|
||||
if enc == nil {
|
||||
l.err = multierror.Append(l.err, fmt.Errorf("unable to configure %q format encoder: nil encoder returned", name))
|
||||
continue
|
||||
}
|
||||
l.encoders = append(l.encoders, enc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: when application configuration is made for this format then this should be ported to the options object
|
||||
// that is created for that configuration (as done with the template output option)
|
||||
func cycloneDxXMLEncoders() ([]sbom.FormatEncoder, error) {
|
||||
var (
|
||||
encs []sbom.FormatEncoder
|
||||
errs error
|
||||
)
|
||||
for _, v := range cyclonedxxml.SupportedVersions() {
|
||||
enc, err := cyclonedxxml.NewFormatEncoderWithConfig(cyclonedxxml.EncoderConfig{Version: v})
|
||||
if err != nil {
|
||||
errs = multierror.Append(errs, err)
|
||||
} else {
|
||||
encs = append(encs, enc)
|
||||
}
|
||||
}
|
||||
return encs, errs
|
||||
}
|
||||
|
||||
// TODO: when application configuration is made for this format then this should be ported to the options object
|
||||
// that is created for that configuration (as done with the template output option)
|
||||
func cycloneDxJSONEncoders() ([]sbom.FormatEncoder, error) {
|
||||
var (
|
||||
encs []sbom.FormatEncoder
|
||||
errs error
|
||||
)
|
||||
for _, v := range cyclonedxjson.SupportedVersions() {
|
||||
enc, err := cyclonedxjson.NewFormatEncoderWithConfig(cyclonedxjson.EncoderConfig{Version: v})
|
||||
if err != nil {
|
||||
errs = multierror.Append(errs, err)
|
||||
} else {
|
||||
encs = append(encs, enc)
|
||||
}
|
||||
}
|
||||
return encs, errs
|
||||
}
|
||||
|
||||
// TODO: when application configuration is made for this format then this should be ported to the options object
|
||||
// that is created for that configuration (as done with the template output option)
|
||||
func spdxJSONEncoders() ([]sbom.FormatEncoder, error) {
|
||||
var (
|
||||
encs []sbom.FormatEncoder
|
||||
errs error
|
||||
)
|
||||
for _, v := range spdxjson.SupportedVersions() {
|
||||
enc, err := spdxjson.NewFormatEncoderWithConfig(spdxjson.EncoderConfig{Version: v})
|
||||
if err != nil {
|
||||
errs = multierror.Append(errs, err)
|
||||
} else {
|
||||
encs = append(encs, enc)
|
||||
}
|
||||
}
|
||||
return encs, errs
|
||||
}
|
||||
|
||||
// TODO: when application configuration is made for this format then this should be ported to the options object
|
||||
// that is created for that configuration (as done with the template output option)
|
||||
func spdxTagValueEncoders() ([]sbom.FormatEncoder, error) {
|
||||
var (
|
||||
encs []sbom.FormatEncoder
|
||||
errs error
|
||||
)
|
||||
for _, v := range spdxtagvalue.SupportedVersions() {
|
||||
enc, err := spdxtagvalue.NewFormatEncoderWithConfig(spdxtagvalue.EncoderConfig{Version: v})
|
||||
if err != nil {
|
||||
errs = multierror.Append(errs, err)
|
||||
} else {
|
||||
encs = append(encs, enc)
|
||||
}
|
||||
}
|
||||
return encs, errs
|
||||
}
|
||||
|
||||
func supportedIDs() []sbom.FormatID {
|
||||
encs := []sbom.FormatID{
|
||||
// encoders that support a single version
|
||||
syftjson.ID,
|
||||
github.ID,
|
||||
table.ID,
|
||||
text.ID,
|
||||
template.ID,
|
||||
|
||||
// encoders that support multiple versions
|
||||
cyclonedxxml.ID,
|
||||
cyclonedxjson.ID,
|
||||
spdxtagvalue.ID,
|
||||
spdxjson.ID,
|
||||
}
|
||||
|
||||
return encs
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/fangs"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
)
|
||||
|
||||
var _ interface {
|
||||
clio.FlagAdder
|
||||
clio.PostLoader
|
||||
} = (*OutputFile)(nil)
|
||||
|
||||
// Deprecated: OutputFile supports the --file to write the SBOM output to
|
||||
type OutputFile struct {
|
||||
Enabled bool `yaml:"-" json:"-" mapstructure:"-"`
|
||||
File string `yaml:"file" json:"file" mapstructure:"file"`
|
||||
}
|
||||
|
||||
func (o *OutputFile) AddFlags(flags clio.FlagSet) {
|
||||
if o.Enabled {
|
||||
flags.StringVarP(&o.File, "file", "",
|
||||
"file to write the default report output to (default is STDOUT)")
|
||||
|
||||
if pfp, ok := flags.(fangs.PFlagSetProvider); ok {
|
||||
flagSet := pfp.PFlagSet()
|
||||
flagSet.Lookup("file").Deprecated = "use: output"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *OutputFile) PostLoad() error {
|
||||
if !o.Enabled {
|
||||
return nil
|
||||
}
|
||||
if o.File != "" {
|
||||
file, err := expandFilePath(o.File)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.File = file
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *OutputFile) SBOMWriter(f sbom.FormatEncoder) (sbom.Writer, error) {
|
||||
if !o.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
writer, err := newSBOMMultiWriter(newSBOMWriterDescription(f, o.File))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return writer, nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/syft/format/template"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
)
|
||||
|
||||
var _ clio.FlagAdder = (*OutputTemplate)(nil)
|
||||
|
||||
type OutputTemplate struct {
|
||||
Enabled bool `yaml:"-" json:"-" mapstructure:"-"`
|
||||
Path string `yaml:"path" json:"path" mapstructure:"path"` // -t template file to use for output
|
||||
}
|
||||
|
||||
func (o *OutputTemplate) AddFlags(flags clio.FlagSet) {
|
||||
if o.Enabled {
|
||||
flags.StringVarP(&o.Path, "template", "t",
|
||||
"specify the path to a Go template file")
|
||||
}
|
||||
}
|
||||
|
||||
func (o OutputTemplate) formatEncoders() ([]sbom.FormatEncoder, error) {
|
||||
if !o.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
enc, err := template.NewFormatEncoder(template.EncoderConfig{
|
||||
TemplatePath: o.Path,
|
||||
})
|
||||
return []sbom.FormatEncoder{enc}, err
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/scylladb/go-set/strset"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/syft/format"
|
||||
"github.com/anchore/syft/syft/format/cyclonedxjson"
|
||||
"github.com/anchore/syft/syft/format/cyclonedxxml"
|
||||
"github.com/anchore/syft/syft/format/github"
|
||||
"github.com/anchore/syft/syft/format/spdxjson"
|
||||
"github.com/anchore/syft/syft/format/spdxtagvalue"
|
||||
"github.com/anchore/syft/syft/format/syftjson"
|
||||
"github.com/anchore/syft/syft/format/table"
|
||||
"github.com/anchore/syft/syft/format/template"
|
||||
"github.com/anchore/syft/syft/format/text"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
)
|
||||
|
||||
func Test_getEncoders(t *testing.T) {
|
||||
allDefaultEncoderNames := strset.New()
|
||||
for _, id := range supportedIDs() {
|
||||
allDefaultEncoderNames.Add(id.String())
|
||||
}
|
||||
|
||||
opts := DefaultOutput()
|
||||
opts.OutputTemplate.Path = "somewhere"
|
||||
|
||||
encoders, err := opts.Encoders()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, encoders)
|
||||
|
||||
encoderNames := strset.New()
|
||||
for _, e := range encoders {
|
||||
encoderNames.Add(e.ID().String())
|
||||
}
|
||||
|
||||
assert.ElementsMatch(t, allDefaultEncoderNames.List(), encoderNames.List(), "not all encoders are expressed")
|
||||
}
|
||||
|
||||
func Test_EncoderCollection_ByString_IDOnly_Defaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want sbom.FormatID
|
||||
}{
|
||||
// SPDX Tag-Value
|
||||
{
|
||||
name: "spdx",
|
||||
want: spdxtagvalue.ID,
|
||||
},
|
||||
{
|
||||
name: "spdx-tag-value",
|
||||
want: spdxtagvalue.ID,
|
||||
},
|
||||
{
|
||||
name: "spdx-tv",
|
||||
want: spdxtagvalue.ID,
|
||||
},
|
||||
{
|
||||
name: "spdxtv", // clean variant
|
||||
want: spdxtagvalue.ID,
|
||||
},
|
||||
|
||||
// SPDX JSON
|
||||
{
|
||||
name: "spdx-json",
|
||||
want: spdxjson.ID,
|
||||
},
|
||||
{
|
||||
name: "spdxjson", // clean variant
|
||||
want: spdxjson.ID,
|
||||
},
|
||||
|
||||
// Cyclonedx JSON
|
||||
{
|
||||
name: "cyclonedx-json",
|
||||
want: cyclonedxjson.ID,
|
||||
},
|
||||
{
|
||||
name: "cyclonedxjson", // clean variant
|
||||
want: cyclonedxjson.ID,
|
||||
},
|
||||
|
||||
// Cyclonedx XML
|
||||
{
|
||||
name: "cdx",
|
||||
want: cyclonedxxml.ID,
|
||||
},
|
||||
{
|
||||
name: "cyclone",
|
||||
want: cyclonedxxml.ID,
|
||||
},
|
||||
{
|
||||
name: "cyclonedx",
|
||||
want: cyclonedxxml.ID,
|
||||
},
|
||||
{
|
||||
name: "cyclonedx-xml",
|
||||
want: cyclonedxxml.ID,
|
||||
},
|
||||
{
|
||||
name: "cyclonedxxml", // clean variant
|
||||
want: cyclonedxxml.ID,
|
||||
},
|
||||
|
||||
// Syft Table
|
||||
{
|
||||
name: "table",
|
||||
want: table.ID,
|
||||
},
|
||||
{
|
||||
name: "syft-table",
|
||||
want: table.ID,
|
||||
},
|
||||
|
||||
// Syft Text
|
||||
{
|
||||
name: "text",
|
||||
want: text.ID,
|
||||
},
|
||||
{
|
||||
name: "syft-text",
|
||||
want: text.ID,
|
||||
},
|
||||
|
||||
// Syft JSON
|
||||
{
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
opts := DefaultOutput()
|
||||
opts.OutputTemplate.Path = "somewhere"
|
||||
|
||||
defaultEncoders, err := opts.Encoders()
|
||||
require.NoError(t, err)
|
||||
|
||||
encoders := format.NewEncoderCollection(defaultEncoders...)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := encoders.GetByString(tt.name)
|
||||
if tt.want == "" {
|
||||
require.Nil(t, f)
|
||||
return
|
||||
}
|
||||
require.NotNil(t, f)
|
||||
assert.Equal(t, tt.want, f.ID())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,17 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/mitchellh/go-homedir"
|
||||
"github.com/scylladb/go-set/strset"
|
||||
|
||||
"github.com/anchore/syft/internal/bus"
|
||||
"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/format"
|
||||
"github.com/anchore/syft/syft/format/table"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
)
|
||||
|
||||
@@ -28,8 +29,8 @@ var _ interface {
|
||||
|
||||
// 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 makeSBOMWriter(outputs []string, defaultFile, templateFilePath string) (sbom.Writer, error) {
|
||||
outputOptions, err := parseSBOMOutputFlags(outputs, defaultFile, templateFilePath)
|
||||
func makeSBOMWriter(outputs []string, defaultFile string, encoders []sbom.FormatEncoder) (sbom.Writer, error) {
|
||||
outputOptions, err := parseSBOMOutputFlags(outputs, defaultFile, encoders)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -42,18 +43,10 @@ func makeSBOMWriter(outputs []string, defaultFile, templateFilePath string) (sbo
|
||||
return writer, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func parseSBOMOutputFlags(outputs []string, defaultFile string, encoders []sbom.FormatEncoder) (out []sbomWriterDescription, errs error) {
|
||||
encoderCollection := format.NewEncoderCollection(encoders...)
|
||||
|
||||
// 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())
|
||||
@@ -76,29 +69,77 @@ func parseSBOMOutputFlags(outputs []string, defaultFile, templateFilePath string
|
||||
file = parts[1]
|
||||
}
|
||||
|
||||
format := formats.ByName(name)
|
||||
if format == nil {
|
||||
errs = multierror.Append(errs, fmt.Errorf(`unsupported output format "%s", supported formats are: %+v`, name, formats.AllIDs()))
|
||||
enc := encoderCollection.GetByString(name)
|
||||
if enc == nil {
|
||||
errs = multierror.Append(errs, fmt.Errorf(`unsupported output format "%s", supported formats are: %+v`, name, formatVersionOptions(encoderCollection.NameVersions())))
|
||||
continue
|
||||
}
|
||||
|
||||
if tmpl, ok := format.(template.OutputFormat); ok {
|
||||
tmpl.SetTemplatePath(templateFilePath)
|
||||
format = tmpl
|
||||
}
|
||||
|
||||
out = append(out, newSBOMWriterDescription(format, file))
|
||||
out = append(out, newSBOMWriterDescription(enc, file))
|
||||
}
|
||||
return out, errs
|
||||
}
|
||||
|
||||
// formatVersionOptions takes a list like ["github-json", "syft-json@11.0.0", "cyclonedx-xml@1.0", "cyclondx-xml@1.1"...]
|
||||
// and formats it into a human-readable string like:
|
||||
//
|
||||
// Available formats:
|
||||
// - cyclonedx-json @ 1.2, 1.3, 1.4, 1.5
|
||||
// - cyclonedx-xml @ 1.0, 1.1, 1.2, 1.3, 1.4, 1.5
|
||||
// - github-json
|
||||
// - spdx-json @ 2.2, 2.3
|
||||
// - spdx-tag-value @ 2.1, 2.2, 2.3
|
||||
// - syft-json
|
||||
// - syft-table
|
||||
// - syft-text
|
||||
// - template
|
||||
func formatVersionOptions(nameVersionPairs []string) string {
|
||||
availableVersions := make(map[string][]string)
|
||||
availableFormats := strset.New()
|
||||
for _, nameVersion := range nameVersionPairs {
|
||||
fields := strings.SplitN(nameVersion, "@", 2)
|
||||
if len(fields) == 2 {
|
||||
availableVersions[fields[0]] = append(availableVersions[fields[0]], fields[1])
|
||||
}
|
||||
availableFormats.Add(fields[0])
|
||||
}
|
||||
|
||||
// find any formats with exactly one version -- remove them from the version map
|
||||
for name, versions := range availableVersions {
|
||||
if len(versions) == 1 {
|
||||
delete(availableVersions, name)
|
||||
}
|
||||
}
|
||||
|
||||
sortedAvailableFormats := availableFormats.List()
|
||||
sort.Strings(sortedAvailableFormats)
|
||||
|
||||
var s strings.Builder
|
||||
|
||||
s.WriteString("\n")
|
||||
s.WriteString("Available formats:")
|
||||
|
||||
for _, name := range sortedAvailableFormats {
|
||||
s.WriteString("\n")
|
||||
|
||||
s.WriteString(fmt.Sprintf(" - %s", name))
|
||||
|
||||
if len(availableVersions[name]) > 0 {
|
||||
s.WriteString(" @ ")
|
||||
s.WriteString(strings.Join(availableVersions[name], ", "))
|
||||
}
|
||||
}
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
// sbomWriterDescription Format and path strings used to create sbom.Writer
|
||||
type sbomWriterDescription struct {
|
||||
Format sbom.Format
|
||||
Format sbom.FormatEncoder
|
||||
Path string
|
||||
}
|
||||
|
||||
func newSBOMWriterDescription(f sbom.Format, p string) sbomWriterDescription {
|
||||
func newSBOMWriterDescription(f sbom.FormatEncoder, p string) sbomWriterDescription {
|
||||
expandedPath, err := homedir.Expand(p)
|
||||
if err != nil {
|
||||
log.Warnf("could not expand given writer output path=%q: %w", p, err)
|
||||
@@ -171,7 +212,7 @@ func (m *sbomMultiWriter) Write(s sbom.SBOM) (errs error) {
|
||||
|
||||
// sbomStreamWriter implements sbom.Writer for a given format and io.Writer, also providing a close function for cleanup
|
||||
type sbomStreamWriter struct {
|
||||
format sbom.Format
|
||||
format sbom.FormatEncoder
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
@@ -191,7 +232,7 @@ func (w *sbomStreamWriter) Close() error {
|
||||
|
||||
// sbomPublisher implements sbom.Writer that publishes results to the event bus
|
||||
type sbomPublisher struct {
|
||||
format sbom.Format
|
||||
format sbom.FormatEncoder
|
||||
}
|
||||
|
||||
// Write the provided SBOM to the data stream
|
||||
|
||||
@@ -8,43 +8,71 @@ import (
|
||||
|
||||
"github.com/docker/docker/pkg/homedir"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
)
|
||||
|
||||
func Test_MakeSBOMWriter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
outputs []string
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "go case",
|
||||
outputs: []string{"json"},
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "multiple",
|
||||
outputs: []string{"table", "json"},
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "unknown format",
|
||||
outputs: []string{"unknown"},
|
||||
wantErr: func(t assert.TestingT, err error, bla ...interface{}) bool {
|
||||
return assert.ErrorContains(t, err, `unsupported output format "unknown", supported formats are: [`)
|
||||
return assert.ErrorContains(t, err, `unsupported output format "unknown", supported formats are:`)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
_, err := makeSBOMWriter(tt.outputs, "", "")
|
||||
tt.wantErr(t, err)
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
opt := DefaultOutput()
|
||||
encoders, err := opt.Encoders()
|
||||
require.NoError(t, err)
|
||||
_, err = makeSBOMWriter(tt.outputs, "", encoders)
|
||||
tt.wantErr(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func dummyEncoder(io.Writer, sbom.SBOM) error {
|
||||
func dummyFormat(name string) sbom.FormatEncoder {
|
||||
return dummyEncoder{name: name}
|
||||
}
|
||||
|
||||
var _ sbom.FormatEncoder = (*dummyEncoder)(nil)
|
||||
|
||||
type dummyEncoder struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (d dummyEncoder) ID() sbom.FormatID {
|
||||
return sbom.FormatID(d.name)
|
||||
}
|
||||
|
||||
func (d dummyEncoder) Aliases() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func dummyFormat(name string) sbom.Format {
|
||||
return sbom.NewFormat(sbom.AnyVersion, dummyEncoder, nil, nil, sbom.FormatID(name))
|
||||
func (d dummyEncoder) Version() string {
|
||||
return sbom.AnyVersion
|
||||
}
|
||||
|
||||
func (d dummyEncoder) Encode(writer io.Writer, s sbom.SBOM) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func Test_newSBOMMultiWriter(t *testing.T) {
|
||||
@@ -227,3 +255,39 @@ func Test_newSBOMWriterDescription(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_formatVersionOptions(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
nameVersionPairs []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "gocase",
|
||||
nameVersionPairs: []string{
|
||||
"cyclonedx-json@1.2", "cyclonedx-json@1.3", "cyclonedx-json@1.4", "cyclonedx-json@1.5",
|
||||
"cyclonedx-xml@1.0", "cyclonedx-xml@1.1", "cyclonedx-xml@1.2", "cyclonedx-xml@1.3",
|
||||
"cyclonedx-xml@1.4", "cyclonedx-xml@1.5", "github-json", "spdx-json@2.2", "spdx-json@2.3",
|
||||
"spdx-tag-value@2.1", "spdx-tag-value@2.2", "spdx-tag-value@2.3", "syft-json@11.0.0",
|
||||
"syft-table", "syft-text", "template",
|
||||
},
|
||||
want: `
|
||||
Available formats:
|
||||
- cyclonedx-json @ 1.2, 1.3, 1.4, 1.5
|
||||
- cyclonedx-xml @ 1.0, 1.1, 1.2, 1.3, 1.4, 1.5
|
||||
- github-json
|
||||
- spdx-json @ 2.2, 2.3
|
||||
- spdx-tag-value @ 2.1, 2.2, 2.3
|
||||
- syft-json
|
||||
- syft-table
|
||||
- syft-text
|
||||
- template`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, formatVersionOptions(tt.nameVersionPairs))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user