From b48b5220738def6ba75a425292aae9f4b84c6995 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Wed, 30 Sep 2026 23:53:41 +0530 Subject: [PATCH 1/2] feat(detector): inventory Rust packages and audit Cargo configuration Add cargo_inventory and cargo_config_audit (schema_version 1) to enterprise telemetry in a new cargo_scan phase after go_scan. The inventory reads Cargo.toml declarations with workspace inheritance, Cargo.lock formats 3 and 4, the registry cache, Git checkouts, vendored sources and local registries, and cargo install receipts. The audit reads .cargo/config(.toml) files, their includes and an allowlisted process environment per invocation context, with findings cargo-001..cargo-004. Everything is static: no cargo, rustc or git command, shell or network call. Reads go through guarded, bounded executor file methods, URL credentials are redacted on the device, credentials files are checked for presence only, and any refusal, failure or limit marks the affected source partial. Bumps go-toml/v2 to v2.4.3. --- README.md | 3 +- SCAN_COVERAGE.md | 24 + go.mod | 2 +- go.sum | 4 +- internal/detector/cargometadata.go | 729 +++++++ internal/detector/cargoscan.go | 1803 +++++++++++++++++ internal/detector/cargoscan_test.go | 207 ++ internal/detector/configaudit/cargoconfig.go | 967 +++++++++ .../detector/configaudit/cargoconfig_test.go | 81 + internal/model/model.go | 357 ++++ .../testdata/cargo_inventory_v1_golden.json | 1344 ++++++++++++ internal/telemetry/telemetry.go | 19 +- 12 files changed, 5534 insertions(+), 6 deletions(-) create mode 100644 internal/detector/cargometadata.go create mode 100644 internal/detector/cargoscan.go create mode 100644 internal/detector/cargoscan_test.go create mode 100644 internal/detector/configaudit/cargoconfig.go create mode 100644 internal/detector/configaudit/cargoconfig_test.go create mode 100644 internal/model/testdata/cargo_inventory_v1_golden.json diff --git a/README.md b/README.md index c38221b..7873481 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,7 @@ See [SCAN_COVERAGE.md](SCAN_COVERAGE.md) for the full catalog of supported detec | Python Packages | pip, poetry, pipenv, uv, conda, rye (opt-in) | | System Packages | rpm, dpkg, pacman, apk, snap, flatpak (Linux) | | Go Modules | `go.mod`/`go.work` declarations, vendored modules, module cache, `GOBIN` tools, recorded checksums (enterprise) | +| Rust Packages | `Cargo.toml` declarations, `Cargo.lock`, registry cache, Git checkouts, vendored sources, `cargo install` receipts (enterprise) | | Package Configs | npm (`.npmrc`), pnpm, bun (`bunfig.toml`), yarn classic & berry (`.yarnrc`/`.yarnrc.yml`), pip (`pip.conf`) — effective registry, cooldown policy, and auth surface across every scope | | Suspicious Files | Malicious-file IOCs from StepSecurity-maintained rules — e.g. `binding.gyp` that runs during `npm install`, and editor/AI-tool config files that auto-execute on project open | @@ -310,7 +311,7 @@ Compromised packages most often reach a machine because that machine resolves di - **Cooldown policy** — whether a cooldown window against newly published packages is in effect. - **Authentication surface** — what credentials are configured against the registry. -Configuration is read from `.npmrc` (npm), pnpm config, `bunfig.toml` (bun), `.yarnrc` / `.yarnrc.yml` (yarn classic and berry), `pip.conf` (pip), and Go's `go/env` and process environment (proxy, checksum database, private-module and auth settings, redacted on the device). In enterprise mode this rolls up into the **Package Configs** view in the dashboard, where you can spot machines that are unprotected or pointed at the wrong registry. +Configuration is read from `.npmrc` (npm), pnpm config, `bunfig.toml` (bun), `.yarnrc` / `.yarnrc.yml` (yarn classic and berry), `pip.conf` (pip), Go's `go/env` and process environment (proxy, checksum database, private-module and auth settings, redacted on the device), and Cargo's `.cargo/config.toml` files and process environment (registries, source replacement, proxy and TLS settings, redacted on the device). In enterprise mode this rolls up into the **Package Configs** view in the dashboard, where you can spot machines that are unprotected or pointed at the wrong registry. Enterprise Device Policy can also set StepSecurity Secure Registry as the sole user-level Python index for pip and uv. It manages only the resolved developer's user configuration and shared StepSecurity `.netrc` entry, keeps pip and uv results independent, and restores owned settings on an explicit policy clear. Project files, virtual environments, system configuration, environment variables, direct URLs, and Poetry are not modified. diff --git a/SCAN_COVERAGE.md b/SCAN_COVERAGE.md index 2356d1c..49323c5 100644 --- a/SCAN_COVERAGE.md +++ b/SCAN_COVERAGE.md @@ -280,6 +280,30 @@ Enterprise telemetry reports Go module evidence for the logged-in developer as t **Privacy: URL credentials, query strings, and fragments are removed from proxy and checksum-database URLs on the device, `GOAUTH` arguments and non-module `GOFLAGS` are dropped, and non-allowlisted environment values, `.netrc` content, and build settings are never collected.** +## Rust Packages + +Enterprise telemetry reports Rust package evidence managed by Cargo for the logged-in developer as two sections, `cargo_inventory` and `cargo_config_audit`. Everything is read statically: **no `cargo`, `rustc` or `git` command, shell, or network call is ever run**, and a root or service account is never scanned in the developer's place. Each evidence kind is reported separately; none of them means a package was compiled or executed. + +| Evidence | Source | +|----------|--------| +| Declared requirements | `Cargo.toml` files found under the search directories: normal, dev, build and target-specific dependencies with aliases, requirement text, features, optional and explicit `default-features`, and registry, Git or path selectors. Workspace inheritance is applied; unused `workspace.dependencies` entries are not reported. | +| Workspaces | `[workspace]` members (literal paths and single-level globs), `exclude`, in-root path dependencies and explicit `package.workspace`. Membership that cannot be established is reported as `workspace_unresolved`. | +| Locked packages | `Cargo.lock` formats 3 and 4, once per workspace root, including a custom `resolver.lockfile-path`. Other formats are `unsupported_format`. Unused patches are not reported. | +| Cached packages | Every `registry/cache//*.crate` and `registry/src//-/` in the default `~/.cargo` and an effective `CARGO_HOME`, merged into one record per registry ID, name and version. Archives are never unpacked; only their `Cargo.toml` member is read when no extracted copy establishes the identity. | +| Git checkouts | Package manifests in `git/checkouts///`, with the full commit from the checkout's `.git/HEAD` when recorded. | +| Vendored packages | Packages in directories holding `.cargo-checksum.json`, found by the walk or configured as `source..directory`, and `.crate` archives in a configured `local-registry`. | +| Installed tools | `.crates.toml` receipts (enriched by `.crates2.json`) in each Cargo home, `install.root` and `CARGO_INSTALL_ROOT`, with each recorded bin checked by presence alone. | +| Recorded checksums | Lockfile and `.cargo-checksum.json` package SHA-256 values, marked `not_verified`. | +| Cargo configuration | `.cargo/config` and `.cargo/config.toml` of each project and its ancestors inside the search directories, the Cargo home config, included files, and the verified process environment: an allowlist of registry, source-replacement, network, install-root and lockfile-path settings, per invocation context, with findings `cargo-001`…`cargo-004`. Credentials files are checked for presence only. | + +**Scope.** The home and configured search directories are walked; `.git`, `.hg`, `.svn`, `node_modules`, `target`, `.rustup` and Cargo's own `registry` and `git` directories are not walked as projects, and directory symlinks are not followed. Paths a manifest or config names exactly (workspace members, path dependencies, includes, lockfile paths, Cargo homes, install roots, configured source directories) are read as targeted files, one level deep for source directories. `.cargo` configs above the search directories are not probed, and a workspace root above them is not found. TCC-protected directories and skipped network volumes stay excluded even when `include_tcc_protected` is set. The process environment counts only when the agent runs as the developer. + +**Origins.** Registry cache directory names are opaque, so cached packages carry `cache_unknown` origin scoped to their Cargo home and cache ID. Git checkouts carry `git_unknown`, vendored and local-registry packages `vendor_unknown`, and a lockfile entry without a source that matches no single known manifest `local_unknown`, scoped to its lockfile. + +**Bounded.** Reads, directory listings, walk depth, archive headers and decompressed bytes, config includes, record count and output size are capped. Any cap, refusal, or failure makes the affected source and section `partial` with a reason code, never silently incomplete. + +**Privacy: URL credentials, query strings, and fragments are removed on the device, registry tokens are reported only as configured, custom credential providers are shown as `custom` with their arguments dropped, and non-allowlisted settings, credentials-file contents, and binary contents are never collected.** + ## System Package Scanning (Linux) System package scanning is **automatic on Linux** — no opt-in flag required. Multiple package managers can coexist. diff --git a/go.mod b/go.mod index 071bf13..ee9b5b3 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require golang.org/x/sys v0.33.0 require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 - github.com/pelletier/go-toml/v2 v2.3.1 + github.com/pelletier/go-toml/v2 v2.4.3 github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd github.com/tidwall/gjson v1.18.0 github.com/tidwall/pretty v1.2.1 diff --git a/go.sum b/go.sum index fb7aa1b..50767c4 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA= github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= diff --git a/internal/detector/cargometadata.go b/internal/detector/cargometadata.go new file mode 100644 index 0000000..1b1dccc --- /dev/null +++ b/internal/detector/cargometadata.go @@ -0,0 +1,729 @@ +package detector + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "io" + "net/url" + "path" + "path/filepath" + "slices" + "strings" + "unicode" + + toml "github.com/pelletier/go-toml/v2" + "golang.org/x/mod/semver" + + "github.com/step-security/dev-machine-guard/internal/detector/configaudit" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// Cargo metadata limits. +// ponytail: unmeasured starting values; tune after lab measurements. +var ( + maxCargoMetadataBytes int64 = 2 << 20 // Cargo.toml, Cargo.lock, receipts, archive manifest member + maxCargoChecksumBytes int64 = 8 << 20 // .cargo-checksum.json + maxCargoArchiveBytes int64 = 32 << 20 + maxCargoUnpackedBytes int64 = 64 << 20 + maxCargoArchiveHeaders = 10_000 + maxCargoTextLen = 256 // package names, versions, requirements, selectors + maxCargoDisplayLen = 4096 // receipt text +) + +// cargoCratesIOIndex is the canonical crates.io index URL, which is also what +// Cargo records in lockfiles for crates.io packages. +const cargoCratesIOIndex = "https://github.com/rust-lang/crates.io-index" + +// cargoDepTables are the dependency tables of one manifest level: the top +// level or one target. table. Both hyphen and underscore spellings are +// accepted by Cargo. +type cargoDepTables struct { + Dependencies map[string]any `toml:"dependencies"` + DevDependencies map[string]any `toml:"dev-dependencies"` + DevDependencies2 map[string]any `toml:"dev_dependencies"` + BuildDependencies map[string]any `toml:"build-dependencies"` + BuildDependencies2 map[string]any `toml:"build_dependencies"` +} + +type cargoTOMLManifest struct { + Package *struct { + Name string `toml:"name"` + Version any `toml:"version"` // string or {workspace = true} + Workspace string `toml:"workspace"` + } `toml:"package"` + Workspace *struct { + Members []string `toml:"members"` + Exclude []string `toml:"exclude"` + Package struct { + Version string `toml:"version"` + } `toml:"package"` + Dependencies map[string]any `toml:"dependencies"` + } `toml:"workspace"` + Dependencies map[string]any `toml:"dependencies"` + DevDependencies map[string]any `toml:"dev-dependencies"` + DevDependencies2 map[string]any `toml:"dev_dependencies"` + BuildDependencies map[string]any `toml:"build-dependencies"` + BuildDependencies2 map[string]any `toml:"build_dependencies"` + Target map[string]cargoDepTables `toml:"target"` + Patch map[string]map[string]any `toml:"patch"` + Replace map[string]any `toml:"replace"` +} + +// cargoManifest is the subset of one Cargo.toml the inventory uses. +type cargoManifest struct { + name, version string // version is empty when missing or inherited + versionInherited bool // version.workspace = true + workspaceRef string // package.workspace, as written + workspace *cargoWorkspaceTable + deps []cargoDep + overrides []cargoDep // [patch.*] and [replace] entries; never declarations + invalid bool // an entry was dropped as malformed +} + +func (m *cargoManifest) isPackage() bool { return m.name != "" } + +type cargoWorkspaceTable struct { + members, exclude []string + packageVersion string + deps map[string]cargoDep // workspace.dependencies, used only when inherited +} + +// cargoDep is one dependency entry before its origin is resolved. +type cargoDep struct { + decl model.CargoDeclaration + packageName string // the package key, or the declared name + requested string + sel cargoSelectors + inherit bool // workspace = true +} + +// cargoSelectors are the source keys of a dependency entry, as written. +type cargoSelectors struct { + registry, registryIndex, git, branch, tag, rev, path string +} + +// parseCargoManifest decodes the fields the inventory reads. A decode failure +// is returned; a malformed dependency entry is dropped and flagged. +func parseCargoManifest(data []byte) (*cargoManifest, error) { + var t cargoTOMLManifest + if err := toml.Unmarshal(data, &t); err != nil { + return nil, err + } + m := &cargoManifest{} + if p := t.Package; p != nil { + name, ok := cargoText(p.Name) + if !ok || name == "" { + return nil, errors.New("invalid package name") + } + m.name, m.workspaceRef = name, p.Workspace + switch v := p.Version.(type) { + case nil: + case string: + if m.version, ok = cargoText(v); !ok { + return nil, errors.New("invalid package version") + } + case map[string]any: + m.versionInherited = v["workspace"] == true + default: + return nil, errors.New("invalid package version") + } + } + if w := t.Workspace; w != nil { + m.workspace = &cargoWorkspaceTable{ + members: w.Members, exclude: w.Exclude, packageVersion: w.Package.Version, deps: map[string]cargoDep{}, + } + for name, raw := range w.Dependencies { + if d, ok := cargoDependency(name, raw, model.CargoDependencyNormal, ""); ok { + m.workspace.deps[name] = d + } else { + m.invalid = true + } + } + } + top := cargoDepTables{t.Dependencies, t.DevDependencies, t.DevDependencies2, t.BuildDependencies, t.BuildDependencies2} + m.addDeps(top, "") + for target, tables := range t.Target { + m.addDeps(tables, target) + } + for _, entries := range t.Patch { + m.addOverrides(entries) + } + m.addOverrides(t.Replace) + return m, nil +} + +func (m *cargoManifest) addDeps(t cargoDepTables, target string) { + for _, table := range []struct { + kind string + entries map[string]any + }{ + {model.CargoDependencyNormal, t.Dependencies}, + {model.CargoDependencyDev, t.DevDependencies}, + {model.CargoDependencyDev, t.DevDependencies2}, + {model.CargoDependencyBuild, t.BuildDependencies}, + {model.CargoDependencyBuild, t.BuildDependencies2}, + } { + for name, raw := range table.entries { + if d, ok := cargoDependency(name, raw, table.kind, target); ok { + m.deps = append(m.deps, d) + } else { + m.invalid = true + } + } + } +} + +// addOverrides keeps [patch] and [replace] entries, which name override +// locations rather than dependencies. A [replace] key is a package ID spec, +// "name:version" or "name@version"; only its name is kept. +func (m *cargoManifest) addOverrides(entries map[string]any) { + for key, raw := range entries { + name, _, _ := strings.Cut(key, "@") + name, _, _ = strings.Cut(name, ":") + if d, ok := cargoDependency(name, raw, model.CargoDependencyNormal, ""); ok { + m.overrides = append(m.overrides, d) + } else { + m.invalid = true + } + } +} + +// cargoDependency reads one dependency entry in string or table form. Omitted +// booleans stay unset so they are never reported as false. +func cargoDependency(declared string, raw any, kind, target string) (cargoDep, bool) { + declared, ok := cargoText(declared) + if !ok || declared == "" || len(target) > maxCargoDisplayLen { + return cargoDep{}, false + } + d := cargoDep{ + decl: model.CargoDeclaration{ + DeclaredName: declared, DependencyKind: kind, Target: target, Features: []string{}, + }, + packageName: declared, + } + switch v := raw.(type) { + case string: + d.requested, ok = cargoText(v) + return d, ok + case map[string]any: + t := cargoTable(v) + if pkg := t.str("package"); pkg != "" { + d.packageName = pkg + } + d.requested = t.str("version") + if optional := t.flag("optional"); optional != nil { + d.decl.Optional = *optional + } + d.decl.DefaultFeatures = t.flag("default-features") + if d.decl.DefaultFeatures == nil { + d.decl.DefaultFeatures = t.flag("default_features") + } + d.decl.Features = t.strs("features") + d.inherit = v["workspace"] == true + d.sel = cargoSelectors{ + registry: t.str("registry"), registryIndex: t.str("registry-index"), + git: t.str("git"), branch: t.str("branch"), tag: t.str("tag"), rev: t.str("rev"), path: t.str("path"), + } + return d, !t.bad + } + return cargoDep{}, false +} + +// cargoTableReader reads typed values from a decoded TOML table; a present +// value of the wrong type or length sets bad. +type cargoTableReader struct { + m map[string]any + bad bool +} + +func cargoTable(m map[string]any) *cargoTableReader { return &cargoTableReader{m: m} } + +func (t *cargoTableReader) str(key string) string { + raw, ok := t.m[key] + if !ok { + return "" + } + s, isStr := raw.(string) + v, fits := cargoText(s) + if !isStr || !fits { + t.bad = true + return "" + } + return v +} + +func (t *cargoTableReader) flag(key string) *bool { + raw, ok := t.m[key] + if !ok { + return nil + } + b, isBool := raw.(bool) + if !isBool { + t.bad = true + return nil + } + return &b +} + +func (t *cargoTableReader) strs(key string) []string { + out := []string{} + raw, ok := t.m[key] + if !ok { + return out + } + list, isList := raw.([]any) + if !isList { + t.bad = true + return out + } + for _, item := range list { + s, isStr := item.(string) + v, fits := cargoText(s) + if !isStr || !fits { + t.bad = true + continue + } + out = append(out, v) + } + slices.Sort(out) + return slices.Compact(out) +} + +// cargoText accepts a bounded string without control characters. +func cargoText(s string) (string, bool) { + if len(s) > maxCargoTextLen || strings.ContainsFunc(s, unicode.IsControl) { + return "", false + } + return s, true +} + +// cargoInherit applies a workspace.dependencies entry to a member's +// `workspace = true` entry. The source and requirement come from the +// workspace; features add together; optional stays the member's own. +func cargoInherit(member, ws cargoDep) cargoDep { + out := member + out.packageName, out.requested, out.sel = ws.packageName, ws.requested, ws.sel + out.decl.WorkspaceInherited = true + if ws.decl.DefaultFeatures != nil { + out.decl.DefaultFeatures = ws.decl.DefaultFeatures + } + features := append(slices.Clone(ws.decl.Features), member.decl.Features...) + slices.Sort(features) + out.decl.Features = slices.Compact(features) + return out +} + +// cargoLockEntry is one [[package]] of a lockfile. Exact duplicates are +// collapsed; checksums lists every distinct valid value recorded for it. +type cargoLockEntry struct { + name, version, source string + checksums []string + reasons []string // checksum_invalid, checksum_mismatch +} + +// parseCargoLock reads lockfile formats 3 and 4. Any other or missing version +// is unsupported_format; unused patches are not selected packages and are not +// read. +func parseCargoLock(data []byte) ([]cargoLockEntry, string) { + var t struct { + Version *int64 `toml:"version"` + Package []struct { + Name string `toml:"name"` + Version string `toml:"version"` + Source string `toml:"source"` + Checksum string `toml:"checksum"` + } `toml:"package"` + } + if err := toml.Unmarshal(data, &t); err != nil { + return nil, model.CargoReasonParseError + } + if t.Version == nil || (*t.Version != 3 && *t.Version != 4) { + return nil, model.CargoReasonUnsupportedFormat + } + type key struct{ name, version, source string } + index := map[key]int{} + var out []cargoLockEntry + malformed := false + for _, p := range t.Package { + name, okName := cargoText(p.Name) + version, okVersion := cargoText(p.Version) + if !okName || !okVersion || name == "" || version == "" || len(p.Source) > maxCargoDisplayLen { + malformed = true + continue + } + k := key{name, version, p.Source} + i, seen := index[k] + if !seen { + i = len(out) + index[k] = i + out = append(out, cargoLockEntry{name: name, version: version, source: p.Source}) + } + e := &out[i] + switch sum, ok := cargoSHA256(p.Checksum); { + case p.Checksum == "": + case !ok: + e.addReason(model.CargoReasonChecksumInvalid) + case !slices.Contains(e.checksums, sum): + e.checksums = append(e.checksums, sum) + if len(e.checksums) > 1 { + e.addReason(model.CargoReasonChecksumMismatch) + } + } + } + if malformed { + return out, model.CargoReasonParseError + } + return out, "" +} + +func (e *cargoLockEntry) addReason(r string) { + if !slices.Contains(e.reasons, r) { + e.reasons = append(e.reasons, r) + } +} + +// cargoSHA256 normalizes a recorded SHA-256 hex value. +func cargoSHA256(s string) (string, bool) { + s = strings.ToLower(s) + return s, cargoIsHex(s, 64) +} + +func cargoIsHex(s string, n int) bool { + if len(s) != n { + return false + } + for i := 0; i < len(s); i++ { + if c := s[i]; (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +// cargoOrigin reads a lockfile or package-ID source string. Git selectors and +// the full revision are taken before the URL is sanitized; ok is false for an +// unrecognized form. +func cargoOrigin(source string) (model.CargoOrigin, bool) { + kind, rest, _ := strings.Cut(source, "+") + switch kind { + case "registry": + u, _ := configaudit.SanitizeCargoURL(rest) + return model.CargoOrigin{Kind: model.CargoOriginRegistry, URL: u}, rest != "" + case "sparse": + u, _ := configaudit.SanitizeCargoURL(rest) + return model.CargoOrigin{Kind: model.CargoOriginRegistry, URL: "sparse+" + u}, rest != "" + case "git": + o := model.CargoOrigin{Kind: model.CargoOriginGit} + base, fragment, _ := strings.Cut(rest, "#") + if sha := strings.ToLower(fragment); cargoIsHex(sha, 40) || cargoIsHex(sha, 64) { + o.ResolvedRevision = sha + } + base, query, _ := strings.Cut(base, "?") + if q, err := url.ParseQuery(query); err == nil { + o.Branch, _ = cargoText(q.Get("branch")) + o.Tag, _ = cargoText(q.Get("tag")) + o.Rev, _ = cargoText(q.Get("rev")) + } + o.URL, _ = configaudit.SanitizeCargoURL(base) + return o, base != "" + case "path": + p, ok := cargoFileURLPath(rest) + return model.CargoOrigin{Kind: model.CargoOriginLocal, Path: p, IsLocal: true}, ok + } + return model.CargoOrigin{}, false +} + +// cargoFileURLPath turns a file:// URL into a native absolute path. +func cargoFileURLPath(raw string) (string, bool) { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "file" || u.Path == "" { + return "", false + } + p := u.Path + if len(p) >= 3 && p[0] == '/' && p[2] == ':' { // /C:/x on Windows + p = p[1:] + } + return filepath.Clean(filepath.FromSlash(p)), true +} + +// parseCargoPackageID splits "name version (source)", the key format of both +// install receipts. +func parseCargoPackageID(id string) (name, version, source string, ok bool) { + name, rest, ok1 := strings.Cut(id, " ") + version, rest, ok2 := strings.Cut(rest, " ") + source, ok3 := strings.CutPrefix(rest, "(") + source, ok4 := strings.CutSuffix(source, ")") + name, okName := cargoText(name) + version, okVersion := cargoText(version) + ok = ok1 && ok2 && ok3 && ok4 && okName && okVersion && name != "" && version != "" && len(source) <= maxCargoDisplayLen + return name, version, source, ok +} + +// parseCratesToml reads .crates.toml v1: package ID to recorded bin names. +func parseCratesToml(data []byte) (map[string][]string, error) { + var t struct { + V1 map[string][]string `toml:"v1"` + } + if err := toml.Unmarshal(data, &t); err != nil { + return nil, err + } + if t.V1 == nil { + t.V1 = map[string][]string{} + } + return t.V1, nil +} + +// cargoInstallInfo is one .crates2.json install entry. +type cargoInstallInfo struct { + VersionReq *string `json:"version_req"` + Features []string `json:"features"` + AllFeatures bool `json:"all_features"` + NoDefaultFeatures bool `json:"no_default_features"` + Profile string `json:"profile"` + Target *string `json:"target"` + Rustc *string `json:"rustc"` +} + +func parseCrates2JSON(data []byte) (map[string]cargoInstallInfo, error) { + var t struct { + Installs map[string]cargoInstallInfo `json:"installs"` + } + if err := json.Unmarshal(data, &t); err != nil { + return nil, err + } + return t.Installs, nil +} + +// apply copies the receipt details onto an installation. Only the first line +// of the recorded rustc text is kept. +func (info cargoInstallInfo) apply(inst *model.CargoInstallation) { + if info.VersionReq != nil { + inst.VersionReq, _ = cargoText(*info.VersionReq) + } + for _, f := range info.Features { + if v, ok := cargoText(f); ok { + inst.Features = append(inst.Features, v) + } + } + slices.Sort(inst.Features) + inst.Features = slices.Compact(inst.Features) + inst.AllFeatures, inst.NoDefaultFeatures = &info.AllFeatures, &info.NoDefaultFeatures + inst.Profile, _ = cargoText(info.Profile) + if info.Target != nil { + inst.Target, _ = cargoText(*info.Target) + } + if info.Rustc != nil { + line, _, _ := strings.Cut(*info.Rustc, "\n") + if line = strings.TrimSpace(line); len(line) <= maxCargoDisplayLen && !strings.ContainsFunc(line, unicode.IsControl) { + inst.Rustc = line + } + } +} + +// cargoBinName accepts a recorded bin name only as a plain file name. +func cargoBinName(name string) bool { + return name != "" && name != "." && name != ".." && len(name) <= 255 && + !strings.ContainsAny(name, `/\:`) && !strings.ContainsFunc(name, unicode.IsControl) +} + +// splitCargoFilename splits "name-version" at the first hyphen that leaves a +// valid package name and a full three-part semantic version, so hyphenated +// names and prerelease versions both split correctly. +func splitCargoFilename(base string) (name, version string, ok bool) { + for i := 0; i < len(base); i++ { + if base[i] != '-' { + continue + } + name, version = base[:i], base[i+1:] + if cargoASCIIName(name) && cargoSemver(version) { + return name, version, true + } + } + return "", "", false +} + +func cargoASCIIName(s string) bool { + if s == "" || len(s) > maxCargoTextLen { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '-' && c != '_' { + return false + } + } + return true +} + +func cargoSemver(v string) bool { + core, _, _ := strings.Cut(v, "+") + core, _, _ = strings.Cut(core, "-") + return len(v) <= maxCargoTextLen && strings.Count(core, ".") == 2 && semver.IsValid("v"+v) +} + +var errCargoArchiveLimit = errors.New("archive limit") + +// cargoArchiveManifest confirms a .crate archive's identity from its +// name-version/Cargo.toml member, reading headers only until that member. It +// returns "" when the member names name and version, else a reason code. +func cargoArchiveManifest(ctx context.Context, data []byte, name, version string) string { + gz, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return model.CargoReasonParseError + } + defer func() { _ = gz.Close() }() + tr := tar.NewReader(&cargoLimitReader{r: gz, left: maxCargoUnpackedBytes}) + prefix := name + "-" + version + for range maxCargoArchiveHeaders { + if ctx.Err() != nil { + return model.CargoReasonDeadlineExceeded + } + hdr, err := tr.Next() + switch { + case errors.Is(err, io.EOF): + return model.CargoReasonParseError // no manifest member + case errors.Is(err, errCargoArchiveLimit): + return model.CargoReasonSizeLimit + case err != nil: + return model.CargoReasonParseError + } + member := hdr.Name + if path.IsAbs(member) || strings.Contains(member, `\`) || slices.Contains(strings.Split(member, "/"), "..") { + return model.CargoReasonParseError + } + dir, file := path.Split(path.Clean(member)) + if file != "Cargo.toml" || strings.Contains(strings.TrimSuffix(dir, "/"), "/") { + continue + } + if dir != prefix+"/" || hdr.Typeflag != tar.TypeReg { + return model.CargoReasonParseError + } + if hdr.Size > maxCargoMetadataBytes { + return model.CargoReasonSizeLimit + } + body, err := io.ReadAll(io.LimitReader(tr, maxCargoMetadataBytes)) + if errors.Is(err, errCargoArchiveLimit) { + return model.CargoReasonSizeLimit + } else if err != nil { + return model.CargoReasonParseError + } + m, err := parseCargoManifest(body) + if err != nil || m.name != name || m.version != version { + return model.CargoReasonParseError + } + return "" + } + return model.CargoReasonEntryLimit +} + +// cargoLimitReader fails once more than left bytes have been decompressed. +type cargoLimitReader struct { + r io.Reader + left int64 +} + +func (l *cargoLimitReader) Read(p []byte) (int, error) { + if l.left <= 0 { + return 0, errCargoArchiveLimit + } + if int64(len(p)) > l.left { + p = p[:l.left] + } + n, err := l.r.Read(p) + l.left -= int64(n) + return n, err +} + +// parseCargoOK accepts every .cargo-ok marker generation: empty, "ok", and +// the current {"v":1} JSON. A marker records completed extraction, not +// integrity. +func parseCargoOK(data []byte) bool { + s := strings.TrimSpace(string(data)) + if s == "" || s == "ok" { + return true + } + var v struct { + V int `json:"v"` + } + return json.Unmarshal(data, &v) == nil && v.V == 1 +} + +// parseCargoChecksumJSON returns the package checksum recorded in a +// .cargo-checksum.json. The per-file map and any other field are ignored. +func parseCargoChecksumJSON(data []byte) (sum string, invalid bool, err error) { + var v struct { + Package *string `json:"package"` + } + if err := json.Unmarshal(data, &v); err != nil { + return "", false, err + } + if v.Package == nil || *v.Package == "" { + return "", false, nil + } + sum, ok := cargoSHA256(*v.Package) + if !ok { + return "", true, nil + } + return sum, false, nil +} + +// cargoMemberMatch matches a workspace members pattern against a slash path +// relative to the workspace root, one path component at a time. ok is false +// for a pattern this matcher cannot evaluate, such as a recursive "**". +func cargoMemberMatch(pattern, rel string) (match, ok bool) { + pattern = path.Clean(strings.ReplaceAll(pattern, `\`, "/")) + pat, got := strings.Split(pattern, "/"), strings.Split(rel, "/") + if slices.Contains(pat, "**") || strings.HasPrefix(pattern, "../") || pattern == ".." { + return false, false + } + if len(pat) != len(got) { + return false, true + } + for i := range pat { + m, err := path.Match(pat[i], got[i]) + if err != nil { + return false, false + } + if !m { + return false, true + } + } + return true, true +} + +// cargoIsGlob reports whether a members entry needs matching rather than +// naming one directory. +func cargoIsGlob(pattern string) bool { return strings.ContainsAny(pattern, "*?[") } + +// parseGitHead reads a .git/HEAD: a detached full commit, or a symbolic ref. +func parseGitHead(data []byte) (sha, ref string) { + s := strings.TrimSpace(string(data)) + if r, ok := strings.CutPrefix(s, "ref: "); ok { + return "", strings.TrimSpace(r) + } + if s = strings.ToLower(s); cargoIsHex(s, 40) || cargoIsHex(s, 64) { + return s, "" + } + return "", "" +} + +// parsePackedRef finds ref in a packed-refs file. +func parsePackedRef(data []byte, ref string) string { + for line := range strings.SplitSeq(string(data), "\n") { + sha, name, ok := strings.Cut(strings.TrimSpace(line), " ") + if sha = strings.ToLower(sha); ok && name == ref && (cargoIsHex(sha, 40) || cargoIsHex(sha, 64)) { + return sha + } + } + return "" +} + +// parseGitDirFile reads a .git file's "gitdir: " indirection. +func parseGitDirFile(data []byte) (string, bool) { + s, ok := strings.CutPrefix(strings.TrimSpace(string(data)), "gitdir: ") + return strings.TrimSpace(s), ok && s != "" +} diff --git a/internal/detector/cargoscan.go b/internal/detector/cargoscan.go new file mode 100644 index 0000000..236a6e6 --- /dev/null +++ b/internal/detector/cargoscan.go @@ -0,0 +1,1803 @@ +package detector + +import ( + "context" + "errors" + "io/fs" + "maps" + "os/user" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/detector/configaudit" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +// Cargo collection limits. +// ponytail: unmeasured on fleet data; tune after VM lab measurements. +var ( + maxCargoWalkEntries = 100_000 // per discovery or cache root + maxCargoWalkDepth = 128 + maxCargoRecords = 50_000 // sources + projects + workspaces + packages + maxCargoOutputBytes = 16 << 20 // both Cargo sections, serialized + maxCargoMarkerBytes int64 = 4096 // .cargo-ok and Git HEAD/ref files +) + +// CargoScanner statically collects Rust package inventory and the Cargo +// config audit for one developer. It never runs a command, sources a shell or +// touches the network: every path goes through a guarded, bounded executor +// file method. +type CargoScanner struct { + exec executor.Executor + log *progress.Logger + // protection builds the path guards; a seam so tests can place protected + // directories and network volumes under a temp home. + protection func(home string, includeNetworkVolumes *bool) (protected func(string) string, volume func(string) bool) +} + +// NewCargoScanner must be given the raw executor, never a UserAwareExecutor, +// whose Getenv sources a login shell. +func NewCargoScanner(exec executor.Executor, log *progress.Logger) *CargoScanner { + return &CargoScanner{exec: exec, log: log, protection: configaudit.GoProtection} +} + +// cargoScan is the state of one Scan call. +type cargoScan struct { + ctx context.Context + exec executor.Executor + goos string + identity string + home string + roots []string // approved walk scope: home plus every absolute search root + guard func(string) string + volume func(string) bool + projFS executor.Executor + verified bool + homes []string // default Cargo home, then a distinct effective one + cargoHome string // effective Cargo home; empty when unresolved + snap configaudit.CargoConfigSnapshot + reasons []string // global, not tied to a source + sources []*model.CargoSource + byID map[string]*model.CargoSource + budget int // record rows left; see charge + + states []*cargoManifestState + byManifest map[string]*cargoManifestState // manifest path key + vendorDirs []cargoFound // directory sources detected by the walk + projects []model.CargoProject + workspaces []model.CargoWorkspace + packages []model.CargoPackage +} + +// cargoFound is a path found under a discovery source. +type cargoFound struct{ path, parentID string } + +// cargoManifestState is one Cargo.toml read for the inventory. Its source is +// registered only once the manifest is known not to belong to a vendored or +// configured source directory. +type cargoManifestState struct { + src *model.CargoSource + path, dir string + m *cargoManifest // nil when unreadable or malformed + root *cargoManifestState + unresolved bool // workspace membership could not be established + members []*cargoManifestState // on a workspace root, itself included when it is a package + missing []model.CargoWorkspaceMember + override bool // named only by a config paths or patch entry, outside any observed context +} + +// Scan returns both Cargo sections, always non-nil. An unresolvable or +// service identity is declined with user_unresolved and no filesystem +// access: a root account's home is never a silent substitute for the +// developer's. +func (s *CargoScanner) Scan(ctx context.Context, target *user.User, searchDirs []string, includeNetworkVolumes *bool) (*model.CargoInventory, *model.CargoConfigAudit) { + start := time.Now() + detector := configaudit.NewCargoConfigDetector(s.exec) + if target == nil || target.Username == "" || target.HomeDir == "" || isGoServiceIdentity(s.exec.GOOS(), target) { + inv := newCargoInventory() + inv.Status, inv.Reasons = model.CargoStatusPartial, []string{model.CargoReasonUserUnresolved} + audit, _ := detector.Detect(ctx, configaudit.CargoConfigScope{}) + s.log.Debug("cargo scan: declined, no developer identity") + return &inv, &audit + } + + home := filepath.Clean(target.HomeDir) + guard, volume := s.protection(home, includeNetworkVolumes) + g := &cargoScan{ + ctx: ctx, exec: s.exec, goos: s.exec.GOOS(), identity: target.Username, home: home, guard: guard, volume: volume, + roots: []string{home}, byID: map[string]*model.CargoSource{}, budget: maxCargoRecords, + byManifest: map[string]*cargoManifestState{}, + } + searchDirs = slices.Clone(searchDirs) + for i, d := range searchDirs { + if d != "" { + searchDirs[i] = canonicalNoStat(d, home) + } + if filepath.IsAbs(searchDirs[i]) { + g.roots = append(g.roots, searchDirs[i]) + } + } + g.projFS = s.exec.GuardedFiles(g.roots, guard, maxCargoMetadataBytes) + current, err := s.exec.CurrentUser() + g.verified = err == nil && target.Uid != "" && current.Uid == target.Uid + g.resolveHomes() + + walk := g.searchRoots(searchDirs) + for _, r := range walk { + g.walkRoot(r) + } + g.readQueued() + g.associate() + for g.enqueueInherited() { + g.readQueued() + g.associate() + } + + audit, snap := detector.Detect(ctx, configaudit.CargoConfigScope{ + Username: g.identity, Home: home, Roots: g.roots, Protected: guard, Volume: volume, + ProcessVerified: g.verified, CargoHome: g.cargoHome, Contexts: g.contexts(), RegistryNames: g.registryNames(), + }) + g.snap = snap + for _, p := range snap.LocalPaths() { + if s := g.enqueue(filepath.Join(p, "Cargo.toml"), ""); s != nil { + s.override = true + } + } + g.readQueued() + + g.registerManifests() + g.emitProjects() + g.emitLockfiles() + g.scanDirectorySources() + g.scanLocalRegistries() + for _, h := range g.homes { + g.scanRegistryCache(h) + g.scanGitCheckouts(h) + } + g.scanInstallRoots() + inv := g.finish() + + if size := goJSONSize(inv) + goJSONSize(&audit); size > maxCargoOutputBytes { + envelope := newCargoInventory() + envelope.Status, envelope.Reasons = model.CargoStatusPartial, []string{model.CargoReasonOutputSizeLimit} + inv = &envelope + } + s.log.Debug("cargo scan: inventory=%s (%d sources, %d projects, %d workspaces, %d packages) audit=%s (%d files, %d findings) in %dms", + inv.Status, len(inv.Sources), len(inv.Projects), len(inv.Workspaces), len(inv.Packages), + audit.Status, len(audit.Files), len(audit.Findings), time.Since(start).Milliseconds()) + return inv, &audit +} + +func newCargoInventory() model.CargoInventory { + return model.CargoInventory{ + SchemaVersion: model.CargoInventorySchemaVersion, Status: model.CargoStatusComplete, Reasons: []string{}, + Sources: []model.CargoSource{}, Projects: []model.CargoProject{}, Workspaces: []model.CargoWorkspace{}, + Packages: []model.CargoPackage{}, + } +} + +// resolveHomes picks the default ~/.cargo and, from a verified process +// environment, a distinct absolute CARGO_HOME. A relative CARGO_HOME depends +// on an unknown working directory, so the effective home is unresolved. +func (g *cargoScan) resolveHomes() { + def := filepath.Join(g.home, ".cargo") + g.homes, g.cargoHome = []string{def}, def + if !g.verified { + return + } + switch v := g.exec.Getenv("CARGO_HOME"); { + case v == "": + case !filepath.IsAbs(v): + g.cargoHome = "" + g.globalReason(model.CargoReasonPathUnresolved) + default: + g.cargoHome = filepath.Clean(v) + if g.key(g.cargoHome) != g.key(def) { + g.homes = append(g.homes, g.cargoHome) + } + } +} + +func (g *cargoScan) key(path string) string { return configaudit.GoPathKey(g.goos, path) } + +func (g *cargoScan) globalReason(reason string) { + if !slices.Contains(g.reasons, reason) { + g.reasons = append(g.reasons, reason) + } +} + +// newSource builds a source without registering or charging it. +func (g *cargoScan) newSource(kind, path, parent string) *model.CargoSource { + return &model.CargoSource{ + SourceID: configaudit.GoSourceID(g.identity, kind, path), Kind: kind, Path: path, + Status: model.CargoStatusComplete, Presence: model.CargoPresencePresent, Reasons: []string{}, + ParentSourceID: parent, DiscoveredSources: []string{}, + } +} + +// register adds src once, charging one row. It returns the registered source +// for that ID, or nil once the record budget is spent. A parent that was +// never registered is dropped rather than left dangling. +func (g *cargoScan) register(src *model.CargoSource) *model.CargoSource { + if existing := g.byID[src.SourceID]; existing != nil { + return existing + } + if g.byID[src.ParentSourceID] == nil { + src.ParentSourceID = "" + } + if !g.charge(g.byID[src.ParentSourceID], 1) { + return nil + } + g.sources = append(g.sources, src) + g.byID[src.SourceID] = src + return src +} + +func (g *cargoScan) addSource(kind, path, parent string) *model.CargoSource { + return g.register(g.newSource(kind, path, parent)) +} + +// charge spends n rows on a record owned by src. The first record that does +// not fit spends the rest, so nothing after it is collected, and marks src and +// every enclosing source partial; a miss with no owner is a global reason. +// Collection keeps charging each remaining owner once, without reading, so no +// owner whose records were dropped is left complete. +func (g *cargoScan) charge(src *model.CargoSource, n int) bool { + if n <= g.budget { + g.budget -= n + return true + } + g.budget = 0 + if src == nil { + g.globalReason(model.CargoReasonRecordLimit) + } + for ; src != nil; src = g.byID[src.ParentSourceID] { + cargoDegrade(src, model.CargoReasonRecordLimit) + } + return false +} + +func cargoDegrade(src *model.CargoSource, reason string) { + src.Status = model.CargoStatusPartial + if reason != "" && !slices.Contains(src.Reasons, reason) { + src.Reasons = append(src.Reasons, reason) + } +} + +// expired marks src when the phase deadline has passed. A blocked syscall is +// not interrupted; the check runs between entries, reads and parses. +func (g *cargoScan) expired(src *model.CargoSource) bool { + if g.ctx.Err() == nil { + return false + } + cargoDegrade(src, model.CargoReasonDeadlineExceeded) + return true +} + +func (g *cargoScan) reason(err error, path string) string { + return configaudit.CargoReadReason(err, path, g.volume) +} + +// readFile stats then reads path within limit. It reports a missing file +// separately from every other failure, which carries a reason. +func (g *cargoScan) readFile(files executor.Executor, path string, limit int64) (data []byte, missing bool, reason string) { + info, err := files.Stat(path) + switch { + case errors.Is(err, fs.ErrNotExist): + return nil, true, "" + case err != nil: + return nil, false, g.reason(err, path) + case !info.Mode().IsRegular(): + return nil, false, model.CargoReasonUnsupportedEntry + case info.Size() > limit: + return nil, false, model.CargoReasonSizeLimit + } + data, err = files.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, false, model.CargoReasonChangedDuringScan + } else if err != nil { + return nil, false, g.reason(err, path) + } + return data, false, "" +} + +// read reads the file a source stands for. A missing targeted file is a +// complete absence; a discovered file that vanished changed during the scan. +func (g *cargoScan) read(src *model.CargoSource, files executor.Executor, path string, limit int64, targeted bool) ([]byte, bool) { + data, missing, reason := g.readFile(files, path, limit) + switch { + case missing && targeted: + src.Presence = model.CargoPresenceAbsent + return nil, false + case missing: + src.Presence = model.CargoPresenceUnknown + cargoDegrade(src, model.CargoReasonChangedDuringScan) + return nil, false + case reason != "": + if reason != model.CargoReasonSizeLimit && reason != model.CargoReasonUnsupportedEntry { + src.Presence = model.CargoPresenceUnknown + } + cargoDegrade(src, reason) + return nil, false + } + return data, true +} + +// fileFS reads a metadata file inside the approved roots, or as one exact +// targeted file outside them; the protection guard applies either way. +func (g *cargoScan) fileFS(path string) executor.Executor { + if configaudit.GoWithinRoots(g.goos, path, g.roots) { + return g.projFS + } + return g.exec.GuardedFiles([]string{path}, g.guard, maxCargoMetadataBytes) +} + +// rootFS resolves a directory source and returns a reader rooted at its +// physical path, so a redirected root can be neither left nor swapped. +func (g *cargoScan) rootFS(src *model.CargoSource, limit int64) (executor.Executor, string, bool) { + if g.expired(src) { + src.Presence = model.CargoPresenceUnknown + return nil, "", false + } + if g.guard(src.Path) != "" { + src.Presence = model.CargoPresenceUnknown + cargoDegrade(src, g.guardReason(src.Path)) + return nil, "", false + } + phys, err := g.exec.GuardedFiles([]string{src.Path}, g.guard, 0).EvalSymlinks(src.Path) + if errors.Is(err, fs.ErrNotExist) { + src.Presence = model.CargoPresenceAbsent + return nil, "", false + } else if err != nil { + src.Presence = model.CargoPresenceUnknown + cargoDegrade(src, g.reason(err, src.Path)) + return nil, "", false + } + return g.exec.GuardedFiles([]string{phys}, g.guard, limit), phys, true +} + +// guardReason is the reason a guard refusal of path is reported with. +func (g *cargoScan) guardReason(path string) string { + if g.volume(path) { + return model.CargoReasonRefusedNetworkVolume + } + return model.CargoReasonSkippedProtected +} + +// cargoRoot is a search root that resolved and will be walked. +type cargoRoot struct { + src *model.CargoSource + path, phys string +} + +// searchRoots registers each approved search root once. The outermost +// walkable root owns what it contains, and the first configured spelling of +// a physical directory is kept. +func (g *cargoScan) searchRoots(searchDirs []string) []cargoRoot { + type candidate struct { + path, phys, presence, reason string + } + var cands []candidate + seen := map[string]bool{} + for _, dir := range searchDirs { + if !filepath.IsAbs(dir) { + g.globalReason(model.CargoReasonPathUnresolved) + continue + } + if seen[g.key(dir)] { + continue + } + seen[g.key(dir)] = true + c := candidate{path: dir, presence: model.CargoPresencePresent} + if g.guard(dir) != "" { + c.presence, c.reason = model.CargoPresenceUnknown, g.guardReason(dir) + } else if g.ctx.Err() != nil { + c.presence, c.reason = model.CargoPresenceUnknown, model.CargoReasonDeadlineExceeded + } else if phys, err := g.exec.GuardedFiles([]string{dir}, g.guard, 0).EvalSymlinks(dir); errors.Is(err, fs.ErrNotExist) { + c.presence = model.CargoPresenceAbsent + } else if err != nil { + c.presence, c.reason = model.CargoPresenceUnknown, g.reason(err, dir) + } else { + c.phys = phys + } + cands = append(cands, c) + } + var walk []cargoRoot + for i, c := range cands { + folded := slices.ContainsFunc(cands[:i], func(o candidate) bool { + return c.phys != "" && o.phys != "" && g.key(o.phys) == g.key(c.phys) + }) || slices.ContainsFunc(cands, func(o candidate) bool { + return c.phys != "" && o.phys != "" && goPathWithin(g.key(c.phys), g.key(o.phys)) + }) + if folded { + continue + } + src := g.addSource(model.CargoSourceProjectSearchRoot, c.path, "") + if src == nil { + break + } + src.Presence = c.presence + if c.reason != "" { + cargoDegrade(src, c.reason) + } + if c.phys != "" { + walk = append(walk, cargoRoot{src: src, path: c.path, phys: c.phys}) + } + } + return walk +} + +var cargoSkippedDirNames = map[string]bool{ + ".git": true, ".hg": true, ".svn": true, "node_modules": true, "target": true, ".rustup": true, +} + +// walkRoot is a bounded breadth-first listing of one root for Cargo.toml. +// Directory symlinks are never followed; Cargo's own caches, build output and +// toolchains are skipped. A directory holding both Cargo.toml and +// .cargo-checksum.json is a vendored package: its parent is recorded as a +// directory source and it is not descended into. +func (g *cargoScan) walkRoot(r cargoRoot) { + skip := map[string]bool{} + for _, h := range g.homes { + skip[g.key(filepath.Join(h, "registry"))] = true + skip[g.key(filepath.Join(h, "git"))] = true + } + if g.verified { + if v := g.exec.Getenv("RUSTUP_HOME"); filepath.IsAbs(v) { + skip[g.key(v)] = true + } + } + type dir struct { + path string + depth int + } + queue := []dir{{r.path, 0}} + budget := maxCargoWalkEntries + for len(queue) > 0 { + if g.expired(r.src) { + return + } + d := queue[0] + queue = queue[1:] + entries, more, err := g.projFS.ReadDirLimit(d.path, budget) + if err != nil { + if d.depth == 0 { + r.src.Presence = model.CargoPresenceUnknown + } + cargoDegrade(r.src, g.reason(err, d.path)) + continue + } + budget -= len(entries) + hasFile := func(name string) bool { + return slices.ContainsFunc(entries, func(e fs.DirEntry) bool { return !e.IsDir() && e.Name() == name }) + } + if d.depth > 0 && hasFile("Cargo.toml") && hasFile(".cargo-checksum.json") { + g.vendorDirs = append(g.vendorDirs, cargoFound{filepath.Dir(d.path), r.src.SourceID}) + continue + } + for _, e := range entries { + p := filepath.Join(d.path, e.Name()) + if !e.IsDir() { + if e.Name() == "Cargo.toml" { + g.enqueue(p, r.src.SourceID) + } + continue + } + switch { + case cargoSkippedDirNames[e.Name()], skip[g.key(p)]: + continue + case g.guard(p) != "": + // Protected directories are deliberate scope; an excluded mount + // depends on configuration, so it must not read as deletion. + if g.volume(p) { + cargoDegrade(r.src, model.CargoReasonRefusedNetworkVolume) + } + continue + case d.depth+1 > maxCargoWalkDepth: + cargoDegrade(r.src, model.CargoReasonDepthLimit) + continue + } + queue = append(queue, dir{p, d.depth + 1}) + } + if more { + cargoDegrade(r.src, model.CargoReasonEntryLimit) + return + } + } +} + +// enqueue adds a manifest to read once and returns it, or nil when it is +// already queued. parent is the discovery source or the manifest that +// references it. The queue never outgrows the record budget. +func (g *cargoScan) enqueue(path, parent string) *cargoManifestState { + path = filepath.Clean(path) + if g.byManifest[g.key(path)] != nil { + return nil + } + if len(g.states) >= maxCargoRecords { + g.globalReason(model.CargoReasonRecordLimit) + return nil + } + s := &cargoManifestState{src: g.newSource(model.CargoSourceManifest, path, parent), path: path, dir: filepath.Dir(path)} + g.states = append(g.states, s) + g.byManifest[g.key(path)] = s + return s +} + +// readQueued reads every manifest not yet read. A walked manifest may +// reference others: a workspace root its literal members, a package its +// explicit workspace root, and path dependencies and overrides their +// directories. Those are read as exact targeted files. +func (g *cargoScan) readQueued() { + for i := 0; i < len(g.states); i++ { + s := g.states[i] + if s.m != nil || s.src.Status != model.CargoStatusComplete || s.src.Presence != model.CargoPresencePresent { + continue + } + if g.expired(s.src) { + s.src.Presence = model.CargoPresenceUnknown + continue + } + targeted := s.src.ParentSourceID == "" || g.byID[s.src.ParentSourceID] == nil + data, ok := g.read(s.src, g.fileFS(s.path), s.path, maxCargoMetadataBytes, targeted) + if !ok { + continue + } + m, err := parseCargoManifest(data) + if err != nil { + cargoDegrade(s.src, model.CargoReasonParseError) + continue + } + if m.invalid { + cargoDegrade(s.src, model.CargoReasonParseError) + } + s.m = m + ref := func(dir string) { + if dir == "" { + return + } + if !filepath.IsAbs(dir) { + dir = filepath.Join(s.dir, filepath.FromSlash(dir)) + } + g.enqueue(filepath.Join(dir, "Cargo.toml"), s.src.SourceID) + } + if m.workspace != nil { + for _, member := range m.workspace.members { + if !cargoIsGlob(member) { + ref(member) + } + } + } + ref(m.workspaceRef) + for _, d := range slices.Concat(m.deps, m.overrides) { + ref(cargoPathDepDir(s, nil, d)) // inherited entries wait for association + } + } +} + +// enqueueInherited queues the directory of each path dependency a member +// inherits from its workspace root, reporting whether any is new. An entry of +// workspace.dependencies that no member inherits is never followed. +func (g *cargoScan) enqueueInherited() bool { + queued := false + for _, s := range g.states { + if s.m == nil || s.root == nil { + continue + } + for _, d := range s.m.deps { + if dir := cargoPathDepDir(s, s.root, d); d.inherit && dir != "" && g.enqueue(filepath.Join(dir, "Cargo.toml"), s.src.SourceID) != nil { + queued = true + } + } + } + return queued +} + +// cargoPathDepDir is the directory a path dependency of s names, or empty for +// any other dependency. An inherited entry takes its path from root's +// workspace.dependencies, relative to the root; it is empty when root is nil. +func cargoPathDepDir(s, root *cargoManifestState, d cargoDep) string { + base := s.dir + if d.inherit { + if root == nil || root.m == nil || root.m.workspace == nil { + return "" + } + d, base = root.m.workspace.deps[d.decl.DeclaredName], root.dir + } + if d.sel.path == "" { + return "" + } + dir := filepath.FromSlash(d.sel.path) + if !filepath.IsAbs(dir) { + dir = filepath.Join(base, dir) + } + return filepath.Clean(dir) +} + +// associate links packages to workspace roots the way Cargo finds one: an +// explicit package.workspace, else the nearest ancestor [workspace] that does +// not exclude the package. The root must then admit it as a member; anything +// Cargo would reject, or this collector cannot evaluate, is left unresolved. +// It is rerun after more manifests are read, so it starts from scratch. +func (g *cargoScan) associate() { + for _, s := range g.states { + s.root, s.unresolved, s.members, s.missing = nil, false, nil, nil + if s.m != nil && s.m.workspace != nil { + s.root = s + } + } + for _, s := range g.states { + if s.m == nil || s.root == s || !s.m.isPackage() { + continue + } + var root *cargoManifestState + if ref := s.m.workspaceRef; ref != "" { + dir := filepath.FromSlash(ref) + if !filepath.IsAbs(dir) { + dir = filepath.Join(s.dir, dir) + } + root = g.byManifest[g.key(filepath.Join(dir, "Cargo.toml"))] + if root == nil || root.m == nil || root.m.workspace == nil { + s.unresolved = true + continue + } + } else { + for dir := filepath.Dir(s.dir); ; dir = filepath.Dir(dir) { + if a := g.byManifest[g.key(filepath.Join(dir, "Cargo.toml"))]; a != nil { + if a.m == nil && a.src.Presence != model.CargoPresenceAbsent { + s.unresolved = true // an unreadable ancestor may be the root + break + } + if a.m != nil && a.m.workspace != nil && !g.excludes(a, s) { + root = a + break + } + } + if filepath.Dir(dir) == dir { + break + } + } + } + if root != nil { + s.root = root + } + } + for _, root := range g.states { + if root.root == root { + g.resolveMembers(root) + } + } + for _, s := range g.states { + if s.root != nil && s.root != s && !slices.Contains(s.root.members, s) { + s.root, s.unresolved = nil, true + } + } +} + +// excludes applies Cargo's exclusion: a path under an exclude entry that is +// not also under a literal members entry. +func (g *cargoScan) excludes(root, s *cargoManifestState) bool { + under := func(entries []string) bool { + return slices.ContainsFunc(entries, func(e string) bool { + return cargoInside(g.goos, s.dir, filepath.Join(root.dir, filepath.FromSlash(e))) + }) + } + return under(root.m.workspace.exclude) && !under(root.m.workspace.members) +} + +// resolveMembers finds the packages a workspace root admits: itself when it +// is a package, members entries, then in-root path dependencies of members, +// repeatedly. A members entry this collector cannot evaluate, or a literal one +// that could not be read, is reported as partial. +func (g *cargoScan) resolveMembers(root *cargoManifestState) { + ws := root.m.workspace + admit := func(s *cargoManifestState) bool { + if s.m == nil || !s.m.isPackage() || slices.Contains(root.members, s) || (s != root && g.excludes(root, s)) { + return false + } + if s.root != nil && s.root != root { + return false // an explicit or nearer root owns it + } + root.members = append(root.members, s) + s.root = root + return true + } + if root.m.isPackage() { + admit(root) + } + for _, pattern := range ws.members { + if !cargoIsGlob(pattern) { + path := filepath.Join(root.dir, filepath.FromSlash(pattern), "Cargo.toml") + switch s := g.byManifest[g.key(path)]; { + case s != nil && s.m != nil && s.m.isPackage(): + admit(s) + case s != nil && s.src.Presence != model.CargoPresenceAbsent && len(s.src.Reasons) > 0: + root.missing = append(root.missing, model.CargoWorkspaceMember{ManifestPath: path, Status: model.CargoStatusPartial, Reason: s.src.Reasons[0]}) + default: + root.missing = append(root.missing, model.CargoWorkspaceMember{ManifestPath: path, Status: model.CargoStatusPartial, Reason: model.CargoReasonWorkspaceUnresolved}) + } + continue + } + evaluable := true + for _, s := range g.states { + if s == root || !cargoInside(g.goos, s.dir, root.dir) { + continue + } + rel, err := filepath.Rel(root.dir, s.dir) + if err != nil { + continue + } + match, ok := cargoMemberMatch(pattern, filepath.ToSlash(rel)) + evaluable = evaluable && ok + if match { + admit(s) + } + } + if !evaluable { + cargoDegrade(root.src, model.CargoReasonWorkspaceUnresolved) + } + } + for changed := true; changed; { + changed = false + for _, m := range slices.Clone(root.members) { + for _, d := range m.m.deps { + dir := cargoPathDepDir(m, root, d) + if dir == "" { + continue + } + if s := g.byManifest[g.key(filepath.Join(dir, "Cargo.toml"))]; s != nil && cargoInside(g.goos, dir, root.dir) && admit(s) { + changed = true + } + } + } + } +} + +// contexts are the invocation directories the config audit describes: every +// package and workspace root read. +func (g *cargoScan) contexts() []configaudit.CargoContextDir { + var out []configaudit.CargoContextDir + for _, s := range g.states { + if s.m == nil || (!s.m.isPackage() && s.m.workspace == nil) { + continue + } + c := configaudit.CargoContextDir{Project: s.dir} + if s.root != nil { + c.Workspace = s.root.dir + } + out = append(out, c) + } + return out +} + +func (g *cargoScan) registryNames() []string { + var out []string + for _, s := range g.states { + if s.m == nil { + continue + } + for _, d := range s.m.deps { + if d.sel.registry != "" { + out = append(out, d.sel.registry) + } + } + if s.m.workspace != nil { + for _, d := range s.m.workspace.deps { + if d.sel.registry != "" { + out = append(out, d.sel.registry) + } + } + } + } + slices.Sort(out) + return slices.Compact(out) +} + +// cargoInside reports whether path is dir or below it, as goos compares names. +func cargoInside(goos, path, dir string) bool { + path, dir = configaudit.GoPathKey(goos, path), configaudit.GoPathKey(goos, dir) + return path == dir || goPathWithin(path, dir) +} + +// registerManifests keeps every manifest that is not package source inside a +// vendored or configured source directory, then charges its source. +func (g *cargoScan) registerManifests() { + sourceDirs := slices.Concat(g.snap.DirectorySources(), g.snap.LocalRegistries()) + for _, v := range g.vendorDirs { + sourceDirs = append(sourceDirs, v.path) + } + kept := g.states[:0] + for _, s := range g.states { + if slices.ContainsFunc(sourceDirs, func(d string) bool { return cargoInside(g.goos, s.dir, d) }) { + delete(g.byManifest, g.key(s.path)) + continue + } + src := g.register(s.src) + if src == nil { + delete(g.byManifest, g.key(s.path)) + continue + } + s.src = src + if s.unresolved { + cargoDegrade(src, model.CargoReasonWorkspaceUnresolved) + } + kept = append(kept, s) + } + g.states = kept + for _, s := range g.states { + if s.root != nil && g.byManifest[g.key(s.root.path)] == nil { + s.root, s.unresolved = nil, true + cargoDegrade(s.src, model.CargoReasonWorkspaceUnresolved) + } + } +} + +// packageVersion is the version written in, or inherited by, a manifest. +func (s *cargoManifestState) packageVersion() string { + if s.m.versionInherited { + if s.root != nil && s.root.m.workspace != nil { + return s.root.m.workspace.packageVersion + } + return "" + } + return s.m.version +} + +// lockVersion is the version Cargo records for a package, which defaults to +// 0.0.0 when the manifest omits it. +func (s *cargoManifestState) lockVersion() string { + if v := s.packageVersion(); v != "" || s.m.versionInherited { + return v + } + return "0.0.0" +} + +// emitProjects records each read manifest, each workspace and each +// declaration. Declarations inherited from workspace.dependencies resolve +// their paths against the workspace root. +func (g *cargoScan) emitProjects() { + for _, s := range g.states { + if s.m == nil || !g.charge(s.src, 1) { + continue + } + p := model.CargoProject{ + ManifestSourceID: s.src.SourceID, ManifestPath: s.path, ProjectPath: s.dir, + PackageName: s.m.name, PackageVersion: s.packageVersion(), LockfilePaths: []string{}, + } + if s.root != nil { + p.WorkspaceManifestPath = s.root.path + } + g.projects = append(g.projects, p) + } + for _, s := range g.states { + if s.root != s { + continue + } + w := model.CargoWorkspace{ManifestSourceID: s.src.SourceID, ManifestPath: s.path, RootPackageName: s.m.name, Members: slices.Clone(s.missing)} + if w.Members == nil { + w.Members = []model.CargoWorkspaceMember{} + } + for _, m := range s.members { + if g.byManifest[g.key(m.path)] != nil { + w.Members = append(w.Members, model.CargoWorkspaceMember{ManifestPath: m.path, Status: model.CargoStatusComplete}) + } + } + if len(s.missing) > 0 { + cargoDegrade(s.src, model.CargoReasonWorkspaceUnresolved) + } + if g.charge(s.src, 1) { + g.workspaces = append(g.workspaces, w) + } + } + for _, s := range g.states { + if s.m == nil || !s.m.isPackage() { + continue + } + for _, d := range s.m.deps { + if !g.emitDeclaration(s, d) { + break + } + } + } +} + +func (g *cargoScan) emitDeclaration(s *cargoManifestState, d cargoDep) bool { + base, reasons := s.dir, []string{} + if d.inherit { + ws, ok := cargoDep{}, false + if s.root != nil { + ws, ok = s.root.m.workspace.deps[d.decl.DeclaredName] + } + if !ok { + return g.addPackage(s.src, model.CargoPackage{ + Evidence: model.CargoEvidenceDeclaredRequirement, PackageName: d.packageName, + Reasons: []string{model.CargoReasonOriginUnresolved, model.CargoReasonWorkspaceUnresolved}, + Origin: model.CargoOrigin{Kind: model.CargoOriginRegistryUnknown, Path: s.dir}, + SourcePath: s.path, ProjectPath: s.dir, Declaration: &d.decl, + }) + } + d, base = cargoInherit(d, ws), s.root.dir + } + origin := g.declarationOrigin(&d, base, s.dir) + if origin.Kind == model.CargoOriginRegistryUnknown { + reasons = append(reasons, model.CargoReasonOriginUnresolved) + } + p := model.CargoPackage{ + Evidence: model.CargoEvidenceDeclaredRequirement, PackageName: d.packageName, Reasons: reasons, + RequestedVersion: d.requested, Origin: origin, SourcePath: s.path, ProjectPath: s.dir, Declaration: &d.decl, + } + if s.root != nil { + p.WorkspacePath = s.root.dir + } + return g.addPackage(s.src, p) +} + +// declarationOrigin resolves a declaration's selectors. A registry alias maps +// through the declaring directory's observed config; registry.default never +// redirects an unqualified dependency away from crates.io. +func (g *cargoScan) declarationOrigin(d *cargoDep, base, contextDir string) model.CargoOrigin { + sel := d.sel + switch { + case sel.git != "": + u, _ := configaudit.SanitizeCargoURL(sel.git) + d.decl.PublishingRegistry = sel.registry + return model.CargoOrigin{Kind: model.CargoOriginGit, URL: u, Branch: sel.branch, Tag: sel.tag, Rev: sel.rev} + case sel.path != "": + p := filepath.FromSlash(sel.path) + if !filepath.IsAbs(p) { + p = filepath.Join(base, p) + } + return model.CargoOrigin{Kind: model.CargoOriginLocal, Path: filepath.Clean(p), IsLocal: true} + case sel.registryIndex != "": + u, _ := configaudit.SanitizeCargoURL(sel.registryIndex) + return model.CargoOrigin{Kind: model.CargoOriginRegistry, URL: u} + case sel.registry != "" && sel.registry != "crates-io": + if u, ok := g.snap.RegistryIndex(contextDir, sel.registry); ok { + return model.CargoOrigin{Kind: model.CargoOriginRegistry, URL: u, RegistryName: sel.registry} + } + return model.CargoOrigin{Kind: model.CargoOriginRegistryUnknown, RegistryName: sel.registry, Path: contextDir} + } + return model.CargoOrigin{Kind: model.CargoOriginRegistry, URL: cargoCratesIOIndex, RegistryName: "crates-io"} +} + +// addPackage charges and appends one package record, filling the fields +// every record carries. +func (g *cargoScan) addPackage(src *model.CargoSource, p model.CargoPackage) bool { + if !g.charge(src, 1) { + return false + } + p.SourceID = src.SourceID + if p.Reasons == nil { + p.Reasons = []string{} + } + slices.Sort(p.Reasons) + if p.Artifacts == nil { + p.Artifacts = []model.CargoArtifact{} + } + if p.RecordedChecksums == nil { + p.RecordedChecksums = []model.CargoRecordedChecksum{} + } + if p.ChecksumStatus == "" { + p.ChecksumStatus = model.CargoChecksumAbsent + } + if p.IdentityStatus == "" { + p.IdentityStatus = model.CargoIdentityMetadata + } + p.DependencyRelation = model.CargoRelationUnknown + if p.Evidence == model.CargoEvidenceDeclaredRequirement { + p.DependencyRelation = model.CargoRelationDirect + } + p.VersionStatus = model.CargoVersionUnknown + if p.ObservedVersion != "" { + p.VersionStatus = model.CargoVersionKnown + } + g.packages = append(g.packages, p) + return true +} + +// cargoLockGroup is one workspace root or standalone package and the +// manifests whose contexts select its lockfiles. +type cargoLockGroup struct { + root *cargoManifestState + members []*cargoManifestState +} + +// emitLockfiles reads the lockfile each context selects, once per path, and +// emits its packages against the workspace root rather than each member. +func (g *cargoScan) emitLockfiles() { + var groups []cargoLockGroup + for _, s := range g.states { + switch { + case s.m == nil: + case s.root == s: + groups = append(groups, cargoLockGroup{root: s, members: slices.Concat([]*cargoManifestState{s}, s.members)}) + case s.root == nil && s.m.isPackage() && !s.unresolved && !s.override: + groups = append(groups, cargoLockGroup{root: s, members: []*cargoManifestState{s}}) + } + } + projects := map[string]*model.CargoProject{} + for i := range g.projects { + projects[g.projects[i].ManifestSourceID] = &g.projects[i] + } + for _, grp := range groups { + unresolved := map[string]bool{} + var paths []string + for _, m := range grp.members { + if g.byManifest[g.key(m.path)] == nil { + continue // reclassified as vendored source + } + path, u := g.snap.LockfilePath(m.dir) + if path == "" { + path = filepath.Join(grp.root.dir, "Cargo.lock") + } + path = filepath.Clean(path) + unresolved[g.key(path)] = unresolved[g.key(path)] || u + if !slices.ContainsFunc(paths, func(p string) bool { return g.key(p) == g.key(path) }) { + paths = append(paths, path) + } + if p := projects[m.src.SourceID]; p != nil && !slices.Contains(p.LockfilePaths, path) { + p.LockfilePaths = append(p.LockfilePaths, path) + } + } + for _, path := range paths { + g.readLockfile(grp, path, unresolved[g.key(path)]) + } + } +} + +// readLockfile emits one lockfile's packages, stopping once the record budget +// is spent. +func (g *cargoScan) readLockfile(grp cargoLockGroup, path string, unresolved bool) { + if g.byID[configaudit.GoSourceID(g.identity, model.CargoSourceLockfile, path)] != nil { + return // another root already selected this lockfile + } + src := g.addSource(model.CargoSourceLockfile, path, grp.root.src.SourceID) + if src == nil { + return + } + if unresolved { + cargoDegrade(src, model.CargoReasonPathUnresolved) + } + if g.expired(src) { + src.Presence = model.CargoPresenceUnknown + return + } + data, ok := g.read(src, g.fileFS(path), path, maxCargoMetadataBytes, true) + if !ok { + return + } + entries, reason := parseCargoLock(data) + if reason != "" { + cargoDegrade(src, reason) + } + project, workspace := grp.root.dir, "" + if grp.root.root == grp.root { + workspace = grp.root.dir + } + for _, e := range entries { + p := model.CargoPackage{ + Evidence: model.CargoEvidenceLockedPackage, PackageName: e.name, ObservedVersion: e.version, + Reasons: slices.Clone(e.reasons), SourcePath: path, ProjectPath: project, WorkspacePath: workspace, + } + if e.source == "" { + origin, keep := g.localLockOrigin(grp, e, path) + if !keep { + continue + } + p.Origin = origin + } else if origin, ok := cargoOrigin(e.source); ok { + p.Origin = origin + } else { + p.Origin = model.CargoOrigin{Kind: model.CargoOriginRegistryUnknown, Path: path} + p.Reasons = append(p.Reasons, model.CargoReasonOriginUnresolved) + } + for _, sum := range e.checksums { + p.RecordedChecksums = append(p.RecordedChecksums, model.CargoRecordedChecksum{ + Algorithm: model.CargoChecksumSHA256, Value: sum, SourceKind: model.CargoChecksumSourceLockfile, + SourcePath: path, SourceID: src.SourceID, Verification: model.CargoChecksumNotVerified, + }) + } + switch { + case len(e.reasons) > 0: + p.ChecksumStatus = model.CargoChecksumPartial + case len(e.checksums) > 0: + p.ChecksumStatus = model.CargoChecksumRecorded + } + if !g.addPackage(src, p) { + return + } + } +} + +// localLockOrigin places a source-less lock entry. A workspace member or root +// package is context, kept only when a declaration references it. Otherwise a +// single manifest with that name and version is its local package; anything +// else stays local_unknown, scoped to the lockfile. +func (g *cargoScan) localLockOrigin(grp cargoLockGroup, e cargoLockEntry, lockPath string) (model.CargoOrigin, bool) { + same := func(s *cargoManifestState) bool { + return s.m != nil && s.m.name == e.name && s.lockVersion() == e.version + } + if i := slices.IndexFunc(grp.members, same); i >= 0 { + member := grp.members[i] + referenced := slices.ContainsFunc(grp.members, func(s *cargoManifestState) bool { + return slices.ContainsFunc(s.m.deps, func(d cargoDep) bool { + dir := cargoPathDepDir(s, s.root, d) + return dir != "" && g.key(dir) == g.key(member.dir) + }) + }) + return model.CargoOrigin{Kind: model.CargoOriginLocal, Path: member.dir, IsLocal: true}, referenced + } + var match *cargoManifestState + for _, s := range g.states { + if same(s) { + if match != nil { + return model.CargoOrigin{Kind: model.CargoOriginLocalUnknown, Path: lockPath, IsLocal: true}, true + } + match = s + } + } + if match != nil { + return model.CargoOrigin{Kind: model.CargoOriginLocal, Path: match.dir, IsLocal: true}, true + } + return model.CargoOrigin{Kind: model.CargoOriginLocalUnknown, Path: lockPath, IsLocal: true}, true +} + +// listDir lists dir on a shared entry budget, degrading src on failure or +// truncation. +func (g *cargoScan) listDir(src *model.CargoSource, files executor.Executor, dir string, budget *int) ([]fs.DirEntry, bool) { + entries, more, err := files.ReadDirLimit(dir, *budget) + if err != nil { + cargoDegrade(src, g.reason(err, dir)) + return nil, false + } + *budget -= len(entries) + if more { + cargoDegrade(src, model.CargoReasonEntryLimit) + } + return entries, true +} + +// scanDirectorySources reads every vendored package directory below each +// detected or configured directory source. +func (g *cargoScan) scanDirectorySources() { + found := slices.Clone(g.vendorDirs) + for _, d := range g.snap.DirectorySources() { + found = append(found, cargoFound{path: d}) + } + seen := map[string]bool{} + for _, f := range found { + if seen[g.key(f.path)] { + continue + } + seen[g.key(f.path)] = true + src := g.addSource(model.CargoSourceDirectorySource, f.path, f.parentID) + if src == nil { + continue + } + files, phys, ok := g.rootFS(src, maxCargoChecksumBytes) + if !ok { + continue + } + budget := maxCargoWalkEntries + entries, _ := g.listDir(src, files, phys, &budget) + for _, e := range entries { + if !e.IsDir() { + continue + } + if g.expired(src) || !g.readVendored(src, files, phys, e.Name()) { + break + } + } + } +} + +// readVendored emits one vendored package from its Cargo.toml and the +// package checksum of its .cargo-checksum.json. It returns false once the +// record budget is spent. +func (g *cargoScan) readVendored(src *model.CargoSource, files executor.Executor, phys, name string) bool { + dir := filepath.Join(src.Path, name) + data, missing, reason := g.readFile(files, filepath.Join(phys, name, "Cargo.toml"), maxCargoMetadataBytes) + if missing { + return true + } + var m *cargoManifest + if reason == "" { + var err error + if m, err = parseCargoManifest(data); err != nil || !m.isPackage() { + reason = model.CargoReasonParseError + } + } + if reason != "" { + cargoDegrade(src, reason) + return true + } + p := model.CargoPackage{ + Evidence: model.CargoEvidenceVendoredPackage, PackageName: m.name, ObservedVersion: m.version, + Origin: model.CargoOrigin{Kind: model.CargoOriginVendorUnknown, Path: src.Path}, + SourcePath: dir, + Artifacts: []model.CargoArtifact{{ + Kind: model.CargoArtifactVendor, Path: dir, Presence: model.CargoPresencePresent, + Status: model.CargoStatusComplete, Reasons: []string{}, + }}, + } + checksumPath := filepath.Join(dir, ".cargo-checksum.json") + switch data, missing, reason := g.readFile(files, filepath.Join(phys, name, ".cargo-checksum.json"), maxCargoChecksumBytes); { + case missing: + case reason != "": + p.ChecksumStatus = model.CargoChecksumUnreadable + cargoDegrade(src, reason) + default: + sum, invalid, err := parseCargoChecksumJSON(data) + switch { + case err != nil: + p.ChecksumStatus = model.CargoChecksumUnreadable + cargoDegrade(src, model.CargoReasonParseError) + case invalid: + p.ChecksumStatus = model.CargoChecksumPartial + p.Reasons = []string{model.CargoReasonChecksumInvalid} + case sum != "": + p.ChecksumStatus = model.CargoChecksumRecorded + p.RecordedChecksums = []model.CargoRecordedChecksum{{ + Algorithm: model.CargoChecksumSHA256, Value: sum, SourceKind: model.CargoChecksumSourceVendorChecksum, + SourcePath: checksumPath, SourceID: src.SourceID, Verification: model.CargoChecksumNotVerified, + }} + } + } + return g.addPackage(src, p) +} + +// scanLocalRegistries reads the .crate archives at the top of each +// configured local registry. +func (g *cargoScan) scanLocalRegistries() { + for _, root := range g.snap.LocalRegistries() { + src := g.addSource(model.CargoSourceLocalRegistry, root, "") + if src == nil { + continue + } + files, phys, ok := g.rootFS(src, maxCargoArchiveBytes) + if !ok { + continue + } + budget := maxCargoWalkEntries + entries, _ := g.listDir(src, files, phys, &budget) + for _, e := range entries { + base, isCrate := strings.CutSuffix(e.Name(), ".crate") + if e.IsDir() || !isCrate { + continue + } + name, version, ok := splitCargoFilename(base) + if !ok { + cargoDegrade(src, model.CargoReasonUnsupportedEntry) + continue + } + if g.expired(src) { + break + } + path := filepath.Join(root, e.Name()) + art, identity := g.archiveArtifact(src, files, filepath.Join(phys, e.Name()), path, name, version) + if !g.addPackage(src, model.CargoPackage{ + Evidence: model.CargoEvidenceCachedPackage, PackageName: name, ObservedVersion: version, + IdentityStatus: identity, Origin: model.CargoOrigin{Kind: model.CargoOriginVendorUnknown, Path: root}, + SourcePath: path, Artifacts: []model.CargoArtifact{art}, + }) { + break + } + } + } +} + +// archiveArtifact confirms a .crate archive from its manifest member. When +// that cannot be read the name and version stay inferred from the file name. +func (g *cargoScan) archiveArtifact(src *model.CargoSource, files executor.Executor, readPath, path, name, version string) (model.CargoArtifact, string) { + art := model.CargoArtifact{ + Kind: model.CargoArtifactArchive, Path: path, Presence: model.CargoPresencePresent, + Status: model.CargoStatusComplete, Reasons: []string{}, + } + data, missing, reason := g.readFile(files, readPath, maxCargoArchiveBytes) + switch { + case missing: + art.Presence, reason = model.CargoPresenceUnknown, model.CargoReasonChangedDuringScan + case reason == "": + reason = cargoArchiveManifest(g.ctx, data, name, version) + } + if reason == "" { + return art, model.CargoIdentityMetadata + } + art.Status, art.Reasons = model.CargoStatusPartial, []string{reason} + cargoDegrade(src, reason) + return art, model.CargoIdentityFilenameInferred +} + +// cargoCacheSlot is one registry ID, name and version in a Cargo home's +// registry cache, from its archive and/or extracted source. +type cargoCacheSlot struct { + id, name, version string + archive, extracted bool +} + +// scanRegistryCache enumerates registry/cache//*.crate and +// registry/src/// for every registry ID present. The ID +// directory is opaque: its origin stays unknown, scoped to the cache. +func (g *cargoScan) scanRegistryCache(home string) { + root := filepath.Join(home, "registry") + src := g.addSource(model.CargoSourceRegistryCache, root, "") + if src == nil { + return + } + files, phys, ok := g.rootFS(src, maxCargoArchiveBytes) + if !ok { + return + } + budget := maxCargoWalkEntries + slots := map[[3]string]*cargoCacheSlot{} + slot := func(id, name, version string) *cargoCacheSlot { + k := [3]string{id, name, version} + if slots[k] == nil { + slots[k] = &cargoCacheSlot{id: id, name: name, version: version} + } + return slots[k] + } + for _, kind := range []string{"cache", "src"} { + ids := g.cacheList(src, files, filepath.Join(phys, kind), &budget) + for _, id := range ids { + if !id.IsDir() { + continue + } + entries := g.cacheList(src, files, filepath.Join(phys, kind, id.Name()), &budget) + for _, e := range entries { + base, isCrate := strings.CutSuffix(e.Name(), ".crate") + if (kind == "cache" && (e.IsDir() || !isCrate)) || (kind == "src" && !e.IsDir()) { + continue + } + name, version, ok := splitCargoFilename(base) + if !ok { + cargoDegrade(src, model.CargoReasonUnsupportedEntry) + continue + } + if s := slot(id.Name(), name, version); kind == "cache" { + s.archive = true + } else { + s.extracted = true + } + } + } + } + keys := slices.SortedFunc(maps.Keys(slots), func(a, b [3]string) int { + return strings.Compare(a[0]+"\x00"+a[1]+"\x00"+a[2], b[0]+"\x00"+b[1]+"\x00"+b[2]) + }) + for _, k := range keys { + if g.expired(src) || !g.cacheRecord(src, files, phys, slots[k]) { + return + } + } +} + +// cacheList lists one cache directory; a missing one is not a failure. +func (g *cargoScan) cacheList(src *model.CargoSource, files executor.Executor, dir string, budget *int) []fs.DirEntry { + entries, more, err := files.ReadDirLimit(dir, *budget) + switch { + case errors.Is(err, fs.ErrNotExist): + return nil + case err != nil: + cargoDegrade(src, g.reason(err, dir)) + return nil + } + *budget -= len(entries) + if more { + cargoDegrade(src, model.CargoReasonEntryLimit) + } + return entries +} + +// cacheRecord emits one cache slot. Extracted Cargo.toml establishes the +// identity, and the archive is then only probed; without it the archive's +// manifest member is read. +func (g *cargoScan) cacheRecord(src *model.CargoSource, files executor.Executor, phys string, s *cargoCacheSlot) bool { + nv := s.name + "-" + s.version + p := model.CargoPackage{ + Evidence: model.CargoEvidenceCachedPackage, PackageName: s.name, ObservedVersion: s.version, + IdentityStatus: model.CargoIdentityFilenameInferred, + Origin: model.CargoOrigin{Kind: model.CargoOriginCacheUnknown, CacheRoot: src.Path, CacheID: s.id}, + } + if s.extracted { + dir := filepath.Join(src.Path, "src", s.id, nv) + art := model.CargoArtifact{ + Kind: model.CargoArtifactExtracted, Path: dir, Presence: model.CargoPresencePresent, + Status: model.CargoStatusComplete, Reasons: []string{}, + } + fail := func(reason string) { + art.Status = model.CargoStatusPartial + if !slices.Contains(art.Reasons, reason) { + art.Reasons = append(art.Reasons, reason) + } + cargoDegrade(src, reason) + } + readDir := filepath.Join(phys, "src", s.id, nv) + switch data, missing, reason := g.readFile(files, filepath.Join(readDir, "Cargo.toml"), maxCargoMetadataBytes); { + case missing: + fail(model.CargoReasonExtractionIncomplete) + case reason != "": + fail(reason) + default: + if m, err := parseCargoManifest(data); err != nil || m.name != s.name || m.version != s.version { + fail(model.CargoReasonParseError) + } else { + p.IdentityStatus = model.CargoIdentityMetadata + } + } + if data, missing, reason := g.readFile(files, filepath.Join(readDir, ".cargo-ok"), maxCargoMarkerBytes); missing || reason != "" || !parseCargoOK(data) { + fail(model.CargoReasonExtractionIncomplete) + } + p.SourcePath = dir + p.Artifacts = append(p.Artifacts, art) + } + if s.archive { + path := filepath.Join(src.Path, "cache", s.id, nv+".crate") + readPath := filepath.Join(phys, "cache", s.id, nv+".crate") + var art model.CargoArtifact + if p.IdentityStatus == model.CargoIdentityMetadata { + art = g.probeArchive(src, files, readPath, path) + } else { + var identity string + art, identity = g.archiveArtifact(src, files, readPath, path, s.name, s.version) + p.IdentityStatus = identity + } + if p.SourcePath == "" { + p.SourcePath = path + } + p.Artifacts = append(p.Artifacts, art) + } + return g.addPackage(src, p) +} + +// probeArchive checks an archive's presence without opening it. +func (g *cargoScan) probeArchive(src *model.CargoSource, files executor.Executor, readPath, path string) model.CargoArtifact { + art := model.CargoArtifact{ + Kind: model.CargoArtifactArchive, Path: path, Presence: model.CargoPresencePresent, + Status: model.CargoStatusComplete, Reasons: []string{}, + } + info, err := files.Stat(readPath) + reason := "" + switch { + case errors.Is(err, fs.ErrNotExist): + art.Presence, reason = model.CargoPresenceAbsent, model.CargoReasonChangedDuringScan + case err != nil: + art.Presence, reason = model.CargoPresenceUnknown, g.reason(err, readPath) + case !info.Mode().IsRegular(): + reason = model.CargoReasonUnsupportedEntry + } + if reason != "" { + art.Status, art.Reasons = model.CargoStatusPartial, []string{reason} + cargoDegrade(src, reason) + } + return art +} + +// scanGitCheckouts reads package manifests in each git/checkouts// +// working copy. Their dependencies are not project requirements, and the +// repository URL is not recorded locally, so origin stays git_unknown scoped +// to the checkout. +func (g *cargoScan) scanGitCheckouts(home string) { + root := filepath.Join(home, "git", "checkouts") + src := g.addSource(model.CargoSourceGitCheckoutRoot, root, "") + if src == nil { + return + } + files, phys, ok := g.rootFS(src, maxCargoMetadataBytes) + if !ok { + return + } + budget := maxCargoWalkEntries + repos, _ := g.listDir(src, files, phys, &budget) + for _, repo := range repos { + if !repo.IsDir() { + continue + } + revs, _ := g.listDir(src, files, filepath.Join(phys, repo.Name()), &budget) + for _, rev := range revs { + if !rev.IsDir() { + continue + } + if g.expired(src) || !g.readCheckout(src, files, phys, repo.Name(), rev.Name(), &budget) { + return + } + } + } +} + +// readCheckout emits the packages of one checkout. It returns false once the +// record budget is spent. +func (g *cargoScan) readCheckout(src *model.CargoSource, files executor.Executor, phys, repo, rev string, budget *int) bool { + rel := filepath.Join(repo, rev) + checkout, readDir := filepath.Join(src.Path, rel), filepath.Join(phys, rel) + origin := model.CargoOrigin{ + Kind: model.CargoOriginGitUnknown, CacheRoot: src.Path, CacheID: repo + "/" + rev, + ResolvedRevision: g.checkoutRevision(files, readDir), + } + art := model.CargoArtifact{Kind: model.CargoArtifactGitCheckout, Path: checkout, Presence: model.CargoPresencePresent, Status: model.CargoStatusComplete, Reasons: []string{}} + if data, missing, reason := g.readFile(files, filepath.Join(readDir, ".cargo-ok"), maxCargoMarkerBytes); missing || reason != "" || !parseCargoOK(data) { + art.Status, art.Reasons = model.CargoStatusPartial, []string{model.CargoReasonExtractionIncomplete} + cargoDegrade(src, model.CargoReasonExtractionIncomplete) + } + + type found struct { + rel string // slash path of the package directory below the checkout + m *cargoManifest + } + var manifests []found + type dir struct { + rel string + depth int + } + queue := []dir{{"", 0}} + for len(queue) > 0 { + if g.expired(src) { + break + } + d := queue[0] + queue = queue[1:] + entries, ok := g.listDir(src, files, filepath.Join(readDir, filepath.FromSlash(d.rel)), budget) + if !ok { + continue + } + for _, e := range entries { + if e.IsDir() { + switch { + case e.Name() == ".git" || e.Name() == "target": + case d.depth+1 > maxCargoWalkDepth: + cargoDegrade(src, model.CargoReasonDepthLimit) + default: + queue = append(queue, dir{pathJoinSlash(d.rel, e.Name()), d.depth + 1}) + } + continue + } + if e.Name() != "Cargo.toml" { + continue + } + data, _, reason := g.readFile(files, filepath.Join(readDir, filepath.FromSlash(d.rel), "Cargo.toml"), maxCargoMetadataBytes) + m, err := parseCargoManifest(data) + if reason == "" && err != nil { + reason = model.CargoReasonParseError + } + if reason != "" { + cargoDegrade(src, reason) + continue + } + manifests = append(manifests, found{d.rel, m}) + } + if *budget <= 0 { + break + } + } + slices.SortFunc(manifests, func(a, b found) int { return strings.Compare(a.rel, b.rel) }) + for _, f := range manifests { + if !f.m.isPackage() { + continue + } + version := f.m.version + if f.m.versionInherited { + // The nearest enclosing workspace inside the checkout supplies it. + best := -1 + for _, w := range manifests { + if w.m.workspace != nil && len(w.rel) > best && (w.rel == "" || f.rel == w.rel || strings.HasPrefix(f.rel, w.rel+"/")) { + best, version = len(w.rel), w.m.workspace.packageVersion + } + } + } + pkgDir := filepath.Join(checkout, filepath.FromSlash(f.rel)) + p := model.CargoPackage{ + Evidence: model.CargoEvidenceGitCachedPackage, PackageName: f.m.name, ObservedVersion: version, + Origin: origin, SourcePath: pkgDir, Artifacts: []model.CargoArtifact{art}, + } + p.Artifacts[0].Path = pkgDir + if origin.ResolvedRevision == "" { + p.Reasons = []string{model.CargoReasonOriginUnresolved} + } + if !g.addPackage(src, p) { + return false + } + } + return true +} + +// checkoutRevision reads the full commit a checkout's .git/HEAD records, +// following a gitdir indirection file and a symbolic ref through loose or +// packed refs. The shortened directory name is never used as a revision. +func (g *cargoScan) checkoutRevision(files executor.Executor, checkout string) string { + gitDir := filepath.Join(checkout, ".git") + if data, _, reason := g.readFile(files, gitDir, maxCargoMarkerBytes); reason == "" && data != nil { + p, ok := parseGitDirFile(data) + if !ok { + return "" + } + if !filepath.IsAbs(p) { + p = filepath.Join(checkout, filepath.FromSlash(p)) + } + gitDir = filepath.Clean(p) + } + data, _, reason := g.readFile(files, filepath.Join(gitDir, "HEAD"), maxCargoMarkerBytes) + if reason != "" || data == nil { + return "" + } + sha, ref := parseGitHead(data) + if sha != "" || !strings.HasPrefix(ref, "refs/") || slices.Contains(strings.Split(ref, "/"), "..") { + return sha + } + if data, _, reason := g.readFile(files, filepath.Join(gitDir, filepath.FromSlash(ref)), maxCargoMarkerBytes); reason == "" && data != nil { + sha, _ = parseGitHead(data) + return sha + } + if data, _, reason := g.readFile(files, filepath.Join(gitDir, "packed-refs"), maxCargoMetadataBytes); reason == "" && data != nil { + return parsePackedRef(data, ref) + } + return "" +} + +// scanInstallRoots reads the install receipts of each Cargo home and each +// observed install.root or CARGO_INSTALL_ROOT. +func (g *cargoScan) scanInstallRoots() { + var roots []string + for _, r := range slices.Concat(g.homes, g.snap.InstallRoots()) { + if !slices.ContainsFunc(roots, func(o string) bool { return g.key(o) == g.key(r) }) { + roots = append(roots, r) + } + } + for _, root := range roots { + if src := g.addSource(model.CargoSourceInstallRoot, root, ""); src != nil { + g.readInstallRoot(src) + } + } +} + +// readInstallRoot emits one record per package tracked by .crates.toml v1, +// which is authoritative for membership; .crates2.json only adds detail. It +// stops once the record budget is spent. +func (g *cargoScan) readInstallRoot(src *model.CargoSource) { + files, phys, ok := g.rootFS(src, maxCargoMetadataBytes) + if !ok { + return + } + receipt := filepath.Join(src.Path, ".crates.toml") + data, missing, reason := g.readFile(files, filepath.Join(phys, ".crates.toml"), maxCargoMetadataBytes) + switch { + case missing: + return // no tracked installs + case reason != "": + cargoDegrade(src, reason) + return + } + v1, err := parseCratesToml(data) + if err != nil { + cargoDegrade(src, model.CargoReasonParseError) + return + } + var v2 map[string]cargoInstallInfo + switch data, missing, reason := g.readFile(files, filepath.Join(phys, ".crates2.json"), maxCargoMetadataBytes); { + case missing: + case reason != "": + cargoDegrade(src, reason) + default: + if v2, err = parseCrates2JSON(data); err != nil { + cargoDegrade(src, model.CargoReasonParseError) + } + } + for _, id := range slices.Sorted(maps.Keys(v1)) { + name, version, source, ok := parseCargoPackageID(id) + if !ok { + cargoDegrade(src, model.CargoReasonParseError) + continue + } + p := model.CargoPackage{ + Evidence: model.CargoEvidenceInstalledTool, PackageName: name, ObservedVersion: version, SourcePath: receipt, + } + if p.Origin, ok = cargoOrigin(source); !ok { + p.Origin = model.CargoOrigin{Kind: model.CargoOriginRegistryUnknown, Path: src.Path} + p.Reasons = []string{model.CargoReasonOriginUnresolved} + } + p.Installation = g.installation(src, files, phys, v1[id]) + if info, ok := v2[id]; ok { + info.apply(p.Installation) + } + if !g.addPackage(src, p) { + return + } + } +} + +// installation probes each recorded bin under the root's bin directory by +// Stat alone; binaries are never opened or run. +func (g *cargoScan) installation(src *model.CargoSource, files executor.Executor, phys string, bins []string) *model.CargoInstallation { + inst := &model.CargoInstallation{RootPath: src.Path, Bins: []model.CargoBin{}} + present, absent := 0, 0 + for _, name := range bins { + if !cargoBinName(name) { + cargoDegrade(src, model.CargoReasonParseError) + continue + } + b := model.CargoBin{Name: name, Path: filepath.Join(src.Path, "bin", name), Presence: model.CargoPresencePresent} + switch _, err := files.Stat(filepath.Join(phys, "bin", name)); { + case err == nil: + present++ + case errors.Is(err, fs.ErrNotExist): + b.Presence = model.CargoPresenceAbsent + absent++ + default: + b.Presence = model.CargoBinUnreadable + } + inst.Bins = append(inst.Bins, b) + } + slices.SortFunc(inst.Bins, func(a, b model.CargoBin) int { return strings.Compare(a.Name, b.Name) }) + switch { + case present == len(inst.Bins) && present > 0: + inst.Status = model.CargoInstallComplete + case absent == len(inst.Bins): + inst.Status = model.CargoInstallReceiptOnly + default: + inst.Status = model.CargoInstallPartial + } + return inst +} + +// finish links discovered sources, rolls up status and sorts everything so an +// unchanged machine yields identical bytes. The record budget was spent +// during collection, in deterministic order. +func (g *cargoScan) finish() *model.CargoInventory { + inv := newCargoInventory() + inv.Projects = append(inv.Projects, g.projects...) + inv.Workspaces = append(inv.Workspaces, g.workspaces...) + inv.Packages = append(inv.Packages, g.packages...) + + for i := range inv.Projects { + slices.Sort(inv.Projects[i].LockfilePaths) + } + slices.SortFunc(inv.Projects, func(a, b model.CargoProject) int { return strings.Compare(a.ManifestPath, b.ManifestPath) }) + for i := range inv.Workspaces { + slices.SortFunc(inv.Workspaces[i].Members, func(a, b model.CargoWorkspaceMember) int { + return strings.Compare(a.ManifestPath, b.ManifestPath) + }) + } + slices.SortFunc(inv.Workspaces, func(a, b model.CargoWorkspace) int { return strings.Compare(a.ManifestPath, b.ManifestPath) }) + slices.SortStableFunc(inv.Packages, func(a, b model.CargoPackage) int { return strings.Compare(cargoPackageKey(a), cargoPackageKey(b)) }) + + for _, s := range g.sources { + if parent := g.byID[s.ParentSourceID]; parent != nil { + parent.DiscoveredSources = append(parent.DiscoveredSources, s.SourceID) + } + } + reasons := append([]string{}, g.reasons...) + for _, s := range g.sources { + slices.Sort(s.Reasons) + slices.Sort(s.DiscoveredSources) + s.DiscoveredSources = slices.Compact(s.DiscoveredSources) + if s.Status != model.CargoStatusComplete { + reasons = append(reasons, s.Reasons...) + } + inv.Sources = append(inv.Sources, *s) + } + slices.SortFunc(inv.Sources, func(a, b model.CargoSource) int { + return strings.Compare(a.Path+"\x00"+a.Kind, b.Path+"\x00"+b.Kind) + }) + slices.Sort(reasons) + inv.Reasons = slices.Compact(reasons) + if len(inv.Reasons) > 0 { + inv.Status = model.CargoStatusPartial + } + return &inv +} + +// cargoPackageKey orders records by every field that distinguishes them. +func cargoPackageKey(p model.CargoPackage) string { + o := p.Origin + parts := []string{ + p.Evidence, p.PackageName, p.ObservedVersion, p.RequestedVersion, p.SourcePath, p.ProjectPath, + o.Kind, o.URL, o.RegistryName, o.Path, o.CacheRoot, o.CacheID, o.Branch, o.Tag, o.Rev, o.ResolvedRevision, + } + if d := p.Declaration; d != nil { + parts = append(parts, d.DependencyKind, d.Target, d.DeclaredName) + } + return strings.Join(parts, "\x00") +} diff --git a/internal/detector/cargoscan_test.go b/internal/detector/cargoscan_test.go new file mode 100644 index 0000000..8a9f734 --- /dev/null +++ b/internal/detector/cargoscan_test.go @@ -0,0 +1,207 @@ +package detector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/user" + "path/filepath" + "runtime" + "slices" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +// cargoTestScan scans home with code as the search root. The process runs as +// another account, so its environment is never read. +func cargoTestScan(t *testing.T, home, code string) (*model.CargoInventory, *model.CargoConfigAudit) { + t.Helper() + exec := &goTrap{Executor: executor.NewReal(), t: t, current: &user.User{Username: "agent", Uid: "1001"}} + return NewCargoScanner(exec, progress.NewNoop()).Scan(context.Background(), goTestTarget(home), []string{code}, nil) +} + +func cargoWritePackage(t *testing.T, dir, name string) { + t.Helper() + goWrite(t, filepath.Join(dir, "Cargo.toml"), "[package]\nname = \""+name+"\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1\"\nlog = \"0.4\"\n", 0o644) + goWrite(t, filepath.Join(dir, "Cargo.lock"), `version = 4 + +[[package]] +name = "`+name+`" +version = "0.1.0" + +[[package]] +name = "serde" +version = "1.0.200" +source = "registry+https://github.com/rust-lang/crates.io-index" +`, 0o644) +} + +func TestCargoScan_CleanScanHasNoNullArrays(t *testing.T) { + home := goTestHome(t) + code := filepath.Join(home, "code") + cargoWritePackage(t, filepath.Join(code, "app"), "app") + + inv, audit := cargoTestScan(t, home, code) + if inv.Status != model.CargoStatusComplete || audit.Status != model.CargoStatusComplete { + t.Fatalf("status = %s %v / %s %v, want complete", inv.Status, inv.Reasons, audit.Status, audit.Reasons) + } + for name, v := range map[string]any{"inventory": inv, "audit": audit} { + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(b, []byte("null")) { + t.Errorf("%s serializes a null: %s", name, b) + } + } +} + +// cargoOwned counts the rows each source owns and lists its children. +func cargoOwned(inv *model.CargoInventory) map[string]string { + counts := map[string][3]int{} + for _, p := range inv.Projects { + c := counts[p.ManifestSourceID] + c[0]++ + counts[p.ManifestSourceID] = c + } + for _, w := range inv.Workspaces { + c := counts[w.ManifestSourceID] + c[1]++ + counts[w.ManifestSourceID] = c + } + for _, p := range inv.Packages { + c := counts[p.SourceID] + c[2]++ + counts[p.SourceID] = c + } + out := map[string]string{} + for _, s := range inv.Sources { + out[s.SourceID] = fmt.Sprint(counts[s.SourceID], s.DiscoveredSources) + } + return out +} + +// Under every budget short of the full record count, a source left complete +// must own exactly what it owns in an unlimited scan. +func TestCargoScan_RecordLimitMarksUnfinishedOwners(t *testing.T) { + home := goTestHome(t) + code := filepath.Join(home, "code") + for _, name := range []string{"a", "b"} { + cargoWritePackage(t, filepath.Join(code, name), name) + } + ws := filepath.Join(code, "ws") + goWrite(t, filepath.Join(ws, "Cargo.toml"), "[workspace]\nmembers = [\"m1\", \"m2\"]\n", 0o644) + cargoWritePackage(t, filepath.Join(ws, "m1"), "m1") + cargoWritePackage(t, filepath.Join(ws, "m2"), "m2") + saved := maxCargoRecords + t.Cleanup(func() { maxCargoRecords = saved }) + + full, _ := cargoTestScan(t, home, code) + want := cargoOwned(full) + total := len(full.Sources) + len(full.Projects) + len(full.Workspaces) + len(full.Packages) + for budget := 1; budget < total; budget++ { + maxCargoRecords = budget + inv, _ := cargoTestScan(t, home, code) + if inv.Status != model.CargoStatusPartial || !slices.Contains(inv.Reasons, model.CargoReasonRecordLimit) { + t.Errorf("budget %d: status = %s %v, want partial with record_limit", budget, inv.Status, inv.Reasons) + } + got := cargoOwned(inv) + for _, s := range inv.Sources { + if s.Status == model.CargoStatusComplete && got[s.SourceID] != want[s.SourceID] { + t.Errorf("budget %d: complete %s %s owns %s, want %s", budget, s.Kind, s.Path, got[s.SourceID], want[s.SourceID]) + } + } + } +} + +func TestCargoScan_InheritedPathDependencyKeepsLockedPackage(t *testing.T) { + home := goTestHome(t) + code := filepath.Join(home, "code") + ws := filepath.Join(code, "ws") + goWrite(t, filepath.Join(ws, "Cargo.toml"), `[workspace] +members = ["app"] + +[workspace.dependencies] +shared = { path = "shared" } +ext = { path = "../../outside/ext" } +unused = { path = "../../outside/unused" } +`, 0o644) + goWrite(t, filepath.Join(ws, "app", "Cargo.toml"), "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nshared.workspace = true\next.workspace = true\n", 0o644) + goWrite(t, filepath.Join(ws, "shared", "Cargo.toml"), "[package]\nname = \"shared\"\nversion = \"0.2.0\"\n", 0o644) + // Outside the search root: only an inherited entry is followed. + outside := filepath.Join(home, "outside") + goWrite(t, filepath.Join(outside, "ext", "Cargo.toml"), "[package]\nname = \"ext\"\nversion = \"0.3.0\"\n", 0o644) + goWrite(t, filepath.Join(outside, "unused", "Cargo.toml"), "[package]\nname = \"unused\"\nversion = \"0.4.0\"\n\n[dependencies]\nserde = \"1\"\n", 0o644) + goWrite(t, filepath.Join(ws, "Cargo.lock"), `version = 4 + +[[package]] +name = "app" +version = "0.1.0" +dependencies = ["ext", "shared"] + +[[package]] +name = "ext" +version = "0.3.0" + +[[package]] +name = "shared" +version = "0.2.0" +`, 0o644) + + inv, _ := cargoTestScan(t, home, code) + var locked []model.CargoPackage + for _, p := range inv.Packages { + if p.Evidence == model.CargoEvidenceLockedPackage { + locked = append(locked, p) + } + } + shared, ext := filepath.Join(ws, "shared"), filepath.Join(outside, "ext") + if len(locked) != 2 || locked[0].PackageName != "ext" || locked[0].Origin.Path != ext || + locked[1].PackageName != "shared" || locked[1].ObservedVersion != "0.2.0" || locked[1].Origin.Path != shared { + t.Errorf("locked packages = %+v, want ext at %s and shared 0.2.0 at %s", locked, ext, shared) + } + for _, p := range inv.Projects { + if p.PackageName == "unused" { + t.Errorf("unused workspace dependency was followed: %+v", p) + } + } + if len(inv.Workspaces) != 1 || !slices.ContainsFunc(inv.Workspaces[0].Members, func(m model.CargoWorkspaceMember) bool { + return m.ManifestPath == filepath.Join(shared, "Cargo.toml") + }) { + t.Errorf("workspaces = %+v, want shared admitted as a member", inv.Workspaces) + } +} + +func TestCargoScan_UnreadableBinIsUnreadable(t *testing.T) { + if runtime.GOOS == model.PlatformWindows || os.Geteuid() == 0 { + t.Skip("needs Unix directory permissions that bind the caller") + } + home := goTestHome(t) + cargoHome := filepath.Join(home, ".cargo") + goWrite(t, filepath.Join(cargoHome, ".crates.toml"), `[v1] +"ripgrep 14.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = ["rg"] +`, 0o644) + bin := filepath.Join(cargoHome, "bin") + goWrite(t, filepath.Join(bin, "rg"), "", 0o755) + if err := os.Chmod(bin, 0o600); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(bin, 0o755) }) + + inv, _ := cargoTestScan(t, home, filepath.Join(home, "code")) + for _, p := range inv.Packages { + if p.Evidence != model.CargoEvidenceInstalledTool { + continue + } + if p.Installation.Status != model.CargoInstallPartial || len(p.Installation.Bins) != 1 || p.Installation.Bins[0].Presence != model.CargoBinUnreadable { + t.Errorf("installation = %+v, want partial with an unreadable bin", p.Installation) + } + return + } + t.Fatal("no installed_tool record") +} diff --git a/internal/detector/configaudit/cargoconfig.go b/internal/detector/configaudit/cargoconfig.go new file mode 100644 index 0000000..534d47d --- /dev/null +++ b/internal/detector/configaudit/cargoconfig.go @@ -0,0 +1,967 @@ +package configaudit + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "path/filepath" + "slices" + "strings" + "unicode" + + toml "github.com/pelletier/go-toml/v2" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// Cargo config limits. +// ponytail: unmeasured starting values; tune with lab sizes. +var ( + maxCargoConfigBytes int64 = 2 << 20 + maxCargoIncludeDepth = 8 + maxCargoIncludeFiles = 64 // config files loaded for one context chain + maxCargoAuditBytes = 256 << 10 + maxCargoSettingLen = 4096 + maxCargoKeyPartLen = 256 +) + +// cargoBuiltinProviders are credential providers shipped with Cargo; their +// names carry no secret. Anything else is shown as custom. +var cargoBuiltinProviders = map[string]bool{ + "cargo:token": true, "cargo:wincred": true, "cargo:macos-keychain": true, "cargo:libsecret": true, +} + +// SanitizeCargoURL strips userinfo, query and fragment from a URL; anything +// that still carries an '@' is redacted whole. +func SanitizeCargoURL(raw string) (string, bool) { return sanitizeGoURL(raw) } + +// CargoReadReason maps a guarded-read failure to a Cargo reason code, +// separating a skipped network volume from a protected directory. +func CargoReadReason(err error, path string, volume func(string) bool) string { + reason := GoReadReason(err) + if reason == model.CargoReasonSkippedProtected && volume != nil && volume(path) { + return model.CargoReasonRefusedNetworkVolume + } + return reason +} + +// CargoConfigScope is what the Cargo config audit may read, decided by the +// caller from the resolved developer identity. +type CargoConfigScope struct { + Username string // resolved developer identity, part of every source ID + Home string // from the OS user record + Roots []string // approved scope for ancestor .cargo probes + // Protected returns a reason code for a path that must not be touched. + Protected func(path string) string + // Volume reports a skipped network volume. + Volume func(path string) bool + // ProcessVerified means this process runs as the developer, so its own + // environment is an observed context for that developer. + ProcessVerified bool + // CargoHome is the effective Cargo home; empty when it is unresolved. + CargoHome string + // Contexts are the directories Cargo would be invoked in. + Contexts []CargoContextDir + // RegistryNames are registry aliases named by manifests, whose process + // environment keys are probed. + RegistryNames []string +} + +// CargoContextDir is one invocation directory and the workspace root it +// belongs to, if any. +type CargoContextDir struct{ Project, Workspace string } + +// CargoConfigSnapshot is the sanitized view inventory uses: per-context +// registry and lockfile values plus configured roots. Token values are never +// held. +type CargoConfigSnapshot struct { + goos string + contexts map[string]*cargoContextValues // context dir key -> merged values + directories []string + localRegistries []string + installRoots []string + localPaths []string +} + +type cargoContextValues struct { + registries map[string]string // alias -> sanitized index URL + envRegistries map[string]string // CARGO_REGISTRIES__INDEX -> sanitized index URL + lockfile cargoSetting + lockfileSet bool + partial bool +} + +// RegistryIndex returns the index URL a context maps alias to; ok is false +// when no readable source defines it, or when a config file of the context +// was not read and so may redefine it. +func (s CargoConfigSnapshot) RegistryIndex(contextDir, alias string) (string, bool) { + if c := s.contexts[GoPathKey(s.goos, contextDir)]; c != nil { + return c.registryIndex(alias) + } + return "", false +} + +// LockfilePath is the resolver.lockfile-path selected in contextDir. path is +// empty for the default location; unresolved means a value or a source could +// not be established, so the returned path is not known to be the one Cargo +// uses. +func (s CargoConfigSnapshot) LockfilePath(contextDir string) (path string, unresolved bool) { + c := s.contexts[GoPathKey(s.goos, contextDir)] + if c == nil { + return "", true + } + if c.lockfileSet { + // Cargo rejects a selected path that is not named Cargo.lock. + if c.lockfile.unresolved || filepath.Base(c.lockfile.Display) != "Cargo.lock" { + return "", true + } + return c.lockfile.Display, c.partial && !c.lockfile.env + } + return "", c.partial +} + +// DirectorySources are the resolved source..directory roots. +func (s CargoConfigSnapshot) DirectorySources() []string { return s.directories } + +// LocalRegistries are the resolved source..local-registry roots. +func (s CargoConfigSnapshot) LocalRegistries() []string { return s.localRegistries } + +// InstallRoots are the resolved install.root values and an absolute +// CARGO_INSTALL_ROOT. +func (s CargoConfigSnapshot) InstallRoots() []string { return s.installRoots } + +// LocalPaths are package directories named by config paths and patch entries. +func (s CargoConfigSnapshot) LocalPaths() []string { return s.localPaths } + +// CargoConfigDetector audits Cargo's configuration sources without running +// Cargo. It must be built with the raw executor: UserAwareExecutor.Getenv +// sources a login shell for some keys. +type CargoConfigDetector struct { + exec executor.Executor +} + +func NewCargoConfigDetector(exec executor.Executor) *CargoConfigDetector { + return &CargoConfigDetector{exec: exec} +} + +// cargoSetting is one allowlisted value. For path settings Display is the +// resolved absolute path unless unresolved is set. +type cargoSetting struct { + model.CargoConfigSetting + unresolved bool + token bool // a literal token value, which never leaves the process + url bool + env bool // from the process environment, which overrides every file +} + +// cargoInclude is one include entry of a config file. +type cargoInclude struct { + path string + optional bool + invalid bool // not a .toml path +} + +// cargoConfigRead is one physical read, shared by every record of that path. +type cargoConfigRead struct { + status string + reasons []string + settings []cargoSetting // SourceID unset + includes []cargoInclude +} + +// cargoAudit is the state of one Detect call. +type cargoAudit struct { + d *CargoConfigDetector + ctx context.Context + scope CargoConfigScope + goos string + reads map[string]*cargoConfigRead // path key -> read + files map[string]*cargoConfigFile // source ID -> record + process *cargoConfigFile +} + +type cargoConfigFile struct { + file model.CargoConfigFile + settings []cargoSetting + includes []cargoInclude +} + +// Detect returns the audit and the snapshot inventory uses. +func (d *CargoConfigDetector) Detect(ctx context.Context, scope CargoConfigScope) (model.CargoConfigAudit, CargoConfigSnapshot) { + audit := model.CargoConfigAudit{ + SchemaVersion: model.CargoInventorySchemaVersion, Status: model.CargoStatusComplete, Reasons: []string{}, + Files: []model.CargoConfigFile{}, Findings: []model.CargoConfigFinding{}, + Contexts: []model.CargoConfigContext{}, CredentialFiles: []model.CargoCredentialFile{}, + } + snap := CargoConfigSnapshot{goos: d.exec.GOOS(), contexts: map[string]*cargoContextValues{}} + if scope.Username == "" || scope.Home == "" { + audit.Status, audit.Reasons = model.CargoStatusPartial, []string{model.CargoReasonUserUnresolved} + return audit, snap + } + if ctx.Err() != nil { + audit.Status, audit.Reasons = model.CargoStatusPartial, []string{model.CargoReasonDeadlineExceeded} + return audit, snap + } + a := &cargoAudit{d: d, ctx: ctx, scope: scope, goos: d.exec.GOOS(), reads: map[string]*cargoConfigRead{}, files: map[string]*cargoConfigFile{}} + + homeChain, homePartial := a.homeChain() + type observed struct { + ctx model.CargoConfigContext + chain []*cargoConfigFile // lowest precedence first + partial bool + } + var contexts []observed + seen := map[string]bool{} + for _, c := range scope.Contexts { + if k := GoPathKey(a.goos, c.Project); c.Project == "" || seen[k] { + continue + } else { + seen[k] = true + } + chain, partial := a.dirChain(c.Project) + chain = append(slices.Clone(homeChain), chain...) + contexts = append(contexts, observed{ + ctx: model.CargoConfigContext{ProjectPath: c.Project, WorkspacePath: c.Workspace}, + chain: chain, partial: partial || homePartial, + }) + } + contexts = append(contexts, observed{chain: homeChain, partial: homePartial}) + + // The process source is read last so it can probe every alias seen above. + a.process = a.processSource() + for _, o := range contexts { + chain := o.chain + if a.scope.ProcessVerified { + chain = append(slices.Clone(chain), a.process) + } + values := &cargoContextValues{registries: map[string]string{}, envRegistries: map[string]string{}, partial: o.partial} + ids := []string{} + for i := len(chain) - 1; i >= 0; i-- { + ids = append(ids, chain[i].file.SourceID) + } + for _, f := range chain { + values.apply(f.settings) + } + o.ctx.ConfigSourceIDs, o.ctx.SelectionStatus = ids, model.CargoSelectionObserved + if o.partial { + o.ctx.SelectionStatus = model.CargoSelectionPartial + } + audit.Contexts = append(audit.Contexts, o.ctx) + snap.contexts[GoPathKey(a.goos, o.ctx.ProjectPath)] = values + } + audit.CredentialFiles = a.credentialFiles() + + for _, f := range a.sortedFiles() { + audit.Files = append(audit.Files, f.file) + audit.Findings = append(audit.Findings, cargoFindings(f)...) + if f.file.ShadowedBy == "" && f.file.Status == model.CargoConfigPresent { + snap.collect(f.settings) + } + if f.file.Scope == model.CargoConfigScopeProcess { + continue // DMG's own context is never a developer config source + } + switch f.file.Status { + case model.CargoConfigPresent, model.CargoConfigAbsent: + default: + audit.Status = model.CargoStatusPartial + } + audit.Reasons = append(audit.Reasons, f.file.Reasons...) + } + for _, c := range audit.Contexts { + if c.SelectionStatus == model.CargoSelectionPartial { + audit.Status = model.CargoStatusPartial + } + } + if len(audit.Reasons) > 0 { + audit.Status = model.CargoStatusPartial + } + slices.Sort(audit.Reasons) + audit.Reasons = slices.Compact(audit.Reasons) + slices.SortFunc(audit.Findings, func(a, b model.CargoConfigFinding) int { + return strings.Compare(a.Code+"\x00"+a.SourceID+"\x00"+a.Key, b.Code+"\x00"+b.SourceID+"\x00"+b.Key) + }) + slices.SortFunc(audit.Contexts, func(a, b model.CargoConfigContext) int { + return strings.Compare(a.ProjectPath+"\x00"+a.WorkspacePath, b.ProjectPath+"\x00"+b.WorkspacePath) + }) + snap.finish(a.goos) + + if b, err := json.Marshal(audit); err == nil && len(b) > maxCargoAuditBytes { + audit = model.CargoConfigAudit{ + SchemaVersion: model.CargoInventorySchemaVersion, Status: model.CargoStatusPartial, + Reasons: []string{model.CargoReasonOutputSizeLimit}, Files: []model.CargoConfigFile{}, + Findings: []model.CargoConfigFinding{}, Contexts: []model.CargoConfigContext{}, CredentialFiles: []model.CargoCredentialFile{}, + } + } + return audit, snap +} + +func (a *cargoAudit) sortedFiles() []*cargoConfigFile { + out := make([]*cargoConfigFile, 0, len(a.files)+1) + for _, f := range a.files { + out = append(out, f) + } + out = append(out, a.process) + slices.SortFunc(out, func(x, y *cargoConfigFile) int { + return strings.Compare(x.file.Scope+"\x00"+x.file.Path, y.file.Scope+"\x00"+y.file.Path) + }) + return out +} + +// homeChain loads the Cargo home config, lowest precedence first. +func (a *cargoAudit) homeChain() ([]*cargoConfigFile, bool) { + if a.scope.CargoHome == "" { + f := a.record(model.CargoConfigScopeUser, "", "") + f.file.Status, f.file.Reasons = model.CargoConfigUnsupported, []string{model.CargoReasonPathUnresolved} + return nil, true + } + var chain []*cargoConfigFile + partial := a.dotCargo(a.scope.CargoHome, model.CargoConfigScopeUser, []string{a.scope.CargoHome}, &chain) + return chain, partial +} + +// dirChain loads the .cargo configs of dir and each ancestor inside the +// approved roots, lowest precedence (outermost) first. The Cargo home's own +// directory is loaded once, as the home source. +func (a *cargoAudit) dirChain(dir string) ([]*cargoConfigFile, bool) { + var dirs []string + for d := filepath.Clean(dir); GoWithinRoots(a.goos, d, a.scope.Roots); d = filepath.Dir(d) { + dirs = append(dirs, d) + if filepath.Dir(d) == d { + break + } + } + var chain []*cargoConfigFile + partial := false + for i := len(dirs) - 1; i >= 0; i-- { + dotCargo := filepath.Join(dirs[i], ".cargo") + if a.scope.CargoHome != "" && GoPathKey(a.goos, dotCargo) == GoPathKey(a.goos, a.scope.CargoHome) { + continue + } + if a.dotCargo(dotCargo, model.CargoConfigScopeProject, a.scope.Roots, &chain) { + partial = true + } + } + return chain, partial +} + +// dotCargo loads one directory's config pair. The legacy extensionless config +// wins when both exist; the other file is still read and reported, but +// marked shadowed and never applied. Absent files are reported only for the +// Cargo home, where their absence is informative. +func (a *cargoAudit) dotCargo(dir, scope string, roots []string, chain *[]*cargoConfigFile) bool { + legacy, current := filepath.Join(dir, "config"), filepath.Join(dir, "config.toml") + legacyRead, currentRead := a.read(legacy, roots), a.read(current, roots) + var chosen string + switch { + case legacyRead.status != model.CargoConfigAbsent: + chosen = legacy + if currentRead.status != model.CargoConfigAbsent { + shadowed := a.record(scope, current, "") + a.fill(shadowed, currentRead) + shadowed.file.ShadowedBy = a.id(scope, legacy) + } + case currentRead.status != model.CargoConfigAbsent: + chosen = current + default: + if scope == model.CargoConfigScopeUser { + a.fill(a.record(scope, current, ""), currentRead) + } + return false + } + return a.load(chosen, scope, "", 0, map[string]bool{}, chain) +} + +// load appends path's include graph to out, lowest precedence first: +// includes left to right, then the file itself. Cargo rejects a path loaded +// twice in one graph, so a repeat marks the including file invalid. +func (a *cargoAudit) load(path, scope, parentID string, depth int, seen map[string]bool, out *[]*cargoConfigFile) (partial bool) { + f := a.record(scope, path, parentID) + r := a.read(path, a.includeRoots(scope, path)) + a.fill(f, r) + seen[GoPathKey(a.goos, path)] = true + if r.status != model.CargoConfigPresent { + return r.status != model.CargoConfigAbsent + } + unresolved := func() { + f.file.Status = model.CargoConfigInvalid + f.addReason(model.CargoReasonConfigIncludeUnresolved) + partial = true + } + for _, inc := range f.includes { + k := GoPathKey(a.goos, inc.path) + switch { + case inc.invalid, seen[k], depth+1 > maxCargoIncludeDepth, len(*out) >= maxCargoIncludeFiles: + unresolved() + continue + } + if !inc.optional || a.read(inc.path, a.includeRoots(model.CargoConfigScopeIncluded, inc.path)).status != model.CargoConfigAbsent { + if a.load(inc.path, model.CargoConfigScopeIncluded, f.file.SourceID, depth+1, seen, out) { + partial = true + } + if child := a.files[a.id(model.CargoConfigScopeIncluded, inc.path)]; child != nil && child.file.Status == model.CargoConfigAbsent { + unresolved() // a required include is missing + } + } + } + *out = append(*out, f) + return partial +} + +// includeRoots scopes a read: ancestor files stay inside the approved roots; +// the Cargo home and included files are exact targeted reads. +func (a *cargoAudit) includeRoots(scope, path string) []string { + if scope == model.CargoConfigScopeProject { + return a.scope.Roots + } + return []string{path} +} + +func (a *cargoAudit) id(scope, path string) string { + return GoSourceID(a.scope.Username, "cargo_config_"+scope, path) +} + +// record returns the one record for scope and path. +func (a *cargoAudit) record(scope, path, parentID string) *cargoConfigFile { + id := a.id(scope, path) + if f := a.files[id]; f != nil { + return f + } + f := &cargoConfigFile{file: model.CargoConfigFile{ + SourceID: id, Scope: scope, Path: path, Status: model.CargoConfigAbsent, + Reasons: []string{}, Settings: []model.CargoConfigSetting{}, IncludeParentSourceID: parentID, + }} + a.files[id] = f + return f +} + +func (f *cargoConfigFile) addReason(r string) { + if !slices.Contains(f.file.Reasons, r) { + f.file.Reasons = append(f.file.Reasons, r) + } +} + +// fill copies a physical read into a record, stamping settings with its ID. +func (a *cargoAudit) fill(f *cargoConfigFile, r *cargoConfigRead) { + if len(f.settings) > 0 || f.file.Status != model.CargoConfigAbsent { + return // already filled + } + f.file.Status = r.status + for _, reason := range r.reasons { + f.addReason(reason) + } + f.includes = r.includes + for _, s := range r.settings { + s.SourceID = f.file.SourceID + f.settings = append(f.settings, s) + f.file.Settings = append(f.file.Settings, s.CargoConfigSetting) + } +} + +// read reads and parses path once, however many records share it. +func (a *cargoAudit) read(path string, roots []string) *cargoConfigRead { + k := GoPathKey(a.goos, path) + if r := a.reads[k]; r != nil { + return r + } + r := &cargoConfigRead{status: model.CargoConfigAbsent} + a.reads[k] = r + fail := func(status, reason string) { r.status, r.reasons = status, []string{reason} } + if a.ctx.Err() != nil { + fail(model.CargoConfigUnreadable, model.CargoReasonDeadlineExceeded) + return r + } + if a.scope.Protected != nil && a.scope.Protected(path) != "" { + fail(model.CargoConfigSkippedProtected, a.reason(nil, path)) + return r + } + files := a.d.exec.GuardedFiles(roots, a.scope.Protected, maxCargoConfigBytes) + info, err := files.Stat(path) + switch { + case errors.Is(err, fs.ErrNotExist): + return r + case err != nil: + fail(a.failStatus(err, path), a.reason(err, path)) + return r + case !info.Mode().IsRegular(): + fail(model.CargoConfigUnreadable, model.CargoReasonUnsupportedEntry) + return r + case info.Size() > maxCargoConfigBytes: + fail(model.CargoConfigUnreadable, model.CargoReasonSizeLimit) + return r + } + data, err := files.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + fail(model.CargoConfigUnreadable, model.CargoReasonChangedDuringScan) + return r + } else if err != nil { + fail(a.failStatus(err, path), a.reason(err, path)) + return r + } + var doc map[string]any + if err := toml.Unmarshal(data, &doc); err != nil { + fail(model.CargoConfigInvalid, model.CargoReasonParseError) + return r + } + r.status = model.CargoConfigPresent + r.settings, r.includes = cargoConfigSettings(doc, path) + return r +} + +func (a *cargoAudit) reason(err error, path string) string { + if err == nil { + if a.scope.Volume != nil && a.scope.Volume(path) { + return model.CargoReasonRefusedNetworkVolume + } + return model.CargoReasonSkippedProtected + } + return CargoReadReason(err, path, a.scope.Volume) +} + +func (a *cargoAudit) failStatus(err error, path string) string { + switch a.reason(err, path) { + case model.CargoReasonSkippedProtected, model.CargoReasonRefusedNetworkVolume: + return model.CargoConfigSkippedProtected + case model.CargoReasonOutsideApprovedRoots: + return model.CargoConfigUnsupported + } + return model.CargoConfigUnreadable +} + +// cargoProcessKeys are the fixed process environment keys read; registry +// keys are added per alias. +var cargoProcessKeys = []string{ + "CARGO_HOME", "CARGO_INSTALL_ROOT", "CARGO_RESOLVER_LOCKFILE_PATH", "CARGO_REGISTRY_DEFAULT", + "CARGO_REGISTRY_TOKEN", "CARGO_REGISTRY_CREDENTIAL_PROVIDER", "CARGO_REGISTRY_GLOBAL_CREDENTIAL_PROVIDERS", + "CARGO_REGISTRIES_CRATES_IO_PROTOCOL", "CARGO_HTTP_PROXY", "CARGO_HTTP_CAINFO", "CARGO_HTTP_PROXY_CAINFO", + "CARGO_HTTP_SSL_VERSION", "CARGO_HTTP_CHECK_REVOKE", "CARGO_NET_OFFLINE", "CARGO_NET_GIT_FETCH_WITH_CLI", +} + +// processSource reads the allowlisted keys of this process's environment, +// only when it runs as the developer. Relative paths stay unresolved: the +// originating shell's working directory is unknown. +func (a *cargoAudit) processSource() *cargoConfigFile { + f := &cargoConfigFile{file: model.CargoConfigFile{ + SourceID: a.id(model.CargoConfigScopeProcess, ""), Scope: model.CargoConfigScopeProcess, + Status: model.CargoConfigAbsent, Reasons: []string{}, Settings: []model.CargoConfigSetting{}, + }} + if !a.scope.ProcessVerified { + f.file.Status = model.CargoConfigUnsupported + return f + } + aliases := slices.Clone(a.scope.RegistryNames) + for _, file := range a.files { + for _, s := range file.settings { + if rest, ok := strings.CutPrefix(s.Key, "registries."); ok { + if name, _, ok := strings.Cut(rest, "."); ok { + aliases = append(aliases, name) + } + } + } + } + slices.Sort(aliases) + keys := slices.Clone(cargoProcessKeys) + for _, name := range slices.Compact(aliases) { + env := cargoRegistryEnv(name) + keys = append(keys, env+"_INDEX", env+"_TOKEN", env+"_CREDENTIAL_PROVIDER") + } + b := &cargoSettings{} + for _, k := range keys { + v := a.d.exec.Getenv(k) + if v == "" { + continue + } + switch { + case strings.HasSuffix(k, "_TOKEN"): + b.token(k, v) + case strings.HasSuffix(k, "_CREDENTIAL_PROVIDER"): + b.provider(k, v) + case k == "CARGO_REGISTRY_GLOBAL_CREDENTIAL_PROVIDERS": + for _, p := range strings.Fields(v) { + b.provider(k, p) + } + case strings.HasSuffix(k, "_INDEX"), k == "CARGO_HTTP_PROXY": + b.url(k, v) + case k == "CARGO_HOME", k == "CARGO_INSTALL_ROOT", k == "CARGO_RESOLVER_LOCKFILE_PATH", + k == "CARGO_HTTP_CAINFO", k == "CARGO_HTTP_PROXY_CAINFO": + b.envPath(k, v) + default: + b.str(k, v) + } + } + f.settings = b.out + for i := range f.settings { + f.settings[i].SourceID, f.settings[i].env = f.file.SourceID, true + f.file.Settings = append(f.file.Settings, f.settings[i].CargoConfigSetting) + } + if len(f.settings) > 0 { + f.file.Status = model.CargoConfigPresent + } + return f +} + +// credentialFiles reports the Cargo home credential files by Stat alone. +func (a *cargoAudit) credentialFiles() []model.CargoCredentialFile { + out := []model.CargoCredentialFile{} + if a.scope.CargoHome == "" { + return out + } + for _, name := range []string{"credentials", "credentials.toml"} { + p := filepath.Join(a.scope.CargoHome, name) + c := model.CargoCredentialFile{Path: p, Status: model.CargoConfigPresent, Presence: model.CargoPresencePresent} + if a.scope.Protected != nil && a.scope.Protected(p) != "" { + c.Status, c.Presence = model.CargoConfigSkippedProtected, model.CargoPresenceUnknown + } else if info, err := a.d.exec.GuardedFiles([]string{p}, a.scope.Protected, 0).Stat(p); errors.Is(err, fs.ErrNotExist) { + c.Status, c.Presence = model.CargoConfigAbsent, model.CargoPresenceAbsent + } else if err != nil { + c.Status, c.Presence = a.failStatus(err, p), model.CargoPresenceUnknown + } else if !info.Mode().IsRegular() { + c.Status, c.Presence = model.CargoConfigUnreadable, model.CargoPresenceUnknown + } + out = append(out, c) + } + return out +} + +// apply merges one source's settings over lower-precedence ones. +func (v *cargoContextValues) apply(settings []cargoSetting) { + for _, s := range settings { + switch { + case s.Key == "resolver.lockfile-path", s.Key == "CARGO_RESOLVER_LOCKFILE_PATH": + v.lockfile, v.lockfileSet = s, true + case strings.HasPrefix(s.Key, "registries.") && strings.HasSuffix(s.Key, ".index"): + v.registries[strings.TrimSuffix(strings.TrimPrefix(s.Key, "registries."), ".index")] = s.Display + case strings.HasPrefix(s.Key, "CARGO_REGISTRIES_") && strings.HasSuffix(s.Key, "_INDEX"): + v.envRegistries[s.Key] = s.Display + } + } +} + +// collect gathers configured roots from an applied source. +func (s *CargoConfigSnapshot) collect(settings []cargoSetting) { + for _, st := range settings { + if st.unresolved { + continue + } + switch k := st.Key; { + case strings.HasPrefix(k, "source.") && strings.HasSuffix(k, ".directory"): + s.directories = append(s.directories, st.Display) + case strings.HasPrefix(k, "source.") && strings.HasSuffix(k, ".local-registry"): + s.localRegistries = append(s.localRegistries, st.Display) + case k == "install.root", k == "CARGO_INSTALL_ROOT": + s.installRoots = append(s.installRoots, st.Display) + case k == "paths", strings.HasPrefix(k, "patch.") && strings.HasSuffix(k, ".path"): + s.localPaths = append(s.localPaths, st.Display) + } + } +} + +func (s *CargoConfigSnapshot) finish(goos string) { + for _, list := range []*[]string{&s.directories, &s.localRegistries, &s.installRoots, &s.localPaths} { + slices.SortFunc(*list, func(a, b string) int { return strings.Compare(GoPathKey(goos, a), GoPathKey(goos, b)) }) + *list = slices.CompactFunc(*list, func(a, b string) bool { return GoPathKey(goos, a) == GoPathKey(goos, b) }) + } +} + +// registryIndex prefers the process environment key, which overrides every +// config file. A file value is not definitive while any file of the context +// is unread. +func (v *cargoContextValues) registryIndex(alias string) (string, bool) { + if u, ok := v.envRegistries[cargoRegistryEnv(alias)+"_INDEX"]; ok { + return u, true + } + if v.partial { + return "", false + } + u, ok := v.registries[alias] + return u, ok +} + +// cargoRegistryEnv is the environment key prefix Cargo derives from an alias. +func cargoRegistryEnv(alias string) string { + return "CARGO_REGISTRIES_" + strings.ToUpper(strings.ReplaceAll(alias, "-", "_")) +} + +// cargoSettings builds the allowlisted settings of one source. +type cargoSettings struct { + file string // defining file; empty for the process environment + out []cargoSetting +} + +func (b *cargoSettings) add(key, display string, redacted bool) *cargoSetting { + if len(display) > maxCargoSettingLen || strings.ContainsFunc(display, unicode.IsControl) { + display, redacted = "", true + } + b.out = append(b.out, cargoSetting{CargoConfigSetting: model.CargoConfigSetting{Key: key, Display: display, Redacted: redacted}}) + return &b.out[len(b.out)-1] +} + +func (b *cargoSettings) str(key string, v any) { + switch x := v.(type) { + case string: + b.add(key, x, false) + case bool, int64, float64: + b.add(key, fmt.Sprint(x), false) + } +} + +func (b *cargoSettings) url(key string, v any) { + if s, ok := v.(string); ok { + display, redacted := SanitizeCargoURL(s) + b.add(key, display, redacted).url = true + } +} + +// token records only that a token is configured. +func (b *cargoSettings) token(key string, v any) { + if s, ok := v.(string); ok && s != "" { + b.add(key, "configured", true).token = true + } +} + +// provider shows a built-in provider name; custom providers and any +// arguments may hold secrets and are never shown. +func (b *cargoSettings) provider(key string, v any) { + var fields []string + switch x := v.(type) { + case string: + fields = strings.Fields(x) + case []any: + for _, item := range x { + if s, ok := item.(string); ok { + fields = append(fields, s) + } + } + } + if len(fields) == 0 { + return + } + switch { + case cargoBuiltinProviders[fields[0]]: + b.add(key, fields[0], len(fields) > 1) + case fields[0] == "cargo:token-from-stdout": + b.add(key, fields[0], len(fields) > 1) + default: + b.add(key, "custom", true) + } +} + +// path resolves a config-relative path against the parent of the directory +// holding the defining file, as Cargo does, even for an included file. +func (b *cargoSettings) path(key string, v any) { + s, ok := v.(string) + if !ok || s == "" { + return + } + if strings.Contains(s, "{") { + b.add(key, s, false).unresolved = true + return + } + if !filepath.IsAbs(s) { + s = filepath.Join(filepath.Dir(filepath.Dir(b.file)), filepath.FromSlash(s)) + } + b.add(key, filepath.Clean(s), false) +} + +// envPath keeps an absolute environment path; a relative one depends on the +// unknown working directory of the originating shell. +func (b *cargoSettings) envPath(key, v string) { + if filepath.IsAbs(v) { + b.add(key, filepath.Clean(v), false) + return + } + b.add(key, v, false).unresolved = true +} + +func cargoTableOf(v any) map[string]any { + m, _ := v.(map[string]any) + return m +} + +// cargoKeyPart makes a user-chosen table name safe inside a setting key. A +// patch table is named by a source URL, whose credentials are stripped like +// any other URL; redacted reports that something was removed. +func cargoKeyPart(name string) (part string, redacted, ok bool) { + if name == "" || len(name) > maxCargoKeyPartLen || strings.ContainsFunc(name, unicode.IsControl) { + return "", false, false + } + part, redacted = SanitizeCargoURL(name) + return part, redacted, true +} + +// redactFrom marks every setting added since index start as redacted. +func (b *cargoSettings) redactFrom(start int) { + for i := start; i < len(b.out); i++ { + b.out[i].Redacted = true + } +} + +// cargoConfigSettings extracts the allowlisted settings and include entries +// of one config document. Every other key is dropped in memory. +func cargoConfigSettings(doc map[string]any, file string) ([]cargoSetting, []cargoInclude) { + b := &cargoSettings{file: file} + reg := cargoTableOf(doc["registry"]) + b.str("registry.default", reg["default"]) + b.token("registry.token", reg["token"]) + b.provider("registry.credential-provider", reg["credential-provider"]) + if list, ok := reg["global-credential-providers"].([]any); ok { + for _, p := range list { + b.provider("registry.global-credential-providers", p) + } + } + for name, raw := range cargoTableOf(doc["registries"]) { + r := cargoTableOf(raw) + part, redacted, ok := cargoKeyPart(name) + if !ok || r == nil { + continue + } + start, prefix := len(b.out), "registries."+part+"." + b.url(prefix+"index", r["index"]) + b.token(prefix+"token", r["token"]) + b.provider(prefix+"credential-provider", r["credential-provider"]) + if name == "crates-io" { + b.str(prefix+"protocol", r["protocol"]) + } + if redacted { + b.redactFrom(start) + } + } + for name, raw := range cargoTableOf(doc["source"]) { + src := cargoTableOf(raw) + part, redacted, ok := cargoKeyPart(name) + if !ok || src == nil { + continue + } + start, prefix := len(b.out), "source."+part+"." + b.str(prefix+"replace-with", src["replace-with"]) + b.url(prefix+"registry", src["registry"]) + b.path(prefix+"directory", src["directory"]) + b.path(prefix+"local-registry", src["local-registry"]) + b.url(prefix+"git", src["git"]) + for _, sel := range []string{"branch", "tag", "rev"} { + b.str(prefix+sel, src[sel]) + } + if redacted { + b.redactFrom(start) + } + } + http := cargoTableOf(doc["http"]) + b.url("http.proxy", http["proxy"]) + b.path("http.cainfo", http["cainfo"]) + b.path("http.proxy-cainfo", http["proxy-cainfo"]) + if ssl := cargoTableOf(http["ssl-version"]); ssl != nil { + b.str("http.ssl-version.min", ssl["min"]) + b.str("http.ssl-version.max", ssl["max"]) + } else { + b.str("http.ssl-version", http["ssl-version"]) + } + b.str("http.check-revoke", http["check-revoke"]) + net := cargoTableOf(doc["net"]) + b.str("net.offline", net["offline"]) + b.str("net.git-fetch-with-cli", net["git-fetch-with-cli"]) + b.path("install.root", cargoTableOf(doc["install"])["root"]) + b.path("resolver.lockfile-path", cargoTableOf(doc["resolver"])["lockfile-path"]) + if list, ok := doc["paths"].([]any); ok { + for _, p := range list { + b.path("paths", p) + } + } + for src, raw := range cargoTableOf(doc["patch"]) { + srcPart, srcRedacted, srcOK := cargoKeyPart(src) + for name, entry := range cargoTableOf(raw) { + e := cargoTableOf(entry) + part, redacted, ok := cargoKeyPart(name) + if !srcOK || !ok || e == nil { + continue + } + start, prefix := len(b.out), "patch."+srcPart+"."+part+"." + b.path(prefix+"path", e["path"]) + b.url(prefix+"git", e["git"]) + if srcRedacted || redacted { + b.redactFrom(start) + } + } + } + + includes := cargoIncludes(doc["include"], file) + for _, inc := range includes { + b.add("include", inc.path, false) + } + slices.SortStableFunc(b.out, func(x, y cargoSetting) int { return strings.Compare(x.Key, y.Key) }) + return b.out, includes +} + +// cargoIncludes reads include as a string, or an array of strings and +// {path, optional} tables. Paths are relative to the including file. +func cargoIncludes(v any, file string) []cargoInclude { + var raw []any + switch x := v.(type) { + case nil: + return nil + case []any: + raw = x + default: + raw = []any{x} + } + var out []cargoInclude + for _, item := range raw { + inc := cargoInclude{} + switch x := item.(type) { + case string: + inc.path = x + case map[string]any: + inc.path, _ = x["path"].(string) + inc.optional, _ = x["optional"].(bool) + } + if inc.path == "" || !strings.HasSuffix(inc.path, ".toml") || len(inc.path) > maxCargoSettingLen { + inc.invalid = true + } + if !filepath.IsAbs(inc.path) { + inc.path = filepath.Join(filepath.Dir(file), filepath.FromSlash(inc.path)) + } + inc.path = filepath.Clean(inc.path) + out = append(out, inc) + } + return out +} + +// cargoFindings evaluates one source's own values; there is no merged +// effective verdict because other invocation contexts remain unknown. +func cargoFindings(f *cargoConfigFile) []model.CargoConfigFinding { + var out []model.CargoConfigFinding + add := func(code, severity, key, detail string) { + out = append(out, model.CargoConfigFinding{Code: code, Severity: severity, SourceID: f.file.SourceID, Key: key, Detail: detail}) + } + for _, s := range f.settings { + scheme := cargoScheme(s.Display) + switch { + case s.url && scheme == "http" && (strings.HasSuffix(s.Key, ".index") || strings.HasSuffix(s.Key, "_INDEX") || + (strings.HasPrefix(s.Key, "source.") && strings.HasSuffix(s.Key, ".registry"))): + add("cargo-001", pipSevMedium, s.Key, "registry index uses plaintext http transport") + case s.url && (scheme == "http" || scheme == "git") && strings.HasSuffix(s.Key, ".git"): + add("cargo-002", pipSevMedium, s.Key, "Git source uses http or unauthenticated git transport") + case s.token && f.file.Scope != model.CargoConfigScopeProcess: + add("cargo-003", pipSevMedium, s.Key, "registry token is written in a config file rather than a credentials file or provider") + case s.Key == "http.check-revoke" || s.Key == "CARGO_HTTP_CHECK_REVOKE": + if s.Display == "false" { + add("cargo-004", pipSevLow, s.Key, "certificate revocation checking is disabled; TLS peer validation is unaffected") + } + } + } + return out +} + +// cargoScheme is a URL's lowercase scheme without a sparse+ or registry+ prefix. +func cargoScheme(u string) string { + scheme, _, ok := strings.Cut(u, "://") + if !ok { + return "" + } + scheme = strings.ToLower(scheme) + if _, s, ok := strings.Cut(scheme, "+"); ok { + scheme = s + } + return scheme +} diff --git a/internal/detector/configaudit/cargoconfig_test.go b/internal/detector/configaudit/cargoconfig_test.go new file mode 100644 index 0000000..cd41087 --- /dev/null +++ b/internal/detector/configaudit/cargoconfig_test.go @@ -0,0 +1,81 @@ +package configaudit + +import ( + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" + + toml "github.com/pelletier/go-toml/v2" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +func TestCargoConfigSettings_URLTableNamesRedacted(t *testing.T) { + doc := map[string]any{} + if err := toml.Unmarshal([]byte(` +[patch."https://user:canary-pass@git.example/repo.git?token=canary-query"] +foo = { path = "vendor/foo" } +[source."https://u:canary-src@mirror.example/"] +registry = "https://mirror.example/index" +[registries.corp] +index = "https://registry.example/index" +`), &doc); err != nil { + t.Fatal(err) + } + settings, _ := cargoConfigSettings(doc, "/work/.cargo/config.toml") + out, _ := json.Marshal(settings) + for _, secret := range []string{"canary-pass", "canary-query", "canary-src"} { + if strings.Contains(string(out), secret) { + t.Errorf("settings leak %q: %s", secret, out) + } + } + for _, s := range settings { + switch { + case strings.HasPrefix(s.Key, "patch."), strings.HasPrefix(s.Key, "source."): + if !s.Redacted { + t.Errorf("%s: redacted = false, want true", s.Key) + } + case s.Key == "registries.corp.index": + if s.Redacted { + t.Errorf("%s: redacted = true, want false", s.Key) + } + } + } +} + +func TestCargoConfigDetect_UnreadProjectConfigKeepsSelectionPartial(t *testing.T) { + home := goTestHome(t) + cargoHome := filepath.Join(home, ".cargo") + project := filepath.Join(home, "code", "app") + mustWriteGoFile(t, filepath.Join(cargoHome, "config.toml"), ` +[registries.corp] +index = "https://registry.example/index" +[resolver] +lockfile-path = "`+filepath.ToSlash(filepath.Join(home, "locks", "Cargo.lock"))+`" +`) + mustWriteGoFile(t, filepath.Join(project, ".cargo", "config.toml"), "[registries.corp\n") + + scope := CargoConfigScope{ + Username: "dev", Home: home, Roots: []string{home}, Protected: goProtectedFor(home), + Volume: func(string) bool { return false }, CargoHome: cargoHome, + Contexts: []CargoContextDir{{Project: project}}, + } + _, snap := NewCargoConfigDetector(executor.NewReal()).Detect(context.Background(), scope) + + // The malformed project file may redefine both values, so neither is definitive there. + if u, ok := snap.RegistryIndex(project, "corp"); ok { + t.Errorf("RegistryIndex(project) = %q, true; want unknown", u) + } + if path, unresolved := snap.LockfilePath(project); !unresolved || path == "" { + t.Errorf("LockfilePath(project) = %q, %v; want the observed path, unresolved", path, unresolved) + } + // The home context has no unread file. + if u, ok := snap.RegistryIndex("", "corp"); !ok || u != "https://registry.example/index" { + t.Errorf("RegistryIndex(home) = %q, %v", u, ok) + } + if _, unresolved := snap.LockfilePath(""); unresolved { + t.Error("LockfilePath(home) unresolved, want resolved") + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 31fd43d..6d966fa 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -1184,3 +1184,360 @@ type GoConfigFinding struct { Key string `json:"key"` Detail string `json:"detail"` } + +// CargoInventorySchemaVersion versions the cargo_inventory and +// cargo_config_audit sections independently of the outer payload_schema_version. +const CargoInventorySchemaVersion = 1 + +// Cargo inventory source kinds. +const ( + CargoSourceProjectSearchRoot = "project_search_root" + CargoSourceManifest = "manifest" + CargoSourceLockfile = "lockfile" + CargoSourceRegistryCache = "registry_cache" // /registry + CargoSourceGitCheckoutRoot = "git_checkout_root" // /git/checkouts + CargoSourceDirectorySource = "directory_source" + CargoSourceLocalRegistry = "local_registry" + CargoSourceInstallRoot = "install_root" +) + +// Cargo section and source statuses. A source that was refused or could not +// be read is partial with a reason, never absent. +const ( + CargoStatusComplete = "complete" + CargoStatusPartial = "partial" +) + +// Cargo presence, for sources, artifacts and installed bins. Only a permitted +// lookup that found nothing is absent. +const ( + CargoPresencePresent = "present" + CargoPresenceAbsent = "absent" + CargoPresenceUnknown = "unknown" + // CargoBinUnreadable is a bin whose lookup failed; bins alone use it in + // place of unknown. + CargoBinUnreadable = "unreadable" +) + +// Cargo package evidence kinds; each is owned by exactly one source kind. +const ( + CargoEvidenceDeclaredRequirement = "declared_requirement" // manifest + CargoEvidenceLockedPackage = "locked_package" // lockfile + CargoEvidenceCachedPackage = "cached_package" // registry_cache or local_registry + CargoEvidenceGitCachedPackage = "git_cached_package" // git_checkout_root + CargoEvidenceVendoredPackage = "vendored_package" // directory_source + CargoEvidenceInstalledTool = "installed_tool" // install_root +) + +// Cargo package attributes. +const ( + CargoRelationDirect = "direct" // declared_requirement only + CargoRelationUnknown = "unknown" + + CargoVersionKnown = "known" // an exact version from lock, manifest, archive or receipt + CargoVersionUnknown = "unknown" + + CargoIdentityMetadata = "metadata" + CargoIdentityFilenameInferred = "filename_inferred" // cache file name only; its metadata could not be read + + CargoDependencyNormal = "normal" + CargoDependencyDev = "dev" + CargoDependencyBuild = "build" +) + +// Cargo origin kinds. The *_unknown kinds carry the scope that bounds them +// (project, lockfile, cache or directory) instead of a guessed URL. +const ( + CargoOriginRegistry = "registry" + CargoOriginGit = "git" + CargoOriginLocal = "local" + CargoOriginLocalUnknown = "local_unknown" // path is the lockfile, not a package directory + CargoOriginRegistryUnknown = "registry_unknown" // registry alias whose index was not observed + CargoOriginCacheUnknown = "cache_unknown" // opaque registry cache directory + CargoOriginGitUnknown = "git_unknown" // Git checkout with no recorded repository URL + CargoOriginVendorUnknown = "vendor_unknown" // directory source or local registry +) + +// Cargo artifact kinds and installation statuses. +const ( + CargoArtifactArchive = "archive" + CargoArtifactExtracted = "extracted" + CargoArtifactVendor = "vendor" + CargoArtifactGitCheckout = "git_checkout" + + CargoInstallComplete = "complete" // every recorded bin is present + CargoInstallPartial = "partial" // some bins present or unreadable + CargoInstallReceiptOnly = "receipt_only" // tracked receipt, every bin absent +) + +// Cargo recorded-checksum vocabulary. A recorded checksum is metadata, never +// an integrity verdict. +const ( + CargoChecksumAbsent = "absent" + CargoChecksumRecorded = "recorded" + CargoChecksumUnreadable = "unreadable" + CargoChecksumPartial = "partial" // conflicting or invalid values were recorded + + CargoChecksumSHA256 = "sha256" + + CargoChecksumSourceLockfile = "lockfile" + CargoChecksumSourceVendorChecksum = "vendor_checksum" // .cargo-checksum.json package value + + CargoChecksumNotVerified = "not_verified" +) + +// Cargo config-audit scopes, file statuses and context selection statuses. +const ( + CargoConfigScopeUser = "user" // Cargo home config + CargoConfigScopeProject = "project" // /.cargo config + CargoConfigScopeProcess = "process" // DMG's own verified process environment + CargoConfigScopeIncluded = "included" // loaded through an include + + CargoConfigPresent = "present" + CargoConfigAbsent = "absent" + CargoConfigSkippedProtected = "skipped_protected" + CargoConfigUnreadable = "unreadable" + CargoConfigInvalid = "invalid" + CargoConfigUnsupported = "unsupported" + + CargoSelectionObserved = "observed" + CargoSelectionPartial = "partial" +) + +// Cargo reason codes, shared by both sections. Spellings shared with Go are +// aliases so the two vocabularies cannot drift apart. +const ( + CargoReasonUserUnresolved = GoReasonUserUnresolved + CargoReasonPathUnresolved = GoReasonPathUnresolved + CargoReasonSkippedProtected = GoReasonSkippedProtected + CargoReasonOutsideApprovedRoots = GoReasonOutsideApprovedRoots + CargoReasonRefusedNetworkVolume = "refused_network_volume" + CargoReasonPermissionDenied = GoReasonPermissionDenied + CargoReasonUnreadable = "unreadable" + CargoReasonChangedDuringScan = GoReasonChangedDuringScan + CargoReasonUnsupportedEntry = GoReasonUnsupportedEntry + CargoReasonDeadlineExceeded = GoReasonDeadlineExceeded + CargoReasonParseError = GoReasonParseError + CargoReasonSizeLimit = GoReasonSizeLimit + CargoReasonEntryLimit = GoReasonEntryLimit + CargoReasonDepthLimit = GoReasonDepthLimit + CargoReasonRecordLimit = GoReasonRecordLimit + CargoReasonOutputSizeLimit = GoReasonOutputSizeLimit + CargoReasonExtractionIncomplete = GoReasonExtractionIncomplete + CargoReasonUnsupportedFormat = "unsupported_format" + CargoReasonWorkspaceUnresolved = "workspace_unresolved" + CargoReasonConfigIncludeUnresolved = "config_include_unresolved" + CargoReasonOriginUnresolved = "origin_unresolved" + CargoReasonChecksumInvalid = "checksum_invalid" + CargoReasonChecksumMismatch = "checksum_mismatch" +) + +// CargoInventory is the enterprise cargo_inventory section: a full bounded +// snapshot of statically read Rust package evidence. Nil means the phase did +// not run. +type CargoInventory struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` // CargoStatusComplete | CargoStatusPartial + Reasons []string `json:"reasons"` // global reasons plus those of every partial source + Sources []CargoSource `json:"sources"` + Projects []CargoProject `json:"projects"` + Workspaces []CargoWorkspace `json:"workspaces"` + Packages []CargoPackage `json:"packages"` +} + +// CargoSource is one thing the collector looked at. Its ID hashes the +// developer identity, kind and absolute logical path. +type CargoSource struct { + SourceID string `json:"source_id"` + Kind string `json:"kind"` // CargoSource* + Path string `json:"path"` + Status string `json:"status"` // CargoStatus* + Presence string `json:"presence"` // CargoPresence* + Reasons []string `json:"reasons"` + // ParentSourceID names the discovery source or referencing manifest. + ParentSourceID string `json:"parent_source_id,omitempty"` + // DiscoveredSources lists every child, including unreadable ones. + DiscoveredSources []string `json:"discovered_sources"` +} + +// CargoProject is one parsed Cargo.toml. A virtual workspace root has no +// package name. Lock contents are carried once, by the lockfile's packages. +type CargoProject struct { + ManifestSourceID string `json:"manifest_source_id"` + ManifestPath string `json:"manifest_path"` + ProjectPath string `json:"project_path"` + PackageName string `json:"package_name,omitempty"` + PackageVersion string `json:"package_version,omitempty"` + WorkspaceManifestPath string `json:"workspace_manifest_path,omitempty"` + LockfilePaths []string `json:"lockfile_paths"` // lockfiles selected from this project's context +} + +// CargoWorkspace is a manifest with a [workspace] table. It is context, not a +// package dependency. +type CargoWorkspace struct { + ManifestSourceID string `json:"manifest_source_id"` + ManifestPath string `json:"manifest_path"` + RootPackageName string `json:"root_package_name,omitempty"` + Members []CargoWorkspaceMember `json:"members"` +} + +type CargoWorkspaceMember struct { + ManifestPath string `json:"manifest_path"` + Status string `json:"status"` // CargoStatus* + Reason string `json:"reason,omitempty"` // why the member is partial +} + +// CargoPackage is one evidence fact about a package. SourceID is the source +// that owns the evidence; the same package seen by two sources is two records. +type CargoPackage struct { + SourceID string `json:"source_id"` + Evidence string `json:"evidence"` // CargoEvidence* + PackageName string `json:"package_name"` + DependencyRelation string `json:"dependency_relation"` // CargoRelation* + Reasons []string `json:"reasons"` // record-level attribution notes + RequestedVersion string `json:"requested_version,omitempty"` + ObservedVersion string `json:"observed_version,omitempty"` + VersionStatus string `json:"version_status"` // CargoVersion* + IdentityStatus string `json:"identity_status"` // CargoIdentity* + Origin CargoOrigin `json:"origin"` + SourcePath string `json:"source_path"` + ProjectPath string `json:"project_path,omitempty"` + WorkspacePath string `json:"workspace_path,omitempty"` + Declaration *CargoDeclaration `json:"declaration,omitempty"` // declared_requirement only + Artifacts []CargoArtifact `json:"artifacts"` + Installation *CargoInstallation `json:"installation,omitempty"` // installed_tool only + ChecksumStatus string `json:"checksum_status"` // CargoChecksum* status + RecordedChecksums []CargoRecordedChecksum `json:"recorded_checksums"` +} + +// CargoOrigin is where a package comes from, as far as local metadata shows. +// URLs are sanitized; unknown kinds keep their scoping root instead of a URL. +type CargoOrigin struct { + Kind string `json:"kind"` // CargoOrigin* + URL string `json:"url,omitempty"` + RegistryName string `json:"registry_name,omitempty"` // contextual alias, not identity + Path string `json:"path,omitempty"` + CacheRoot string `json:"cache_root,omitempty"` + CacheID string `json:"cache_id,omitempty"` + Branch string `json:"branch,omitempty"` + Tag string `json:"tag,omitempty"` + Rev string `json:"rev,omitempty"` + ResolvedRevision string `json:"resolved_revision,omitempty"` // full commit, only from recorded metadata + IsLocal bool `json:"is_local,omitempty"` +} + +// CargoDeclaration is a dependency entry as written in its manifest. +type CargoDeclaration struct { + DeclaredName string `json:"declared_name"` + DependencyKind string `json:"dependency_kind"` // CargoDependency* + Target string `json:"target,omitempty"` + Optional bool `json:"optional,omitempty"` + DefaultFeatures *bool `json:"default_features,omitempty"` // set only when written + Features []string `json:"features"` + WorkspaceInherited bool `json:"workspace_inherited"` + PublishingRegistry string `json:"publishing_registry,omitempty"` // registry fallback of a Git dependency +} + +type CargoArtifact struct { + Kind string `json:"kind"` // CargoArtifact* + Path string `json:"path"` + Presence string `json:"presence"` // CargoPresence* + Status string `json:"status"` // CargoStatus* + Reasons []string `json:"reasons"` +} + +// CargoInstallation is what the install receipts record for one package. +type CargoInstallation struct { + RootPath string `json:"root_path"` + Bins []CargoBin `json:"bins"` + Status string `json:"status"` // CargoInstall* + VersionReq string `json:"version_req,omitempty"` + Features []string `json:"features,omitempty"` + AllFeatures *bool `json:"all_features,omitempty"` + NoDefaultFeatures *bool `json:"no_default_features,omitempty"` + Target string `json:"target,omitempty"` + Profile string `json:"profile,omitempty"` + Rustc string `json:"rustc,omitempty"` // receipt text +} + +// CargoBin is one recorded binary. +type CargoBin struct { + Name string `json:"name"` + Path string `json:"path"` + Presence string `json:"presence"` // present, absent or CargoBinUnreadable +} + +// CargoRecordedChecksum is one recorded SHA-256 value. SourceID is the +// evidence owner; SourcePath is the file that held the value. +type CargoRecordedChecksum struct { + Algorithm string `json:"algorithm"` // CargoChecksumSHA256 + Value string `json:"value"` // 64 lowercase hex + SourceKind string `json:"source_kind"` + SourcePath string `json:"source_path"` + SourceID string `json:"source_id"` + Verification string `json:"verification"` // always CargoChecksumNotVerified +} + +// CargoConfigAudit is the enterprise cargo_config_audit section: observed +// Cargo settings per source, sanitized on the device, with per-context source +// order and no device-wide effective verdict. +type CargoConfigAudit struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + Reasons []string `json:"reasons"` + Files []CargoConfigFile `json:"files"` + Findings []CargoConfigFinding `json:"findings"` + Contexts []CargoConfigContext `json:"contexts"` + CredentialFiles []CargoCredentialFile `json:"credential_files"` +} + +// CargoConfigFile is one configuration source. The process scope has no path. +type CargoConfigFile struct { + SourceID string `json:"source_id"` + Scope string `json:"scope"` // CargoConfigScope* + Path string `json:"path,omitempty"` + Status string `json:"status"` // CargoConfig* status + Reasons []string `json:"reasons"` + Settings []CargoConfigSetting `json:"settings"` + // ShadowedBy names the legacy .cargo/config that wins over this file. + ShadowedBy string `json:"shadowed_by,omitempty"` + // IncludeParentSourceID names the file whose include loaded this one. + IncludeParentSourceID string `json:"include_parent_source_id,omitempty"` +} + +// CargoConfigSetting is an allowlisted key with a sanitized display value. +// Redacted marks that a credential, query, argument or custom command was removed. +type CargoConfigSetting struct { + Key string `json:"key"` + Display string `json:"display"` + SourceID string `json:"source_id"` + Redacted bool `json:"redacted"` +} + +// CargoConfigFinding is a stable-coded observation tied to the source where +// the value was seen. Detail never contains a value. +type CargoConfigFinding struct { + Code string `json:"code"` // cargo-001 … + Severity string `json:"severity"` // CRITICAL | HIGH | MEDIUM | LOW | INFO + SourceID string `json:"source_id"` + Key string `json:"key"` + Detail string `json:"detail"` +} + +// CargoConfigContext is the config sources Cargo would load when invoked in +// ProjectPath, highest precedence first. It does not describe CLI flags or +// other shells. The Cargo-home context has no paths. +type CargoConfigContext struct { + ProjectPath string `json:"project_path,omitempty"` + WorkspacePath string `json:"workspace_path,omitempty"` + ConfigSourceIDs []string `json:"config_source_ids"` + SelectionStatus string `json:"selection_status"` // CargoSelection* +} + +// CargoCredentialFile reports only whether a credentials file exists; its +// contents are never read. +type CargoCredentialFile struct { + Path string `json:"path"` + Status string `json:"status"` // CargoConfig* status + Presence string `json:"presence"` // CargoPresence* +} diff --git a/internal/model/testdata/cargo_inventory_v1_golden.json b/internal/model/testdata/cargo_inventory_v1_golden.json new file mode 100644 index 0000000..edacba7 --- /dev/null +++ b/internal/model/testdata/cargo_inventory_v1_golden.json @@ -0,0 +1,1344 @@ +{ + "cargo_inventory": { + "schema_version": 1, + "status": "partial", + "reasons": [ + "extraction_incomplete", + "parse_error", + "path_unresolved", + "permission_denied", + "skipped_protected", + "unsupported_format" + ], + "sources": [ + { + "source_id": "8905068107d33cde3731bca3d641d20a0b4f4030d471302dd87bb4273f793031", + "kind": "install_root", + "path": "/opt/cargo-tools", + "status": "complete", + "presence": "absent", + "reasons": [], + "discovered_sources": [] + }, + { + "source_id": "df3970b19cab9a65a2d37be09f6a80abab641114265d69b0c7eccd9964cc4105", + "kind": "install_root", + "path": "/Users/dev/.cargo", + "status": "complete", + "presence": "present", + "reasons": [], + "discovered_sources": [] + }, + { + "source_id": "143557442baf02cbb6be674e5ef3a703ace66ba693497698721d39a512c2387d", + "kind": "git_checkout_root", + "path": "/Users/dev/.cargo/git/checkouts", + "status": "partial", + "presence": "present", + "reasons": [ + "extraction_incomplete" + ], + "discovered_sources": [] + }, + { + "source_id": "475bfb9f26f82c3029eec3f5ed81281a07e39d0849e485f11707861dafba5e86", + "kind": "registry_cache", + "path": "/Users/dev/.cargo/registry", + "status": "partial", + "presence": "present", + "reasons": [ + "extraction_incomplete", + "parse_error" + ], + "discovered_sources": [] + }, + { + "source_id": "8ea97bb1a66be91de8b9013eedd3e300e47f98c74feec63dd75a43e56f799615", + "kind": "project_search_root", + "path": "/Users/dev/Documents", + "status": "partial", + "presence": "unknown", + "reasons": [ + "skipped_protected" + ], + "discovered_sources": [] + }, + { + "source_id": "6b3a956d3cf6144da563cdb862cd24ce83c97c49173d0ca33a5c11da2cc69841", + "kind": "project_search_root", + "path": "/Users/dev/code", + "status": "complete", + "presence": "present", + "reasons": [], + "discovered_sources": [ + "5d57b04308fbe06210f6352543de5418d376c35acac3211c75ae9379fee1aa51", + "419034f0fe8e8417792da0fe3c598470ed2b64b0ec2f18761daf8f70657adc65", + "b4e029f5458222ea9864851ff5d2d717d65a0bbacec71ba2a9e3d38989fcda24", + "54e931b84112086fc9b8b3d9f421504b47f82fecd3e9ca1a3b7af232739b96e9", + "355843aed7375a9971af2bff2577e588c464a7ef188e861cd9e983ceab4693f2", + "6ea696797b1a49e36144de59a044a173282fb6c3970989d0d1e4cb398e952525", + "bcdf5a06f92bd25fdd295f1656ebcfabb285d912958596edd5a0c0dfec4cc593", + "8e6f580762e827c39d0425cd39590f52dc5c80555b466bb2081931adf457c994" + ] + }, + { + "source_id": "f7f452b98eab6a2e194e73de28e02336cfd2a87d3f32f502035858b93537f4dd", + "kind": "lockfile", + "path": "/Users/dev/code/lib/Cargo.lock", + "status": "partial", + "presence": "absent", + "reasons": [ + "path_unresolved" + ], + "parent_source_id": "6ea696797b1a49e36144de59a044a173282fb6c3970989d0d1e4cb398e952525", + "discovered_sources": [] + }, + { + "source_id": "6ea696797b1a49e36144de59a044a173282fb6c3970989d0d1e4cb398e952525", + "kind": "manifest", + "path": "/Users/dev/code/lib/Cargo.toml", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "6b3a956d3cf6144da563cdb862cd24ce83c97c49173d0ca33a5c11da2cc69841", + "discovered_sources": [ + "f7f452b98eab6a2e194e73de28e02336cfd2a87d3f32f502035858b93537f4dd" + ] + }, + { + "source_id": "8e6f580762e827c39d0425cd39590f52dc5c80555b466bb2081931adf457c994", + "kind": "manifest", + "path": "/Users/dev/code/locked/Cargo.toml", + "status": "partial", + "presence": "unknown", + "reasons": [ + "permission_denied" + ], + "parent_source_id": "6b3a956d3cf6144da563cdb862cd24ce83c97c49173d0ca33a5c11da2cc69841", + "discovered_sources": [] + }, + { + "source_id": "9c2ddf092ca9d4b31a137b6672d2ed3cb4fe88770a9c5086d38fb4f5c3c94e5c", + "kind": "lockfile", + "path": "/Users/dev/code/old/Cargo.lock", + "status": "partial", + "presence": "present", + "reasons": [ + "unsupported_format" + ], + "parent_source_id": "419034f0fe8e8417792da0fe3c598470ed2b64b0ec2f18761daf8f70657adc65", + "discovered_sources": [] + }, + { + "source_id": "419034f0fe8e8417792da0fe3c598470ed2b64b0ec2f18761daf8f70657adc65", + "kind": "manifest", + "path": "/Users/dev/code/old/Cargo.toml", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "6b3a956d3cf6144da563cdb862cd24ce83c97c49173d0ca33a5c11da2cc69841", + "discovered_sources": [ + "9c2ddf092ca9d4b31a137b6672d2ed3cb4fe88770a9c5086d38fb4f5c3c94e5c" + ] + }, + { + "source_id": "2725180af5f8b5e3a011a0d48fb30559b4d7044afd3c988c0d9e82cde8ae74fd", + "kind": "lockfile", + "path": "/Users/dev/code/ws/Cargo.lock", + "status": "partial", + "presence": "present", + "reasons": [ + "path_unresolved" + ], + "parent_source_id": "5d57b04308fbe06210f6352543de5418d376c35acac3211c75ae9379fee1aa51", + "discovered_sources": [] + }, + { + "source_id": "5d57b04308fbe06210f6352543de5418d376c35acac3211c75ae9379fee1aa51", + "kind": "manifest", + "path": "/Users/dev/code/ws/Cargo.toml", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "6b3a956d3cf6144da563cdb862cd24ce83c97c49173d0ca33a5c11da2cc69841", + "discovered_sources": [ + "2725180af5f8b5e3a011a0d48fb30559b4d7044afd3c988c0d9e82cde8ae74fd" + ] + }, + { + "source_id": "bcdf5a06f92bd25fdd295f1656ebcfabb285d912958596edd5a0c0dfec4cc593", + "kind": "manifest", + "path": "/Users/dev/code/ws/crates/core/Cargo.toml", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "6b3a956d3cf6144da563cdb862cd24ce83c97c49173d0ca33a5c11da2cc69841", + "discovered_sources": [] + }, + { + "source_id": "f8b8c8f1c6e745b41c2e6b0565dca26345a1f41c8ddcabf71371c7da5045197f", + "kind": "lockfile", + "path": "/Users/dev/code/ws/crates/skip/Cargo.lock", + "status": "partial", + "presence": "absent", + "reasons": [ + "path_unresolved" + ], + "parent_source_id": "355843aed7375a9971af2bff2577e588c464a7ef188e861cd9e983ceab4693f2", + "discovered_sources": [] + }, + { + "source_id": "355843aed7375a9971af2bff2577e588c464a7ef188e861cd9e983ceab4693f2", + "kind": "manifest", + "path": "/Users/dev/code/ws/crates/skip/Cargo.toml", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "6b3a956d3cf6144da563cdb862cd24ce83c97c49173d0ca33a5c11da2cc69841", + "discovered_sources": [ + "f8b8c8f1c6e745b41c2e6b0565dca26345a1f41c8ddcabf71371c7da5045197f" + ] + }, + { + "source_id": "54e931b84112086fc9b8b3d9f421504b47f82fecd3e9ca1a3b7af232739b96e9", + "kind": "manifest", + "path": "/Users/dev/code/ws/crates/util/Cargo.toml", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "6b3a956d3cf6144da563cdb862cd24ce83c97c49173d0ca33a5c11da2cc69841", + "discovered_sources": [] + }, + { + "source_id": "34e46c93ce42de179b7dbca0b0756e5ac92d44aa811eb1b27b3e1a9672df1dfd", + "kind": "local_registry", + "path": "/Users/dev/code/ws/localreg", + "status": "complete", + "presence": "present", + "reasons": [], + "discovered_sources": [] + }, + { + "source_id": "b4e029f5458222ea9864851ff5d2d717d65a0bbacec71ba2a9e3d38989fcda24", + "kind": "directory_source", + "path": "/Users/dev/code/ws/vendor", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "6b3a956d3cf6144da563cdb862cd24ce83c97c49173d0ca33a5c11da2cc69841", + "discovered_sources": [] + } + ], + "projects": [ + { + "manifest_source_id": "6ea696797b1a49e36144de59a044a173282fb6c3970989d0d1e4cb398e952525", + "manifest_path": "/Users/dev/code/lib/Cargo.toml", + "project_path": "/Users/dev/code/lib", + "package_name": "tiny-lib", + "package_version": "0.1.0", + "lockfile_paths": [ + "/Users/dev/code/lib/Cargo.lock" + ] + }, + { + "manifest_source_id": "419034f0fe8e8417792da0fe3c598470ed2b64b0ec2f18761daf8f70657adc65", + "manifest_path": "/Users/dev/code/old/Cargo.toml", + "project_path": "/Users/dev/code/old", + "package_name": "old", + "package_version": "0.1.0", + "lockfile_paths": [ + "/Users/dev/code/old/Cargo.lock" + ] + }, + { + "manifest_source_id": "5d57b04308fbe06210f6352543de5418d376c35acac3211c75ae9379fee1aa51", + "manifest_path": "/Users/dev/code/ws/Cargo.toml", + "project_path": "/Users/dev/code/ws", + "workspace_manifest_path": "/Users/dev/code/ws/Cargo.toml", + "lockfile_paths": [ + "/Users/dev/code/ws/Cargo.lock" + ] + }, + { + "manifest_source_id": "bcdf5a06f92bd25fdd295f1656ebcfabb285d912958596edd5a0c0dfec4cc593", + "manifest_path": "/Users/dev/code/ws/crates/core/Cargo.toml", + "project_path": "/Users/dev/code/ws/crates/core", + "package_name": "ws-core", + "package_version": "0.3.0", + "workspace_manifest_path": "/Users/dev/code/ws/Cargo.toml", + "lockfile_paths": [ + "/Users/dev/code/ws/Cargo.lock" + ] + }, + { + "manifest_source_id": "355843aed7375a9971af2bff2577e588c464a7ef188e861cd9e983ceab4693f2", + "manifest_path": "/Users/dev/code/ws/crates/skip/Cargo.toml", + "project_path": "/Users/dev/code/ws/crates/skip", + "package_name": "skipped", + "package_version": "0.0.1", + "lockfile_paths": [ + "/Users/dev/code/ws/crates/skip/Cargo.lock" + ] + }, + { + "manifest_source_id": "54e931b84112086fc9b8b3d9f421504b47f82fecd3e9ca1a3b7af232739b96e9", + "manifest_path": "/Users/dev/code/ws/crates/util/Cargo.toml", + "project_path": "/Users/dev/code/ws/crates/util", + "package_name": "ws-util", + "package_version": "0.1.0", + "workspace_manifest_path": "/Users/dev/code/ws/Cargo.toml", + "lockfile_paths": [ + "/Users/dev/code/ws/Cargo.lock" + ] + } + ], + "workspaces": [ + { + "manifest_source_id": "5d57b04308fbe06210f6352543de5418d376c35acac3211c75ae9379fee1aa51", + "manifest_path": "/Users/dev/code/ws/Cargo.toml", + "members": [ + { + "manifest_path": "/Users/dev/code/ws/crates/core/Cargo.toml", + "status": "complete" + }, + { + "manifest_path": "/Users/dev/code/ws/crates/util/Cargo.toml", + "status": "complete" + } + ] + } + ], + "packages": [ + { + "source_id": "475bfb9f26f82c3029eec3f5ed81281a07e39d0849e485f11707861dafba5e86", + "evidence": "cached_package", + "package_name": "anyhow", + "dependency_relation": "unknown", + "reasons": [], + "observed_version": "1.0.86", + "version_status": "known", + "identity_status": "metadata", + "origin": { + "kind": "cache_unknown", + "cache_root": "/Users/dev/.cargo/registry", + "cache_id": "index.crates.io-1949cf8c6b5b557f" + }, + "source_path": "/Users/dev/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/anyhow-1.0.86", + "artifacts": [ + { + "kind": "extracted", + "path": "/Users/dev/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/anyhow-1.0.86", + "presence": "present", + "status": "partial", + "reasons": [ + "extraction_incomplete" + ] + } + ], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "475bfb9f26f82c3029eec3f5ed81281a07e39d0849e485f11707861dafba5e86", + "evidence": "cached_package", + "package_name": "log", + "dependency_relation": "unknown", + "reasons": [], + "observed_version": "0.4.21", + "version_status": "known", + "identity_status": "filename_inferred", + "origin": { + "kind": "cache_unknown", + "cache_root": "/Users/dev/.cargo/registry", + "cache_id": "index.crates.io-1949cf8c6b5b557f" + }, + "source_path": "/Users/dev/.cargo/registry/cache/index.crates.io-1949cf8c6b5b557f/log-0.4.21.crate", + "artifacts": [ + { + "kind": "archive", + "path": "/Users/dev/.cargo/registry/cache/index.crates.io-1949cf8c6b5b557f/log-0.4.21.crate", + "presence": "present", + "status": "partial", + "reasons": [ + "parse_error" + ] + } + ], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "34e46c93ce42de179b7dbca0b0756e5ac92d44aa811eb1b27b3e1a9672df1dfd", + "evidence": "cached_package", + "package_name": "make-me", + "dependency_relation": "unknown", + "reasons": [], + "observed_version": "0.2.0", + "version_status": "known", + "identity_status": "metadata", + "origin": { + "kind": "vendor_unknown", + "path": "/Users/dev/code/ws/localreg" + }, + "source_path": "/Users/dev/code/ws/localreg/make-me-0.2.0.crate", + "artifacts": [ + { + "kind": "archive", + "path": "/Users/dev/code/ws/localreg/make-me-0.2.0.crate", + "presence": "present", + "status": "complete", + "reasons": [] + } + ], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "475bfb9f26f82c3029eec3f5ed81281a07e39d0849e485f11707861dafba5e86", + "evidence": "cached_package", + "package_name": "serde", + "dependency_relation": "unknown", + "reasons": [], + "observed_version": "1.0.200", + "version_status": "known", + "identity_status": "metadata", + "origin": { + "kind": "cache_unknown", + "cache_root": "/Users/dev/.cargo/registry", + "cache_id": "index.crates.io-1949cf8c6b5b557f" + }, + "source_path": "/Users/dev/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde-1.0.200", + "artifacts": [ + { + "kind": "extracted", + "path": "/Users/dev/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde-1.0.200", + "presence": "present", + "status": "complete", + "reasons": [] + }, + { + "kind": "archive", + "path": "/Users/dev/.cargo/registry/cache/index.crates.io-1949cf8c6b5b557f/serde-1.0.200.crate", + "presence": "present", + "status": "complete", + "reasons": [] + } + ], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "6ea696797b1a49e36144de59a044a173282fb6c3970989d0d1e4cb398e952525", + "evidence": "declared_requirement", + "package_name": "anyhow", + "dependency_relation": "direct", + "reasons": [], + "requested_version": "1", + "version_status": "unknown", + "identity_status": "metadata", + "origin": { + "kind": "registry", + "url": "https://github.com/rust-lang/crates.io-index", + "registry_name": "crates-io" + }, + "source_path": "/Users/dev/code/lib/Cargo.toml", + "project_path": "/Users/dev/code/lib", + "declaration": { + "declared_name": "anyhow", + "dependency_kind": "normal", + "features": [], + "workspace_inherited": false + }, + "artifacts": [], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "bcdf5a06f92bd25fdd295f1656ebcfabb285d912958596edd5a0c0dfec4cc593", + "evidence": "declared_requirement", + "package_name": "cc", + "dependency_relation": "direct", + "reasons": [], + "requested_version": "1", + "version_status": "unknown", + "identity_status": "metadata", + "origin": { + "kind": "registry", + "url": "https://github.com/rust-lang/crates.io-index", + "registry_name": "crates-io" + }, + "source_path": "/Users/dev/code/ws/crates/core/Cargo.toml", + "project_path": "/Users/dev/code/ws/crates/core", + "workspace_path": "/Users/dev/code/ws", + "declaration": { + "declared_name": "cc", + "dependency_kind": "build", + "features": [], + "workspace_inherited": false + }, + "artifacts": [], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "bcdf5a06f92bd25fdd295f1656ebcfabb285d912958596edd5a0c0dfec4cc593", + "evidence": "declared_requirement", + "package_name": "libc", + "dependency_relation": "direct", + "reasons": [], + "requested_version": "0.2", + "version_status": "unknown", + "identity_status": "metadata", + "origin": { + "kind": "registry", + "url": "https://github.com/rust-lang/crates.io-index", + "registry_name": "crates-io" + }, + "source_path": "/Users/dev/code/ws/crates/core/Cargo.toml", + "project_path": "/Users/dev/code/ws/crates/core", + "workspace_path": "/Users/dev/code/ws", + "declaration": { + "declared_name": "libc", + "dependency_kind": "normal", + "target": "cfg(unix)", + "features": [], + "workspace_inherited": false + }, + "artifacts": [], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "bcdf5a06f92bd25fdd295f1656ebcfabb285d912958596edd5a0c0dfec4cc593", + "evidence": "declared_requirement", + "package_name": "mystery", + "dependency_relation": "direct", + "reasons": [ + "origin_unresolved" + ], + "requested_version": "1", + "version_status": "unknown", + "identity_status": "metadata", + "origin": { + "kind": "registry_unknown", + "registry_name": "unknown-alias", + "path": "/Users/dev/code/ws/crates/core" + }, + "source_path": "/Users/dev/code/ws/crates/core/Cargo.toml", + "project_path": "/Users/dev/code/ws/crates/core", + "workspace_path": "/Users/dev/code/ws", + "declaration": { + "declared_name": "mystery", + "dependency_kind": "normal", + "features": [], + "workspace_inherited": false + }, + "artifacts": [], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "bcdf5a06f92bd25fdd295f1656ebcfabb285d912958596edd5a0c0dfec4cc593", + "evidence": "declared_requirement", + "package_name": "proptest", + "dependency_relation": "direct", + "reasons": [], + "requested_version": "*", + "version_status": "unknown", + "identity_status": "metadata", + "origin": { + "kind": "registry", + "url": "https://github.com/rust-lang/crates.io-index", + "registry_name": "crates-io" + }, + "source_path": "/Users/dev/code/ws/crates/core/Cargo.toml", + "project_path": "/Users/dev/code/ws/crates/core", + "workspace_path": "/Users/dev/code/ws", + "declaration": { + "declared_name": "proptest", + "dependency_kind": "dev", + "features": [], + "workspace_inherited": false + }, + "artifacts": [], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "bcdf5a06f92bd25fdd295f1656ebcfabb285d912958596edd5a0c0dfec4cc593", + "evidence": "declared_requirement", + "package_name": "secret", + "dependency_relation": "direct", + "reasons": [], + "requested_version": "2", + "version_status": "unknown", + "identity_status": "metadata", + "origin": { + "kind": "registry", + "url": "sparse+https://crates.internal.example/api/", + "registry_name": "internal" + }, + "source_path": "/Users/dev/code/ws/crates/core/Cargo.toml", + "project_path": "/Users/dev/code/ws/crates/core", + "workspace_path": "/Users/dev/code/ws", + "declaration": { + "declared_name": "secret", + "dependency_kind": "normal", + "features": [], + "workspace_inherited": false + }, + "artifacts": [], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "bcdf5a06f92bd25fdd295f1656ebcfabb285d912958596edd5a0c0dfec4cc593", + "evidence": "declared_requirement", + "package_name": "serde", + "dependency_relation": "direct", + "reasons": [], + "requested_version": "1.0", + "version_status": "unknown", + "identity_status": "metadata", + "origin": { + "kind": "registry", + "url": "https://github.com/rust-lang/crates.io-index", + "registry_name": "crates-io" + }, + "source_path": "/Users/dev/code/ws/crates/core/Cargo.toml", + "project_path": "/Users/dev/code/ws/crates/core", + "workspace_path": "/Users/dev/code/ws", + "declaration": { + "declared_name": "serde", + "dependency_kind": "normal", + "optional": true, + "features": [ + "derive", + "rc" + ], + "workspace_inherited": true + }, + "artifacts": [], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "bcdf5a06f92bd25fdd295f1656ebcfabb285d912958596edd5a0c0dfec4cc593", + "evidence": "declared_requirement", + "package_name": "serde_json", + "dependency_relation": "direct", + "reasons": [], + "requested_version": "=1.0.117", + "version_status": "unknown", + "identity_status": "metadata", + "origin": { + "kind": "registry", + "url": "https://github.com/rust-lang/crates.io-index", + "registry_name": "crates-io" + }, + "source_path": "/Users/dev/code/ws/crates/core/Cargo.toml", + "project_path": "/Users/dev/code/ws/crates/core", + "workspace_path": "/Users/dev/code/ws", + "declaration": { + "declared_name": "json", + "dependency_kind": "normal", + "default_features": false, + "features": [], + "workspace_inherited": false + }, + "artifacts": [], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "bcdf5a06f92bd25fdd295f1656ebcfabb285d912958596edd5a0c0dfec4cc593", + "evidence": "declared_requirement", + "package_name": "tokio", + "dependency_relation": "direct", + "reasons": [], + "version_status": "unknown", + "identity_status": "metadata", + "origin": { + "kind": "git", + "url": "https://github.com/tokio-rs/tokio", + "tag": "tokio-1.38.0" + }, + "source_path": "/Users/dev/code/ws/crates/core/Cargo.toml", + "project_path": "/Users/dev/code/ws/crates/core", + "workspace_path": "/Users/dev/code/ws", + "declaration": { + "declared_name": "tokio", + "dependency_kind": "normal", + "features": [], + "workspace_inherited": false + }, + "artifacts": [], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "bcdf5a06f92bd25fdd295f1656ebcfabb285d912958596edd5a0c0dfec4cc593", + "evidence": "declared_requirement", + "package_name": "util", + "dependency_relation": "direct", + "reasons": [], + "version_status": "unknown", + "identity_status": "metadata", + "origin": { + "kind": "local", + "path": "/Users/dev/code/ws/crates/util", + "is_local": true + }, + "source_path": "/Users/dev/code/ws/crates/core/Cargo.toml", + "project_path": "/Users/dev/code/ws/crates/core", + "workspace_path": "/Users/dev/code/ws", + "declaration": { + "declared_name": "util", + "dependency_kind": "normal", + "features": [], + "workspace_inherited": false + }, + "artifacts": [], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "143557442baf02cbb6be674e5ef3a703ace66ba693497698721d39a512c2387d", + "evidence": "git_cached_package", + "package_name": "rand", + "dependency_relation": "unknown", + "reasons": [ + "origin_unresolved" + ], + "observed_version": "0.9.0", + "version_status": "known", + "identity_status": "metadata", + "origin": { + "kind": "git_unknown", + "cache_root": "/Users/dev/.cargo/git/checkouts", + "cache_id": "rand-99aa00bb11cc22dd/fedcba9" + }, + "source_path": "/Users/dev/.cargo/git/checkouts/rand-99aa00bb11cc22dd/fedcba9", + "artifacts": [ + { + "kind": "git_checkout", + "path": "/Users/dev/.cargo/git/checkouts/rand-99aa00bb11cc22dd/fedcba9", + "presence": "present", + "status": "partial", + "reasons": [ + "extraction_incomplete" + ] + } + ], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "143557442baf02cbb6be674e5ef3a703ace66ba693497698721d39a512c2387d", + "evidence": "git_cached_package", + "package_name": "tokio", + "dependency_relation": "unknown", + "reasons": [], + "observed_version": "1.38.0", + "version_status": "known", + "identity_status": "metadata", + "origin": { + "kind": "git_unknown", + "cache_root": "/Users/dev/.cargo/git/checkouts", + "cache_id": "tokio-6ad7bb1d2a1b1c1a/0123456", + "resolved_revision": "0123456789abcdef0123456789abcdef01234567" + }, + "source_path": "/Users/dev/.cargo/git/checkouts/tokio-6ad7bb1d2a1b1c1a/0123456/tokio", + "artifacts": [ + { + "kind": "git_checkout", + "path": "/Users/dev/.cargo/git/checkouts/tokio-6ad7bb1d2a1b1c1a/0123456/tokio", + "presence": "present", + "status": "complete", + "reasons": [] + } + ], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "df3970b19cab9a65a2d37be09f6a80abab641114265d69b0c7eccd9964cc4105", + "evidence": "installed_tool", + "package_name": "mytool", + "dependency_relation": "unknown", + "reasons": [], + "observed_version": "0.1.0", + "version_status": "known", + "identity_status": "metadata", + "origin": { + "kind": "git", + "url": "https://github.com/example/mytool", + "branch": "main", + "resolved_revision": "89abcdef0123456789abcdef0123456789abcdef" + }, + "source_path": "/Users/dev/.cargo/.crates.toml", + "artifacts": [], + "installation": { + "root_path": "/Users/dev/.cargo", + "bins": [ + { + "name": "mytool", + "path": "/Users/dev/.cargo/bin/mytool", + "presence": "present" + }, + { + "name": "mytool-helper", + "path": "/Users/dev/.cargo/bin/mytool-helper", + "presence": "absent" + } + ], + "status": "partial" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "df3970b19cab9a65a2d37be09f6a80abab641114265d69b0c7eccd9964cc4105", + "evidence": "installed_tool", + "package_name": "ripgrep", + "dependency_relation": "unknown", + "reasons": [], + "observed_version": "14.1.0", + "version_status": "known", + "identity_status": "metadata", + "origin": { + "kind": "registry", + "url": "https://github.com/rust-lang/crates.io-index" + }, + "source_path": "/Users/dev/.cargo/.crates.toml", + "artifacts": [], + "installation": { + "root_path": "/Users/dev/.cargo", + "bins": [ + { + "name": "rg", + "path": "/Users/dev/.cargo/bin/rg", + "presence": "present" + } + ], + "status": "complete", + "version_req": "^14", + "features": [ + "pcre2" + ], + "all_features": false, + "no_default_features": false, + "target": "aarch64-apple-darwin", + "profile": "release", + "rustc": "rustc 1.79.0 (129f3b996 2024-06-10)" + }, + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "2725180af5f8b5e3a011a0d48fb30559b4d7044afd3c988c0d9e82cde8ae74fd", + "evidence": "locked_package", + "package_name": "memchr", + "dependency_relation": "unknown", + "reasons": [ + "checksum_mismatch" + ], + "observed_version": "2.7.4", + "version_status": "known", + "identity_status": "metadata", + "origin": { + "kind": "registry", + "url": "https://github.com/rust-lang/crates.io-index" + }, + "source_path": "/Users/dev/code/ws/Cargo.lock", + "project_path": "/Users/dev/code/ws", + "workspace_path": "/Users/dev/code/ws", + "artifacts": [], + "checksum_status": "partial", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a8e50a1b0d8d14d4b5", + "source_kind": "lockfile", + "source_path": "/Users/dev/code/ws/Cargo.lock", + "source_id": "2725180af5f8b5e3a011a0d48fb30559b4d7044afd3c988c0d9e82cde8ae74fd", + "verification": "not_verified" + }, + { + "algorithm": "sha256", + "value": "0000000000000000000000000000000000000000000000000000000000000000", + "source_kind": "lockfile", + "source_path": "/Users/dev/code/ws/Cargo.lock", + "source_id": "2725180af5f8b5e3a011a0d48fb30559b4d7044afd3c988c0d9e82cde8ae74fd", + "verification": "not_verified" + } + ] + }, + { + "source_id": "2725180af5f8b5e3a011a0d48fb30559b4d7044afd3c988c0d9e82cde8ae74fd", + "evidence": "locked_package", + "package_name": "orphan-local", + "dependency_relation": "unknown", + "reasons": [], + "observed_version": "0.9.0", + "version_status": "known", + "identity_status": "metadata", + "origin": { + "kind": "local_unknown", + "path": "/Users/dev/code/ws/Cargo.lock", + "is_local": true + }, + "source_path": "/Users/dev/code/ws/Cargo.lock", + "project_path": "/Users/dev/code/ws", + "workspace_path": "/Users/dev/code/ws", + "artifacts": [], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "2725180af5f8b5e3a011a0d48fb30559b4d7044afd3c988c0d9e82cde8ae74fd", + "evidence": "locked_package", + "package_name": "serde", + "dependency_relation": "unknown", + "reasons": [], + "observed_version": "1.0.200", + "version_status": "known", + "identity_status": "metadata", + "origin": { + "kind": "registry", + "url": "https://github.com/rust-lang/crates.io-index" + }, + "source_path": "/Users/dev/code/ws/Cargo.lock", + "project_path": "/Users/dev/code/ws", + "workspace_path": "/Users/dev/code/ws", + "artifacts": [], + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f", + "source_kind": "lockfile", + "source_path": "/Users/dev/code/ws/Cargo.lock", + "source_id": "2725180af5f8b5e3a011a0d48fb30559b4d7044afd3c988c0d9e82cde8ae74fd", + "verification": "not_verified" + } + ] + }, + { + "source_id": "2725180af5f8b5e3a011a0d48fb30559b4d7044afd3c988c0d9e82cde8ae74fd", + "evidence": "locked_package", + "package_name": "serde_json", + "dependency_relation": "unknown", + "reasons": [ + "checksum_invalid" + ], + "observed_version": "1.0.117", + "version_status": "known", + "identity_status": "metadata", + "origin": { + "kind": "registry", + "url": "sparse+https://index.crates.io/" + }, + "source_path": "/Users/dev/code/ws/Cargo.lock", + "project_path": "/Users/dev/code/ws", + "workspace_path": "/Users/dev/code/ws", + "artifacts": [], + "checksum_status": "partial", + "recorded_checksums": [] + }, + { + "source_id": "2725180af5f8b5e3a011a0d48fb30559b4d7044afd3c988c0d9e82cde8ae74fd", + "evidence": "locked_package", + "package_name": "tokio", + "dependency_relation": "unknown", + "reasons": [], + "observed_version": "1.38.0", + "version_status": "known", + "identity_status": "metadata", + "origin": { + "kind": "git", + "url": "https://github.com/tokio-rs/tokio", + "tag": "tokio-1.38.0", + "resolved_revision": "0123456789abcdef0123456789abcdef01234567" + }, + "source_path": "/Users/dev/code/ws/Cargo.lock", + "project_path": "/Users/dev/code/ws", + "workspace_path": "/Users/dev/code/ws", + "artifacts": [], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "2725180af5f8b5e3a011a0d48fb30559b4d7044afd3c988c0d9e82cde8ae74fd", + "evidence": "locked_package", + "package_name": "ws-util", + "dependency_relation": "unknown", + "reasons": [], + "observed_version": "0.1.0", + "version_status": "known", + "identity_status": "metadata", + "origin": { + "kind": "local", + "path": "/Users/dev/code/ws/crates/util", + "is_local": true + }, + "source_path": "/Users/dev/code/ws/Cargo.lock", + "project_path": "/Users/dev/code/ws", + "workspace_path": "/Users/dev/code/ws", + "artifacts": [], + "checksum_status": "absent", + "recorded_checksums": [] + }, + { + "source_id": "b4e029f5458222ea9864851ff5d2d717d65a0bbacec71ba2a9e3d38989fcda24", + "evidence": "vendored_package", + "package_name": "itoa", + "dependency_relation": "unknown", + "reasons": [], + "observed_version": "1.0.11", + "version_status": "known", + "identity_status": "metadata", + "origin": { + "kind": "vendor_unknown", + "path": "/Users/dev/code/ws/vendor" + }, + "source_path": "/Users/dev/code/ws/vendor/itoa", + "artifacts": [ + { + "kind": "vendor", + "path": "/Users/dev/code/ws/vendor/itoa", + "presence": "present", + "status": "complete", + "reasons": [] + } + ], + "checksum_status": "recorded", + "recorded_checksums": [ + { + "algorithm": "sha256", + "value": "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b", + "source_kind": "vendor_checksum", + "source_path": "/Users/dev/code/ws/vendor/itoa/.cargo-checksum.json", + "source_id": "b4e029f5458222ea9864851ff5d2d717d65a0bbacec71ba2a9e3d38989fcda24", + "verification": "not_verified" + } + ] + } + ] + }, + "cargo_config_audit": { + "schema_version": 1, + "status": "partial", + "reasons": [ + "config_include_unresolved" + ], + "files": [ + { + "source_id": "617f2dedd54f1f0e6fb6ed0f7d671a87a68a08c164d15ba7ae23465c1cb898a2", + "scope": "included", + "path": "/Users/dev/.cargo/extra.toml", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "net.git-fetch-with-cli", + "display": "true", + "source_id": "617f2dedd54f1f0e6fb6ed0f7d671a87a68a08c164d15ba7ae23465c1cb898a2", + "redacted": false + } + ], + "include_parent_source_id": "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478" + }, + { + "source_id": "9cb9c53ce482e002c10064cdc1ac8a902c97b7f35de51013576feaa21238534d", + "scope": "included", + "path": "/Users/dev/code/lib/.cargo/missing.toml", + "status": "absent", + "reasons": [], + "settings": [], + "include_parent_source_id": "b23fe9622a2fc51bf7f3a92a71f27b7d99f8ce023496776f9113629010b12349" + }, + { + "source_id": "fcbdb66633c76fc24485747279521cf63106db17fc3797f1632f746a299eec3b", + "scope": "process", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "CARGO_NET_OFFLINE", + "display": "true", + "source_id": "fcbdb66633c76fc24485747279521cf63106db17fc3797f1632f746a299eec3b", + "redacted": false + }, + { + "key": "CARGO_REGISTRIES_CORP_TOKEN", + "display": "configured", + "source_id": "fcbdb66633c76fc24485747279521cf63106db17fc3797f1632f746a299eec3b", + "redacted": true + } + ] + }, + { + "source_id": "b23fe9622a2fc51bf7f3a92a71f27b7d99f8ce023496776f9113629010b12349", + "scope": "project", + "path": "/Users/dev/code/lib/.cargo/config", + "status": "invalid", + "reasons": [ + "config_include_unresolved" + ], + "settings": [ + { + "key": "include", + "display": "/Users/dev/code/lib/.cargo/missing.toml", + "source_id": "b23fe9622a2fc51bf7f3a92a71f27b7d99f8ce023496776f9113629010b12349", + "redacted": false + }, + { + "key": "net.git-fetch-with-cli", + "display": "true", + "source_id": "b23fe9622a2fc51bf7f3a92a71f27b7d99f8ce023496776f9113629010b12349", + "redacted": false + } + ] + }, + { + "source_id": "e9de3efb4866e2faad1ca0ebf6dc8cd341923f2eab7a6bf5b55728163819d59e", + "scope": "project", + "path": "/Users/dev/code/lib/.cargo/config.toml", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "net.offline", + "display": "true", + "source_id": "e9de3efb4866e2faad1ca0ebf6dc8cd341923f2eab7a6bf5b55728163819d59e", + "redacted": false + } + ], + "shadowed_by": "b23fe9622a2fc51bf7f3a92a71f27b7d99f8ce023496776f9113629010b12349" + }, + { + "source_id": "13a627b21ef76c78bda217bd029e02c378abd37030e3e283ca5024c623eef719", + "scope": "project", + "path": "/Users/dev/code/ws/.cargo/config.toml", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "registries.internal.index", + "display": "sparse+https://crates.internal.example/api/", + "source_id": "13a627b21ef76c78bda217bd029e02c378abd37030e3e283ca5024c623eef719", + "redacted": false + }, + { + "key": "resolver.lockfile-path", + "display": "{workspace}/Cargo.lock", + "source_id": "13a627b21ef76c78bda217bd029e02c378abd37030e3e283ca5024c623eef719", + "redacted": false + }, + { + "key": "source.crates-io.replace-with", + "display": "vendored", + "source_id": "13a627b21ef76c78bda217bd029e02c378abd37030e3e283ca5024c623eef719", + "redacted": false + }, + { + "key": "source.gitmirror.branch", + "display": "main", + "source_id": "13a627b21ef76c78bda217bd029e02c378abd37030e3e283ca5024c623eef719", + "redacted": false + }, + { + "key": "source.gitmirror.git", + "display": "http://git.example/mirror.git", + "source_id": "13a627b21ef76c78bda217bd029e02c378abd37030e3e283ca5024c623eef719", + "redacted": false + }, + { + "key": "source.localreg.local-registry", + "display": "/Users/dev/code/ws/localreg", + "source_id": "13a627b21ef76c78bda217bd029e02c378abd37030e3e283ca5024c623eef719", + "redacted": false + }, + { + "key": "source.mirror.registry", + "display": "sparse+https://mirror.example/index/", + "source_id": "13a627b21ef76c78bda217bd029e02c378abd37030e3e283ca5024c623eef719", + "redacted": false + }, + { + "key": "source.vendored.directory", + "display": "/Users/dev/code/ws/vendor", + "source_id": "13a627b21ef76c78bda217bd029e02c378abd37030e3e283ca5024c623eef719", + "redacted": false + } + ] + }, + { + "source_id": "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "scope": "user", + "path": "/Users/dev/.cargo/config.toml", + "status": "present", + "reasons": [], + "settings": [ + { + "key": "http.check-revoke", + "display": "false", + "source_id": "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "redacted": false + }, + { + "key": "http.proxy", + "display": "http://proxy.example:8080", + "source_id": "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "redacted": true + }, + { + "key": "include", + "display": "/Users/dev/.cargo/extra.toml", + "source_id": "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "redacted": false + }, + { + "key": "install.root", + "display": "/opt/cargo-tools", + "source_id": "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "redacted": false + }, + { + "key": "registries.corp.credential-provider", + "display": "cargo:token-from-stdout", + "source_id": "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "redacted": true + }, + { + "key": "registries.corp.index", + "display": "http://registry.corp.example/index", + "source_id": "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "redacted": true + }, + { + "key": "registries.corp.token", + "display": "configured", + "source_id": "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "redacted": true + } + ] + } + ], + "findings": [ + { + "code": "cargo-001", + "severity": "MEDIUM", + "source_id": "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "key": "registries.corp.index", + "detail": "registry index uses plaintext http transport" + }, + { + "code": "cargo-002", + "severity": "MEDIUM", + "source_id": "13a627b21ef76c78bda217bd029e02c378abd37030e3e283ca5024c623eef719", + "key": "source.gitmirror.git", + "detail": "Git source uses http or unauthenticated git transport" + }, + { + "code": "cargo-003", + "severity": "MEDIUM", + "source_id": "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "key": "registries.corp.token", + "detail": "registry token is written in a config file rather than a credentials file or provider" + }, + { + "code": "cargo-004", + "severity": "LOW", + "source_id": "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "key": "http.check-revoke", + "detail": "certificate revocation checking is disabled; TLS peer validation is unaffected" + } + ], + "contexts": [ + { + "config_source_ids": [ + "fcbdb66633c76fc24485747279521cf63106db17fc3797f1632f746a299eec3b", + "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "617f2dedd54f1f0e6fb6ed0f7d671a87a68a08c164d15ba7ae23465c1cb898a2" + ], + "selection_status": "observed" + }, + { + "project_path": "/Users/dev/code/lib", + "config_source_ids": [ + "fcbdb66633c76fc24485747279521cf63106db17fc3797f1632f746a299eec3b", + "b23fe9622a2fc51bf7f3a92a71f27b7d99f8ce023496776f9113629010b12349", + "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "617f2dedd54f1f0e6fb6ed0f7d671a87a68a08c164d15ba7ae23465c1cb898a2" + ], + "selection_status": "partial" + }, + { + "project_path": "/Users/dev/code/old", + "config_source_ids": [ + "fcbdb66633c76fc24485747279521cf63106db17fc3797f1632f746a299eec3b", + "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "617f2dedd54f1f0e6fb6ed0f7d671a87a68a08c164d15ba7ae23465c1cb898a2" + ], + "selection_status": "observed" + }, + { + "project_path": "/Users/dev/code/ws", + "workspace_path": "/Users/dev/code/ws", + "config_source_ids": [ + "fcbdb66633c76fc24485747279521cf63106db17fc3797f1632f746a299eec3b", + "13a627b21ef76c78bda217bd029e02c378abd37030e3e283ca5024c623eef719", + "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "617f2dedd54f1f0e6fb6ed0f7d671a87a68a08c164d15ba7ae23465c1cb898a2" + ], + "selection_status": "observed" + }, + { + "project_path": "/Users/dev/code/ws/crates/core", + "workspace_path": "/Users/dev/code/ws", + "config_source_ids": [ + "fcbdb66633c76fc24485747279521cf63106db17fc3797f1632f746a299eec3b", + "13a627b21ef76c78bda217bd029e02c378abd37030e3e283ca5024c623eef719", + "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "617f2dedd54f1f0e6fb6ed0f7d671a87a68a08c164d15ba7ae23465c1cb898a2" + ], + "selection_status": "observed" + }, + { + "project_path": "/Users/dev/code/ws/crates/skip", + "config_source_ids": [ + "fcbdb66633c76fc24485747279521cf63106db17fc3797f1632f746a299eec3b", + "13a627b21ef76c78bda217bd029e02c378abd37030e3e283ca5024c623eef719", + "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "617f2dedd54f1f0e6fb6ed0f7d671a87a68a08c164d15ba7ae23465c1cb898a2" + ], + "selection_status": "observed" + }, + { + "project_path": "/Users/dev/code/ws/crates/util", + "workspace_path": "/Users/dev/code/ws", + "config_source_ids": [ + "fcbdb66633c76fc24485747279521cf63106db17fc3797f1632f746a299eec3b", + "13a627b21ef76c78bda217bd029e02c378abd37030e3e283ca5024c623eef719", + "d19e0b5e61d61111310927948b3dfc647601c1d87c3f3c31b45ce223fef49478", + "617f2dedd54f1f0e6fb6ed0f7d671a87a68a08c164d15ba7ae23465c1cb898a2" + ], + "selection_status": "observed" + } + ], + "credential_files": [ + { + "path": "/Users/dev/.cargo/credentials", + "status": "absent", + "presence": "absent" + }, + { + "path": "/Users/dev/.cargo/credentials.toml", + "status": "present", + "presence": "present" + } + ] + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 404fcc1..4d205ad 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -119,8 +119,10 @@ type Payload struct { // machine's browsers hold no extensions. BrowserExtensionScan *model.BrowserExtensionScanInfo `json:"browser_extension_scan,omitempty"` // Full bounded snapshots on every run, independent of the npm/Python deltas. - GoInventory *model.GoInventory `json:"go_inventory,omitempty"` - GoConfigAudit *model.GoConfigAudit `json:"go_config_audit,omitempty"` + GoInventory *model.GoInventory `json:"go_inventory,omitempty"` + GoConfigAudit *model.GoConfigAudit `json:"go_config_audit,omitempty"` + CargoInventory *model.CargoInventory `json:"cargo_inventory,omitempty"` + CargoConfigAudit *model.CargoConfigAudit `json:"cargo_config_audit,omitempty"` ExecutionLogs *ExecutionLogs `json:"execution_logs,omitempty"` PerformanceMetrics *PerformanceMetrics `json:"performance_metrics,omitempty"` @@ -1083,6 +1085,17 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err endPhase(phaseCtx, phaseCancel, tracker, log, "go_scan") postPhase() + // Rust packages and Cargo configuration, read statically the same way: no + // cargo, rustc or git command, shell or network, and the raw executor. + phaseCtx, phaseCancel = startPhase(ctx, tracker, "cargo_scan") + log.Progress("Collecting Rust packages and Cargo configuration...") + cargoInventory, cargoConfigAudit := detector.NewCargoScanner(exec, log).Scan(phaseCtx, browserTarget, searchDirs, cfg.IncludeNetworkVolumes) + log.Progress(" Rust: %d projects, %d packages (inventory %s, config %s)", + len(cargoInventory.Projects), len(cargoInventory.Packages), cargoInventory.Status, cargoConfigAudit.Status) + fmt.Fprintln(os.Stderr) + endPhase(phaseCtx, phaseCancel, tracker, log, "cargo_scan") + postPhase() + // npm + pip configuration audits — surface-only inventory of every // .npmrc and pip.conf on the host, plus the merged effective views // each tool would resolve. We use the user-aware executor so npm and @@ -1245,6 +1258,8 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err BrowserExtensionScan: browserExtensionScan, GoInventory: goInventory, GoConfigAudit: goConfigAudit, + CargoInventory: cargoInventory, + CargoConfigAudit: cargoConfigAudit, ExecutionLogs: &ExecutionLogs{ OutputBase64: execLogsBase64, From 93f42720eaed21ec8494b0a0e267ee773128b2ec Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Sat, 3 Oct 2026 12:59:13 +0530 Subject: [PATCH 2/2] fix(detector): correct Cargo path origins and Go config coverage --- internal/detector/cargoscan.go | 70 ++++++++--- internal/detector/cargoscan_test.go | 125 ++++++++++++++++++++ internal/detector/configaudit/goenv.go | 8 +- internal/detector/configaudit/goenv_test.go | 27 +++++ internal/detector/goscan.go | 6 +- internal/detector/goscan_test.go | 22 ++++ 6 files changed, 237 insertions(+), 21 deletions(-) diff --git a/internal/detector/cargoscan.go b/internal/detector/cargoscan.go index 236a6e6..e006131 100644 --- a/internal/detector/cargoscan.go +++ b/internal/detector/cargoscan.go @@ -130,24 +130,30 @@ func (s *CargoScanner) Scan(ctx context.Context, target *user.User, searchDirs [ for _, r := range walk { g.walkRoot(r) } - g.readQueued() - g.associate() - for g.enqueueInherited() { + var audit model.CargoConfigAudit + for { g.readQueued() g.associate() - } - - audit, snap := detector.Detect(ctx, configaudit.CargoConfigScope{ - Username: g.identity, Home: home, Roots: g.roots, Protected: guard, Volume: volume, - ProcessVerified: g.verified, CargoHome: g.cargoHome, Contexts: g.contexts(), RegistryNames: g.registryNames(), - }) - g.snap = snap - for _, p := range snap.LocalPaths() { - if s := g.enqueue(filepath.Join(p, "Cargo.toml"), ""); s != nil { - s.override = true + for g.enqueueInherited() { + g.readQueued() + g.associate() + } + audit, g.snap = detector.Detect(ctx, configaudit.CargoConfigScope{ + Username: g.identity, Home: home, Roots: g.roots, Protected: guard, Volume: volume, + ProcessVerified: g.verified, CargoHome: g.cargoHome, Contexts: g.contexts(), RegistryNames: g.registryNames(), + }) + before := len(g.states) + for _, p := range g.snap.LocalPaths() { + if s := g.enqueue(filepath.Join(p, "Cargo.toml"), ""); s != nil { + s.override = true + } + } + if len(g.states) == before { + break } + // Config overrides need the same workspace and inherited-path handling + // as walked manifests. enqueue deduplicates and bounds the queue. } - g.readQueued() g.registerManifests() g.emitProjects() @@ -1004,6 +1010,7 @@ func (g *cargoScan) addPackage(src *model.CargoSource, p model.CargoPackage) boo type cargoLockGroup struct { root *cargoManifestState members []*cargoManifestState + locals []*cargoManifestState } // emitLockfiles reads the lockfile each context selects, once per path, and @@ -1024,6 +1031,7 @@ func (g *cargoScan) emitLockfiles() { projects[g.projects[i].ManifestSourceID] = &g.projects[i] } for _, grp := range groups { + grp.locals = g.lockLocalPackages(grp.members) unresolved := map[string]bool{} var paths []string for _, m := range grp.members { @@ -1113,10 +1121,38 @@ func (g *cargoScan) readLockfile(grp cargoLockGroup, path string, unresolved boo } } +// lockLocalPackages follows already-read path dependencies and overrides from +// this lockfile's manifests. Unrelated checkouts cannot establish its origins. +func (g *cargoScan) lockLocalPackages(members []*cargoManifestState) []*cargoManifestState { + queue := slices.Clone(members) + seen := map[*cargoManifestState]bool{} + for _, s := range queue { + seen[s] = true + } + var out []*cargoManifestState + for i := 0; i < len(queue); i++ { + s := queue[i] + for _, d := range slices.Concat(s.m.deps, s.m.overrides) { + dir := cargoPathDepDir(s, s.root, d) + if dir == "" { + continue + } + dep := g.byManifest[g.key(filepath.Join(dir, "Cargo.toml"))] + if dep == nil || dep.m == nil || seen[dep] { + continue + } + seen[dep] = true + queue = append(queue, dep) + out = append(out, dep) + } + } + return out +} + // localLockOrigin places a source-less lock entry. A workspace member or root // package is context, kept only when a declaration references it. Otherwise a -// single manifest with that name and version is its local package; anything -// else stays local_unknown, scoped to the lockfile. +// single referenced path package with that name and version is its origin; +// anything else stays local_unknown, scoped to the lockfile. func (g *cargoScan) localLockOrigin(grp cargoLockGroup, e cargoLockEntry, lockPath string) (model.CargoOrigin, bool) { same := func(s *cargoManifestState) bool { return s.m != nil && s.m.name == e.name && s.lockVersion() == e.version @@ -1132,7 +1168,7 @@ func (g *cargoScan) localLockOrigin(grp cargoLockGroup, e cargoLockEntry, lockPa return model.CargoOrigin{Kind: model.CargoOriginLocal, Path: member.dir, IsLocal: true}, referenced } var match *cargoManifestState - for _, s := range g.states { + for _, s := range grp.locals { if same(s) { if match != nil { return model.CargoOrigin{Kind: model.CargoOriginLocalUnknown, Path: lockPath, IsLocal: true}, true diff --git a/internal/detector/cargoscan_test.go b/internal/detector/cargoscan_test.go index 8a9f734..8477729 100644 --- a/internal/detector/cargoscan_test.go +++ b/internal/detector/cargoscan_test.go @@ -205,3 +205,128 @@ func TestCargoScan_UnreadableBinIsUnreadable(t *testing.T) { } t.Fatal("no installed_tool record") } + +func TestCargoScan_ConfigPatchWorkspace(t *testing.T) { + home := goTestHome(t) + code := filepath.Join(home, "code") + app := filepath.Join(code, "app") + ws := filepath.Join(home, "outside", "workspace") + goWrite(t, filepath.Join(app, "Cargo.toml"), "[package]\nname = \"app\"\nversion = \"0.1.0\"\n[dependencies]\nwidgets = \"1\"\n", 0o644) + goWrite(t, filepath.Join(app, ".cargo", "config.toml"), fmt.Sprintf("[patch.crates-io]\nwidgets = { path = %q }\n", filepath.ToSlash(filepath.Join(ws, "member"))), 0o644) + goWrite(t, filepath.Join(ws, "Cargo.toml"), `[workspace] +members = ["member"] +[workspace.package] +version = "1.2.3" +[workspace.dependencies] +helper = { path = "helper", package = "actual-helper" } +`, 0o644) + goWrite(t, filepath.Join(ws, "member", "Cargo.toml"), `[package] +name = "widgets" +version.workspace = true +workspace = ".." +[dependencies] +helper.workspace = true +`, 0o644) + goWrite(t, filepath.Join(ws, "helper", "Cargo.toml"), "[package]\nname = \"actual-helper\"\nversion = \"0.1.0\"\n", 0o644) + for _, dir := range []string{app, filepath.Join(ws, "member"), filepath.Join(ws, "helper")} { + goWrite(t, filepath.Join(dir, "src", "lib.rs"), "", 0o644) + } + inv, audit := cargoTestScan(t, home, code) + var widgets, helper, contextFound bool + for _, p := range inv.Projects { + if p.PackageName == "widgets" { + widgets = true + if p.PackageVersion != "1.2.3" || p.WorkspaceManifestPath != filepath.Join(ws, "Cargo.toml") { + t.Errorf("widgets version = %q, workspace = %q, want inherited version and workspace", p.PackageVersion, p.WorkspaceManifestPath) + } + } + } + for _, p := range inv.Packages { + if p.Evidence == model.CargoEvidenceDeclaredRequirement && p.PackageName == "actual-helper" { + helper = p.Origin.Kind == model.CargoOriginLocal && p.Origin.Path == filepath.Join(ws, "helper") + } + if p.PackageName == "helper" { + t.Errorf("dependency alias emitted as package name: %+v", p) + } + } + for _, c := range audit.Contexts { + if c.ProjectPath == filepath.Join(ws, "member") && c.WorkspacePath == ws { + contextFound = true + } + } + if !widgets || !helper || !contextFound { + t.Errorf("widgets=%v, inherited helper=%v, config context=%v, want all true", widgets, helper, contextFound) + } +} + +func TestCargoScan_LocalLockOriginRequiresReference(t *testing.T) { + for _, tc := range []struct { + name string + present, transitive bool + }{ + {name: "missing"}, + {name: "direct", present: true}, + {name: "transitive", present: true, transitive: true}, + } { + t.Run(tc.name, func(t *testing.T) { + home := goTestHome(t) + code := filepath.Join(home, "code") + referenced := filepath.Join(home, "outside", "shared") + goWrite(t, filepath.Join(code, "app", "Cargo.toml"), `[package] +name = "app" +version = "0.1.0" +[dependencies] +shared = {path = "../../outside/shared"} +`, 0o644) + goWrite(t, filepath.Join(code, "app", "Cargo.lock"), `version = 4 +[[package]] +name = "app" +version = "0.1.0" +dependencies = ["shared"] +[[package]] +name = "shared" +version = "1.2.3" +`, 0o644) + manifest := "[package]\nname = \"shared\"\nversion = \"1.2.3\"\n" + goWrite(t, filepath.Join(code, "unrelated", "Cargo.toml"), manifest, 0o644) + if tc.present { + goWrite(t, filepath.Join(referenced, "Cargo.toml"), manifest, 0o644) + } + if tc.transitive { + goWrite(t, filepath.Join(code, "app", "Cargo.lock"), `version = 4 +[[package]] +name = "app" +version = "0.1.0" +dependencies = ["bridge"] +[[package]] +name = "bridge" +version = "0.1.0" +dependencies = ["shared"] +[[package]] +name = "shared" +version = "1.2.3" +`, 0o644) + goWrite(t, filepath.Join(code, "app", "Cargo.toml"), "[package]\nname = \"app\"\nversion = \"0.1.0\"\n[dependencies]\nbridge = {path = \"../../outside/bridge\"}\n", 0o644) + goWrite(t, filepath.Join(home, "outside", "bridge", "Cargo.toml"), "[package]\nname = \"bridge\"\nversion = \"0.1.0\"\n[dependencies]\nshared = {path = \"../shared\"}\n", 0o644) + } + inv, _ := cargoTestScan(t, home, code) + found := false + for _, p := range inv.Packages { + if p.Evidence != model.CargoEvidenceLockedPackage || p.PackageName != "shared" { + continue + } + found = true + kind, path := model.CargoOriginLocalUnknown, filepath.Join(code, "app", "Cargo.lock") + if tc.present { + kind, path = model.CargoOriginLocal, referenced + } + if p.Origin.Kind != kind || p.Origin.Path != path { + t.Errorf("locked shared origin = %s %s, want %s %s", p.Origin.Kind, p.Origin.Path, kind, path) + } + } + if !found { + t.Fatal("locked shared package missing") + } + }) + } +} diff --git a/internal/detector/configaudit/goenv.go b/internal/detector/configaudit/goenv.go index 5d70f55..1979437 100644 --- a/internal/detector/configaudit/goenv.go +++ b/internal/detector/configaudit/goenv.go @@ -750,7 +750,9 @@ func goProxyFallsBackPublic(raw string) bool { switch public := goIsPublicProxy(e); { case e == "off": return false - case e == "direct" || public: + case e == "direct": + return private + case public: if private { return true } @@ -762,6 +764,10 @@ func goProxyFallsBackPublic(raw string) bool { } func goIsPublicProxy(entry string) bool { + // Go accepts host names without a URL scheme as HTTPS proxies. + if strings.ContainsAny(entry, ".:/") && !strings.Contains(entry, ":/") && !filepath.IsAbs(entry) && !strings.HasPrefix(entry, "/") { + entry = "https://" + entry + } u, err := url.Parse(entry) return err == nil && goPublicProxyHosts[strings.ToLower(u.Hostname())] } diff --git a/internal/detector/configaudit/goenv_test.go b/internal/detector/configaudit/goenv_test.go index ceca4de..d78e738 100644 --- a/internal/detector/configaudit/goenv_test.go +++ b/internal/detector/configaudit/goenv_test.go @@ -449,3 +449,30 @@ func TestGoWithinRoots(t *testing.T) { }) } } + +func TestGoEnvProxyFallback(t *testing.T) { + for _, tc := range []struct { + proxy string + want bool + }{ + {"proxy.golang.org,direct", false}, + {"https://proxy.golang.org,direct", false}, + {"direct,https://private.example,https://proxy.golang.org", false}, + {"https://private.example,direct", true}, + {"https://private.example|proxy.golang.org", true}, + {"https://private.example,off,https://proxy.golang.org", false}, + } { + t.Run(tc.proxy, func(t *testing.T) { + clearGoEnvVars(t) + home := goTestHome(t) + path := filepath.Join(home, "go.env") + mustWriteGoFile(t, path, "GOPROXY="+tc.proxy+"\n") + t.Setenv("GOENV", path) + audit, _ := NewGoEnvDetector(executor.NewReal()).Detect(context.Background(), GoEnvScope{Username: "dev", Home: home, Roots: []string{home}, ProcessVerified: true}) + got := slices.ContainsFunc(audit.Findings, func(f model.GoConfigFinding) bool { return f.Code == "go-003" }) + if got != tc.want { + t.Errorf("GOPROXY=%q: go-003=%v, want %v", tc.proxy, got, tc.want) + } + }) + } +} diff --git a/internal/detector/goscan.go b/internal/detector/goscan.go index 42ec67c..c38d56e 100644 --- a/internal/detector/goscan.go +++ b/internal/detector/goscan.go @@ -755,6 +755,9 @@ func (g *goScan) readVendor(dir, parentID string, owner *model.GoProject, sums [ // returns a reader rooted at its physical path, so a redirected root can // neither leave scope nor be swapped afterwards. func (g *goScan) goRootFS(src *model.GoSource, limit int64) (executor.Executor, string, bool) { + if g.snap.RedirectUnknown { + goDegrade(src, model.GoStatusPartial, model.GoReasonRootRedirectUnknown) + } if reason := g.guard(src.Path); reason != "" { src.Presence = model.GoPresenceUnknown goDegrade(src, model.GoStatusSkipped, reason) @@ -769,9 +772,6 @@ func (g *goScan) goRootFS(src *model.GoSource, limit int64) (executor.Executor, goDegrade(src, goRefusalStatus(err), configaudit.GoReadReason(err)) return nil, "", false } - if g.snap.RedirectUnknown { - goDegrade(src, model.GoStatusPartial, model.GoReasonRootRedirectUnknown) - } return g.exec.GuardedFiles([]string{phys}, g.guard, limit), phys, true } diff --git a/internal/detector/goscan_test.go b/internal/detector/goscan_test.go index 544aba1..0a44eb6 100644 --- a/internal/detector/goscan_test.go +++ b/internal/detector/goscan_test.go @@ -1295,3 +1295,25 @@ func TestGoScanner_PathCase(t *testing.T) { } }) } + +func TestGoScanner_UnknownRedirectAbsentRoots(t *testing.T) { + home := goTestHome(t) + t.Setenv("GOENV", "relative.env") + inv, audit := goTestScanner(t, &user.User{Uid: "1000"}).Scan(context.Background(), goTestTarget(home), nil, nil) + if audit.Status != model.GoStatusPartial || inv.Status != model.GoStatusPartial || !slices.Contains(inv.Reasons, model.GoReasonRootRedirectUnknown) { + t.Errorf("audit=%s, inventory=%s %v, want partial root_redirect_unknown", audit.Status, inv.Status, inv.Reasons) + } + roots := 0 + for _, s := range inv.Sources { + if s.Kind != model.GoSourceBinRoot && s.Kind != model.GoSourceCacheRoot { + continue + } + roots++ + if s.Presence != model.GoPresenceAbsent || s.Status != model.GoStatusPartial || !slices.Contains(s.Reasons, model.GoReasonRootRedirectUnknown) { + t.Errorf("fallback %s = %s %s %v, want absent, partial, root_redirect_unknown", s.Kind, s.Presence, s.Status, s.Reasons) + } + } + if roots != 2 { + t.Errorf("roots=%d, want 2", roots) + } +}