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 }