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 }