diff --git a/internal/parsers/cargo.go b/internal/parsers/cargo.go new file mode 100644 index 0000000..3590321 --- /dev/null +++ b/internal/parsers/cargo.go @@ -0,0 +1,86 @@ +package parsers + +import "strings" + +// Cargo handles `cargo add` and `cargo install`. +// cargo add serde → direct +// cargo add serde@1.0.150 → direct, pinned (@ form, cargo ≥ 1.62) +// cargo add serde --vers 1.0 → direct, pinned (--vers form) +// cargo add serde --features derive → direct +// cargo install ripgrep → direct (binary crate) +// cargo build / test / run / ... → passthrough +// +// `cargo install --path .` / `--git ` install from local source or a +// repo; the gate has nothing useful to say, so those positionals are skipped. + +var cargoFlagsTakingArg = map[string]bool{ + "--vers": true, + "--version": true, + "--features": true, + "-F": true, + "--path": true, + "--git": true, + "--branch": true, + "--tag": true, + "--rev": true, + "--registry": true, + "--index": true, + "--root": true, + "--profile": true, + "--target": true, + "--bin": true, + "--example": true, + "-j": true, + "--jobs": true, +} + +// Cargo returns a parser for `cargo` argv. +func Cargo() Parser { return cargoParser{} } + +type cargoParser struct{} + +func (cargoParser) Parse(args []string) ParseResult { + if len(args) == 0 { + return ParseResult{} + } + verb := args[0] + if verb != "add" && verb != "install" { + return ParseResult{} + } + rest := args[1:] + + // A --vers/--version flag (if present) applies to the crate(s) named in + // the same command. Capture it so `cargo add serde --vers 1.0` pins. + var flagVersion string + for i, a := range rest { + switch { + case (a == "--vers" || a == "--version") && i+1 < len(rest): + flagVersion = rest[i+1] + case strings.HasPrefix(a, "--vers="): + flagVersion = strings.TrimPrefix(a, "--vers=") + case strings.HasPrefix(a, "--version="): + flagVersion = strings.TrimPrefix(a, "--version=") + } + } + + positionals := splitPositionals(rest, cargoFlagsTakingArg) + pkgs := make([]PkgRef, 0, len(positionals)) + for _, spec := range positionals { + // Local path installs aren't registry crates. + if strings.Contains(spec, "/") || strings.HasPrefix(spec, ".") { + continue + } + name, version := splitNameVersion(spec) + if version == "" { + version = flagVersion + } + if name == "" { + continue + } + pkgs = append(pkgs, PkgRef{Ecosystem: "crates.io", Name: name, Version: version}) + } + if len(pkgs) == 0 { + return ParseResult{} + } + return ParseResult{IsInstall: true, Mode: ModeDirect, Packages: pkgs, Reason: "cargo " + verb} +} diff --git a/internal/parsers/cargo_test.go b/internal/parsers/cargo_test.go new file mode 100644 index 0000000..d000362 --- /dev/null +++ b/internal/parsers/cargo_test.go @@ -0,0 +1,75 @@ +package parsers + +import ( + "reflect" + "testing" +) + +func TestCargoParser(t *testing.T) { + cases := []struct { + name string + args []string + want ParseResult + }{ + { + name: "cargo add unpinned", + args: []string{"add", "serde"}, + want: ParseResult{IsInstall: true, Mode: ModeDirect, Packages: []PkgRef{{Ecosystem: "crates.io", Name: "serde"}}, Reason: "cargo add"}, + }, + { + name: "cargo add @ version", + args: []string{"add", "serde@1.0.150"}, + want: ParseResult{IsInstall: true, Mode: ModeDirect, Packages: []PkgRef{{Ecosystem: "crates.io", Name: "serde", Version: "1.0.150"}}, Reason: "cargo add"}, + }, + { + name: "cargo add --vers", + args: []string{"add", "serde", "--vers", "1.0.150"}, + want: ParseResult{IsInstall: true, Mode: ModeDirect, Packages: []PkgRef{{Ecosystem: "crates.io", Name: "serde", Version: "1.0.150"}}, Reason: "cargo add"}, + }, + { + name: "cargo add --version=", + args: []string{"add", "serde", "--version=1.0.150"}, + want: ParseResult{IsInstall: true, Mode: ModeDirect, Packages: []PkgRef{{Ecosystem: "crates.io", Name: "serde", Version: "1.0.150"}}, Reason: "cargo add"}, + }, + { + name: "cargo add with --features (flag value not treated as crate)", + args: []string{"add", "serde", "--features", "derive"}, + want: ParseResult{IsInstall: true, Mode: ModeDirect, Packages: []PkgRef{{Ecosystem: "crates.io", Name: "serde"}}, Reason: "cargo add"}, + }, + { + name: "cargo install binary crate", + args: []string{"install", "ripgrep@14.1.0"}, + want: ParseResult{IsInstall: true, Mode: ModeDirect, Packages: []PkgRef{{Ecosystem: "crates.io", Name: "ripgrep", Version: "14.1.0"}}, Reason: "cargo install"}, + }, + { + name: "cargo install --path skips local", + args: []string{"install", "--path", "."}, + want: ParseResult{}, + }, + { + name: "cargo build → passthrough", + args: []string{"build", "--release"}, + want: ParseResult{}, + }, + { + name: "cargo test → passthrough", + args: []string{"test"}, + want: ParseResult{}, + }, + { + name: "cargo bare → passthrough", + args: []string{}, + want: ParseResult{}, + }, + } + + p := Cargo() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := p.Parse(tc.args) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("parse(%v):\n got: %+v\n want: %+v", tc.args, got, tc.want) + } + }) + } +} diff --git a/internal/parsers/gem.go b/internal/parsers/gem.go new file mode 100644 index 0000000..c271674 --- /dev/null +++ b/internal/parsers/gem.go @@ -0,0 +1,74 @@ +package parsers + +import "strings" + +// Gem handles `gem install` (and its `gem i` alias). +// gem install rails → direct +// gem install rails -v 6.1.0 → direct, pinned (-v form) +// gem install rails --version 6.1.0 → direct, pinned +// gem install rails:6.1.0 → direct, pinned (colon form) +// gem update / list / build / ... → passthrough +// +// Local `.gem` file installs are skipped — the gate vets registry gems. + +var gemFlagsTakingArg = map[string]bool{ + "-v": true, + "--version": true, + "-i": true, + "--install-dir": true, + "-n": true, + "--bindir": true, + "--platform": true, + "-g": true, + "--file": true, + "-s": true, + "--source": true, +} + +// Gem returns a parser for `gem` argv. +func Gem() Parser { return gemParser{} } + +type gemParser struct{} + +func (gemParser) Parse(args []string) ParseResult { + if len(args) == 0 || (args[0] != "install" && args[0] != "i") { + return ParseResult{} + } + rest := args[1:] + + // -v/--version applies to the gem(s) named in the same command. RubyGems + // version specs can be operators (`-v '>= 6.0'`); we keep just the digits + // and let the server resolve. Trim common operator/space noise. + var flagVersion string + for i, a := range rest { + switch { + case (a == "-v" || a == "--version") && i+1 < len(rest): + flagVersion = strings.TrimLeft(rest[i+1], "=<>~! ") + case strings.HasPrefix(a, "--version="): + flagVersion = strings.TrimLeft(strings.TrimPrefix(a, "--version="), "=<>~! ") + } + } + + positionals := splitPositionals(rest, gemFlagsTakingArg) + pkgs := make([]PkgRef, 0, len(positionals)) + for _, spec := range positionals { + if strings.HasSuffix(spec, ".gem") || strings.Contains(spec, "/") { + continue + } + name := spec + version := flagVersion + // Colon form: `gem install rails:6.1.0`. + if i := strings.IndexByte(spec, ':'); i != -1 { + name = spec[:i] + version = spec[i+1:] + } + if name == "" { + continue + } + pkgs = append(pkgs, PkgRef{Ecosystem: "RubyGems", Name: name, Version: version}) + } + if len(pkgs) == 0 { + return ParseResult{} + } + return ParseResult{IsInstall: true, Mode: ModeDirect, Packages: pkgs, Reason: "gem install"} +} diff --git a/internal/parsers/gem_test.go b/internal/parsers/gem_test.go new file mode 100644 index 0000000..c43a9c7 --- /dev/null +++ b/internal/parsers/gem_test.go @@ -0,0 +1,70 @@ +package parsers + +import ( + "reflect" + "testing" +) + +func TestGemParser(t *testing.T) { + cases := []struct { + name string + args []string + want ParseResult + }{ + { + name: "gem install unpinned", + args: []string{"install", "rails"}, + want: ParseResult{IsInstall: true, Mode: ModeDirect, Packages: []PkgRef{{Ecosystem: "RubyGems", Name: "rails"}}, Reason: "gem install"}, + }, + { + name: "gem install -v", + args: []string{"install", "rails", "-v", "6.1.0"}, + want: ParseResult{IsInstall: true, Mode: ModeDirect, Packages: []PkgRef{{Ecosystem: "RubyGems", Name: "rails", Version: "6.1.0"}}, Reason: "gem install"}, + }, + { + name: "gem install --version=", + args: []string{"install", "rails", "--version=6.1.0"}, + want: ParseResult{IsInstall: true, Mode: ModeDirect, Packages: []PkgRef{{Ecosystem: "RubyGems", Name: "rails", Version: "6.1.0"}}, Reason: "gem install"}, + }, + { + name: "gem install colon form", + args: []string{"install", "rails:6.1.0"}, + want: ParseResult{IsInstall: true, Mode: ModeDirect, Packages: []PkgRef{{Ecosystem: "RubyGems", Name: "rails", Version: "6.1.0"}}, Reason: "gem install"}, + }, + { + name: "gem i alias", + args: []string{"i", "rack"}, + want: ParseResult{IsInstall: true, Mode: ModeDirect, Packages: []PkgRef{{Ecosystem: "RubyGems", Name: "rack"}}, Reason: "gem install"}, + }, + { + name: "gem install operator version trimmed", + args: []string{"install", "rails", "-v", ">=6.0"}, + want: ParseResult{IsInstall: true, Mode: ModeDirect, Packages: []PkgRef{{Ecosystem: "RubyGems", Name: "rails", Version: "6.0"}}, Reason: "gem install"}, + }, + { + name: "gem install local .gem skipped", + args: []string{"install", "./mygem-1.0.gem"}, + want: ParseResult{}, + }, + { + name: "gem update → passthrough", + args: []string{"update"}, + want: ParseResult{}, + }, + { + name: "gem list → passthrough", + args: []string{"list"}, + want: ParseResult{}, + }, + } + + p := Gem() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := p.Parse(tc.args) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("parse(%v):\n got: %+v\n want: %+v", tc.args, got, tc.want) + } + }) + } +} diff --git a/internal/parsers/gomod.go b/internal/parsers/gomod.go new file mode 100644 index 0000000..9475515 --- /dev/null +++ b/internal/parsers/gomod.go @@ -0,0 +1,57 @@ +package parsers + +import "strings" + +// Go handles `go get` and `go install` for module-version specs. +// go get github.com/foo/bar@v1.2.3 → direct, pinned +// go install github.com/foo/cmd@latest → direct (latest → unpinned) +// go get golang.org/x/text → direct, unpinned +// go get ./... / go build / test ... → passthrough +// +// Go's version syntax is always module@version. `@latest`, `@upgrade`, +// `@patch`, `@none` are pseudo-selectors — we treat them as unpinned so the +// gate resolves the concrete latest. + +// Go returns a parser for `go` argv. +func Go() Parser { return goParser{} } + +type goParser struct{} + +func (goParser) Parse(args []string) ParseResult { + if len(args) == 0 { + return ParseResult{} + } + verb := args[0] + if verb != "get" && verb != "install" { + return ParseResult{} + } + + positionals := splitPositionals(args[1:], nil) + pkgs := make([]PkgRef, 0, len(positionals)) + for _, spec := range positionals { + // Local paths and package-pattern meta-args aren't registry installs. + if spec == "all" || strings.HasPrefix(spec, ".") || strings.HasPrefix(spec, "/") || + strings.Contains(spec, "...") { + continue + } + name := spec + version := "" + // Module paths never contain '@'; the last '@' separates the version. + if at := strings.LastIndexByte(spec, '@'); at != -1 { + name = spec[:at] + version = spec[at+1:] + switch version { + case "latest", "upgrade", "patch", "none": + version = "" + } + } + if name == "" { + continue + } + pkgs = append(pkgs, PkgRef{Ecosystem: "Go", Name: name, Version: version}) + } + if len(pkgs) == 0 { + return ParseResult{} + } + return ParseResult{IsInstall: true, Mode: ModeDirect, Packages: pkgs, Reason: "go " + verb} +} diff --git a/internal/parsers/gomod_test.go b/internal/parsers/gomod_test.go new file mode 100644 index 0000000..50c2b8c --- /dev/null +++ b/internal/parsers/gomod_test.go @@ -0,0 +1,65 @@ +package parsers + +import ( + "reflect" + "testing" +) + +func TestGoParser(t *testing.T) { + cases := []struct { + name string + args []string + want ParseResult + }{ + { + name: "go get pinned", + args: []string{"get", "github.com/dgrijalva/jwt-go@v3.2.0"}, + want: ParseResult{IsInstall: true, Mode: ModeDirect, Packages: []PkgRef{{Ecosystem: "Go", Name: "github.com/dgrijalva/jwt-go", Version: "v3.2.0"}}, Reason: "go get"}, + }, + { + name: "go install @latest → unpinned", + args: []string{"install", "github.com/foo/cmd@latest"}, + want: ParseResult{IsInstall: true, Mode: ModeDirect, Packages: []PkgRef{{Ecosystem: "Go", Name: "github.com/foo/cmd"}}, Reason: "go install"}, + }, + { + name: "go get unpinned", + args: []string{"get", "golang.org/x/text"}, + want: ParseResult{IsInstall: true, Mode: ModeDirect, Packages: []PkgRef{{Ecosystem: "Go", Name: "golang.org/x/text"}}, Reason: "go get"}, + }, + { + name: "go get ./... → passthrough (no registry pkgs)", + args: []string{"get", "./..."}, + want: ParseResult{}, + }, + { + name: "go get all → passthrough", + args: []string{"get", "all"}, + want: ParseResult{}, + }, + { + name: "go build → passthrough", + args: []string{"build", "./..."}, + want: ParseResult{}, + }, + { + name: "go test → passthrough", + args: []string{"test", "./..."}, + want: ParseResult{}, + }, + { + name: "go bare → passthrough", + args: []string{}, + want: ParseResult{}, + }, + } + + p := Go() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := p.Parse(tc.args) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("parse(%v):\n got: %+v\n want: %+v", tc.args, got, tc.want) + } + }) + } +} diff --git a/internal/parsers/registry.go b/internal/parsers/registry.go index 62cb90e..a85237b 100644 --- a/internal/parsers/registry.go +++ b/internal/parsers/registry.go @@ -14,6 +14,12 @@ func ForName(name string) Parser { return Bun() case "pip", "pip3": return Pip() + case "cargo": + return Cargo() + case "gem": + return Gem() + case "go": + return Go() } return nil } diff --git a/internal/shim/install.go b/internal/shim/install.go index c2051fe..7a0c692 100644 --- a/internal/shim/install.go +++ b/internal/shim/install.go @@ -11,7 +11,7 @@ import ( // Default managers to install shims for. install/uninstall iterate over this. // Order is stable for deterministic output. -var DefaultShims = []string{"npm", "pnpm", "yarn", "bun", "pip", "pip3"} +var DefaultShims = []string{"npm", "pnpm", "yarn", "bun", "pip", "pip3", "cargo", "gem", "go"} // InstallResult is the structured summary `refuse install` reports back. type InstallResult struct {