github.com/anchore/syft@v1.4.2-0.20240516191711-1bec1fc5d397/cmd/syft/internal/options/writer.go (about)

     1  package options
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"io"
     7  	"os"
     8  	"path"
     9  	"sort"
    10  	"strings"
    11  
    12  	"github.com/hashicorp/go-multierror"
    13  	"github.com/mitchellh/go-homedir"
    14  	"github.com/scylladb/go-set/strset"
    15  
    16  	"github.com/anchore/syft/internal/bus"
    17  	"github.com/anchore/syft/internal/log"
    18  	"github.com/anchore/syft/syft/format"
    19  	"github.com/anchore/syft/syft/format/table"
    20  	"github.com/anchore/syft/syft/sbom"
    21  )
    22  
    23  var _ sbom.Writer = (*sbomMultiWriter)(nil)
    24  
    25  var _ interface {
    26  	io.Closer
    27  	sbom.Writer
    28  } = (*sbomStreamWriter)(nil)
    29  
    30  // makeSBOMWriter creates a sbom.Writer for output or returns an error. this will either return a valid writer
    31  // or an error but neither both and if there is no error, sbom.Writer.Close() should be called
    32  func makeSBOMWriter(outputs []string, defaultFile string, encoders []sbom.FormatEncoder) (sbom.Writer, error) {
    33  	outputOptions, err := parseSBOMOutputFlags(outputs, defaultFile, encoders)
    34  	if err != nil {
    35  		return nil, err
    36  	}
    37  
    38  	writer, err := newSBOMMultiWriter(outputOptions...)
    39  	if err != nil {
    40  		return nil, err
    41  	}
    42  
    43  	return writer, nil
    44  }
    45  
    46  // parseSBOMOutputFlags utility to parse command-line option strings and retain the existing behavior of default format and file
    47  func parseSBOMOutputFlags(outputs []string, defaultFile string, encoders []sbom.FormatEncoder) (out []sbomWriterDescription, errs error) {
    48  	encoderCollection := format.NewEncoderCollection(encoders...)
    49  
    50  	// always should have one option -- we generally get the default of "table", but just make sure
    51  	if len(outputs) == 0 {
    52  		outputs = append(outputs, table.ID.String())
    53  	}
    54  
    55  	for _, name := range outputs {
    56  		name = strings.TrimSpace(name)
    57  
    58  		// split to at most two parts for <format>=<file>
    59  		parts := strings.SplitN(name, "=", 2)
    60  
    61  		// the format name is the first part
    62  		name = parts[0]
    63  
    64  		// default to the --file or empty string if not specified
    65  		file := defaultFile
    66  
    67  		// If a file is specified as part of the output formatName, use that
    68  		if len(parts) > 1 {
    69  			file = parts[1]
    70  		}
    71  
    72  		enc := encoderCollection.GetByString(name)
    73  		if enc == nil {
    74  			errs = multierror.Append(errs, fmt.Errorf(`unsupported output format "%s", supported formats are: %+v`, name, formatVersionOptions(encoderCollection.NameVersions())))
    75  			continue
    76  		}
    77  
    78  		out = append(out, newSBOMWriterDescription(enc, file))
    79  	}
    80  	return out, errs
    81  }
    82  
    83  // formatVersionOptions takes a list like ["github-json", "syft-json@11.0.0", "cyclonedx-xml@1.0", "cyclondx-xml@1.1"...]
    84  // and formats it into a human-readable string like:
    85  //
    86  // Available formats:
    87  //   - cyclonedx-json @ 1.2, 1.3, 1.4, 1.5
    88  //   - cyclonedx-xml @ 1.0, 1.1, 1.2, 1.3, 1.4, 1.5
    89  //   - github-json
    90  //   - spdx-json @ 2.2, 2.3
    91  //   - spdx-tag-value @ 2.1, 2.2, 2.3
    92  //   - syft-json
    93  //   - syft-table
    94  //   - syft-text
    95  //   - template
    96  func formatVersionOptions(nameVersionPairs []string) string {
    97  	availableVersions := make(map[string][]string)
    98  	availableFormats := strset.New()
    99  	for _, nameVersion := range nameVersionPairs {
   100  		fields := strings.SplitN(nameVersion, "@", 2)
   101  		if len(fields) == 2 {
   102  			availableVersions[fields[0]] = append(availableVersions[fields[0]], fields[1])
   103  		}
   104  		availableFormats.Add(fields[0])
   105  	}
   106  
   107  	// find any formats with exactly one version -- remove them from the version map
   108  	for name, versions := range availableVersions {
   109  		if len(versions) == 1 {
   110  			delete(availableVersions, name)
   111  		}
   112  	}
   113  
   114  	sortedAvailableFormats := availableFormats.List()
   115  	sort.Strings(sortedAvailableFormats)
   116  
   117  	var s strings.Builder
   118  
   119  	s.WriteString("\n")
   120  	s.WriteString("Available formats:")
   121  
   122  	for _, name := range sortedAvailableFormats {
   123  		s.WriteString("\n")
   124  
   125  		s.WriteString(fmt.Sprintf("   - %s", name))
   126  
   127  		if len(availableVersions[name]) > 0 {
   128  			s.WriteString(" @ ")
   129  			s.WriteString(strings.Join(availableVersions[name], ", "))
   130  		}
   131  	}
   132  
   133  	return s.String()
   134  }
   135  
   136  // sbomWriterDescription Format and path strings used to create sbom.Writer
   137  type sbomWriterDescription struct {
   138  	Format sbom.FormatEncoder
   139  	Path   string
   140  }
   141  
   142  func newSBOMWriterDescription(f sbom.FormatEncoder, p string) sbomWriterDescription {
   143  	expandedPath, err := homedir.Expand(p)
   144  	if err != nil {
   145  		log.Warnf("could not expand given writer output path=%q: %w", p, err)
   146  		// ignore errors
   147  		expandedPath = p
   148  	}
   149  	return sbomWriterDescription{
   150  		Format: f,
   151  		Path:   expandedPath,
   152  	}
   153  }
   154  
   155  // sbomMultiWriter holds a list of child sbom.Writers to apply all Write and Close operations to
   156  type sbomMultiWriter struct {
   157  	writers []sbom.Writer
   158  }
   159  
   160  // newSBOMMultiWriter create all report writers from input options; if a file is not specified the given defaultWriter is used
   161  func newSBOMMultiWriter(options ...sbomWriterDescription) (_ *sbomMultiWriter, err error) {
   162  	if len(options) == 0 {
   163  		return nil, fmt.Errorf("no output options provided")
   164  	}
   165  
   166  	out := &sbomMultiWriter{}
   167  
   168  	for _, option := range options {
   169  		switch len(option.Path) {
   170  		case 0:
   171  			out.writers = append(out.writers, &sbomPublisher{
   172  				format: option.Format,
   173  			})
   174  		default:
   175  			// create any missing subdirectories
   176  			dir := path.Dir(option.Path)
   177  			if dir != "" {
   178  				s, err := os.Stat(dir)
   179  				if err != nil {
   180  					err = os.MkdirAll(dir, 0755) // maybe should be os.ModePerm ?
   181  					if err != nil {
   182  						return nil, err
   183  					}
   184  				} else if !s.IsDir() {
   185  					return nil, fmt.Errorf("output path does not contain a valid directory: %s", option.Path)
   186  				}
   187  			}
   188  			fileOut, err := os.OpenFile(option.Path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
   189  			if err != nil {
   190  				return nil, fmt.Errorf("unable to create report file: %w", err)
   191  			}
   192  			out.writers = append(out.writers, &sbomStreamWriter{
   193  				format: option.Format,
   194  				out:    fileOut,
   195  			})
   196  		}
   197  	}
   198  
   199  	return out, nil
   200  }
   201  
   202  // Write writes the SBOM to all writers
   203  func (m *sbomMultiWriter) Write(s sbom.SBOM) (errs error) {
   204  	for _, w := range m.writers {
   205  		err := w.Write(s)
   206  		if err != nil {
   207  			errs = multierror.Append(errs, fmt.Errorf("unable to write SBOM: %w", err))
   208  		}
   209  	}
   210  	return errs
   211  }
   212  
   213  // sbomStreamWriter implements sbom.Writer for a given format and io.Writer, also providing a close function for cleanup
   214  type sbomStreamWriter struct {
   215  	format sbom.FormatEncoder
   216  	out    io.Writer
   217  }
   218  
   219  // Write the provided SBOM to the data stream
   220  func (w *sbomStreamWriter) Write(s sbom.SBOM) error {
   221  	defer w.Close()
   222  	return w.format.Encode(w.out, s)
   223  }
   224  
   225  // Close any resources, such as open files
   226  func (w *sbomStreamWriter) Close() error {
   227  	if closer, ok := w.out.(io.Closer); ok {
   228  		return closer.Close()
   229  	}
   230  	return nil
   231  }
   232  
   233  // sbomPublisher implements sbom.Writer that publishes results to the event bus
   234  type sbomPublisher struct {
   235  	format sbom.FormatEncoder
   236  }
   237  
   238  // Write the provided SBOM to the data stream
   239  func (w *sbomPublisher) Write(s sbom.SBOM) error {
   240  	buf := &bytes.Buffer{}
   241  	if err := w.format.Encode(buf, s); err != nil {
   242  		return fmt.Errorf("unable to encode SBOM: %w", err)
   243  	}
   244  
   245  	bus.Report(buf.String())
   246  	return nil
   247  }