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