Add "pretty" json configuration and change default behavior to be space-efficient (#2275)

* expose underlying format options

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* remove escape html options and address PR feedback

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* incorporate PR feedback

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix cli test

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:
Alex Goodman
2023-11-20 15:29:34 +00:00
committed by GitHub
parent 7cfb5f630a
commit 1676934c63
31 changed files with 723 additions and 204 deletions
+92 -8
View File
@@ -1,6 +1,11 @@
package options
import (
"fmt"
"github.com/hashicorp/go-multierror"
"github.com/anchore/clio"
"github.com/anchore/syft/syft/format/cyclonedxjson"
"github.com/anchore/syft/syft/format/cyclonedxxml"
"github.com/anchore/syft/syft/format/github"
@@ -13,16 +18,34 @@ import (
"github.com/anchore/syft/syft/sbom"
)
var _ clio.PostLoader = (*Format)(nil)
// Format contains all user configuration for output formatting.
type Format struct {
Template FormatTemplate `yaml:"template" json:"template" mapstructure:"template"`
JSON FormatJSON `yaml:"json" json:"json" mapstructure:"json"`
Pretty *bool `yaml:"pretty" json:"pretty" mapstructure:"pretty"`
Template FormatTemplate `yaml:"template" json:"template" mapstructure:"template"`
SyftJSON FormatSyftJSON `yaml:"json" json:"json" mapstructure:"json"`
SPDXJSON FormatSPDXJSON `yaml:"spdx-json" json:"spdx-json" mapstructure:"spdx-json"`
CyclonedxJSON FormatCyclonedxJSON `yaml:"cyclonedx-json" json:"cyclonedx-json" mapstructure:"cyclonedx-json"`
CyclonedxXML FormatCyclonedxXML `yaml:"cyclonedx-xml" json:"cyclonedx-xml" mapstructure:"cyclonedx-xml"`
}
func (o *Format) PostLoad() error {
o.SyftJSON.Pretty = multiLevelOption[bool](false, o.Pretty, o.SyftJSON.Pretty)
o.SPDXJSON.Pretty = multiLevelOption[bool](false, o.Pretty, o.SPDXJSON.Pretty)
o.CyclonedxJSON.Pretty = multiLevelOption[bool](false, o.Pretty, o.CyclonedxJSON.Pretty)
o.CyclonedxXML.Pretty = multiLevelOption[bool](false, o.Pretty, o.CyclonedxXML.Pretty)
return nil
}
func DefaultFormat() Format {
return Format{
Template: DefaultFormatTemplate(),
JSON: DefaultFormatJSON(),
Template: DefaultFormatTemplate(),
SyftJSON: DefaultFormatJSON(),
SPDXJSON: DefaultFormatSPDXJSON(),
CyclonedxJSON: DefaultFormatCyclonedxJSON(),
CyclonedxXML: DefaultFormatCyclonedxXML(),
}
}
@@ -32,14 +55,75 @@ func (o *Format) Encoders() ([]sbom.FormatEncoder, error) {
// in the future there will be application configuration options that can be used to set the default output format
list.addWithErr(template.ID)(o.Template.formatEncoders())
list.addWithErr(syftjson.ID)(o.JSON.formatEncoders())
list.addWithErr(syftjson.ID)(o.SyftJSON.formatEncoders())
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(cyclonedxxml.ID)(o.CyclonedxXML.formatEncoders())
list.addWithErr(cyclonedxjson.ID)(o.CyclonedxJSON.formatEncoders())
list.addWithErr(spdxjson.ID)(o.SPDXJSON.formatEncoders())
list.addWithErr(spdxtagvalue.ID)(spdxTagValueEncoders())
return list.encoders, list.err
}
// 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
}
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 {
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)
}
}
}
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)
}
}
}
func multiLevelOption[T any](defaultValue T, option ...*T) *T {
result := defaultValue
for _, opt := range option {
if opt != nil {
result = *opt
}
}
return &result
}
@@ -0,0 +1,39 @@
package options
import (
"github.com/hashicorp/go-multierror"
"github.com/anchore/syft/syft/format/cyclonedxjson"
"github.com/anchore/syft/syft/sbom"
)
type FormatCyclonedxJSON struct {
Pretty *bool `yaml:"pretty" json:"pretty" mapstructure:"pretty"`
}
func DefaultFormatCyclonedxJSON() FormatCyclonedxJSON {
return FormatCyclonedxJSON{}
}
func (o FormatCyclonedxJSON) formatEncoders() ([]sbom.FormatEncoder, error) {
var (
encs []sbom.FormatEncoder
errs error
)
for _, v := range cyclonedxjson.SupportedVersions() {
enc, err := cyclonedxjson.NewFormatEncoderWithConfig(o.buildConfig(v))
if err != nil {
errs = multierror.Append(errs, err)
} else {
encs = append(encs, enc)
}
}
return encs, errs
}
func (o FormatCyclonedxJSON) buildConfig(version string) cyclonedxjson.EncoderConfig {
return cyclonedxjson.EncoderConfig{
Version: version,
Pretty: *o.Pretty,
}
}
@@ -0,0 +1,67 @@
package options
import (
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFormatCyclonedxJSON_buildConfig(t *testing.T) {
// assert when building the config that we respond to all possible fields
ft := &FormatCyclonedxJSON{}
ft = setAllToNonZero(t, ft).(*FormatCyclonedxJSON)
subject := ft.buildConfig("Version")
assertExpectedValue(t, subject)
}
func setAllToNonZero(t testing.TB, structPtr any) any {
// set all fields on the struct to non-zero values
rt := reflect.TypeOf(structPtr)
if rt.Kind() != reflect.Ptr || rt.Elem().Kind() != reflect.Struct {
t.Fatal("expected a pointer to a struct")
}
rv := reflect.ValueOf(structPtr).Elem()
for i := 0; i < rt.Elem().NumField(); i++ {
val := getNonZeroExampleValue(t, rv.Field(i).Interface(), rt.Elem().Field(i).Name)
rv.Field(i).Set(reflect.ValueOf(val))
}
return structPtr
}
func getNonZeroExampleValue(t testing.TB, v any, name string) any {
switch v.(type) {
case bool:
return true
case *bool:
val := true
return &val
case string:
return name
}
t.Fatalf("unsupported type: %T", v)
return nil
}
func assertExpectedValue(t *testing.T, structTy any) {
rt := reflect.TypeOf(structTy)
rv := reflect.ValueOf(structTy)
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
fieldValue := rv.Field(i)
if fieldValue.Type().Kind() == reflect.String {
// use the field name as the expected value
assert.Equalf(t, f.Name, fieldValue.String(), "field %q value differs", f.Name)
} else {
// use the zero value for the type
if reflect.DeepEqual(fieldValue.Interface(), reflect.Zero(fieldValue.Type()).Interface()) {
t.Errorf("field '%s' is zero", f.Name)
}
}
}
}
@@ -0,0 +1,39 @@
package options
import (
"github.com/hashicorp/go-multierror"
"github.com/anchore/syft/syft/format/cyclonedxxml"
"github.com/anchore/syft/syft/sbom"
)
type FormatCyclonedxXML struct {
Pretty *bool `yaml:"pretty" json:"pretty" mapstructure:"pretty"`
}
func DefaultFormatCyclonedxXML() FormatCyclonedxXML {
return FormatCyclonedxXML{}
}
func (o FormatCyclonedxXML) formatEncoders() ([]sbom.FormatEncoder, error) {
var (
encs []sbom.FormatEncoder
errs error
)
for _, v := range cyclonedxxml.SupportedVersions() {
enc, err := cyclonedxxml.NewFormatEncoderWithConfig(o.buildConfig(v))
if err != nil {
errs = multierror.Append(errs, err)
} else {
encs = append(encs, enc)
}
}
return encs, errs
}
func (o FormatCyclonedxXML) buildConfig(version string) cyclonedxxml.EncoderConfig {
return cyclonedxxml.EncoderConfig{
Version: version,
Pretty: *o.Pretty,
}
}
@@ -0,0 +1,15 @@
package options
import (
"testing"
)
func TestFormatCyclonedxXML_buildConfig(t *testing.T) {
// assert when building the config that we respond to all possible fields
ft := FormatCyclonedxXML{}
ftp := setAllToNonZero(t, &ft).(*FormatCyclonedxXML)
subject := ftp.buildConfig("Version")
assertExpectedValue(t, subject)
}
-25
View File
@@ -1,25 +0,0 @@
package options
import (
"github.com/anchore/syft/syft/format/syftjson"
"github.com/anchore/syft/syft/sbom"
)
type FormatJSON struct {
Legacy bool `yaml:"legacy" json:"legacy" mapstructure:"legacy"`
}
func DefaultFormatJSON() FormatJSON {
return FormatJSON{
Legacy: false,
}
}
func (o FormatJSON) formatEncoders() ([]sbom.FormatEncoder, error) {
enc, err := syftjson.NewFormatEncoderWithConfig(
syftjson.EncoderConfig{
Legacy: o.Legacy,
},
)
return []sbom.FormatEncoder{enc}, err
}
+39
View File
@@ -0,0 +1,39 @@
package options
import (
"github.com/hashicorp/go-multierror"
"github.com/anchore/syft/syft/format/spdxjson"
"github.com/anchore/syft/syft/sbom"
)
type FormatSPDXJSON struct {
Pretty *bool `yaml:"pretty" json:"pretty" mapstructure:"pretty"`
}
func DefaultFormatSPDXJSON() FormatSPDXJSON {
return FormatSPDXJSON{}
}
func (o FormatSPDXJSON) formatEncoders() ([]sbom.FormatEncoder, error) {
var (
encs []sbom.FormatEncoder
errs error
)
for _, v := range spdxjson.SupportedVersions() {
enc, err := spdxjson.NewFormatEncoderWithConfig(o.buildConfig(v))
if err != nil {
errs = multierror.Append(errs, err)
} else {
encs = append(encs, enc)
}
}
return encs, errs
}
func (o FormatSPDXJSON) buildConfig(v string) spdxjson.EncoderConfig {
return spdxjson.EncoderConfig{
Version: v,
Pretty: *o.Pretty,
}
}
@@ -0,0 +1,15 @@
package options
import (
"testing"
)
func TestFormatSPDXJSON_buildConfig(t *testing.T) {
// assert when building the config that we respond to all possible fields
ft := &FormatSPDXJSON{}
ft = setAllToNonZero(t, ft).(*FormatSPDXJSON)
subject := ft.buildConfig("Version")
assertExpectedValue(t, subject)
}
+29
View File
@@ -0,0 +1,29 @@
package options
import (
"github.com/anchore/syft/syft/format/syftjson"
"github.com/anchore/syft/syft/sbom"
)
type FormatSyftJSON struct {
Legacy bool `yaml:"legacy" json:"legacy" mapstructure:"legacy"`
Pretty *bool `yaml:"pretty" json:"pretty" mapstructure:"pretty"`
}
func DefaultFormatJSON() FormatSyftJSON {
return FormatSyftJSON{
Legacy: false,
}
}
func (o FormatSyftJSON) formatEncoders() ([]sbom.FormatEncoder, error) {
enc, err := syftjson.NewFormatEncoderWithConfig(o.buildConfig())
return []sbom.FormatEncoder{enc}, err
}
func (o FormatSyftJSON) buildConfig() syftjson.EncoderConfig {
return syftjson.EncoderConfig{
Legacy: o.Legacy,
Pretty: *o.Pretty,
}
}
@@ -0,0 +1,15 @@
package options
import (
"testing"
)
func TestFormatSyftJSON_buildConfig(t *testing.T) {
// assert when building the config that we respond to all possible fields
ft := &FormatSyftJSON{}
ft = setAllToNonZero(t, ft).(*FormatSyftJSON)
subject := ft.buildConfig()
assertExpectedValue(t, subject)
}
+11 -105
View File
@@ -46,6 +46,17 @@ func DefaultOutput() Output {
}
}
func (o *Output) PostLoad() error {
var errs error
for _, loader := range []clio.PostLoader{&o.OutputFile, &o.Format} {
if err := loader.PostLoad(); err != nil {
errs = multierror.Append(errs, err)
}
}
return errs
}
func (o *Output) AddFlags(flags clio.FlagSet) {
var names []string
for _, id := range supportedIDs() {
@@ -88,111 +99,6 @@ func (o Output) OutputNameSet() *strset.Set {
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 {
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)
}
}
}
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
+2
View File
@@ -27,6 +27,7 @@ func Test_getEncoders(t *testing.T) {
}
opts := DefaultOutput()
require.NoError(t, opts.Format.PostLoad())
opts.Format.Template.Path = "somewhere"
encoders, err := opts.Encoders()
@@ -158,6 +159,7 @@ func Test_EncoderCollection_ByString_IDOnly_Defaults(t *testing.T) {
}
opts := DefaultOutput()
require.NoError(t, opts.Format.PostLoad())
opts.Format.Template.Path = "somewhere"
defaultEncoders, err := opts.Encoders()
+1
View File
@@ -41,6 +41,7 @@ func Test_MakeSBOMWriter(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opt := DefaultOutput()
require.NoError(t, opt.Format.PostLoad())
encoders, err := opt.Encoders()
require.NoError(t, err)
_, err = makeSBOMWriter(tt.outputs, "", encoders)