From 7a4d71f95a2cd5b06b671a72aff40d24ea65be0d Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Tue, 21 Oct 2025 18:08:05 -0400 Subject: [PATCH] [VAULT-40043]: pipeline: add `go diff mod` command (#10188) (#10292) * [VAULT-40043]: pipeline: add `go diff mod` command Add a `pipeline go diff mod` command that is capable of comparing two go.mod files at a directive level. We also support strict or lax comparisons of several directives to flexible diff comparisons. This is especially useful when you want to compare two go.mod files that have some different dependencies (CE vs. Ent) but still want to compare versions of like dependencies. This command is not currently used in the pipeline but was useful in developing the diff library that is used. Subsequent work will use the library and be integrated into CI. * review feedback * one more comment fix --------- Signed-off-by: Ryan Cragun Co-authored-by: Ryan Cragun --- tools/pipeline/go.mod | 17 +- tools/pipeline/go.sum | 28 +- tools/pipeline/internal/cmd/go.go | 18 + tools/pipeline/internal/cmd/go_diff.go | 20 + tools/pipeline/internal/cmd/go_diff_mod.go | 130 +++++ tools/pipeline/internal/cmd/root.go | 1 + .../internal/pkg/golang/diff_exclude.go | 102 ++++ tools/pipeline/internal/pkg/golang/diff_go.go | 31 ++ .../internal/pkg/golang/diff_godebug.go | 92 ++++ .../internal/pkg/golang/diff_ignore.go | 56 ++ .../internal/pkg/golang/diff_mod_request.go | 79 +++ .../internal/pkg/golang/diff_module.go | 31 ++ .../internal/pkg/golang/diff_replace.go | 125 +++++ .../internal/pkg/golang/diff_require.go | 99 ++++ .../internal/pkg/golang/diff_retract.go | 121 ++++ .../pipeline/internal/pkg/golang/diff_tool.go | 53 ++ .../internal/pkg/golang/diff_toolchain.go | 29 + tools/pipeline/internal/pkg/golang/doc.go | 5 + .../internal/pkg/golang/fixtures/moda/go.mod | 25 + .../internal/pkg/golang/fixtures/moda/go.sum | 42 ++ .../internal/pkg/golang/fixtures/moda/main.go | 8 + .../internal/pkg/golang/fixtures/modb/go.mod | 38 ++ .../internal/pkg/golang/fixtures/modb/go.sum | 44 ++ .../internal/pkg/golang/fixtures/modb/main.go | 8 + .../pipeline/internal/pkg/golang/mod_diff.go | 254 +++++++++ .../internal/pkg/golang/mod_diff_test.go | 517 ++++++++++++++++++ 26 files changed, 1952 insertions(+), 21 deletions(-) create mode 100644 tools/pipeline/internal/cmd/go.go create mode 100644 tools/pipeline/internal/cmd/go_diff.go create mode 100644 tools/pipeline/internal/cmd/go_diff_mod.go create mode 100644 tools/pipeline/internal/pkg/golang/diff_exclude.go create mode 100644 tools/pipeline/internal/pkg/golang/diff_go.go create mode 100644 tools/pipeline/internal/pkg/golang/diff_godebug.go create mode 100644 tools/pipeline/internal/pkg/golang/diff_ignore.go create mode 100644 tools/pipeline/internal/pkg/golang/diff_mod_request.go create mode 100644 tools/pipeline/internal/pkg/golang/diff_module.go create mode 100644 tools/pipeline/internal/pkg/golang/diff_replace.go create mode 100644 tools/pipeline/internal/pkg/golang/diff_require.go create mode 100644 tools/pipeline/internal/pkg/golang/diff_retract.go create mode 100644 tools/pipeline/internal/pkg/golang/diff_tool.go create mode 100644 tools/pipeline/internal/pkg/golang/diff_toolchain.go create mode 100644 tools/pipeline/internal/pkg/golang/doc.go create mode 100644 tools/pipeline/internal/pkg/golang/fixtures/moda/go.mod create mode 100644 tools/pipeline/internal/pkg/golang/fixtures/moda/go.sum create mode 100644 tools/pipeline/internal/pkg/golang/fixtures/moda/main.go create mode 100644 tools/pipeline/internal/pkg/golang/fixtures/modb/go.mod create mode 100644 tools/pipeline/internal/pkg/golang/fixtures/modb/go.sum create mode 100644 tools/pipeline/internal/pkg/golang/fixtures/modb/main.go create mode 100644 tools/pipeline/internal/pkg/golang/mod_diff.go create mode 100644 tools/pipeline/internal/pkg/golang/mod_diff_test.go diff --git a/tools/pipeline/go.mod b/tools/pipeline/go.mod index 3cfcc6567f..845ae0a3f1 100644 --- a/tools/pipeline/go.mod +++ b/tools/pipeline/go.mod @@ -2,6 +2,9 @@ module github.com/hashicorp/vault/tools/pipeline go 1.24.0 +// We have test modules in here but they ought to be completely ignored +ignore internal/pkg/golang/fixtures + require ( github.com/Masterminds/semver v1.5.0 github.com/avast/retry-go/v4 v4.6.1 @@ -9,11 +12,13 @@ require ( github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/releases-api v0.2.3 github.com/jedib0t/go-pretty/v6 v6.6.8 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 github.com/veqryn/slog-context v0.8.0 github.com/zclconf/go-cty v1.16.4 + golang.org/x/mod v0.29.0 golang.org/x/oauth2 v0.31.0 ) @@ -57,7 +62,6 @@ require ( github.com/oklog/ulid v1.3.1 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/spf13/pflag v1.0.7 // indirect @@ -66,12 +70,11 @@ require ( go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect + golang.org/x/tools v0.37.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/tools/pipeline/go.sum b/tools/pipeline/go.sum index 82b130e298..d2aa4c3cd9 100644 --- a/tools/pipeline/go.sum +++ b/tools/pipeline/go.sum @@ -207,30 +207,30 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= diff --git a/tools/pipeline/internal/cmd/go.go b/tools/pipeline/internal/cmd/go.go new file mode 100644 index 0000000000..6167ea7ed2 --- /dev/null +++ b/tools/pipeline/internal/cmd/go.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cmd + +import "github.com/spf13/cobra" + +func newGoCmd() *cobra.Command { + goCmd := &cobra.Command{ + Use: "go", + Short: "Go commands", + Long: "Go commands", + } + + goCmd.AddCommand(newGoDiffCmd()) + + return goCmd +} diff --git a/tools/pipeline/internal/cmd/go_diff.go b/tools/pipeline/internal/cmd/go_diff.go new file mode 100644 index 0000000000..6a62b7cde8 --- /dev/null +++ b/tools/pipeline/internal/cmd/go_diff.go @@ -0,0 +1,20 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cmd + +import ( + "github.com/spf13/cobra" +) + +func newGoDiffCmd() *cobra.Command { + goModCmd := &cobra.Command{ + Use: "diff", + Short: "Go diff commands", + Long: "Go diff commands", + } + + goModCmd.AddCommand(newGoDiffModCmd()) + + return goModCmd +} diff --git a/tools/pipeline/internal/cmd/go_diff_mod.go b/tools/pipeline/internal/cmd/go_diff_mod.go new file mode 100644 index 0000000000..d51d8684f6 --- /dev/null +++ b/tools/pipeline/internal/cmd/go_diff_mod.go @@ -0,0 +1,130 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/hashicorp/vault/tools/pipeline/internal/pkg/golang" + "github.com/spf13/cobra" +) + +var goModDiffReq = &golang.DiffModReq{ + A: &golang.ModSource{}, + B: &golang.ModSource{}, + Opts: golang.DefaultDiffOpts(), +} + +func newGoDiffModCmd() *cobra.Command { + goModDiffCmd := &cobra.Command{ + Use: "mod [ARGS]", + Short: "Diff two local go.mod files", + Long: "Diff two local go.mod files", + RunE: runGoModDiffCmd, + Args: func(cmd *cobra.Command, args []string) error { + switch len(args) { + case 2: + err := setUpGoModSourceFromPath(args[0], goModDiffReq.A) + if err != nil { + return err + } + return setUpGoModSourceFromPath(args[1], goModDiffReq.B) + case 0, 1: + return errors.New("invalid arguments: you must provide two local file paths") + default: + return fmt.Errorf("invalid arguments: expected two paths as arguments, received %d arguments", len(args)) + } + }, + } + + goModDiffCmd.PersistentFlags().BoolVar(&goModDiffReq.Opts.ParseLax, "lax", false, "Parse the modules in lax mode to ignore newer and unknown directives") + + goModDiffCmd.PersistentFlags().BoolVar(&goModDiffReq.Opts.Module, "module", true, "Compare the module directives in both files") + goModDiffCmd.PersistentFlags().BoolVar(&goModDiffReq.Opts.Go, "go", true, "Compare the go directives in both files") + goModDiffCmd.PersistentFlags().BoolVar(&goModDiffReq.Opts.Toolchain, "toolchain", true, "Compare the toolchain directives in both files") + goModDiffCmd.PersistentFlags().BoolVar(&goModDiffReq.Opts.Godebug, "godebug", true, "Compare the godebug directives in both files") + goModDiffCmd.PersistentFlags().BoolVar(&goModDiffReq.Opts.Require, "require", true, "Compare the require directives in both files") + goModDiffCmd.PersistentFlags().BoolVar(&goModDiffReq.Opts.Replace, "replace", true, "Compare the replace directives in both files") + goModDiffCmd.PersistentFlags().BoolVar(&goModDiffReq.Opts.Retract, "retract", true, "Compare the retract directives in both files") + goModDiffCmd.PersistentFlags().BoolVar(&goModDiffReq.Opts.Tool, "tool", true, "Compare the tool directives in both files") + goModDiffCmd.PersistentFlags().BoolVar(&goModDiffReq.Opts.Ignore, "ignore", true, "Compare the ignore directives in both files") + + goModDiffCmd.PersistentFlags().BoolVar(&goModDiffReq.Opts.StrictDiffRequire, "strict-require", true, "Strictly compare the requires directives in both files. When true all requires are compared, otherwise only shared requires are compared") + goModDiffCmd.PersistentFlags().BoolVar(&goModDiffReq.Opts.StrictDiffExclude, "strict-exclude", true, "Strictly compare the excludes directives in both files. When true all excludes are compared, otherwise only shared excludes are compared") + goModDiffCmd.PersistentFlags().BoolVar(&goModDiffReq.Opts.StrictDiffReplace, "strict-replace", true, "Strictly compare the replace directives in both files. When true all replaces are compared, otherwise only shared replaces are compared") + goModDiffCmd.PersistentFlags().BoolVar(&goModDiffReq.Opts.StrictDiffRetract, "strict-retract", true, "Strictly compare the retract directives in both files. When true all retracts are compared, otherwise only shared retract directives are compared") + + return goModDiffCmd +} + +func runGoModDiffCmd(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true // Don't spam the usage on failure + + res, err := goModDiffReq.Run(context.TODO()) + if err != nil { + return err + } + + switch rootCfg.format { + case "json": + b, err1 := res.ToJSON() + if err1 != nil { + err = errors.Join(err, err1) + } else { + fmt.Println(string(b)) + } + case "markdown": + tbl, err1 := res.ToTable(err) + if err1 != nil { + err = errors.Join(err, err1) + } else { + tbl.SetTitle("Go Mod Diff") + fmt.Println(tbl.RenderMarkdown()) + } + default: + tbl, err1 := res.ToTable(err) + if err1 != nil { + err = errors.Join(err, err1) + } else { + if text := tbl.Render(); text != "" { + fmt.Println(text) + } + } + } + + if l := len(res.ModDiff); l > 0 { + err = errors.Join(fmt.Errorf("%d differences were found", l), err) + } + + return err +} + +func setUpGoModSourceFromPath(path string, source *golang.ModSource) (err error) { + if source == nil { + return errors.New("you must provide a mod source") + } + + aPath, err := filepath.Abs(path) + if err != nil { + return err + } + + f, err := os.Open(aPath) + if err != nil { + return err + } + defer func() { + err = errors.Join(f.Close()) + }() + + source.Name = path + source.Data, err = io.ReadAll(f) + + return err +} diff --git a/tools/pipeline/internal/cmd/root.go b/tools/pipeline/internal/cmd/root.go index 4dcab3c66d..86b5b586e5 100644 --- a/tools/pipeline/internal/cmd/root.go +++ b/tools/pipeline/internal/cmd/root.go @@ -32,6 +32,7 @@ func newRootCmd() *cobra.Command { rootCmd.AddCommand(newGenerateCmd()) rootCmd.AddCommand(newGithubCmd()) + rootCmd.AddCommand(newGoCmd()) rootCmd.AddCommand(newHCPCmd()) rootCmd.AddCommand(newReleasesCmd()) diff --git a/tools/pipeline/internal/pkg/golang/diff_exclude.go b/tools/pipeline/internal/pkg/golang/diff_exclude.go new file mode 100644 index 0000000000..1306e7fe11 --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/diff_exclude.go @@ -0,0 +1,102 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package golang + +import ( + "maps" + "slices" + "strings" + + "golang.org/x/mod/modfile" +) + +// diffExclude compares the exclude directives in two module files and returns +// a slice of *Diff's. When strict parsing is set to true, exclude directives +// that are ommitted from one or the other modfile will also be included. +func diffExclude(a *modfile.File, b *modfile.File, strictDiff bool) []*Diff { + if (a == nil && b == nil) || (len(a.Exclude) == 0 && len(b.Exclude) == 0) { + return nil + } + + var diffs []*Diff + if strictDiff { + diffs = append(diffExcludeFindMissing(a, b), diffExcludeFindMissing(b, a)...) + } + versionDiffsA := diffExcludeFindDifferent(a, b) + versionDiffsB := diffExcludeFindDifferent(b, a) + maps.Copy(versionDiffsB, versionDiffsA) + + return slices.DeleteFunc( + append(diffs, slices.Collect(maps.Values(versionDiffsB))...), + func(d *Diff) bool { return d == nil }, + ) +} + +func diffExcludeFindMissing(a, b *modfile.File) []*Diff { + diffs := []*Diff{} + for _, needle := range a.Exclude { + if needle == nil { + continue + } + + // See if there's a matching require + idx := slices.IndexFunc(b.Exclude, func(hay *modfile.Exclude) bool { + if hay == nil { + return false + } + + return needle.Mod.Path == hay.Mod.Path + }) + + if idx >= 0 { + // We have a matching require + continue + } + + diff := newDiffFromModFiles(a, b, DirectiveExclude) + if needle.Syntax != nil { + diff.Diff.A = []string{strings.Join(needle.Syntax.Token, " ")} + } + diffs = append(diffs, diff) + } + + return diffs +} + +func diffExcludeFindDifferent(a, b *modfile.File) map[string]*Diff { + diffs := map[string]*Diff{} + for _, needle := range a.Exclude { + if needle == nil { + continue + } + + // See if there's a matching require + idx := slices.IndexFunc(b.Exclude, func(hay *modfile.Exclude) bool { + if hay == nil { + return false + } + + return needle.Mod.Path == hay.Mod.Path + }) + + if idx < 0 { + // We don't have a matching require + continue + } + + hay := b.Exclude[idx] + if needle.Mod.Version != hay.Mod.Version { + diff := newDiffFromModFiles(a, b, DirectiveExclude) + if needle.Syntax != nil { + diff.Diff.A = []string{strings.Join(needle.Syntax.Token, " ") + "\n"} + } + if hay.Syntax != nil { + diff.Diff.B = []string{strings.Join(hay.Syntax.Token, " ")} + } + diffs[needle.Mod.Path] = diff + } + } + + return diffs +} diff --git a/tools/pipeline/internal/pkg/golang/diff_go.go b/tools/pipeline/internal/pkg/golang/diff_go.go new file mode 100644 index 0000000000..be4f2ebee8 --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/diff_go.go @@ -0,0 +1,31 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package golang + +import ( + "strings" + + "golang.org/x/mod/modfile" +) + +// diffGo compares the go directives in two modules files and returns +// a slice of *Diff's. +func diffGo(a *modfile.File, b *modfile.File) *Diff { + if (a == nil && b == nil) || + (a.Go == nil && b.Go == nil) || + ((a.Go != nil && b.Go != nil) && (a.Go.Version == b.Go.Version)) { + + return nil + } + + diff := newDiffFromModFiles(a, b, DirectiveGo) + if a != nil && a.Go != nil && a.Go.Syntax != nil { + diff.Diff.A = []string{strings.Join(a.Go.Syntax.Token, " ") + "\n"} + } + if b != nil && b.Go != nil && b.Go.Syntax != nil { + diff.Diff.B = []string{strings.Join(b.Go.Syntax.Token, " ")} + } + + return diff +} diff --git a/tools/pipeline/internal/pkg/golang/diff_godebug.go b/tools/pipeline/internal/pkg/golang/diff_godebug.go new file mode 100644 index 0000000000..46c247be52 --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/diff_godebug.go @@ -0,0 +1,92 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package golang + +import ( + "slices" + "strings" + + "golang.org/x/mod/modfile" +) + +// diffGodebug compares the godebug directives in two modules files and returns +// a slice of *Diff's. When strict parsing is set to true, godebug directives +// that are ommitted from one or the other modfile will also be included. +func diffGodebug(a *modfile.File, b *modfile.File) []*Diff { + if (a == nil && b == nil) || (len(a.Godebug) == 0 && len(b.Godebug) == 0) { + return nil + } + + return slices.DeleteFunc( + append(diffGodebugFindDiffs(a, b), diffGodebugFindDiffs(b, a)...), + func(d *Diff) bool { return d == nil }, + ) +} + +func diffGodebugFindDiffs(a, b *modfile.File) []*Diff { + diffs := []*Diff{} + for _, needle := range a.Godebug { + idx := slices.IndexFunc(b.Godebug, func(hay *modfile.Godebug) bool { + return godebugMatchingKey(needle, hay) + }) + + if idx < 0 { + // We don't have matching godebug with the same key, create a single + // sided diff + diff := newDiffFromModFiles(a, b, DirectiveGodebug) + if needle != nil && needle.Syntax != nil { + diff.Diff.A = []string{strings.Join(needle.Syntax.Token, " ")} + } + diffs = append(diffs, diff) + continue + } + + // We have a matching key + hay := b.Godebug[idx] + if godebugEqual(needle, hay) { + continue + } + + // We have differing values. Create a double sided diff + diff := newDiffFromModFiles(a, b, DirectiveGodebug) + if needle != nil && needle.Syntax != nil { + diff.Diff.A = []string{strings.Join(needle.Syntax.Token, " ") + "\n"} + } + if hay != nil && hay.Syntax != nil { + diff.Diff.B = []string{strings.Join(hay.Syntax.Token, " ")} + } + + diffs = append(diffs, diff) + } + + return diffs +} + +func godebugEqual(a *modfile.Godebug, b *modfile.Godebug) bool { + if a == nil && b == nil { + return true + } + + if (a == nil && b != nil) || (a != nil && b == nil) { + return false + } + + if a.Key != b.Key { + return false + } + + return a.Value == b.Value +} + +func godebugMatchingKey(a *modfile.Godebug, b *modfile.Godebug) bool { + if a == nil && b == nil { + return false + } + + if (a == nil && b != nil) || (a != nil && b == nil) { + return false + } + + return a.Key == b.Key +} diff --git a/tools/pipeline/internal/pkg/golang/diff_ignore.go b/tools/pipeline/internal/pkg/golang/diff_ignore.go new file mode 100644 index 0000000000..a979cb9591 --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/diff_ignore.go @@ -0,0 +1,56 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package golang + +import ( + "slices" + "strings" + + "golang.org/x/mod/modfile" +) + +// diffIgnore compares the ignore directives in two module files and returns +// a slice of *Diff's. When strict parsing is set to true, ignore directives +// that are ommitted from one or the other modfile will also be included. +func diffIgnore(a *modfile.File, b *modfile.File) []*Diff { + if (a == nil && b == nil) || (len(a.Ignore) == 0 && len(b.Ignore) == 0) { + return nil + } + + return slices.DeleteFunc( + append(diffIgnoreFindDiffs(a, b), diffIgnoreFindDiffs(b, a)...), + func(d *Diff) bool { return d == nil }, + ) +} + +func diffIgnoreFindDiffs(a, b *modfile.File) []*Diff { + diffs := []*Diff{} + for _, needle := range a.Ignore { + if needle == nil { + continue + } + + // See if there's a matching ignore + idx := slices.IndexFunc(b.Ignore, func(hay *modfile.Ignore) bool { + if hay == nil { + return false + } + + return needle.Path == hay.Path + }) + + if idx >= 0 { + // We have a matching ignore + continue + } + + diff := newDiffFromModFiles(a, b, DirectiveIgnore) + if needle.Syntax != nil { + diff.Diff.A = []string{strings.Join(needle.Syntax.Token, " ")} + } + diffs = append(diffs, diff) + } + + return diffs +} diff --git a/tools/pipeline/internal/pkg/golang/diff_mod_request.go b/tools/pipeline/internal/pkg/golang/diff_mod_request.go new file mode 100644 index 0000000000..bb41a41a89 --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/diff_mod_request.go @@ -0,0 +1,79 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package golang + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/pmezard/go-difflib/difflib" +) + +type DiffModReq struct { + A *ModSource `json:"a,omitempty"` + B *ModSource `json:"b,omitempty"` + Opts *DiffOpts `json:"opts,omitempty"` +} + +func (r *DiffModReq) Run(ctx context.Context) (*DiffModRes, error) { + res := &DiffModRes{} + var err error + + res.ModDiff, err = DiffModFiles(r.A, r.B, r.Opts) + + return res, err +} + +type DiffModRes struct { + ModDiff +} + +func (r *DiffModRes) ToJSON() ([]byte, error) { + b, err := json.Marshal(r) + if err != nil { + return nil, fmt.Errorf("marshaling latest HCP image response to JSON: %w", err) + } + + return b, nil +} + +// ToTable marshals the response to a text table. +func (r *DiffModRes) ToTable(err error) (table.Writer, error) { + t := table.NewWriter() + t.Style().Options.DrawBorder = false + t.Style().Options.SeparateColumns = false + t.Style().Options.SeparateFooter = false + t.Style().Options.SeparateHeader = false + t.Style().Options.SeparateRows = false + + if r == nil || r.ModDiff == nil || len(r.ModDiff) == 0 || err != nil { + if err != nil { + t.AppendHeader(table.Row{"error"}) + t.AppendRow(table.Row{err.Error()}) + } + return t, err + } + + t.AppendHeader(table.Row{"explanation", "diff"}) + for _, diff := range r.ModDiff { + if diff == nil { + continue + } + if diff.Diff == nil { + return nil, fmt.Errorf("missing unified diff: %v", diff) + } + + diffText, err := difflib.GetUnifiedDiffString(*diff.Diff) + if err != nil { + return nil, err + } + t.AppendRow(table.Row{diff.Directive.Explanation(), diffText}) + } + t.SuppressEmptyColumns() + t.SuppressTrailingSpaces() + + return t, nil +} diff --git a/tools/pipeline/internal/pkg/golang/diff_module.go b/tools/pipeline/internal/pkg/golang/diff_module.go new file mode 100644 index 0000000000..9eb3137318 --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/diff_module.go @@ -0,0 +1,31 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package golang + +import ( + "strings" + + "golang.org/x/mod/modfile" +) + +// diffModule compares the module directives in two modules files and returns +// a slice of *Diff's. +func diffModule(a *modfile.File, b *modfile.File) *Diff { + if (a == nil && b == nil) || + (a.Module == nil && b.Module == nil) || + (a.Module.Mod.String() == b.Module.Mod.String()) { + + return nil + } + + diff := newDiffFromModFiles(a, b, DirectiveModule) + if a != nil && a.Module != nil && a.Module.Syntax != nil { + diff.Diff.A = []string{strings.Join(a.Module.Syntax.Token, " ") + "\n"} + } + if b != nil && b.Module != nil && b.Module.Syntax != nil { + diff.Diff.B = []string{strings.Join(b.Module.Syntax.Token, " ")} + } + + return diff +} diff --git a/tools/pipeline/internal/pkg/golang/diff_replace.go b/tools/pipeline/internal/pkg/golang/diff_replace.go new file mode 100644 index 0000000000..ddea936257 --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/diff_replace.go @@ -0,0 +1,125 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package golang + +import ( + "maps" + "slices" + "strings" + + "golang.org/x/mod/modfile" +) + +func diffReplace(a *modfile.File, b *modfile.File, strictDiff bool) []*Diff { + if (a == nil && b == nil) || (len(a.Replace) == 0 && len(b.Replace) == 0) { + return nil + } + + var diffs []*Diff + if strictDiff { + diffs = append(diffReplaceFindMissing(a, b), diffReplaceFindMissing(b, a)...) + } + versionDiffsA := diffReplaceFindDifferent(a, b) + versionDiffsB := diffReplaceFindDifferent(b, a) + maps.Copy(versionDiffsB, versionDiffsA) + + return slices.DeleteFunc( + append(diffs, slices.Collect(maps.Values(versionDiffsB))...), + func(d *Diff) bool { return d == nil }, + ) +} + +func diffReplaceFindMissing(a, b *modfile.File) []*Diff { + diffs := []*Diff{} + for _, needle := range a.Replace { + if needle == nil { + continue + } + + // See if there's a matching old exclude + idx := slices.IndexFunc(b.Replace, func(hay *modfile.Replace) bool { + if hay == nil { + return false + } + + return needle.Old.Path == hay.Old.Path + }) + + if idx >= 0 { + // We have a matching old exclude, we'll handle this in the version diff check + continue + } + + diff := newDiffFromModFiles(a, b, DirectiveReplace) + if needle.Syntax != nil { + diff.Diff.A = []string{strings.Join(needle.Syntax.Token, " ")} + } + diffs = append(diffs, diff) + } + + return diffs +} + +func diffReplaceFindDifferent(a, b *modfile.File) map[string]*Diff { + diffs := map[string]*Diff{} + for _, needle := range a.Replace { + if needle == nil { + continue + } + + // See if there's a matching exclude + idx := slices.IndexFunc(b.Replace, func(hay *modfile.Replace) bool { + if hay == nil { + return false + } + + return needle.Old.Path == hay.Old.Path + }) + + if idx < 0 { + // We don't have a matching exclude + continue + } + + hay := b.Replace[idx] + if replaceEqual(needle, hay) { + continue + } + + diff := newDiffFromModFiles(a, b, DirectiveReplace) + if needle.Syntax != nil { + diff.Diff.A = []string{strings.Join(needle.Syntax.Token, " ") + "\n"} + } + if hay.Syntax != nil { + diff.Diff.B = []string{strings.Join(hay.Syntax.Token, " ")} + } + diffs[needle.Old.Path] = diff + } + + return diffs +} + +func replaceEqual(a *modfile.Replace, b *modfile.Replace) bool { + if a == nil && b == nil { + return true + } + + if (a == nil && b != nil) || (a != nil && b == nil) { + return false + } + + if a.Old.Path != b.Old.Path { + return false + } + + if a.Old.Version != b.Old.Version { + return false + } + + if a.New.Path != b.New.Path { + return false + } + + return a.New.Version == b.New.Version +} diff --git a/tools/pipeline/internal/pkg/golang/diff_require.go b/tools/pipeline/internal/pkg/golang/diff_require.go new file mode 100644 index 0000000000..a537e26c85 --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/diff_require.go @@ -0,0 +1,99 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package golang + +import ( + "maps" + "slices" + "strings" + + "golang.org/x/mod/modfile" +) + +func diffRequire(a *modfile.File, b *modfile.File, strictDiff bool) []*Diff { + if (a == nil && b == nil) || (len(a.Require) == 0 && len(b.Require) == 0) { + return nil + } + + var diffs []*Diff + if strictDiff { + diffs = append(diffRequireFindMissing(a, b), diffRequireFindMissing(b, a)...) + } + versionDiffsA := diffRequireFindDifferent(a, b) + versionDiffsB := diffRequireFindDifferent(b, a) + maps.Copy(versionDiffsB, versionDiffsA) + + return slices.DeleteFunc( + append(diffs, slices.Collect(maps.Values(versionDiffsB))...), + func(d *Diff) bool { return d == nil }, + ) +} + +func diffRequireFindMissing(a, b *modfile.File) []*Diff { + diffs := []*Diff{} + for _, needle := range a.Require { + if needle == nil { + continue + } + + // See if there's a matching require + idx := slices.IndexFunc(b.Require, func(hay *modfile.Require) bool { + if hay == nil { + return false + } + + return needle.Mod.Path == hay.Mod.Path + }) + + if idx >= 0 { + // We have a matching require + continue + } + + diff := newDiffFromModFiles(a, b, DirectiveRequire) + if needle.Syntax != nil { + diff.Diff.A = []string{strings.Join(needle.Syntax.Token, " ")} + } + diffs = append(diffs, diff) + } + + return diffs +} + +func diffRequireFindDifferent(a, b *modfile.File) map[string]*Diff { + diffs := map[string]*Diff{} + for _, needle := range a.Require { + if needle == nil { + continue + } + + // See if there's a matching require + idx := slices.IndexFunc(b.Require, func(hay *modfile.Require) bool { + if hay == nil { + return false + } + + return needle.Mod.Path == hay.Mod.Path + }) + + if idx < 0 { + // We don't have a matching require + continue + } + + hay := b.Require[idx] + if needle.Mod.Version != hay.Mod.Version { + diff := newDiffFromModFiles(a, b, DirectiveRequire) + if needle.Syntax != nil { + diff.Diff.A = []string{strings.Join(needle.Syntax.Token, " ") + "\n"} + } + if hay.Syntax != nil { + diff.Diff.B = []string{strings.Join(hay.Syntax.Token, " ")} + } + diffs[needle.Mod.Path] = diff + } + } + + return diffs +} diff --git a/tools/pipeline/internal/pkg/golang/diff_retract.go b/tools/pipeline/internal/pkg/golang/diff_retract.go new file mode 100644 index 0000000000..b0189c300d --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/diff_retract.go @@ -0,0 +1,121 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package golang + +import ( + "maps" + "slices" + "strings" + + "golang.org/x/mod/modfile" +) + +func diffRetract(a *modfile.File, b *modfile.File, strictDiff bool) []*Diff { + if (a == nil && b == nil) || (len(a.Retract) == 0 && len(b.Retract) == 0) { + return nil + } + + var diffs []*Diff + if strictDiff { + diffs = append(diffRetractFindMissing(a, b), diffRetractFindMissing(b, a)...) + } + versionDiffsA := diffRetractFindDifferent(a, b) + versionDiffsB := diffRetractFindDifferent(b, a) + maps.Copy(versionDiffsB, versionDiffsA) + + return slices.DeleteFunc( + append(diffs, slices.Collect(maps.Values(versionDiffsB))...), + func(d *Diff) bool { return d == nil }, + ) +} + +func diffRetractFindMissing(a, b *modfile.File) []*Diff { + diffs := []*Diff{} + for _, needle := range a.Retract { + if needle == nil { + continue + } + + // See if there's a matching retract + idx := slices.IndexFunc(b.Retract, func(hay *modfile.Retract) bool { + if hay == nil { + return false + } + + return needle.VersionInterval.Low == hay.VersionInterval.Low + }) + + if idx >= 0 { + // We have a matching retract + continue + } + + diff := newDiffFromModFiles(a, b, DirectiveRetract) + if needle.Syntax != nil { + diff.Diff.A = []string{strings.Join(needle.Syntax.Token, " ")} + } + diffs = append(diffs, diff) + } + + return diffs +} + +func diffRetractFindDifferent(a, b *modfile.File) map[string]*Diff { + diffs := map[string]*Diff{} + for _, needle := range a.Retract { + if needle == nil { + continue + } + + // See if there's a matching require + idx := slices.IndexFunc(b.Retract, func(hay *modfile.Retract) bool { + if hay == nil { + return false + } + + return needle.VersionInterval.Low == hay.VersionInterval.Low + }) + + if idx < 0 { + // We don't have a matching require + continue + } + + hay := b.Retract[idx] + if retractEqual(needle, hay) { + continue + } + + diff := newDiffFromModFiles(a, b, DirectiveRetract) + if needle.Syntax != nil { + diff.Diff.A = []string{strings.Join(needle.Syntax.Token, " ") + "\n"} + } + if hay.Syntax != nil { + diff.Diff.B = []string{strings.Join(hay.Syntax.Token, " ")} + } + diffs[needle.VersionInterval.Low] = diff + } + + return diffs +} + +func retractEqual(a *modfile.Retract, b *modfile.Retract) bool { + if a == nil && b == nil { + return true + } + + if (a == nil && b != nil) || (a != nil && b == nil) { + return false + } + + if a.VersionInterval.Low != b.VersionInterval.Low { + return false + } + + if a.VersionInterval.High != b.VersionInterval.High { + return false + } + + return a.Rationale == b.Rationale +} diff --git a/tools/pipeline/internal/pkg/golang/diff_tool.go b/tools/pipeline/internal/pkg/golang/diff_tool.go new file mode 100644 index 0000000000..b32d87861d --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/diff_tool.go @@ -0,0 +1,53 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package golang + +import ( + "slices" + "strings" + + "golang.org/x/mod/modfile" +) + +func diffTool(a *modfile.File, b *modfile.File) []*Diff { + if (a == nil && b == nil) || (len(a.Tool) == 0 && len(b.Tool) == 0) { + return nil + } + + return slices.DeleteFunc( + append(diffToolFindDiffs(a, b), diffToolFindDiffs(b, a)...), + func(d *Diff) bool { return d == nil }, + ) +} + +func diffToolFindDiffs(a, b *modfile.File) []*Diff { + diffs := []*Diff{} + for _, needle := range a.Tool { + if needle == nil { + continue + } + + // See if there's a matching tool + idx := slices.IndexFunc(b.Tool, func(hay *modfile.Tool) bool { + if hay == nil { + return false + } + + return needle.Path == hay.Path + }) + + if idx >= 0 { + // We have a matching tool + continue + } + + diff := newDiffFromModFiles(a, b, DirectiveTool) + if needle.Syntax != nil { + diff.Diff.A = []string{strings.Join(needle.Syntax.Token, " ")} + } + diffs = append(diffs, diff) + } + + return diffs +} diff --git a/tools/pipeline/internal/pkg/golang/diff_toolchain.go b/tools/pipeline/internal/pkg/golang/diff_toolchain.go new file mode 100644 index 0000000000..1fa8de7638 --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/diff_toolchain.go @@ -0,0 +1,29 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package golang + +import ( + "strings" + + "golang.org/x/mod/modfile" +) + +func diffToolchain(a *modfile.File, b *modfile.File) *Diff { + if (a == nil && b == nil) || + (a.Toolchain == nil && b.Toolchain == nil) || + ((a.Toolchain != nil && b.Toolchain != nil) && (a.Toolchain.Name == b.Toolchain.Name)) { + + return nil + } + + diff := newDiffFromModFiles(a, b, DirectiveToolchain) + if a != nil && a.Toolchain != nil && a.Toolchain.Syntax != nil { + diff.Diff.A = []string{strings.Join(a.Toolchain.Syntax.Token, " ") + "\n"} + } + if b != nil && b.Toolchain != nil && b.Toolchain.Syntax != nil { + diff.Diff.B = []string{strings.Join(b.Toolchain.Syntax.Token, " ")} + } + + return diff +} diff --git a/tools/pipeline/internal/pkg/golang/doc.go b/tools/pipeline/internal/pkg/golang/doc.go new file mode 100644 index 0000000000..6de1110a53 --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/doc.go @@ -0,0 +1,5 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +// Package golang contains functions and types for Go analysis +package golang diff --git a/tools/pipeline/internal/pkg/golang/fixtures/moda/go.mod b/tools/pipeline/internal/pkg/golang/fixtures/moda/go.mod new file mode 100644 index 0000000000..148e8c9881 --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/fixtures/moda/go.mod @@ -0,0 +1,25 @@ +module github.com/hashicorp/vault/pipeline/golang/moda + +go 1.25 + +require github.com/99designs/keyring v1.2.2 + +require ( + github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/danieljoos/wincred v1.1.2 // indirect + github.com/dvsekhvalnov/jose2go v1.5.0 // indirect + github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect + github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect + github.com/mtibben/percent v0.2.1 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/term v0.3.0 // indirect + golang.org/x/tools v0.38.0 // indirect +) + +tool golang.org/x/tools/cmd/bisect + +ignore ( + ./third_party/javascript + content/html + static +) diff --git a/tools/pipeline/internal/pkg/golang/fixtures/moda/go.sum b/tools/pipeline/internal/pkg/golang/fixtures/moda/go.sum new file mode 100644 index 0000000000..c4dd84b4ee --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/fixtures/moda/go.sum @@ -0,0 +1,42 @@ +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= +github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= +github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= +github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= +github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM= +github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= +github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As= +github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/pipeline/internal/pkg/golang/fixtures/moda/main.go b/tools/pipeline/internal/pkg/golang/fixtures/moda/main.go new file mode 100644 index 0000000000..2814088bfe --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/fixtures/moda/main.go @@ -0,0 +1,8 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package main + +import ( + _ "github.com/99designs/keyring" +) diff --git a/tools/pipeline/internal/pkg/golang/fixtures/modb/go.mod b/tools/pipeline/internal/pkg/golang/fixtures/modb/go.mod new file mode 100644 index 0000000000..f1a08a2871 --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/fixtures/modb/go.mod @@ -0,0 +1,38 @@ +module github.com/hashicorp/vault/pipeline/golang/modb + +go 1.25.2 + +toolchain go1.24 + +godebug ( + default=go1.21 + httpcookiemaxnum=4000 + panicnil=1 +) + +exclude golang.org/x/term v0.2.0 + +replace github.com/99designs/keyring => github.com/Jeffail/keyring v1.2.3 + +require github.com/99designs/keyring v0.0.0-00010101000000-000000000000 + +retract ( + [v1.0.0, v1.9.9] + v0.9.0 +) + +tool golang.org/x/tools/cmd/stringer + +require ( + github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/danieljoos/wincred v1.1.2 // indirect + github.com/dvsekhvalnov/jose2go v1.5.0 // indirect + github.com/mtibben/percent v0.2.1 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/term v0.3.0 // indirect + golang.org/x/tools v0.38.0 // indirect +) + +ignore ./node_modules diff --git a/tools/pipeline/internal/pkg/golang/fixtures/modb/go.sum b/tools/pipeline/internal/pkg/golang/fixtures/modb/go.sum new file mode 100644 index 0000000000..b6e89f5251 --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/fixtures/modb/go.sum @@ -0,0 +1,44 @@ +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= +github.com/Jeffail/keyring v1.2.3 h1:WRmYdGPmHoJqX66KjGXQBALp6mUN00tD0ds5C4pqEsQ= +github.com/Jeffail/keyring v1.2.3/go.mod h1:xIg4RDmDwDuUFoU4IzDIT3b+HV24JUYlzo6ILZUH3Sc= +github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= +github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM= +github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= +github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As= +github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/pipeline/internal/pkg/golang/fixtures/modb/main.go b/tools/pipeline/internal/pkg/golang/fixtures/modb/main.go new file mode 100644 index 0000000000..2814088bfe --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/fixtures/modb/main.go @@ -0,0 +1,8 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package main + +import ( + _ "github.com/99designs/keyring" +) diff --git a/tools/pipeline/internal/pkg/golang/mod_diff.go b/tools/pipeline/internal/pkg/golang/mod_diff.go new file mode 100644 index 0000000000..1930a6500d --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/mod_diff.go @@ -0,0 +1,254 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package golang + +import ( + "errors" + "fmt" + "slices" + "strings" + + "github.com/pmezard/go-difflib/difflib" + "golang.org/x/mod/modfile" +) + +// DiffOpts are options for the module diff. +type DiffOpts struct { + ParseLax bool + + Module bool + Go bool + Toolchain bool + Godebug bool + Require bool + Exclude bool + Replace bool + Retract bool + Tool bool + Ignore bool + + StrictDiffRequire bool + StrictDiffExclude bool + StrictDiffReplace bool + StrictDiffRetract bool +} + +func DefaultDiffOpts() *DiffOpts { + return &DiffOpts{ + ParseLax: false, + Module: true, + Go: true, + Toolchain: true, + Godebug: true, + Require: true, + Exclude: true, + Replace: true, + Retract: true, + Tool: true, + Ignore: true, + StrictDiffRequire: true, + StrictDiffExclude: true, + StrictDiffReplace: true, + StrictDiffRetract: true, + } +} + +// ModDiff is the result of comparing two Go modules. +type ModDiff []*Diff + +// ModSource is a go.mod file +type ModSource struct { + Name string + Data []byte +} + +type Diff struct { + Directive Directive + Diff *difflib.UnifiedDiff +} + +type Directive string + +const ( + DirectiveModule Directive = "module" + DirectiveGo Directive = "go" + DirectiveToolchain Directive = "toolchain" + DirectiveGodebug Directive = "godebug" + DirectiveRequire Directive = "require" + DirectiveExclude Directive = "exclude" + DirectiveReplace Directive = "replace" + DirectiveRetract Directive = "retract" + DirectiveTool Directive = "tool" + DirectiveIgnore Directive = "ignore" +) + +func (d *Diff) Explanation() string { + if d == nil || d.Directive == "" { + return "" + } + + return d.Directive.Explanation() +} + +func (d *Diff) UnifiedText() string { + if d == nil || d.Diff == nil { + return "" + } + + txt, _ := difflib.GetUnifiedDiffString(*d.Diff) + + return txt +} + +func (d Directive) Explanation() string { + return fmt.Sprintf("The '%s' directives do not match", d) +} + +// DiffModFiles diffs two go.mod "files" and returns a ModDiff. +func DiffModFiles(as *ModSource, bs *ModSource, opts *DiffOpts) (ModDiff, error) { + if as == nil { + return nil, errors.New("missing a mod source") + } + + if bs == nil { + return nil, errors.New("missing b mod source") + } + + var af *modfile.File + var bf *modfile.File + var err error + if opts.ParseLax { + af, err = modfile.ParseLax(as.Name, as.Data, nil) + if err != nil { + return nil, fmt.Errorf("parsing %s contents: %w", as.Name, err) + } + + bf, err = modfile.ParseLax(bs.Name, bs.Data, nil) + if err != nil { + return nil, fmt.Errorf("parsing %s contents: %w", bs.Name, err) + } + } else { + af, err = modfile.Parse(as.Name, as.Data, nil) + if err != nil { + return nil, fmt.Errorf("parsing %s contents: %w", as.Name, err) + } + + bf, err = modfile.Parse(bs.Name, bs.Data, nil) + if err != nil { + return nil, fmt.Errorf("parsing %s contents: %w", bs.Name, err) + } + } + + return diffModFiles(af, bf, opts) +} + +func diffModFiles(a *modfile.File, b *modfile.File, opts *DiffOpts) (ModDiff, error) { + if a == nil { + return nil, errors.New("missing a mod file") + } + + if b == nil { + return nil, errors.New("missing b mod file") + } + + diff := ModDiff{} + if opts.Module { + diff = append(diff, diffModule(a, b)) + } + if opts.Go { + diff = append(diff, diffGo(a, b)) + } + if opts.Toolchain { + diff = append(diff, diffToolchain(a, b)) + } + if opts.Godebug { + diff = append(diff, diffGodebug(a, b)...) + } + if opts.Require || opts.StrictDiffRequire { + diff = append(diff, diffRequire(a, b, opts.StrictDiffRequire)...) + } + if opts.Exclude || opts.StrictDiffExclude { + diff = append(diff, diffExclude(a, b, opts.StrictDiffExclude)...) + } + if opts.Replace || opts.StrictDiffReplace { + diff = append(diff, diffReplace(a, b, opts.StrictDiffReplace)...) + } + if opts.Retract || opts.StrictDiffRetract { + diff = append(diff, diffRetract(a, b, opts.StrictDiffRetract)...) + } + if opts.Tool { + diff = append(diff, diffTool(a, b)...) + } + if opts.Ignore { + diff = append(diff, diffIgnore(a, b)...) + } + + if len(diff) == 0 { + return nil, nil + } + + diff = slices.DeleteFunc(diff, func(d *Diff) bool { return d == nil }) + slices.SortStableFunc(diff, func(a *Diff, b *Diff) int { + // a < b = 1 + // a == b = 0 + // a > b = -1 + if a == nil && b == nil { + return 0 + } + + if a == nil && b != nil { + return 1 + } + + if a != nil && b == nil { + return -1 + } + + if n := strings.Compare(string(a.Directive), string(b.Directive)); n != 0 { + return n + } + + if a.Diff == nil && b.Diff == nil { + return 0 + } + + if a.Diff == nil && b.Diff != nil { + return 1 + } + + if a.Diff != nil && b.Diff == nil { + return -1 + } + + atxt, aerr := difflib.GetUnifiedDiffString(*a.Diff) + btxt, berr := difflib.GetUnifiedDiffString(*b.Diff) + + if aerr == nil && berr != nil { + return 1 + } + + if aerr != nil && berr == nil { + return -1 + } + + return strings.Compare(atxt, btxt) + }) + + return diff, nil +} + +func newDiffFromModFiles(a, b *modfile.File, typ Directive) *Diff { + res := &Diff{ + Directive: typ, + Diff: &difflib.UnifiedDiff{Context: 1}, + } + if a != nil && a.Syntax != nil { + res.Diff.FromFile = a.Syntax.Name + } + if b != nil && b.Syntax != nil { + res.Diff.ToFile = b.Syntax.Name + } + + return res +} diff --git a/tools/pipeline/internal/pkg/golang/mod_diff_test.go b/tools/pipeline/internal/pkg/golang/mod_diff_test.go new file mode 100644 index 0000000000..66d78e28f9 --- /dev/null +++ b/tools/pipeline/internal/pkg/golang/mod_diff_test.go @@ -0,0 +1,517 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package golang + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_DiffModFiles_Equal(t *testing.T) { + t.Parallel() + + modA, err := os.ReadFile("./fixtures/moda/go.mod") + require.NoError(t, err) + + modB, err := os.ReadFile("./fixtures/modb/go.mod") + require.NoError(t, err) + + for desc, test := range map[string]struct { + as *ModSource + bs *ModSource + opts *DiffOpts + }{ + "moda not lax not strict": { + &ModSource{Name: "moda-1", Data: modA}, + &ModSource{Name: "moda-2", Data: modA}, + &DiffOpts{StrictDiffRequire: false, ParseLax: false}, + }, + "moda lax not strict": { + &ModSource{Name: "moda-1", Data: modA}, + &ModSource{Name: "moda-2", Data: modA}, + &DiffOpts{StrictDiffRequire: false, ParseLax: true}, + }, + "moda not lax strict": { + &ModSource{Name: "moda-1", Data: modA}, + &ModSource{Name: "moda-2", Data: modA}, + &DiffOpts{StrictDiffRequire: true, ParseLax: false}, + }, + "moda lax strict": { + &ModSource{Name: "moda-1", Data: modA}, + &ModSource{Name: "moda-2", Data: modA}, + &DiffOpts{StrictDiffRequire: true, ParseLax: true}, + }, + "modb not lax not strict": { + &ModSource{Name: "modb-1", Data: modB}, + &ModSource{Name: "modb-2", Data: modB}, + &DiffOpts{StrictDiffRequire: false, ParseLax: false}, + }, + "modb lax not strict": { + &ModSource{Name: "modb-1", Data: modB}, + &ModSource{Name: "modb-2", Data: modB}, + &DiffOpts{StrictDiffRequire: false, ParseLax: true}, + }, + "modb not lax strict": { + &ModSource{Name: "modb-1", Data: modB}, + &ModSource{Name: "modb-2", Data: modB}, + &DiffOpts{StrictDiffRequire: true, ParseLax: false}, + }, + "modb lax strict": { + &ModSource{Name: "modb-1", Data: modB}, + &ModSource{Name: "modb-2", Data: modB}, + &DiffOpts{StrictDiffRequire: true, ParseLax: true}, + }, + } { + t.Run(desc, func(t *testing.T) { + t.Parallel() + diff, err := DiffModFiles(test.as, test.bs, test.opts) + require.NoError(t, err) + require.Nil(t, diff, "expected no diff, got:\n%v", printModDiff(diff)) + }) + } +} + +func Test_DiffModFiles_Diff(t *testing.T) { + t.Parallel() + + modA, err := os.ReadFile("./fixtures/moda/go.mod") + require.NoError(t, err) + + modB, err := os.ReadFile("./fixtures/modb/go.mod") + require.NoError(t, err) + + as := &ModSource{Name: "moda", Data: modA} + bs := &ModSource{Name: "modb", Data: modB} + + for desc, test := range map[string]struct { + opts *DiffOpts + condition func(t *testing.T, got ModDiff) + }{ + "strict parse lax diff": { + &DiffOpts{ + ParseLax: false, + Module: true, + Go: true, + Toolchain: true, + Godebug: true, + Require: true, + Exclude: true, + Replace: true, + Retract: true, + Tool: true, + Ignore: true, + StrictDiffRequire: false, + StrictDiffExclude: false, + StrictDiffReplace: false, + StrictDiffRetract: false, + }, + func(t *testing.T, got ModDiff) { + hasDiffMatching(t, got, DirectiveModule, []string{ + "-module github.com/hashicorp/vault/pipeline/golang/moda", + "+module github.com/hashicorp/vault/pipeline/golang/modb", + }) + hasDiffMatching(t, got, DirectiveGo, []string{ + "-go 1.25", + "+go 1.25.2", + }) + hasDiffMatching(t, got, DirectiveToolchain, []string{ + "+toolchain go1.24", + }) + hasDiffMatching(t, got, DirectiveGodebug, []string{ + "-default=go1.21", + }) + hasDiffMatching(t, got, DirectiveGodebug, []string{ + "-httpcookiemaxnum=4000", + }) + hasDiffMatching(t, got, DirectiveGodebug, []string{ + "-panicnil=1", + }) + hasDiffMatching(t, got, DirectiveRequire, []string{ + "-require github.com/99designs/keyring v1.2.2", + "+require github.com/99designs/keyring v0.0.0-00010101000000-000000000000", + }) + noDiffMatching(t, got, DirectiveRequire, []string{ + "-github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c", + }) + noDiffMatching(t, got, DirectiveExclude, []string{ + "-exclude golang.org/x/term v0.2.0", + }) + noDiffMatching(t, got, DirectiveReplace, []string{ + "-replace github.com/99designs/keyring => github.com/Jeffail/keyring v1.2.3", + }) + noDiffMatching(t, got, DirectiveRetract, []string{ + "-[ v1.0.0 , v1.9.9 ]", + }) + noDiffMatching(t, got, DirectiveRetract, []string{ + "-v0.9.0", + }) + hasDiffMatching(t, got, DirectiveTool, []string{ + "-tool golang.org/x/tools/cmd/bisect", + }) + hasDiffMatching(t, got, DirectiveTool, []string{ + "-tool golang.org/x/tools/cmd/stringer", + }) + hasDiffMatching(t, got, DirectiveIgnore, []string{ + "-./third_party/javascript", + }) + hasDiffMatching(t, got, DirectiveIgnore, []string{ + "-content/html", + }) + hasDiffMatching(t, got, DirectiveIgnore, []string{ + "-static", + }) + hasDiffMatching(t, got, DirectiveIgnore, []string{ + "-ignore ./node_modules", + }) + }, + }, + "lax parse lax diff": { + &DiffOpts{ + ParseLax: true, + Module: true, + Go: true, + Toolchain: true, + Godebug: true, + Require: true, + Exclude: true, + Replace: true, + Retract: true, + Tool: true, + Ignore: true, + StrictDiffRequire: false, + StrictDiffExclude: false, + StrictDiffReplace: false, + StrictDiffRetract: false, + }, + func(t *testing.T, got ModDiff) { + hasDiffMatching(t, got, DirectiveModule, []string{ + "-module github.com/hashicorp/vault/pipeline/golang/moda", + "+module github.com/hashicorp/vault/pipeline/golang/modb", + }) + hasDiffMatching(t, got, DirectiveGo, []string{ + "-go 1.25", + "+go 1.25.2", + }) + noDiffMatching(t, got, DirectiveToolchain, []string{ + "+toolchain go1.24", + }) + noDiffMatching(t, got, DirectiveGodebug, []string{ + "-default=go1.21", + }) + noDiffMatching(t, got, DirectiveGodebug, []string{ + "-httpcookiemaxnum=4000", + }) + noDiffMatching(t, got, DirectiveGodebug, []string{ + "-panicnil=1", + }) + hasDiffMatching(t, got, DirectiveRequire, []string{ + "-require github.com/99designs/keyring v1.2.2", + "+require github.com/99designs/keyring v0.0.0-00010101000000-000000000000", + }) + noDiffMatching(t, got, DirectiveRequire, []string{ + "-github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c", + }) + noDiffMatching(t, got, DirectiveExclude, []string{ + "-exclude golang.org/x/term v0.2.0", + }) + noDiffMatching(t, got, DirectiveReplace, []string{ + "-replace github.com/99designs/keyring => github.com/Jeffail/keyring v1.2.3", + }) + noDiffMatching(t, got, DirectiveRetract, []string{ + "-[ v1.0.0 , v1.9.9 ]", + }) + noDiffMatching(t, got, DirectiveRetract, []string{ + "-v0.9.0", + }) + noDiffMatching(t, got, DirectiveTool, []string{ + "-tool golang.org/x/tools/cmd/bisect", + }) + noDiffMatching(t, got, DirectiveTool, []string{ + "-tool golang.org/x/tools/cmd/stringer", + }) + noDiffMatching(t, got, DirectiveTool, []string{ + "-tool golang.org/x/tools/cmd/bisect", + }) + noDiffMatching(t, got, DirectiveTool, []string{ + "-tool golang.org/x/tools/cmd/stringer", + }) + hasDiffMatching(t, got, DirectiveIgnore, []string{ + "-./third_party/javascript", + }) + hasDiffMatching(t, got, DirectiveIgnore, []string{ + "-content/html", + }) + hasDiffMatching(t, got, DirectiveIgnore, []string{ + "-static", + }) + hasDiffMatching(t, got, DirectiveIgnore, []string{ + "-ignore ./node_modules", + }) + }, + }, + "strict parse strict diff": { + &DiffOpts{ + ParseLax: false, + Module: true, + Go: true, + Toolchain: true, + Godebug: true, + Require: true, + Exclude: true, + Replace: true, + Retract: true, + Tool: true, + Ignore: true, + StrictDiffRequire: true, + StrictDiffExclude: true, + StrictDiffReplace: true, + StrictDiffRetract: true, + }, + func(t *testing.T, got ModDiff) { + hasDiffMatching(t, got, DirectiveModule, []string{ + "-module github.com/hashicorp/vault/pipeline/golang/moda", + "+module github.com/hashicorp/vault/pipeline/golang/modb", + }) + hasDiffMatching(t, got, DirectiveGo, []string{ + "-go 1.25", + "+go 1.25.2", + }) + hasDiffMatching(t, got, DirectiveToolchain, []string{ + "+toolchain go1.24", + }) + hasDiffMatching(t, got, DirectiveGodebug, []string{ + "-default=go1.21", + }) + hasDiffMatching(t, got, DirectiveGodebug, []string{ + "-httpcookiemaxnum=4000", + }) + hasDiffMatching(t, got, DirectiveGodebug, []string{ + "-panicnil=1", + }) + hasDiffMatching(t, got, DirectiveRequire, []string{ + "-require github.com/99designs/keyring v1.2.2", + "+require github.com/99designs/keyring v0.0.0-00010101000000-000000000000", + }) + hasDiffMatching(t, got, DirectiveRequire, []string{ + "-github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c", + }) + hasDiffMatching(t, got, DirectiveExclude, []string{ + "-exclude golang.org/x/term v0.2.0", + }) + hasDiffMatching(t, got, DirectiveReplace, []string{ + "-replace github.com/99designs/keyring => github.com/Jeffail/keyring v1.2.3", + }) + hasDiffMatching(t, got, DirectiveRetract, []string{ + "-[ v1.0.0 , v1.9.9 ]", + }) + hasDiffMatching(t, got, DirectiveRetract, []string{ + "-v0.9.0", + }) + hasDiffMatching(t, got, DirectiveTool, []string{ + "-tool golang.org/x/tools/cmd/bisect", + }) + hasDiffMatching(t, got, DirectiveTool, []string{ + "-tool golang.org/x/tools/cmd/stringer", + }) + hasDiffMatching(t, got, DirectiveIgnore, []string{ + "-./third_party/javascript", + }) + hasDiffMatching(t, got, DirectiveIgnore, []string{ + "-content/html", + }) + hasDiffMatching(t, got, DirectiveIgnore, []string{ + "-static", + }) + hasDiffMatching(t, got, DirectiveIgnore, []string{ + "-ignore ./node_modules", + }) + }, + }, + "lax parse strict diff": { + &DiffOpts{ + ParseLax: true, + Module: true, + Go: true, + Toolchain: true, + Godebug: true, + Require: true, + Exclude: true, + Replace: true, + Retract: true, + Tool: true, + Ignore: true, + StrictDiffRequire: true, + StrictDiffExclude: true, + StrictDiffReplace: true, + StrictDiffRetract: true, + }, + func(t *testing.T, got ModDiff) { + hasDiffMatching(t, got, DirectiveModule, []string{ + "-module github.com/hashicorp/vault/pipeline/golang/moda", + "+module github.com/hashicorp/vault/pipeline/golang/modb", + }) + hasDiffMatching(t, got, DirectiveGo, []string{ + "-go 1.25", + "+go 1.25.2", + }) + noDiffMatching(t, got, DirectiveToolchain, []string{ + "+toolchain go1.24", + }) + noDiffMatching(t, got, DirectiveGodebug, []string{ + "-default=go1.21", + }) + noDiffMatching(t, got, DirectiveGodebug, []string{ + "-httpcookiemaxnum=4000", + }) + noDiffMatching(t, got, DirectiveGodebug, []string{ + "-panicnil=1", + }) + hasDiffMatching(t, got, DirectiveRequire, []string{ + "-require github.com/99designs/keyring v1.2.2", + "+require github.com/99designs/keyring v0.0.0-00010101000000-000000000000", + }) + hasDiffMatching(t, got, DirectiveRequire, []string{ + "-github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c", + }) + noDiffMatching(t, got, DirectiveExclude, []string{ + "-exclude golang.org/x/term v0.2.0", + }) + noDiffMatching(t, got, DirectiveReplace, []string{ + "-replace github.com/99designs/keyring => github.com/Jeffail/keyring v1.2.3", + }) + hasDiffMatching(t, got, DirectiveRetract, []string{ + "-[ v1.0.0 , v1.9.9 ]", + }) + hasDiffMatching(t, got, DirectiveRetract, []string{ + "-v0.9.0", + }) + noDiffMatching(t, got, DirectiveTool, []string{ + "-tool golang.org/x/tools/cmd/bisect", + }) + noDiffMatching(t, got, DirectiveTool, []string{ + "-tool golang.org/x/tools/cmd/stringer", + }) + hasDiffMatching(t, got, DirectiveIgnore, []string{ + "-content/html", + }) + hasDiffMatching(t, got, DirectiveIgnore, []string{ + "-static", + }) + hasDiffMatching(t, got, DirectiveIgnore, []string{ + "-ignore ./node_modules", + }) + }, + }, + } { + t.Run(desc, func(t *testing.T) { + t.Parallel() + diff, err := DiffModFiles(as, bs, test.opts) + require.NoError(t, err) + require.NotNil(t, diff, "expected a module diff") + test.condition(t, diff) + }) + } +} + +func hasDiffMatching(t *testing.T, diff ModDiff, dir Directive, matches []string) { + t.Helper() + + require.NotNil(t, diff) + diffs := getDiffsForDirective(dir, diff) + require.True(t, len(diffs) > 0, "expected %s matching %v, got diff:\n%s", + dir, + matches, + printModDiff(diff), + ) + for _, df := range diffs { + if unifiedTextMatches(df, matches) { + return + } + } + t.Fatalf("expected %s diff matching %v, got diff:\n%s", + dir, + matches, + printModDiff(diff), + ) +} + +func noDiffMatching(t *testing.T, diff ModDiff, dir Directive, matches []string) { + t.Helper() + + require.NotNil(t, diff) + diffs := getDiffsForDirective(dir, diff) + if len(diffs) < 1 { + return + } + for _, df := range diffs { + if unifiedTextMatches(df, matches) { + t.Fatalf("expected no %s diff matching %v, got diff:\n%s", + dir, + matches, + printModDiff(diff), + ) + } + } +} + +func printModDiff(d ModDiff) string { + if d == nil { + return "" + } + + b := strings.Builder{} + for _, diff := range d { + if exp := diff.Explanation(); exp != "" { + b.WriteString(exp + "\n") + } + if dt := diff.UnifiedText(); dt != "" { + b.WriteString(dt + "\n") + } + } + + return b.String() +} + +func getDiffsForDirective(dir Directive, diff ModDiff) []*Diff { + if len(diff) < 1 { + return nil + } + + diffs := []*Diff{} + for _, d := range diff { + if d == nil { + continue + } + if d.Directive == dir { + diffs = append(diffs, d) + } + } + + return diffs +} + +func unifiedTextMatches(diff *Diff, matches []string) bool { + if diff == nil && len(matches) == 0 { + return true + } + + if diff == nil && len(matches) > 0 { + return false + } + + txt := diff.UnifiedText() + if txt == "" { + return false + } + for _, m := range matches { + if !strings.Contains(txt, m) { + return false + } + } + + return true +}