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  }