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  }