github.com/CycloneDX/sbom-utility@v0.16.0/cmd/license_policy.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/slicemultimap"
    33  	"github.com/spf13/cobra"
    34  )
    35  
    36  const (
    37  	SUBCOMMAND_POLICY_LIST = "list"
    38  )
    39  
    40  const (
    41  	FLAG_LICENSE_POLICY_LIST_SUMMARY_HELP = "summarize licenses and policies when listing in supported formats"
    42  )
    43  
    44  var VALID_SUBCOMMANDS_POLICY = []string{SUBCOMMAND_POLICY_LIST}
    45  
    46  // Subcommand flags
    47  // TODO: Support a new --sort <column> flag
    48  const (
    49  	FLAG_POLICY_REPORT_LINE_WRAP = "wrap"
    50  )
    51  
    52  // filter keys
    53  const (
    54  	POLICY_FILTER_KEY_USAGE_POLICY = "usage-policy"
    55  	POLICY_FILTER_KEY_FAMILY       = "family"
    56  	POLICY_FILTER_KEY_SPDX_ID      = "id"
    57  	POLICY_FILTER_KEY_NAME         = "name"
    58  	POLICY_FILTER_KEY_OSI_APPROVED = "osi"
    59  	POLICY_FILTER_KEY_FSF_APPROVED = "fsf"
    60  	POLICY_FILTER_KEY_DEPRECATED   = "deprecated"
    61  	POLICY_FILTER_KEY_REFERENCE    = "reference"
    62  	POLICY_FILTER_KEY_ALIASES      = "aliases"
    63  	POLICY_FILTER_KEY_ANNOTATIONS  = "annotations"
    64  	POLICY_FILTER_KEY_NOTES        = "notes"
    65  )
    66  
    67  // TODO use to pre-validate --where clause keys
    68  
    69  // Describe the column data and their attributes and constraints used for formatting
    70  var LICENSE_POLICY_LIST_ROW_DATA = []ColumnFormatData{
    71  	*NewColumnFormatData(POLICY_FILTER_KEY_USAGE_POLICY, 16, REPORT_SUMMARY_DATA, false),
    72  	*NewColumnFormatData(POLICY_FILTER_KEY_FAMILY, 20, REPORT_SUMMARY_DATA, false),
    73  	*NewColumnFormatData(POLICY_FILTER_KEY_SPDX_ID, 20, REPORT_SUMMARY_DATA, false),
    74  	*NewColumnFormatData(POLICY_FILTER_KEY_NAME, 20, REPORT_SUMMARY_DATA, false),
    75  	*NewColumnFormatData(POLICY_FILTER_KEY_OSI_APPROVED, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false),
    76  	*NewColumnFormatData(POLICY_FILTER_KEY_FSF_APPROVED, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false),
    77  	*NewColumnFormatData(POLICY_FILTER_KEY_DEPRECATED, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false),
    78  	*NewColumnFormatData(POLICY_FILTER_KEY_REFERENCE, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false),
    79  	*NewColumnFormatData(POLICY_FILTER_KEY_ALIASES, 24, false, false),
    80  	*NewColumnFormatData(POLICY_FILTER_KEY_ANNOTATIONS, 24, false, false),
    81  	*NewColumnFormatData(POLICY_FILTER_KEY_NOTES, 24, false, false),
    82  }
    83  
    84  // TODO: remove if we always map the old field names to new ones
    85  // var PROPERTY_MAP_FIELD_TITLE_TO_JSON_KEY = map[string]string{
    86  // 	"usage-policy": "usagePolicy",
    87  // 	"spdx-id":      "id",
    88  // 	"annotations":  "annotationRefs",
    89  // }
    90  
    91  // Subcommand flags
    92  const (
    93  	FLAG_POLICY_OUTPUT_FORMAT_HELP    = "format output using the specified type"
    94  	FLAG_POLICY_REPORT_LINE_WRAP_HELP = "toggles the wrapping of text within report column output (default: false)"
    95  )
    96  
    97  // License list policy command informational messages
    98  // TODO Use only for Warning messages
    99  const (
   100  	MSG_OUTPUT_NO_POLICIES_FOUND = "no license policies found in BOM document"
   101  )
   102  
   103  // Command help formatting
   104  var LICENSE_POLICY_SUPPORTED_FORMATS = MSG_SUPPORTED_OUTPUT_FORMATS_HELP +
   105  	strings.Join([]string{FORMAT_TEXT, FORMAT_CSV, FORMAT_MARKDOWN}, ", ")
   106  
   107  // WARNING: Cobra will not recognize a subcommand if its `command.Use` is not a single
   108  // word string that matches one of the `command.ValidArgs` set on the parent command
   109  func NewCommandPolicy() *cobra.Command {
   110  	var command = new(cobra.Command)
   111  	command.Use = CMD_USAGE_LICENSE_POLICY
   112  	command.Short = "List policies associated with known licenses"
   113  	command.Long = "List caller-supplied, \"allow/deny\"-style policies associated with known software, hardware or data licenses"
   114  	command.Flags().StringVarP(&utils.GlobalFlags.PersistentFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", FORMAT_TEXT,
   115  		FLAG_POLICY_OUTPUT_FORMAT_HELP+LICENSE_POLICY_SUPPORTED_FORMATS)
   116  	command.Flags().BoolVarP(
   117  		&utils.GlobalFlags.LicenseFlags.Summary, // re-use license flag
   118  		FLAG_LICENSE_SUMMARY, "", false,
   119  		FLAG_LICENSE_POLICY_LIST_SUMMARY_HELP)
   120  	command.Flags().StringP(FLAG_REPORT_WHERE, "", "", FLAG_REPORT_WHERE_HELP)
   121  	command.Flags().BoolVarP(
   122  		&utils.GlobalFlags.LicenseFlags.ListLineWrap,
   123  		FLAG_POLICY_REPORT_LINE_WRAP, "", false,
   124  		FLAG_POLICY_REPORT_LINE_WRAP_HELP)
   125  	command.RunE = policyCmdImpl
   126  	command.PreRunE = func(cmd *cobra.Command, args []string) (err error) {
   127  		// the command requires at least 1 valid subcommand (argument)
   128  		if len(args) > 1 {
   129  			return getLogger().Errorf("Too many arguments provided: %v", args)
   130  		}
   131  
   132  		// Make sure (optional) subcommand is known/valid
   133  		if len(args) == 1 {
   134  			if !preRunTestForSubcommand(VALID_SUBCOMMANDS_POLICY, args[0]) {
   135  				return getLogger().Errorf("Subcommand provided is not valid: `%v`", args[0])
   136  			}
   137  		}
   138  
   139  		if len(args) == 0 {
   140  			getLogger().Tracef("No subcommands provided; defaulting to: `%s` subcommand", SUBCOMMAND_SCHEMA_LIST)
   141  		}
   142  
   143  		return
   144  	}
   145  	return command
   146  }
   147  
   148  // NOTE: The license command ONLY WORKS on CDX format
   149  func policyCmdImpl(cmd *cobra.Command, args []string) (err error) {
   150  	getLogger().Enter(args)
   151  	defer getLogger().Exit()
   152  
   153  	outputFile, writer, err := createOutputFile(utils.GlobalFlags.PersistentFlags.OutputFile)
   154  
   155  	// use function closure to assure consistent error output based upon error type
   156  	defer func() {
   157  		// always close the output file
   158  		if outputFile != nil {
   159  			err = outputFile.Close()
   160  			getLogger().Infof("Closed output file: `%s`", utils.GlobalFlags.PersistentFlags.OutputFile)
   161  		}
   162  	}()
   163  
   164  	// process filters supplied on the --where command flag
   165  	// TODO: validate if where clauses reference valid column names (filter keys)
   166  	whereFilters, err := processWhereFlag(cmd)
   167  	if err != nil {
   168  		return
   169  	}
   170  
   171  	// Use global license policy config. as loaded by initConfigurations() as
   172  	// using (optional) filename passed on command line OR the default, built-in config.
   173  	err = ListLicensePolicies(writer, LicensePolicyConfig,
   174  		utils.GlobalFlags.PersistentFlags, utils.GlobalFlags.LicenseFlags,
   175  		whereFilters)
   176  
   177  	return
   178  }
   179  
   180  // Assure all errors are logged
   181  func processLicensePolicyListResults(err error) {
   182  	if err != nil {
   183  		getLogger().Error(err)
   184  	}
   185  }
   186  
   187  func sortLicensePolicies(keyNames []interface{}) {
   188  	sort.Slice(keyNames, func(i, j int) bool {
   189  		return keyNames[i].(string) < keyNames[j].(string)
   190  	})
   191  }
   192  
   193  func ListLicensePolicies(writer io.Writer, policyConfig *schema.LicensePolicyConfig,
   194  	persistentFlags utils.PersistentCommandFlags, licenseFlags utils.LicenseCommandFlags,
   195  	whereFilters []common.WhereFilter) (err error) {
   196  	getLogger().Enter()
   197  	defer getLogger().Exit()
   198  
   199  	// use function closure to assure consistent error output based upon error type
   200  	defer func() {
   201  		if err != nil {
   202  			processLicensePolicyListResults(err)
   203  		}
   204  	}()
   205  
   206  	// Retrieve the subset of policies that match the where filters
   207  	// NOTE: This has the side-effect of mapping alt. policy field name values
   208  	var filteredMap *slicemultimap.MultiMap
   209  	filteredMap, err = policyConfig.GetFilteredFamilyNameMap(whereFilters)
   210  
   211  	if err != nil {
   212  		return
   213  	}
   214  
   215  	// default output (writer) to standard out
   216  	switch utils.GlobalFlags.PersistentFlags.OutputFormat {
   217  	case FORMAT_DEFAULT:
   218  		// defaults to text if no explicit `--format` parameter
   219  		err = DisplayLicensePoliciesTabbedText(writer, filteredMap, licenseFlags)
   220  	case FORMAT_TEXT:
   221  		err = DisplayLicensePoliciesTabbedText(writer, filteredMap, licenseFlags)
   222  	case FORMAT_CSV:
   223  		err = DisplayLicensePoliciesCSV(writer, filteredMap, licenseFlags)
   224  	case FORMAT_MARKDOWN:
   225  		err = DisplayLicensePoliciesMarkdown(writer, filteredMap, licenseFlags)
   226  	default:
   227  		// default to text format for anything else
   228  		getLogger().Warningf("Unsupported format: `%s`; using default format.",
   229  			utils.GlobalFlags.PersistentFlags.OutputFormat)
   230  		err = DisplayLicensePoliciesTabbedText(writer, filteredMap, licenseFlags)
   231  	}
   232  	return
   233  }
   234  
   235  // Display all license policies including those with SPDX IDs and those
   236  // only with "family" names which is reflected in the contents of the
   237  // hashmap keyed on family names.
   238  // NOTE: assumes all entries in the policy config file MUST have family names
   239  // TODO: Allow caller to pass flag to truncate or not (perhaps with value)
   240  // TODO: Add a --no-title flag to skip title output
   241  func DisplayLicensePoliciesTabbedText(writer io.Writer, filteredPolicyMap *slicemultimap.MultiMap, flags utils.LicenseCommandFlags) (err error) {
   242  	getLogger().Enter()
   243  	defer getLogger().Exit()
   244  
   245  	// initialize tabwriter
   246  	w := new(tabwriter.Writer)
   247  	defer w.Flush()
   248  
   249  	// min-width, tab-width, padding, pad-char, flags
   250  	w.Init(writer, 8, 2, 2, ' ', 0)
   251  
   252  	// create title row and underline row from slices of optional and compulsory titles
   253  	titles, underlines := prepareReportTitleData(LICENSE_POLICY_LIST_ROW_DATA, flags.Summary)
   254  
   255  	// Add tabs between column titles for the tabWRiter
   256  	fmt.Fprintf(w, "%s\n", strings.Join(titles, "\t"))
   257  	fmt.Fprintf(w, "%s\n", strings.Join(underlines, "\t"))
   258  
   259  	// Sort entries for listing by family name keys
   260  	keyNames := filteredPolicyMap.KeySet()
   261  
   262  	// Emit no schemas found warning into output
   263  	// TODO Use only for Warning messages, do not emit in output table
   264  	if len(keyNames) == 0 {
   265  		return fmt.Errorf(MSG_OUTPUT_NO_POLICIES_FOUND)
   266  	}
   267  
   268  	// Sort entries by family name
   269  	sortLicensePolicies(keyNames)
   270  
   271  	// output each license policy entry as a line (by sorted key)
   272  	var lines [][]string
   273  	var line []string
   274  
   275  	for _, key := range keyNames {
   276  		values, match := filteredPolicyMap.Get(key)
   277  		getLogger().Tracef("%v (%t)", values, match)
   278  
   279  		for _, value := range values {
   280  			// Wrap all column text (i.e. flag `--wrap=true`)
   281  			if utils.GlobalFlags.LicenseFlags.ListLineWrap {
   282  				policy := value.(schema.LicensePolicy)
   283  
   284  				lines, err = wrapTableRowText(24, ",",
   285  					policy.UsagePolicy,
   286  					policy.Family,
   287  					policy.Id,
   288  					policy.Name,
   289  					policy.IsOsiApproved,
   290  					policy.IsFsfLibre,
   291  					policy.IsDeprecated,
   292  					policy.Reference,
   293  					policy.Aliases,
   294  					policy.AnnotationRefs,
   295  					policy.Notes,
   296  				)
   297  
   298  				// TODO: make truncate length configurable
   299  				for _, line := range lines {
   300  					fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
   301  						truncateString(line[0], 16, true),  // usage-policy
   302  						truncateString(line[1], 20, true),  // family
   303  						truncateString(line[2], 20, true),  // id
   304  						truncateString(line[3], 20, true),  // name
   305  						line[4],                            // IsOSIApproved
   306  						line[5],                            // IsFsfLibre
   307  						line[6],                            // IsDeprecated
   308  						truncateString(line[7], 36, true),  // Reference,
   309  						truncateString(line[8], 24, true),  // alias
   310  						truncateString(line[9], 24, true),  // annotation
   311  						truncateString(line[10], 24, true), // note
   312  					)
   313  				}
   314  
   315  			} else {
   316  				line, err = prepareReportLineData(
   317  					value.(schema.LicensePolicy),
   318  					LICENSE_POLICY_LIST_ROW_DATA,
   319  					flags.Summary,
   320  				)
   321  				// Only emit line if no error
   322  				if err != nil {
   323  					return
   324  				}
   325  				fmt.Fprintf(w, "%s\n", strings.Join(line, "\t"))
   326  
   327  			}
   328  		}
   329  	}
   330  	return
   331  }
   332  
   333  // TODO: Add a --no-title flag to skip title output
   334  func DisplayLicensePoliciesCSV(writer io.Writer, filteredPolicyMap *slicemultimap.MultiMap, flags utils.LicenseCommandFlags) (err error) {
   335  	getLogger().Enter()
   336  	defer getLogger().Exit()
   337  
   338  	// initialize writer and prepare the list of entries (i.e., the "rows")
   339  	w := csv.NewWriter(writer)
   340  	defer w.Flush()
   341  
   342  	// Create title row data as []string
   343  	titles, _ := prepareReportTitleData(LICENSE_POLICY_LIST_ROW_DATA, flags.Summary)
   344  
   345  	if err = w.Write(titles); err != nil {
   346  		return getLogger().Errorf("error writing to output (%v): %s", titles, err)
   347  	}
   348  
   349  	// Retrieve keys for policies to list
   350  	keyNames := filteredPolicyMap.KeySet()
   351  
   352  	// Emit no schemas found warning into output
   353  	// TODO Use only for Warning messages, do not emit in output table
   354  	if len(keyNames) == 0 {
   355  		fmt.Fprintf(writer, "%s\n", MSG_OUTPUT_NO_POLICIES_FOUND)
   356  		return fmt.Errorf(MSG_OUTPUT_NO_POLICIES_FOUND)
   357  	}
   358  
   359  	// Sort entries by family name
   360  	sortLicensePolicies(keyNames)
   361  
   362  	var line []string
   363  	for _, key := range keyNames {
   364  		values, match := filteredPolicyMap.Get(key)
   365  		getLogger().Tracef("%v (%t)", values, match)
   366  
   367  		for _, value := range values {
   368  			line, err = prepareReportLineData(
   369  				value.(schema.LicensePolicy),
   370  				LICENSE_POLICY_LIST_ROW_DATA,
   371  				flags.Summary,
   372  			)
   373  			// Only emit line if no error
   374  			if err != nil {
   375  				return
   376  			}
   377  			if err = w.Write(line); err != nil {
   378  				err = getLogger().Errorf("csv.Write: %w", err)
   379  			}
   380  		}
   381  	}
   382  	return
   383  }
   384  
   385  // TODO: Add a --no-title flag to skip title output
   386  func DisplayLicensePoliciesMarkdown(writer io.Writer, filteredPolicyMap *slicemultimap.MultiMap, flags utils.LicenseCommandFlags) (err error) {
   387  	getLogger().Enter()
   388  	defer getLogger().Exit()
   389  
   390  	// Create title row data as []string, include columns depending on value of Summary flag.
   391  	titles, _ := prepareReportTitleData(LICENSE_POLICY_LIST_ROW_DATA, flags.Summary)
   392  	titleRow := createMarkdownRow(titles)
   393  	fmt.Fprintf(writer, "%s\n", titleRow)
   394  
   395  	// create alignment row, include columns depending on value of Summary flag.
   396  	alignments := createMarkdownColumnAlignmentRow(LICENSE_POLICY_LIST_ROW_DATA, flags.Summary)
   397  	alignmentRow := createMarkdownRow(alignments)
   398  	fmt.Fprintf(writer, "%s\n", alignmentRow)
   399  
   400  	// Retrieve keys for policies to list
   401  	keyNames := filteredPolicyMap.KeySet()
   402  
   403  	// Display a warning messing in the actual output and return (short-circuit)
   404  	// Emit no schemas found warning into output
   405  	// TODO Use only for Warning messages, do not emit in output table
   406  	if len(keyNames) == 0 {
   407  		fmt.Fprintf(writer, "%s\n", MSG_OUTPUT_NO_POLICIES_FOUND)
   408  		return fmt.Errorf(MSG_OUTPUT_NO_POLICIES_FOUND)
   409  	}
   410  
   411  	// Sort entries by family name
   412  	sortLicensePolicies(keyNames)
   413  
   414  	var line []string
   415  	var lineRow string
   416  	for _, key := range keyNames {
   417  		values, match := filteredPolicyMap.Get(key)
   418  		getLogger().Tracef("%v (%t)", values, match)
   419  
   420  		for _, value := range values {
   421  			line, err = prepareReportLineData(
   422  				value.(schema.LicensePolicy),
   423  				LICENSE_POLICY_LIST_ROW_DATA,
   424  				flags.Summary,
   425  			)
   426  			// Only emit line if no error
   427  			if err != nil {
   428  				return
   429  			}
   430  			lineRow = createMarkdownRow(line)
   431  			fmt.Fprintf(writer, "%s\n", lineRow)
   432  		}
   433  	}
   434  	return
   435  }