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 }