github.com/CycloneDX/sbom-utility@v0.16.0/cmd/diff.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/json" 23 "fmt" 24 "os" 25 "strings" 26 27 "github.com/CycloneDX/sbom-utility/utils" 28 diff "github.com/mrutkows/go-jsondiff" 29 "github.com/mrutkows/go-jsondiff/formatter" 30 "github.com/spf13/cobra" 31 ) 32 33 // Command help formatting 34 const ( 35 FLAG_DIFF_OUTPUT_FORMAT_HELP = "format output using the specified type" 36 ) 37 38 var DIFF_OUTPUT_SUPPORTED_FORMATS = MSG_SUPPORTED_OUTPUT_FORMATS_HELP + 39 strings.Join([]string{FORMAT_TEXT, FORMAT_JSON}, ", ") 40 41 // validation flags 42 const ( 43 FLAG_DIFF_FILENAME_REVISION = "input-revision" 44 FLAG_DIFF_FILENAME_REVISION_SHORT = "r" 45 MSG_FLAG_INPUT_REVISION = "input filename for the revised file to compare against the base file" 46 MSG_FLAG_DIFF_COLORIZE = "Colorize diff text output (true|false); default false" 47 ) 48 49 func NewCommandDiff() *cobra.Command { 50 var command = new(cobra.Command) 51 command.Use = CMD_USAGE_DIFF 52 command.Short = "(experimental) Report on differences between two similar BOM files using RFC 6902 format" 53 command.Long = "(experimental) Report on differences between two similar BOM files using RFC 6902 format" 54 command.Flags().StringVarP(&utils.GlobalFlags.PersistentFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", FORMAT_TEXT, 55 FLAG_DIFF_OUTPUT_FORMAT_HELP+DIFF_OUTPUT_SUPPORTED_FORMATS) 56 command.Flags().StringVarP(&utils.GlobalFlags.DiffFlags.RevisedFile, 57 FLAG_DIFF_FILENAME_REVISION, 58 FLAG_DIFF_FILENAME_REVISION_SHORT, 59 "", // no default value (empty) 60 MSG_FLAG_INPUT_REVISION) 61 command.Flags().BoolVarP(&utils.GlobalFlags.DiffFlags.Colorize, FLAG_COLORIZE_OUTPUT, "", false, MSG_FLAG_DIFF_COLORIZE) 62 command.RunE = diffCmdImpl 63 command.PreRunE = func(cmd *cobra.Command, args []string) (err error) { 64 // Test for required flags (parameters) 65 err = preRunTestForFiles(args) 66 67 return 68 } 69 return command 70 } 71 72 func preRunTestForFiles(args []string) error { 73 getLogger().Enter() 74 defer getLogger().Exit() 75 getLogger().Tracef("args: %v", args) 76 77 // Make sure the base (input) file is present and exists 78 baseFilename := utils.GlobalFlags.PersistentFlags.InputFile 79 if baseFilename == "" { 80 return getLogger().Errorf("Missing required argument(s): %s", FLAG_FILENAME_INPUT) 81 } else if _, err := os.Stat(baseFilename); err != nil { 82 return getLogger().Errorf("File not found: `%s`", baseFilename) 83 } 84 85 // Make sure the revision file is present and exists 86 revisedFilename := utils.GlobalFlags.DiffFlags.RevisedFile 87 if revisedFilename == "" { 88 return getLogger().Errorf("Missing required argument(s): %s", FLAG_DIFF_FILENAME_REVISION) 89 } else if _, err := os.Stat(revisedFilename); err != nil { 90 return getLogger().Errorf("File not found: `%s`", revisedFilename) 91 } 92 93 return nil 94 } 95 96 func diffCmdImpl(cmd *cobra.Command, args []string) (err error) { 97 getLogger().Enter(args) 98 defer getLogger().Exit() 99 100 // Create output writer 101 outputFilename := utils.GlobalFlags.PersistentFlags.OutputFile 102 outputFile, writer, err := createOutputFile(outputFilename) 103 getLogger().Tracef("outputFile: `%v`; writer: `%v`", outputFile, writer) 104 105 // use function closure to assure consistent error output based upon error type 106 defer func() { 107 // always close the output file 108 if outputFile != nil { 109 err = outputFile.Close() 110 if err != nil { 111 return 112 } 113 getLogger().Infof("Closed output file: `%s`", utils.GlobalFlags.PersistentFlags.OutputFile) 114 } 115 }() 116 117 err = Diff(utils.GlobalFlags.PersistentFlags, utils.GlobalFlags.DiffFlags) 118 // Note: we turn diff library panics into errors that should change exit status code 119 if err != nil { 120 getLogger().Errorf("diff failed: differences between files perhaps too large.") 121 os.Exit(ERROR_APPLICATION) 122 } 123 return 124 } 125 126 func Diff(persistentFlags utils.PersistentCommandFlags, flags utils.DiffCommandFlags) (err error) { 127 getLogger().Enter() 128 defer getLogger().Exit() 129 130 // create locals 131 format := persistentFlags.OutputFormat 132 inputFilename := persistentFlags.InputFile 133 outputFilename := persistentFlags.OutputFile 134 outputFormat := persistentFlags.OutputFormat 135 revisedFilename := flags.RevisedFile 136 deltaColorize := flags.Colorize 137 138 // Create output writer 139 outputFile, output, err := createOutputFile(outputFilename) 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 err = outputFile.Close() 146 getLogger().Infof("Closed output file: `%s`", outputFilename) 147 } 148 }() 149 150 getLogger().Infof("Reading file (--input-file): `%s` ...", inputFilename) 151 // #nosec G304 (suppress warning) 152 bBaseData, errReadBase := os.ReadFile(inputFilename) 153 if errReadBase != nil { 154 if len(bBaseData) > 255 { 155 getLogger().Debugf("%v", bBaseData[:255]) 156 } 157 err = getLogger().Errorf("Failed to ReadFile '%s': %s", inputFilename, errReadBase.Error()) 158 return 159 } 160 161 getLogger().Infof("Reading file (--input-revision): `%s` ...", revisedFilename) 162 // #nosec G304 (suppress warning) 163 bRevisedData, errReadDelta := os.ReadFile(revisedFilename) 164 if errReadDelta != nil { 165 if len(bRevisedData) > 255 { 166 getLogger().Debugf("%v", bRevisedData[:255]) 167 } 168 err = getLogger().Errorf("Failed to ReadFile '%s': %s", revisedFilename, errReadDelta.Error()) 169 return 170 } 171 172 // Compare the base with the revision 173 getLogger().Infof("Comparing files: `%s` (base) to `%s` (revised) ...", inputFilename, revisedFilename) 174 diffResults, errCompare := compareBinaryData(bBaseData, bRevisedData) 175 if errCompare != nil { 176 return errCompare 177 } 178 179 // Output the result 180 var diffString string 181 if diffResults.Modified() { 182 getLogger().Infof("Outputting listing (`%s` format)...", format) 183 switch outputFormat { 184 case FORMAT_TEXT: 185 var aJson map[string]interface{} 186 err = json.Unmarshal(bBaseData, &aJson) 187 188 if err != nil { 189 err = getLogger().Errorf("json.Unmarshal() failed '%s': %s", inputFilename, err.Error()) 190 return 191 } 192 193 config := formatter.AsciiFormatterConfig{ 194 ShowArrayIndex: true, 195 } 196 config.Coloring = deltaColorize 197 formatter := formatter.NewAsciiFormatter(aJson, config) 198 diffString, err = formatter.Format(diffResults) 199 case FORMAT_JSON: 200 formatter := formatter.NewDeltaFormatter() 201 diffString, err = formatter.Format(diffResults) 202 // Note: JSON data files MUST ends in a newline as this is a POSIX standard 203 default: 204 // Default to Text output for anything else (set as flag default) 205 getLogger().Warningf("Diff output format not supported for `%s` format.", format) 206 } 207 208 // Output complete diff in either supported format 209 fmt.Fprintf(output, "%s\n", diffString) 210 211 } else { 212 getLogger().Infof("No deltas found. baseFilename: `%s`, revisedFilename=`%s` match.", 213 inputFilename, revisedFilename) 214 } 215 216 return 217 } 218 219 func compareBinaryData(bBaseData []byte, bRevisedData []byte) (diffResults diff.Diff, err error) { 220 defer func() { 221 if recoveredPanic := recover(); recoveredPanic != nil { 222 getLogger().Infof("ADVICE: Use the Trim command before Diff to remove highly variable data, such as: \"bom-ref\", \"hashes\" and \"properties\".") 223 err = getLogger().Errorf("panic occurred: %v", recoveredPanic) 224 return 225 } 226 }() 227 228 differ := diff.New() 229 diffResults, err = differ.Compare(bBaseData, bRevisedData) 230 if err != nil { 231 err = getLogger().Errorf("differ.Compare() failed: %s", err.Error()) 232 } 233 return 234 }