github.com/CycloneDX/sbom-utility@v0.16.0/cmd/resource.go (about) 1 // SPDX-License-Identifier: Apache-2.0 2 /* 3 * Licensed to the Apache Software Foundation (ASF) under one or more 4 * contributor license agreements. See the NOTICE file distributed with 5 * this work for additional information regarding copyright ownership. 6 * The ASF licenses this file to You under the Apache License, Version 2.0 7 * (the "License"); you may not use this file except in compliance with 8 * the License. You may obtain a copy of the License at 9 * 10 * http://www.apache.org/licenses/LICENSE-2.0 11 * 12 * Unless required by applicable law or agreed to in writing, software 13 * distributed under the License is distributed on an "AS IS" BASIS, 14 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 * See the License for the specific language governing permissions and 16 * limitations under the License. 17 */ 18 19 package cmd 20 21 import ( 22 "encoding/csv" 23 "fmt" 24 "io" 25 "sort" 26 "strings" 27 "text/tabwriter" 28 29 "github.com/CycloneDX/sbom-utility/common" 30 "github.com/CycloneDX/sbom-utility/schema" 31 "github.com/CycloneDX/sbom-utility/utils" 32 "github.com/jwangsadinata/go-multimap" 33 "github.com/spf13/cobra" 34 ) 35 36 const ( 37 SUBCOMMAND_RESOURCE_LIST = "list" 38 ) 39 40 var VALID_SUBCOMMANDS_RESOURCE = []string{SUBCOMMAND_RESOURCE_LIST} 41 42 // filter keys 43 // Note: these string values MUST match annotations for the ResourceInfo struct fields 44 const ( 45 RESOURCE_FILTER_KEY_RESOURCE_TYPE = "resource-type" 46 RESOURCE_FILTER_KEY_NAME = "name" 47 RESOURCE_FILTER_KEY_VERSION = "version" 48 RESOURCE_FILTER_KEY_BOMREF = "bom-ref" 49 RESOURCE_FILTER_KEY_GROUP = "group" 50 RESOURCE_FILTER_KEY_DESCRIPTION = "description" 51 ) 52 53 var RESOURCE_LIST_ROW_DATA = []ColumnFormatData{ 54 *NewColumnFormatData(RESOURCE_FILTER_KEY_BOMREF, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false), 55 *NewColumnFormatData(RESOURCE_FILTER_KEY_RESOURCE_TYPE, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false), 56 *NewColumnFormatData(RESOURCE_FILTER_KEY_GROUP, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false), 57 *NewColumnFormatData(RESOURCE_FILTER_KEY_NAME, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false), 58 *NewColumnFormatData(RESOURCE_FILTER_KEY_VERSION, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false), 59 *NewColumnFormatData(RESOURCE_FILTER_KEY_DESCRIPTION, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, REPORT_REPLACE_LINE_FEEDS_TRUE), 60 } 61 62 // Flags. Reuse query flag values where possible 63 const ( 64 FLAG_RESOURCE_TYPE = "type" 65 FLAG_RESOURCE_TYPE_HELP = "filter output by resource type (i.e., component | service)" 66 ) 67 68 const ( 69 MSG_OUTPUT_NO_RESOURCES_FOUND = "[WARN] no matching resources found for query" 70 ) 71 72 // Command help formatting 73 const ( 74 FLAG_RESOURCE_OUTPUT_FORMAT_HELP = "format output using the specified type" 75 ) 76 77 var RESOURCE_LIST_OUTPUT_SUPPORTED_FORMATS = MSG_SUPPORTED_OUTPUT_FORMATS_HELP + 78 strings.Join([]string{FORMAT_TEXT, FORMAT_CSV, FORMAT_MARKDOWN}, ", ") 79 80 func NewCommandResource() *cobra.Command { 81 var command = new(cobra.Command) 82 command.Use = CMD_USAGE_RESOURCE_LIST 83 command.Short = "Report on resources (i.e., components, services) found in the BOM input file" 84 command.Long = "Report on resources (i.e., components, services) found in the BOM input file" 85 command.Flags().StringVarP(&utils.GlobalFlags.PersistentFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", FORMAT_TEXT, 86 FLAG_RESOURCE_OUTPUT_FORMAT_HELP+RESOURCE_LIST_OUTPUT_SUPPORTED_FORMATS) 87 command.Flags().StringP(FLAG_RESOURCE_TYPE, "", schema.RESOURCE_TYPE_DEFAULT, FLAG_RESOURCE_TYPE_HELP) 88 command.Flags().StringP(FLAG_REPORT_WHERE, "", "", FLAG_REPORT_WHERE_HELP) 89 command.RunE = resourceCmdImpl 90 command.ValidArgs = VALID_SUBCOMMANDS_RESOURCE 91 command.PreRunE = func(cmd *cobra.Command, args []string) (err error) { 92 // the command requires at least 1 valid subcommand (argument) 93 if len(args) > 1 { 94 return getLogger().Errorf("Too many arguments provided: %v", args) 95 } 96 97 // Make sure (optional) subcommand is known/valid 98 if len(args) == 1 { 99 if !preRunTestForSubcommand(VALID_SUBCOMMANDS_RESOURCE, args[0]) { 100 return getLogger().Errorf("Subcommand provided is not valid: `%v`", args[0]) 101 } 102 } 103 104 if len(args) == 0 { 105 getLogger().Tracef("No subcommands provided; defaulting to: `%s` subcommand", SUBCOMMAND_SCHEMA_LIST) 106 } 107 108 // Test for required flags (parameters) 109 err = preRunTestForInputFile(args) 110 111 return 112 } 113 return command 114 } 115 116 func retrieveResourceType(cmd *cobra.Command) (resourceType string, err error) { 117 118 resourceType, err = cmd.Flags().GetString(FLAG_RESOURCE_TYPE) 119 if err != nil { 120 return 121 } 122 123 // validate resource type is a known keyword 124 if !schema.IsValidResourceType(resourceType) { 125 // invalid 126 err = getLogger().Errorf("invalid resource `%s`: `%s`", FLAG_RESOURCE_TYPE, resourceType) 127 } 128 129 return 130 } 131 132 func resourceCmdImpl(cmd *cobra.Command, args []string) (err error) { 133 getLogger().Enter(args) 134 defer getLogger().Exit() 135 136 // Create output writer 137 outputFilename := utils.GlobalFlags.PersistentFlags.OutputFile 138 outputFile, writer, err := createOutputFile(outputFilename) 139 getLogger().Tracef("outputFile: `%v`; writer: `%v`", outputFilename, writer) 140 141 // use function closure to assure consistent error output based upon error type 142 defer func() { 143 // always close the output file 144 if outputFile != nil { 145 outputFile.Close() 146 getLogger().Infof("Closed output file: `%s`", outputFilename) 147 } 148 }() 149 150 // process filters supplied on the --where command flag 151 whereFilters, err := processWhereFlag(cmd) 152 153 // Process flag: --type 154 var resourceType string 155 var resourceFlags utils.ResourceCommandFlags 156 resourceType, err = retrieveResourceType(cmd) 157 158 if err == nil { 159 resourceFlags.ResourceType = resourceType 160 err = ListResources(writer, utils.GlobalFlags.PersistentFlags, resourceFlags, whereFilters) 161 } 162 163 return 164 } 165 166 // Assure all errors are logged 167 func processResourceListResults(err error) { 168 if err != nil { 169 // No special processing at this time 170 getLogger().Error(err) 171 } 172 } 173 174 // NOTE: resourceType has already been validated 175 func ListResources(writer io.Writer, persistentFlags utils.PersistentCommandFlags, resourceFlags utils.ResourceCommandFlags, whereFilters []common.WhereFilter) (err error) { 176 getLogger().Enter() 177 defer getLogger().Exit() 178 179 // use function closure to assure consistent error output based upon error type 180 defer func() { 181 if err != nil { 182 processResourceListResults(err) 183 } 184 }() 185 186 // Note: returns error if either file load or unmarshal to JSON map fails 187 var document *schema.BOM 188 document, err = LoadInputBOMFileAndDetectSchema() 189 190 if err != nil { 191 return 192 } 193 194 // Hash all resources (i.e., components, services for now) within input file 195 getLogger().Infof("Scanning document for licenses...") 196 err = loadDocumentResources(document, resourceFlags.ResourceType, whereFilters) 197 198 if err != nil { 199 return 200 } 201 202 format := persistentFlags.OutputFormat 203 getLogger().Infof("Outputting listing (`%s` format)...", format) 204 switch format { 205 case FORMAT_TEXT: 206 DisplayResourceListText(document, writer) 207 case FORMAT_CSV: 208 DisplayResourceListCSV(document, writer) 209 case FORMAT_MARKDOWN: 210 DisplayResourceListMarkdown(document, writer) 211 default: 212 // Default to Text output for anything else (set as flag default) 213 getLogger().Warningf("Listing not supported for `%s` format; defaulting to `%s` format...", 214 format, FORMAT_TEXT) 215 DisplayResourceListText(document, writer) 216 } 217 218 return 219 } 220 221 func loadDocumentResources(document *schema.BOM, resourceType string, whereFilters []common.WhereFilter) (err error) { 222 getLogger().Enter() 223 defer getLogger().Exit(err) 224 225 // At this time, fail SPDX format SBOMs as "unsupported" (for "any" format) 226 if !document.FormatInfo.IsCycloneDx() { 227 err = schema.NewUnsupportedFormatForCommandError( 228 document.FormatInfo.CanonicalName, 229 document.GetFilename(), 230 CMD_LICENSE, FORMAT_ANY) 231 return 232 } 233 234 // Before looking for license data, fully unmarshal the SBOM into named structures 235 if err = document.UnmarshalCycloneDXBOM(); err != nil { 236 return 237 } 238 239 // Add top-level SBOM component 240 if resourceType == schema.RESOURCE_TYPE_DEFAULT || resourceType == schema.RESOURCE_TYPE_COMPONENT { 241 err = document.HashmapComponentResources(whereFilters) 242 if err != nil { 243 return 244 } 245 } 246 247 if resourceType == schema.RESOURCE_TYPE_DEFAULT || resourceType == schema.RESOURCE_TYPE_SERVICE { 248 err = document.HashmapServiceResources(whereFilters) 249 if err != nil { 250 return 251 } 252 } 253 254 return 255 } 256 257 func sortResources(entries []multimap.Entry) { 258 // Sort by Type then Name 259 sort.Slice(entries, func(i, j int) bool { 260 resource1 := (entries[i].Value).(schema.CDXResourceInfo) 261 resource2 := (entries[j].Value).(schema.CDXResourceInfo) 262 if resource1.ResourceType != resource2.ResourceType { 263 return resource1.ResourceType < resource2.ResourceType 264 } 265 if resource1.Group != resource2.Group { 266 return resource1.Group < resource2.Group 267 } 268 if resource1.Name != resource2.Name { 269 return resource1.Name < resource2.Name 270 } 271 return resource1.Version < resource2.Version 272 }) 273 } 274 275 // NOTE: This list is NOT de-duplicated 276 // TODO: Add a --no-title flag to skip title output 277 func DisplayResourceListText(bom *schema.BOM, writer io.Writer) (err error) { 278 getLogger().Enter() 279 defer getLogger().Exit() 280 281 // initialize tabwriter 282 w := new(tabwriter.Writer) 283 defer w.Flush() 284 285 // min-width, tab-width, padding, pad-char, flags 286 w.Init(writer, 8, 2, 2, ' ', 0) 287 288 // create title row and underline row from slices of optional and compulsory titles 289 titles, underlines := prepareReportTitleData(RESOURCE_LIST_ROW_DATA, false) 290 291 // Add tabs between column titles for the tabWRiter 292 fmt.Fprintf(w, "%s\n", strings.Join(titles, "\t")) 293 fmt.Fprintf(w, "%s\n", strings.Join(underlines, "\t")) 294 295 // Display a warning "missing" in the actual output and return (short-circuit) 296 entries := bom.ResourceMap.Entries() 297 298 // Emit no license warning into output 299 if len(entries) == 0 { 300 fmt.Fprintf(w, "%s\n", MSG_OUTPUT_NO_RESOURCES_FOUND) 301 return 302 } 303 304 // Sort resources prior to outputting 305 sortResources(entries) 306 307 // Emit row data 308 var line []string 309 for _, entry := range entries { 310 line, err = prepareReportLineData( 311 entry.Value.(schema.CDXResourceInfo), 312 RESOURCE_LIST_ROW_DATA, 313 true, 314 ) 315 // Only emit line if no error 316 if err != nil { 317 return 318 } 319 fmt.Fprintf(w, "%s\n", strings.Join(line, "\t")) 320 } 321 return 322 } 323 324 // TODO: Add a --no-title flag to skip title output 325 func DisplayResourceListCSV(bom *schema.BOM, writer io.Writer) (err error) { 326 getLogger().Enter() 327 defer getLogger().Exit() 328 329 // initialize writer and prepare the list of entries (i.e., the "rows") 330 w := csv.NewWriter(writer) 331 defer w.Flush() 332 333 // Create title row data as []string 334 titles, _ := prepareReportTitleData(RESOURCE_LIST_ROW_DATA, false) 335 336 if err = w.Write(titles); err != nil { 337 return getLogger().Errorf("error writing to output (%v): %s", titles, err) 338 } 339 340 // Display a warning "missing" in the actual output and return (short-circuit) 341 entries := bom.ResourceMap.Entries() 342 343 // Emit no resource found warning into output 344 if len(entries) == 0 { 345 currentRow := []string{MSG_OUTPUT_NO_RESOURCES_FOUND} 346 if err = w.Write(currentRow); err != nil { 347 // unable to emit an error message into output stream 348 return getLogger().Errorf("error writing to output (%v): %s", currentRow, err) 349 } 350 return fmt.Errorf(currentRow[0]) 351 } 352 353 // Sort resources prior to outputting 354 sortResources(entries) 355 356 var line []string 357 for _, entry := range entries { 358 line, err = prepareReportLineData( 359 entry.Value.(schema.CDXResourceInfo), 360 RESOURCE_LIST_ROW_DATA, 361 true, 362 ) 363 // Only emit line if no error 364 if err != nil { 365 return 366 } 367 if err = w.Write(line); err != nil { 368 err = getLogger().Errorf("csv.Write: %w", err) 369 } 370 } 371 return 372 } 373 374 // TODO: Add a --no-title flag to skip title output 375 func DisplayResourceListMarkdown(bom *schema.BOM, writer io.Writer) (err error) { 376 getLogger().Enter() 377 defer getLogger().Exit() 378 379 // Create title row data as []string, include all columns that are flagged "summary" data 380 titles, _ := prepareReportTitleData(RESOURCE_LIST_ROW_DATA, true) 381 titleRow := createMarkdownRow(titles) 382 fmt.Fprintf(writer, "%s\n", titleRow) 383 384 // create alignment row, include all columns that are flagged "summary" data 385 alignments := createMarkdownColumnAlignmentRow(RESOURCE_LIST_ROW_DATA, true) 386 alignmentRow := createMarkdownRow(alignments) 387 fmt.Fprintf(writer, "%s\n", alignmentRow) 388 389 // Display a warning "missing" in the actual output and return (short-circuit) 390 entries := bom.ResourceMap.Entries() 391 392 // Emit no resource found warning into output 393 if len(entries) == 0 { 394 fmt.Fprintf(writer, "%s\n", MSG_OUTPUT_NO_RESOURCES_FOUND) 395 return fmt.Errorf(MSG_OUTPUT_NO_RESOURCES_FOUND) 396 } 397 398 // Sort resources prior to outputting 399 sortResources(entries) 400 401 var line []string 402 var lineRow string 403 for _, entry := range entries { 404 line, err = prepareReportLineData( 405 entry.Value.(schema.CDXResourceInfo), 406 RESOURCE_LIST_ROW_DATA, 407 true, 408 ) 409 // Only emit line if no error 410 if err != nil { 411 return 412 } 413 lineRow = createMarkdownRow(line) 414 fmt.Fprintf(writer, "%s\n", lineRow) 415 } 416 return 417 }