diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dd3c2c1..5bc8ca96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,13 +198,12 @@ jobs: # gitignore pattern so a stray local run can't accidentally # commit a 600 KB report. # - # Explicit feature list (instead of --all-features) excludes the + # Default features (instead of --all-features) exclude the # docker-e2e feature — those tests need Docker images this job # doesn't build. The coverage-docker matrix covers them # separately, and coverage-merge stitches everything together. run: | cargo llvm-cov --workspace \ - --features cargo,golang,maven,composer,nuget,deno \ --no-report cargo llvm-cov report --lcov --output-path coverage-host.lcov cargo llvm-cov report --summary-only | tee coverage-summary.txt @@ -320,7 +319,7 @@ jobs: # cargo llvm-cov manages its own env in the test step). run: | eval "$(cargo llvm-cov show-env --export-prefix 2>/dev/null)" - cargo build --bin socket-patch --features cargo,golang,maven,composer,nuget,deno + cargo build --bin socket-patch - name: Configure docker-e2e coverage hooks run: | @@ -341,7 +340,7 @@ jobs: nuget) EXTRA="--test docker_e2e_vendor_nuget" ;; esac cargo llvm-cov \ - --features docker-e2e,cargo,golang,maven,composer,nuget,deno \ + --features docker-e2e \ --no-report \ --test docker_e2e_${{ matrix.ecosystem }} $EXTRA @@ -639,19 +638,10 @@ jobs: load: true - name: Run ${{ matrix.ecosystem }} Docker e2e test - # The optional ecosystems gate their docker_e2e file behind their - # own feature (`#![cfg(all(feature = "docker-e2e", feature = - # ""))]`), so the harness must be built with that feature too — - # otherwise the test binary compiles to zero tests and passes - # vacuously. npm/pypi/gem are unconditional and have no such feature. - # (The socket-patch binary inside the image is always --all-features.) + # Every ecosystem is unconditionally compiled in; only the + # `docker-e2e` feature is needed to compile the suite itself. run: | - eco='${{ matrix.ecosystem }}' - case "$eco" in - npm|pypi|gem) features=docker-e2e ;; - *) features="docker-e2e,$eco" ;; - esac - cargo test -p socket-patch-cli --features "$features" --test "docker_e2e_$eco" + cargo test -p socket-patch-cli --features docker-e2e --test "docker_e2e_${{ matrix.ecosystem }}" # ---------------------------------------------------------------------- # Experimental `setup`-flow matrix (NON-BLOCKING). diff --git a/.gitignore b/.gitignore index 76fac0f4..bd852c1c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* +*.DS_Store # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json @@ -151,3 +152,4 @@ pypi/socket-patch/README.md # Generated by scripts/study-crates.ts study-output/ +simplify-output/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index fc86d8ad..a2ec3e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -244,6 +244,19 @@ in this file — see `.github/workflows/release.yml` (`version` job). ### Changed +- **All ecosystem feature flags removed — every ecosystem is always compiled + in.** The `cargo`, `golang`, `maven`, `composer`, `nuget`, and `deno` Cargo + features are gone from both crates; npm, PyPI, Ruby gems, Go, Cargo, NuGet, + Maven, Composer, and Deno support is now unconditional. Builds that passed + `--features ` will get an "unknown feature" error and should simply + drop the flag; `--no-default-features` no longer produces a minimal binary + (there is nothing left to strip). The runtime gates are unchanged: + Maven/NuGet crawling and apply still require `SOCKET_EXPERIMENTAL_MAVEN=1` / + `SOCKET_EXPERIMENTAL_NUGET=1`. The only remaining features are the + test-suite gates `docker-e2e` and `setup-e2e` on `socket-patch-cli`. (MAJOR + for anyone scripting `--features`; no behavior change for default builds + beyond composer/deno support now being present.) + - **Token-less `scan` now batch-queries the public proxy.** Proxy-mode scans POST `{proxy}/patch/batch` (one request per `--batch-size` chunk, mirroring the authenticated `/v0/orgs/{slug}/patches/batch` endpoint) instead of diff --git a/Cargo.lock b/Cargo.lock index a894b86d..3e12ce24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2424,6 +2424,7 @@ dependencies = [ "fs2", "hex", "indicatif", + "libc", "portable-pty", "regex", "reqwest", diff --git a/README.md b/README.md index c83990a3..5682c15d 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ socket-patch delivers the same patched bytes three different ways. The modes dif | golang | ✅ `go.mod` `replace` → `.socket/go-patches/` | ✅ `replace` → the committed vendor tree | ❌ **not possible** — sumdb, module-path identity, and default-GOPROXY leakage each rule it out; see [docs/design/golang-hosted-no-go.md](docs/design/golang-hosted-no-go.md). **Use vendored** (`redirect_golang_unsupported` names the remedy) | | maven | ⚠️ experimental — gated behind `SOCKET_EXPERIMENTAL_MAVEN=1` (in-place jar patching corrupts the `~/.m2` checksum sidecars); prefer vendored / hosted | ✅ **new** — committed maven2 `file://` repository. A root pom declaring `` (multi-module aggregator) is refused (`vendor_maven_multimodule_unsupported`), and a gradle-only project is refused (`vendor_gradle_unsupported`) | ✅ **pom projects only, fail-closed** — the patched jar is served under a Socket-only `-socket.` suffix, so the rewriter pins that version (rewrite the literal ``, or add a `` entry for a transitive) alongside the `` insert (`checksumPolicy=fail`). An outage or tamper on the Socket repo then hard-fails the build — the suffixed version exists nowhere else, so there is no silent fall-through to Central. Optionally emits Maven 3.9+ Trusted Checksums files pinning the jar + pom sha256. `${property}` versions are refused. Gradle builds get a paste-able `exclusiveContent` snippet (`redirect_gradle_manual_snippet`); no build script is edited. See [Maven & NuGet caveats](#maven--nuget-caveats) | | nuget | ⚠️ experimental — gated behind `SOCKET_EXPERIMENTAL_NUGET=1` (in-place patching breaks the `.nupkg.sha512` tamper-evidence sidecar); prefer vendored / hosted | ✅ **new** — committed folder feed + `packageSourceMapping` + `packages.lock.json` contentHash pin | ✅ `nuget.config` source + source-mapping, `packages.lock.json` contentHash rewrite. See the locked-mode note in [Maven & NuGet caveats](#maven--nuget-caveats) | -| composer | ✅ post-install script events (opt-in `composer` compile feature) | ✅ `composer.lock` `dist: path` rewrite | ✅ `composer.lock` dist url + shasum rewrite | +| composer | ✅ post-install script events | ✅ `composer.lock` `dist: path` rewrite | ✅ `composer.lock` dist url + shasum rewrite | > **Maven / NuGet discovery gate**: discovering *installed* Maven and NuGet packages (the crawl behind `scan` / `apply` / `vendor`) currently requires the same `SOCKET_EXPERIMENTAL_MAVEN=1` / `SOCKET_EXPERIMENTAL_NUGET=1` opt-in in every mode. The vendored/hosted wiring itself is safe — the gate guards the agent-mode sidecar risk. @@ -105,12 +105,8 @@ pip install socket-patch cargo install socket-patch-cli ``` -By default this builds with npm, PyPI, and Cargo support. For additional -ecosystems: - -```bash -cargo install socket-patch-cli --features golang,maven,composer,nuget,deno -``` +This builds with every supported ecosystem (npm, PyPI, Ruby gems, Cargo, +Go, Maven, Composer, NuGet, Deno) compiled in. ### RubyGems @@ -150,7 +146,7 @@ Each flag has a matching `SOCKET_*` environment variable. **Precedence is CLI ar | Flag | Env var | Description | |------|---------|-------------| | `--cwd ` | `SOCKET_CWD` | Working directory (default: `.`). The manifest path is resolved relative to this. | -| `-m, --manifest-path ` | `SOCKET_MANIFEST_PATH` | Path to the patch manifest, resolved relative to `--cwd` (default: `.socket/manifest.json`). | +| `--manifest-path ` | `SOCKET_MANIFEST_PATH` | Path to the patch manifest, resolved relative to `--cwd` (default: `.socket/manifest.json`). | | `--api-url ` | `SOCKET_API_URL` | Socket API URL for the authenticated endpoint (default: `https://api.socket.dev`). | | `--api-token ` | `SOCKET_API_TOKEN` | Socket API token. When omitted, the public patch proxy is used. | | `-o, --org ` | `SOCKET_ORG_SLUG` | Organization slug. Auto-resolved when omitted and a token is set. | @@ -161,15 +157,16 @@ Each flag has a matching `SOCKET_*` environment variable. **Precedence is CLI ar | `--vendor-url ` | `SOCKET_VENDOR_URL` | Base host for the vendoring service's package-reference request (default: the active `--api-url`/`--proxy-url` base). Point at staging / local dev for testing. | | `--patch-server-url ` | `SOCKET_PATCH_SERVER_URL` | Override the host of the prebuilt-archive download URL the service returns (default: as returned). Mainly for local-dev / testing. | | `--offline` | `SOCKET_OFFLINE` | Strict airgap: never contact the network. Operations that need remote data fail loudly. | +| `--strict` | `SOCKET_STRICT` | Fail-closed on before-hash mismatches instead of the default warn-and-overwrite: a file whose current content matches neither `beforeHash` nor `afterHash` aborts that package's apply. Overridden by `--force`. | | `-g, --global` | `SOCKET_GLOBAL` | Operate on globally-installed packages. | | `--global-prefix ` | `SOCKET_GLOBAL_PREFIX` | Override the path used to discover globally-installed packages. | -| `-j, --json` | `SOCKET_JSON` | Emit machine-readable JSON output. Every JSON response includes a `"status"` field (`"success"`, `"error"`, `"no_manifest"`, etc.) for reliable programmatic consumption. | +| `-j, --json` | `SOCKET_JSON` | Emit machine-readable JSON output. Every JSON response includes a `"status"` field — camelCase on the envelope commands (`"success"`, `"error"`, `"noManifest"`, `"partialFailure"`; apply/list/repair/remove/vendor), snake_case on the legacy shapes (`"partial_failure"`, `"not_found"`; get/scan/rollback/setup). See [CLI_CONTRACT.md](crates/socket-patch-cli/CLI_CONTRACT.md) for the exact shapes. | | `-v, --verbose` | `SOCKET_VERBOSE` | Show extra detail in human-readable output. | | `-s, --silent` | `SOCKET_SILENT` | Suppress non-error output. | | `--dry-run` | `SOCKET_DRY_RUN` | Preview the operation without making any mutations. | | `-y, --yes` | `SOCKET_YES` | Skip interactive confirmation prompts. | | `--lock-timeout ` | `SOCKET_LOCK_TIMEOUT` | Seconds to wait for `.socket/apply.lock` before giving up. `0`/unset = a single non-blocking try; a positive value retries with backoff. Only meaningful for mutating commands (`apply`, `rollback`, `repair`, `remove`). | -| `--break-lock` | `SOCKET_BREAK_LOCK` | Force-remove a stale `.socket/apply.lock` before acquiring it. Use only when no other socket-patch process is running; emits an auditable `lock_broken` event in the JSON envelope. | +| `--break-lock` | `SOCKET_BREAK_LOCK` | Reclaim a stale `.socket/apply.lock` (one left by a crashed run) before acquiring it — the file itself is never deleted, and a lock with a live holder is refused. Use only when no other socket-patch process is running; emits an auditable `lock_broken` event in the JSON envelope. | | `--debug` | `SOCKET_DEBUG` | Emit verbose debug logs to stderr. | | `--no-telemetry` | `SOCKET_TELEMETRY_DISABLED` | Disable anonymous usage telemetry. | @@ -536,6 +533,34 @@ socket-patch repair --download-only socket-patch repair --json ``` +### `unlock` + +Inspect — and optionally release — the `.socket/apply.lock` advisory lock that the mutating commands (`apply`, `rollback`, `repair`, `remove`, `vendor`) take. Exits `0` when the lock is free and `1` when another socket-patch process holds it, so CI gates and monitoring can pattern-match the exit code without parsing JSON. + +Reach for it when recovering from a crashed run that left a stale `apply.lock` behind: `unlock` tells you whether anything still holds the lock, and `unlock --release` deletes the file once it's confirmed free. A held lock is never released — for that scenario re-run the failing mutating command with [`--break-lock`](#global-options) (or wait for the holder with `--lock-timeout`). + +**Usage:** +```bash +socket-patch unlock [options] +``` + +**Command-specific options** (plus all [Global Options](#global-options)): +| Flag | Description | +|------|-------------| +| `--release` | When the lock is free, also delete the lock file (it is normally retained across runs). Refused when the lock is held. (env: `SOCKET_UNLOCK_RELEASE`) | + +**Examples:** +```bash +# Is anything holding the lock? +socket-patch unlock + +# Free a stale lock left by a crashed run +socket-patch unlock --release + +# JSON output for scripting +socket-patch unlock --json +``` + ### `setup` Configure your project so patches are **re-applied automatically after install** — no manual `socket-patch apply` step in CI. `setup` is a one-time operation: run it, commit the change together with your `.socket/` patches, and every later install handles the rest. It is strictly **opt-in** — nothing is hooked unless you run `setup` and commit the result. @@ -543,7 +568,7 @@ Configure your project so patches are **re-applied automatically after install** - **npm / yarn / pnpm / bun** — writes a `postinstall` script into `package.json` so any install re-applies patches (pnpm: root package only). - **Python (pip / uv / poetry / pdm / hatch)** — Python has no universal post-install hook, so `setup` instead commits a **`socket-patch[hook]`** dependency (for classic Poetry, the equivalent `socket-patch = { extras = ["hook"] }`). Installing it lays down a startup `.pth` (shipped by the small `socket-patch-hook` wheel) that re-applies your committed `.socket/` patches the next time the interpreter runs. It is package-manager-agnostic (it rides the interpreter, not any one installer) and **fail-open** — a hook error can never break interpreter startup. - **Ruby gems (Bundler)** — adds a managed `plugin "socket-patch"` block to the `Gemfile` and commits an in-tree Bundler plugin under `.socket/bundler-plugin/`. It re-applies patches on every `bundle install` (cached *and* fresh). (Requires the `socket-patch` CLI on `PATH`.) -- **Composer (PHP)** *(opt-in `composer` feature)* — appends `socket-patch apply` to `composer.json`'s `post-install-cmd` / `post-update-cmd` script events, so patches re-apply on every `composer install` / `composer update`. Only available in a build compiled with `--features composer`. (Requires the `socket-patch` CLI on `PATH`.) +- **Composer (PHP)** — appends `socket-patch apply` to `composer.json`'s `post-install-cmd` / `post-update-cmd` script events, so patches re-apply on every `composer install` / `composer update`. (Requires the `socket-patch` CLI on `PATH`.) - **Cargo & Go** — *apply-only, no `setup` hook.* A one-click auto-repatch-on-build isn't possible for these, so `setup` skips them. Patch with `socket-patch apply` directly: **cargo** patches the crate in place (in `vendor/` or the registry cache, rewriting `.cargo-checksum.json` so `cargo build` accepts it); **go** writes a project-local patched copy under `.socket/go-patches/` plus a `go.mod` `replace` directive (the module cache is `go.sum`-verified, so in-place patching can't build). Commit `go.mod` + `.socket/go-patches/` so a clone builds the patched bytes. Declare them in `setup.manual` for VEX attestation. - **Apply-only ecosystems** (nuget · maven · deno) — no native install hook to wire, so `setup` reports `no_files`; patch them on demand with `socket-patch apply`. @@ -628,6 +653,19 @@ socket-patch vex --json --output socket.vex.json socket-patch vex --no-verify --output socket.vex.json ``` +### Undoing things + +Six commands undo different layers of socket-patch state — pick by what you want back: + +| Command | What it undoes | +|---------|----------------| +| [`rollback`](#rollback) | Restores the original file bytes but **keeps the manifest entry** — the next `apply` re-applies the patch | +| [`remove`](#remove) | Rollback **plus** deletes the manifest entry and reverts any vendoring — **permanent**, the patch is fully gone in one command | +| [`vendor --revert`](#vendor) | **Un-vendors wholesale**: restores the recorded original lockfile fragments byte-for-byte and removes the `.socket/vendor/` artifacts — works without a manifest | +| [`scan --prune`](#scan) | **Reconciles, doesn't reverse**: drops manifest entries for packages no longer installed and garbage-collects orphan blob/diff/archive files — installed patches stay | +| [`repair`](#repair) (alias `gc`) | **Restores health, not originals**: re-downloads missing blobs, rebuilds missing/corrupt vendored artifacts, and cleans up unused ones | +| [`unlock --release`](#unlock) | **Frees a stale lock** left by a crashed run — never touches patches or the manifest, and refuses while the lock is held | + ## OpenVEX attestations `socket-patch vex` turns your local manifest into a signed-off statement of *which known vulnerabilities no longer affect your build* because a Socket patch has been applied. This lets vulnerability scanners stop flagging CVEs that you've already remediated in place — without bumping the package version. @@ -721,7 +759,7 @@ socket-patch scan --json --mode agent --prune --yes | jq '{ # Apply patches and check result socket-patch apply --json | jq '.status' -# "success", "partial_failure", "no_manifest", or "error" +# "success", "partialFailure", "noManifest", or "error" ``` When stdin is not a TTY (e.g., in CI pipelines), interactive prompts auto-proceed instead of blocking. Progress indicators and ANSI colors are automatically suppressed when output is piped. diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 28b1e425..d5ca249b 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -16,6 +16,7 @@ This document defines the **public surface** of the `socket-patch` binary. Anyth | `remove` | — | Remove patch from manifest (rolls back first); requires positional `identifier` | | `setup` | — | Wire automatic-patching install hooks (npm/pypi/gem) | | `repair` | `gc` | Download missing blobs, rebuild missing/corrupt vendored artifacts, clean up unused ones | +| `unlock` | — | Inspect (and optionally `--release`) the `<.socket>/apply.lock` advisory lock the mutating subcommands take; exits 0 when free, 1 when held (see [`src/commands/unlock.rs`](src/commands/unlock.rs)) | | `vendor` | — | Eject patched dependencies into committable `.socket/vendor/` and rewire lockfiles | | `vex` | — | Emit an OpenVEX 0.2.0 attestation derived from the local manifest | @@ -39,6 +40,7 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha | `--vendor-url` | — | `SOCKET_VENDOR_URL` | (active API/proxy base) | string | Base host for the vendoring-service package-reference request | | `--patch-server-url` | — | `SOCKET_PATCH_SERVER_URL` | (server-returned) | string | Override the host of the prebuilt-archive download URL (local-dev / testing) | | `--offline` | — | `SOCKET_OFFLINE` | `false` | bool | **Strict airgap on every command** — never contact the network | +| `--strict` | — | `SOCKET_STRICT` | `false` | bool | Treat a beforeHash mismatch as a hard error in the in-place apply paths (see the mismatch-policy note below) | | `--global` | `-g` | `SOCKET_GLOBAL` | `false` | bool | Operate on globally-installed packages | | `--global-prefix` | — | `SOCKET_GLOBAL_PREFIX` | (auto) | path | Override global packages root | | `--json` | `-j` | `SOCKET_JSON` | `false` | bool | Machine-readable output | @@ -46,10 +48,14 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha | `--silent` | `-s` | `SOCKET_SILENT` | `false` | bool | Errors only | | `--dry-run` | — | `SOCKET_DRY_RUN` | `false` | bool | Preview, no mutations | | `--yes` | `-y` | `SOCKET_YES` | `false` | bool | Skip prompts | +| `--lock-timeout` | — | `SOCKET_LOCK_TIMEOUT` | (none) | seconds (u64) | How long to wait for `<.socket>/apply.lock`. Unset and `0` both mean a single non-blocking try; a positive value retries with a 100 ms backoff. Only meaningful on the mutating subcommands | +| `--break-lock` | — | `SOCKET_BREAK_LOCK` | `false` | bool | Reclaim a stale `apply.lock` left by a crashed run (the file is never deleted — that would defeat mutual exclusion). Refuses with `lock_held` when a live process holds it; emits an auditable `lock_broken` warning event | | `--debug` | — | `SOCKET_DEBUG` | `false` | bool | Verbose debug logs to stderr | | `--no-telemetry` | — | `SOCKET_TELEMETRY_DISABLED` | `false` | bool | Disable anonymous usage telemetry | -The `--offline` semantics unified in v3.0. Previously `apply` enforced strict airgap, `repair` skipped network ops, and `rollback` failed when blobs were missing. All three now mean the same thing: never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive. +The `--offline` semantics unified in v3.0. Previously `apply` enforced strict airgap, `repair` skipped network ops, and `rollback` failed when blobs were missing. All three now mean the same thing: never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --vendor` too: offline vendored staging is `vendor --offline`'s job. + +The `--strict` mismatch policy applies to the in-place apply paths (apply/get/scan --apply/hook/go redirect). DEFAULT (v3.4): a file whose on-disk content matches neither the patch's beforeHash nor its afterHash is overwritten with the FULL verified patched content (the diff strategy self-disables on a wrong base; archive/blob writes are hash-gated to exactly afterHash; the missing blob is downloaded on demand) and surfaced as a `content_mismatch_overwritten` stderr warning + Skipped event. `--strict` turns that case into a hard error. `--force` overrides `--strict` and additionally skips missing files. Vendor staging is unaffected (it always auto-overwrites into its private stage). ## Per-subcommand arguments @@ -58,21 +64,23 @@ Beyond the globals above, each subcommand defines a small set of local arguments | Subcommand | Local arg | Env var | Purpose | |---|---|---|---| | `apply` | `--force` / `-f` | `SOCKET_FORCE` | Bypass beforeHash check | +| `apply` | `--check` | — | Read-only audit that the committed **Go** `replace`-redirects match the manifest (CI / GitHub-App auditing) — Go ONLY (cargo patches in place, so there is no redirect to audit). Lock-free, crawl-free, offline-safe; exits 0 in sync, 1 on drift. Vendored modules are excluded from the audit | | `vendor` | `--force` / `-f` | `SOCKET_FORCE` | Tolerate missing patch-target files in the stage + bypass the variant probe. A beforeHash mismatch no longer needs it: vendor staging auto-overwrites with the verified patched content (`vendor_content_mismatch_overwritten` warning) | -| (global) | `--strict` | `SOCKET_STRICT` | Treat a beforeHash mismatch as a hard error in the in-place apply paths (apply/get/scan --apply/hook/go redirect). DEFAULT (v3.4): a mismatched file is overwritten with the FULL verified patched content (the diff strategy self-disables on a wrong base; archive/blob writes are hash-gated to exactly afterHash; the missing blob is downloaded on demand) and surfaced as a `content_mismatch_overwritten` stderr warning + Skipped event. `--force` overrides `--strict` and additionally skips missing files. Vendor staging is unaffected (it always auto-overwrites into its private stage). | | `vendor` | `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore recorded original lockfile fragments + remove `.socket/vendor/` artifacts. Works without a manifest | | `apply`, `scan`, `vendor` | `--vex` | `SOCKET_VEX` | Generate an OpenVEX 0.2.0 document at this path on a successful run; see "embedded VEX" below | | `apply`, `scan`, `vendor` | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | Passthrough to the embedded VEX builder; mirror the standalone `vex` knobs. Inert unless `--vex` is set | | `scan` | `--mode ` | — | The documented selector for the three patch-application modes. Each value is equivalent to one legacy boolean spelling: `hosted` == `--redirect`, `vendored` == `--vendor`, `agent` == `--apply` (`--sync` counts as an agent spelling). Combining `--mode` with a boolean of a DIFFERENT mode is a usage error (exit 2, enforced in `resolve_mode_flags` — clap's `conflicts_with` is value-independent); the same mode spelled both ways is accepted. `--prune` is an orthogonal GC knob and never conflicts | -| `scan` | `--redirect` | — | Hosted mode's legacy boolean spelling (**hidden from `--help`**; `--mode hosted` is the documented spelling, but the flag is part of the contract): rewrite lockfiles / registry configs so ONLY the patched dependencies resolve to Socket's hosted patch server; no artifact bytes land in the repo. Conflicts with `--apply`/`--sync`/`--vendor` | +| `scan` | `--redirect` | — | Hosted mode's legacy boolean spelling (**hidden from `--help`** and **deprecated** — `--mode hosted` is the documented spelling; this alias is scheduled for removal in v4): rewrite lockfiles / registry configs so ONLY the patched dependencies resolve to Socket's hosted patch server; no artifact bytes land in the repo. Conflicts with `--apply`/`--sync`/`--vendor` | | `scan` | `--apply` / `--prune` / `--sync` | — | Mode selectors (sync = apply + prune); `--apply` == `--mode agent` | | `scan` | `--vendor` / `--detached` | — | Vendor every patched dependency instead of applying in place (`--vendor` == `--mode vendored`; conflicts with `--apply`/`--sync`, combines with `--prune`); `--detached` additionally skips all manifest writes — the vendor ledger embeds the patch records (requires vendored mode in either spelling) | | `scan` | `--batch-size` | `SOCKET_BATCH_SIZE` | API batch chunk size (default `100`) | +| `get`, `scan` | `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package — PyPI wheel/sdist (`artifact_id`), RubyGems (`platform`), Maven (`classifier`) — not just the one(s) matching the locally-installed distribution. On `scan` this makes the stored manifest portable across environments (e.g. cross-platform CI caches) | | `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--one-off` | `SOCKET_SAVE_ONLY`, `SOCKET_ONE_OFF` | Patch lookup + save-vs-apply mode | | `remove` | positional `identifier`; `--skip-rollback` | `SOCKET_SKIP_ROLLBACK` | Manifest entry removal | | `rollback` | optional positional `identifier`; `--one-off` | `SOCKET_ONE_OFF` | Rollback target | | `vex` | `--output` / `-O`, `--product`, `--no-verify`, `--doc-id`, `--compact` | `SOCKET_VEX_OUTPUT`, `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | OpenVEX 0.2.0 document generation; see "vex output channels" below | -| `repair` | `--download-only` | `SOCKET_DOWNLOAD_ONLY` | Repair-specific cleanup mode (mutually exclusive with `--offline`) | +| `repair` | `--download-only` | `SOCKET_DOWNLOAD_ONLY` | Repair-specific cleanup mode (mutually exclusive with `--offline`; combining them is a usage error, exit 2) | +| `unlock` | `--release` | `SOCKET_UNLOCK_RELEASE` | When the lock is free, also delete the lock file (normally retained across runs). Refused when the lock is held — reclaiming a held-looking lock is `--break-lock`'s job on the mutating subcommand | | `setup` | `--check`, `--remove` (mutually exclusive); `--exclude` (CSV member paths); honors global `--ecosystems` | `SOCKET_SETUP_EXCLUDE`, `SOCKET_ECOSYSTEMS` | Wire / verify / revert the automatic-patching install hooks. `--exclude` skips + persists workspace members (property 9). See [Setup command contract](#setup-command-contract) | `scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + `updates` array only). No effect outside `--json` mode — the non-JSON path always prompts the user interactively. @@ -100,7 +108,7 @@ The rewriter reads a fixed set of candidate files from the project root: the npm * `.socket/vendor/state.json` — the **vendored**-mode ledger (see "Ownership, state, and reversal" below): wiring edits with verbatim pre-vendor originals, artifact fingerprints, optional `detached` records. * `.socket/vendor/redirect-state.json` — the **hosted**-mode ledger (`RedirectState` in `socket-patch-core/src/patch/redirect/state.rs`): `{ version, mode: "hosted", edits[], records{} }`. `edits` are recorded `FileEdit`s (append-only across re-runs — merge, never clobber: the pre-redirect originals a future revert needs live here); `records` maps PURL → the full manifest `PatchRecord` so a post-install `vex` can attest redirected patches with no manifest entry. The `mode` string is opaque to the loader (pre-rename ledgers carrying `"redirect"` still load; a hosted re-run normalizes them to `"hosted"`). Written identically by this CLI and by the depscan backend's hosted PR flow (`github-patch-pr-hosted.ts`). -`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` would do without mutating disk. In JSON mode, the envelope is populated with would-be actions and counts. +`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` / `unlock --release` would do without mutating disk. In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"; `unlock --release --dry-run` keeps `released: false` and reports the preview via additive `dryRun` + `wouldRelease` fields). The hidden alias `--no-apply` on `get --save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts. @@ -163,8 +171,8 @@ in particular, are behavior changes that gate a version bump when implemented). 4. **`check` proves a correctly-patched state.** `setup --check` reports `configured` only when the in-scope ecosystems are *actually in a correctly patched state* — install hooks present **and** on-disk patch consistency verified (the `apply --check` invariant: every manifest file's hash matches - `afterHash`). *(Partially implemented; **hook-presence only today** — `check` does not yet verify - on-disk patch consistency. RED-guarded.)* + `afterHash`). *(Implemented — `run_check` appends a `patch` entry per installed-but-drifted PURL via + `append_patch_consistency_entries`; uninstalled packages and zero-file records are not drift.)* 5. **In-repo and committable.** `setup` writes only inside the working tree: `package.json`, `pyproject.toml`/`requirements.txt`, the `Gemfile` + generated `.socket/bundler-plugin/`. Every @@ -224,8 +232,8 @@ still show up in VEX). |---|---|---|---| | npm / yarn / pnpm / bun | `scripts.postinstall` + `scripts.dependencies` | `npm/pnpm install` (+ `install `) | pnpm: root package only | | pypi | `socket-patch[hook]` dependency → `.pth` startup hook | Python interpreter startup after installed-set change | manifest = `pyproject.toml` (uv/poetry/pdm/hatch) or `requirements.txt` (pip) | -| gem | managed `plugin "socket-patch"` block in the `Gemfile` → committed in-tree Bundler plugin under `.socket/bundler-plugin/` | every `bundle install` (cached + fresh: load-time digest gate + `after-install-all` hook) | Bundler loads only committed git plugins, so the generated dir must be committed; CLI must be on `PATH`. Phase 1 references the in-tree plugin via `git:`; Phase 2 (follow-up) switches to a published `socket-patch-bundler` gem | -| composer *(opt-in `composer` feature)* | `socket-patch apply` appended to `composer.json`'s `post-install-cmd` + `post-update-cmd` script events | every `composer install` / `composer update` | CLI must be on `PATH`; only compiled in with `--features composer` (apply support is likewise feature-gated). Without the feature, composer is a `no_files` no-op | +| gem | managed `plugin "socket-patch"` block in the `Gemfile` → committed in-tree Bundler plugin under `.socket/bundler-plugin/` | every `bundle install` (cached + fresh: load-time digest gate + `after-install-all` hook) | the plugin is `path:`-sourced (a `git:` dir source is uncloneable — the generated dir is not a git repo — and fails `bundle install`); the dir must be committed so clones/CI have it; CLI must be on `PATH`. Phase 2 (follow-up) switches to a published `socket-patch-bundler` gem | +| composer | `socket-patch apply` appended to `composer.json`'s `post-install-cmd` + `post-update-cmd` script events | every `composer install` / `composer update` | CLI must be on `PATH` | | cargo · golang | **none** (apply-only) | — | see "Cargo and Go: apply-only, no setup" below; candidates for the **manual** declaration | | nuget · maven · deno | **none** (apply-only) | — | `setup` reports `no_files`; candidates for the **manual** declaration | @@ -274,9 +282,12 @@ share the limitation). **Deeply nested transitive dependencies are fully supported.** The npm crawler recurses `node_modules` at unbounded depth, and `apply` is path-agnostic — it patches a package by PURL against the manifest regardless of how deep in the dependency tree it was installed, so a deeply-nested transitive dependency -is patched identically to a direct one. Pinned by -`crawl_all_discovers_deeply_nested_transitive_deps` in -`crates/socket-patch-core/tests/crawler_npm_e2e.rs`. +is patched identically to a direct one. Both halves are pinned in +`crates/socket-patch-core/tests/crawler_npm_e2e.rs`: discovery by +`crawl_all_discovers_deeply_nested_transitive_deps`, and apply-side resolution by +`find_by_purls_resolves_nested_only_install` (`find_by_purls` probes the tree root first, then falls +back breadth-first into nested `node_modules` for still-unresolved PURLs; a root-level install always +wins, pinned by `find_by_purls_prefers_root_copy_over_nested_duplicate`). ### JSON output shapes (`setup`, `setup --check`, `setup --remove`) @@ -485,8 +496,8 @@ to **six flavors**. | nuget | deterministically rebuilt `.nupkg` at `..nupkg` (the uuid dir IS a NuGet folder feed; the stale embedded signature is dropped — unsigned is accepted under NuGet's default validation) | `nuget.config` source + `packageSourceMapping` for the id (creating the mapping from scratch ALSO fans a `` out to every pre-existing source — mapping is exclusive, NU1100 otherwise) **+** `packages.lock.json` `contentHash` → `base64(sha512(nupkg))` when the lock exists (`vendor_nuget_no_lockfile` warning otherwise) | `dotnet restore --locked-mode`, cold cache, `--network none` (tampered nupkg fails NU1403) | | maven | deterministically rebuilt `.jar` + the **verbatim upstream pom** (transitives survive; refused via `vendor_maven_pom_unavailable` rather than fabricated) + `.sha1` sidecars, laid out as a maven2 repository under the uuid dir | `pom.xml` `` (`id=socket-patch-vendor-`, `url=file://${project.basedir}/.socket/vendor/maven/`, `checksumPolicy=fail`, snapshots disabled). Multi-module aggregator poms refused (`vendor_maven_multimodule_unsupported`); gradle-only projects refused (`vendor_gradle_unsupported`); always-on `vendor_maven_local_cache_shadow` advisory (warm `~/.m2` wins over any repository) | `mvn` build on a fresh checkout with the GAV purged from the local repo, `--network none` (docker capstone; note `mvn -o` refuses `file://` repositories outright) | -Ecosystems with no vendor backend that this build still *recognizes* (jsr when its feature is -compiled in) refuse per-purl with `vendor_unsupported_ecosystem`. yarn-berry **PnP** +Ecosystems with no vendor backend (jsr) refuse per-purl with +`vendor_unsupported_ecosystem`. yarn-berry **PnP** (`.pnp.*`) and bun's binary `bun.lockb` are refused with stable codes pointing at the native alternative / a text-lockfile migration; a lock-less tool marker (a `[tool.uv]`/`[tool.poetry]`/ `[tool.pdm]` table or a `Pipfile` without its lock) refuses `_no_lockfile` unless a @@ -602,6 +613,7 @@ All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names | `SOCKET_VENDOR_URL` | `--vendor-url` | (active API/proxy base) | Vendoring-service package-reference host. | | `SOCKET_PATCH_SERVER_URL` | `--patch-server-url` | (server-returned) | Rewrites the prebuilt-archive download host. | | `SOCKET_OFFLINE` | `--offline` | `false` | — | +| `SOCKET_STRICT` | `--strict` | `false` | Mismatch policy for the in-place apply paths; see "Global arguments". | | `SOCKET_GLOBAL` | `--global` / `-g` | `false` | — | | `SOCKET_GLOBAL_PREFIX` | `--global-prefix` | (auto) | — | | `SOCKET_JSON` | `--json` / `-j` | `false` | — | @@ -609,16 +621,42 @@ All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names | `SOCKET_SILENT` | `--silent` / `-s` | `false` | — | | `SOCKET_DRY_RUN` | `--dry-run` | `false` | — | | `SOCKET_YES` | `--yes` / `-y` | `false` | — | +| `SOCKET_LOCK_TIMEOUT` | `--lock-timeout` | (none) | Seconds to wait for `apply.lock`; unset/`0` = single non-blocking try. | +| `SOCKET_BREAK_LOCK` | `--break-lock` | `false` | Reclaim a stale `apply.lock`; refused when a live process holds it. | | `SOCKET_DEBUG` | `--debug` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_DEBUG`). | | `SOCKET_TELEMETRY_DISABLED` | `--no-telemetry` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_TELEMETRY_DISABLED`). | | `SOCKET_FORCE` | `apply --force` / `-f` | `false` | Local to `apply`. | | `SOCKET_BATCH_SIZE` | `scan --batch-size` | `100` | Local to `scan`. | | `SOCKET_SAVE_ONLY` | `get --save-only` | `false` | Local to `get`. | -| `SOCKET_ONE_OFF` | `get --one-off` / `rollback --one-off` | `false` | Local to `get`/`rollback`. | +| `SOCKET_ONE_OFF` | `get --one-off` / `rollback --one-off` | `false` | Local to `get`/`rollback`. Both are **not yet implemented**: the flag parses (boolishly, empty-tolerant) and the command fails up front with a "not yet implemented" error, before any network or disk activity. | +| `SOCKET_ALL_RELEASES` | `get --all-releases` / `scan --all-releases` | `false` | Local to `get`/`scan`. Download patches for every release/distribution variant, not just the installed one. | | `SOCKET_SKIP_ROLLBACK` | `remove --skip-rollback` | `false` | Local to `remove`. | | `SOCKET_DOWNLOAD_ONLY` | `repair --download-only` | `false` | Local to `repair`. | +| `SOCKET_UNLOCK_RELEASE` | `unlock --release` | `false` | Local to `unlock`. Delete the lock file when free; refused when held. | | `SOCKET_SETUP_EXCLUDE` | `setup --exclude` | (none) | Local to `setup`; comma-separated workspace-member paths, persisted to `setup.exclude`. | -| `SOCKET_VEX` | `apply --vex` / `scan --vex` | (none) | Embedded OpenVEX output path. The `SOCKET_VEX_*` knobs (`_PRODUCT`, `_NO_VERIFY`, `_DOC_ID`, `_COMPACT`) are shared with the standalone `vex` command; on `apply`/`scan` they bind to `--vex-product` etc. | +| `SOCKET_VEX` | `apply --vex` / `scan --vex` / `vendor --vex` | (none) | Embedded OpenVEX output path. The `SOCKET_VEX_*` knobs (`_PRODUCT`, `_NO_VERIFY`, `_DOC_ID`, `_COMPACT`) are shared with the standalone `vex` command; on the host commands they bind to `--vex-product` etc. | +| `SOCKET_VEX_OUTPUT` | `vex --output` / `-O` | (none) | Local to the standalone `vex`: document output path (required with `--json`). | + +### Registry override env vars + +Env-only knobs (no CLI flag) read by the vendor auto-fetch / artifact-rebuild paths in `socket-patch-core` (`src/patch/vendor/registry_fetch.rs`, `src/patch/vendor/maven_repo.rs`). Each is the enterprise-mirror / test escape hatch for one registry base; trailing slashes are trimmed and an exported-but-empty value falls back to the default. Lock-recorded URLs (npm/yarn/composer/gem/uv `resolved`/dist URLs) are used verbatim and bypass these. + +| Env var | Default | Notes | +|---|---|---| +| `SOCKET_NPM_REGISTRY` | `https://registry.npmjs.org` | Base for conventional npm tarball URLs (vendor auto-fetch + the npm-family lockfile-integrity reconstruction rung in `repair`). | +| `SOCKET_CRATES_REGISTRY` | `https://static.crates.io/crates` | crates.io static `.crate` download host. | +| `SOCKET_GOPROXY` | `https://proxy.golang.org` | Go module proxy. Wins over the standard `GOPROXY` env var, whose first non-`direct`/`off` element is used otherwise. | +| `SOCKET_MAVEN_REGISTRY` | `https://repo1.maven.org/maven2` | maven2 base for the fallback upstream-pom download. | + +### Internal env vars (no stability guarantee) + +These exist for staged rollouts and the launcher wrappers. They are **internal**: names, semantics, and existence may change in any release without a semver bump. + +| Env var | Purpose | +|---|---| +| `SOCKET_EXPERIMENTAL_MAVEN` | Opt-in gate (`=1`) for the maven installed-package crawl behind `scan`/`apply`/`vendor` — agent-mode in-place jar patching corrupts the `~/.m2` checksum sidecars, so discovery stays off by default (`src/ecosystem_dispatch.rs`). | +| `SOCKET_EXPERIMENTAL_NUGET` | Same gate for nuget — in-place patching breaks the `.nupkg.sha512` tamper-evidence sidecar. | +| `SOCKET_PATCH_BIN` | Points the RubyGems / Composer launcher wrappers and the gem Bundler plugin at an existing `socket-patch` binary (skips the download-on-first-run); also the escape hatch `apply` names when a golang-featureless binary is asked to audit Go redirects. | ### Deprecated env vars @@ -640,7 +678,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified ```jsonc { - "command": "apply" | "rollback" | "get" | "scan" | "list" | "remove" | "repair" | "setup", + "command": "apply" | "rollback" | "get" | "scan" | "list" | "remove" | "repair" | "setup" | "unlock" | "vendor" | "vex", "status": "success" | "partialFailure" | "error" | "noManifest" | "paidRequired" | "notFound", "dryRun": false, "events": [ , ... ], @@ -711,13 +749,14 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `no_local_source` | `skipped`/`failed` | `--offline` and the patch is missing from `.socket/`. | | `paid_required` | `failed` / status=`paidRequired` | get/scan: patch needs a paid plan and the caller's token isn't entitled. | | `download_failed` | `failed` | repair/get: network or 404 on patch fetch. | +| `cleanup_failed` | `skipped` (warning) | repair: an orphan-sweep pass (blobs, diff or package archives) failed mid-way (e.g. permission error). The run continues and exits 0; human mode carries the warning on stderr (not muted by `--silent`). | | `rollback_failed` | `failed` | remove/rollback: file restore could not complete. | | `vendored` | `skipped` | apply (every ecosystem) + scan `--apply`: the package is managed by `socket-patch vendor`; the command yields ownership (scan also skips the download). Rollback surfaces the same skip via its `vendored: []` array. | | `vendor_reverted` | `removed` | remove: vendoring reverted (lock fragments restored, artifact + ledger entry gone) as part of removing the patch. | | `vendor_revert_failed` | top-level error | remove: the vendor revert failed; the manifest was NOT modified. | | `vendor_state_retained` | `skipped` | remove `--skip-rollback`: vendor wiring + artifact deliberately left in place (the next `vendor` run reconciles the dropped entry). Also the top-level error code when `--skip-rollback` targets a detached-only patch. | | `vendor_stale_artifact_removed` | `removed` | vendor / scan `--vendor`: re-vendor under a newer patch uuid removed the previous uuid's orphaned artifact dir. | -| `vendor_unsupported_ecosystem` | `skipped` | vendor: no vendor backend for this purl's ecosystem (jsr, or compiled out — maven/nuget have backends since their promotion to default features). | +| `vendor_unsupported_ecosystem` | `skipped` | vendor: no vendor backend for this purl's ecosystem (jsr). | | `already_vendored` | `skipped` | vendor: artifact + wiring already in sync for this patch uuid. | | `unsafe_coordinates` | `failed` | vendor: purl/uuid would escape `.socket/vendor/` (tampered manifest/state); refused before any write. | | `revert_failed` | `failed` | vendor --revert: a recorded entry could not be reverted. | @@ -760,7 +799,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor` | `Applied` (= vendored; `command` routes) · `Skipped` (refusals, warnings, unsupported ecosystems) · `Failed` · `Removed` (reconcile + `--revert`) · `Verified` (dry-run) | | `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`) | | `repair`/`gc`| `Downloaded` (or `Verified` on dry-run) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run) · `Skipped` (vendor_uuid_mismatch) · `Removed` (or `Verified`) · `Failed` events | -| `remove` | `Removed` (per purl) · artifact-level `Removed` event (with `details.blobsRemoved`, `details.rolledBack`) | +| `remove` | `Removed` (per purl; `Verified` on dry-run) · artifact-level `Removed`/`Verified` event (with `details.blobsRemoved`, `details.rolledBack`) | ### Migration status (v3.0) @@ -770,6 +809,7 @@ The unified envelope is the v3.0 contract. As of this release, these commands em - ✅ `list` - ✅ `repair` / `gc` - ✅ `remove` +- ✅ `vendor` The remaining commands still emit their pre-v3.0 ad-hoc JSON shapes and will migrate in a follow-up PR. Until then, downstream consumers should branch on the `command` field (envelope) vs the legacy shape (no `command` field, `status` in snake_case): @@ -778,6 +818,11 @@ The remaining commands still emit their pre-v3.0 ad-hoc JSON shapes and will mig - ⏳ `rollback` — still emits per-package result records. - ⏳ `setup` — still emits its own `{ status, updated, alreadyConfigured, errors, files }` shape (and the `--check` / `--remove` variants), now documented in full under [Setup command contract](#setup-command-contract). +Two commands are **intentionally not** plain-envelope and will stay that way (not migration debt): + +- `unlock` — **mixed**: the free path emits a flat `{ "command": "unlock", "status": "free", "lockFile": "...", "released": bool }` object (jq-friendly; documented at `src/commands/unlock.rs::emit_free`; under `--dry-run` it gains additive `dryRun: true` and — with `--release` — `wouldRelease: bool` fields, `released` staying truthfully false), while the held/error paths emit the standard envelope (`status: "error"`, code `lock_held` / `lock_io`). +- `vex` — **hybrid**: the OpenVEX document is itself JSON and is the primary output; the envelope appears only under `--json --output `. See the [vex output channels](#vex-output-channels) table. + ### `patches[]` entry shape for `get` and `scan --apply` Per-patch records emitted in `patches[]` (and in `scan --apply`'s @@ -861,14 +906,17 @@ socket-patch apply --json | jq ' Exit `0` when `status` is `success`, `noManifest`, or `notFound`-with-zero-failed. Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) or `error`. +`apply` with no manifest at all is a clean exit-0 no-op (`status: "noManifest"`), and an **empty** manifest (zero patches) is a plain `success` exit 0 — this is load-bearing for the install hooks, which run `apply` on every install. Pinned by `tests/in_process_edge_cases.rs` and `tests/cli_dry_run_paths_e2e.rs`. + ## Exit codes | Code | Meaning | |---|---| | `0` | Success | | `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, etc.) | +| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg — including the clap-enforced `setup --check --remove` conflict) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below) | -`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing. +`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing. `unlock` returns **`0`** when the lock is free and **`1`** when it is held (its `--release` refusal on a held lock is that same exit 1). `vex` exit codes are tri-state: diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml index 10016232..52905b1c 100644 --- a/crates/socket-patch-cli/Cargo.toml +++ b/crates/socket-patch-cli/Cargo.toml @@ -27,23 +27,18 @@ uuid = { workspace = true } regex = { workspace = true } tempfile = { workspace = true } +[target.'cfg(unix)'.dependencies] +# main.rs restores the default SIGPIPE disposition so piped invocations +# (`socket-patch scan | head -1`) die quietly instead of panicking. +libc = { workspace = true } + [features] -# Shipped defaults: npm + PyPI + Ruby gems are always compiled in (no feature -# gate); `cargo`, `golang`, `nuget`, and `maven` are on by default so released -# binaries and `cargo install socket-patch-cli` patch Rust, Go, .NET, and Java -# deps (and run the build-time cargo guard) out of the box. Maven `apply` stays -# runtime-gated behind `SOCKET_EXPERIMENTAL_MAVEN=1` (in-place jar patching -# corrupts sidecars); committable `vendor` is safe. The still-unsupported -# ecosystems (composer, deno) stay opt-in. Build `--no-default-features` for a -# minimal (npm + PyPI + Ruby gems only) binary — its `apply --check` then fails -# closed. -default = ["cargo", "golang", "nuget", "maven"] -cargo = ["socket-patch-core/cargo"] -golang = ["socket-patch-core/golang"] -maven = ["socket-patch-core/maven"] -composer = ["socket-patch-core/composer"] -nuget = ["socket-patch-core/nuget"] -deno = ["socket-patch-core/deno"] +# Every ecosystem (npm, PyPI, Ruby gems, Go, Cargo, NuGet, Maven, Composer, +# Deno) is unconditionally compiled in — there are no ecosystem feature gates. +# Maven `apply` stays runtime-gated behind `SOCKET_EXPERIMENTAL_MAVEN=1` +# (in-place jar patching corrupts sidecars); committable `vendor` is safe. +# The only features left gate opt-in test suites: +# # Enables the Docker-driven real-package e2e test suite under # `tests/docker_e2e_*.rs`. Tests in this suite require either a running # Docker daemon OR `SOCKET_PATCH_TEST_HOST=1` (host-toolchain mode). diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 3e4562df..a14955fd 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -13,7 +13,7 @@ //! names are still read at runtime (via `socket_patch_core::env_compat`) with //! a one-shot deprecation warning; they will be removed in the next major. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use clap::Args; @@ -26,11 +26,8 @@ use socket_patch_core::patch::vendor::VendorSource; /// clap value-parser for each `--ecosystems` / `SOCKET_ECOSYSTEMS` token. /// -/// Rejects any name this build does not support — both typos and -/// ecosystems whose Cargo feature is not compiled in (e.g. `maven` / -/// `nuget` on a default build, which ships npm + PyPI + Ruby gems + Go + -/// Cargo). `Ecosystem::all()` is itself `#[cfg]`-gated, so the accepted -/// set tracks the compiled feature set exactly. +/// Rejects any name that is not a supported ecosystem, so typos fail +/// loudly instead of silently matching nothing. /// /// Without this, an unsupported name parsed fine and was then silently /// dropped by `partition_purls`/`crawl_all_ecosystems`, so the user got a @@ -124,8 +121,8 @@ pub struct GlobalArgs { )] pub proxy_url: String, - /// Restrict to these ecosystems (comma-separated). Names not supported - /// by this build (e.g. `maven`/`nuget` unless compiled in) are rejected. + /// Restrict to these ecosystems (comma-separated). Names that are not + /// supported ecosystems are rejected. #[arg( long = "ecosystems", short = 'e', @@ -305,13 +302,14 @@ pub struct GlobalArgs { } impl GlobalArgs { - /// Resolve `manifest_path` against `cwd`. See - /// `socket_patch_core::manifest::operations::resolve_manifest_path`. - pub fn resolved_manifest_path(&self) -> PathBuf { - socket_patch_core::manifest::operations::resolve_manifest_path( - &self.cwd, - &self.manifest_path, - ) + /// Resolve `manifest_path` against `cwd`: absolute paths are returned + /// as-is, relative paths are joined to `cwd`. + pub(crate) fn resolved_manifest_path(&self) -> PathBuf { + if Path::new(&self.manifest_path).is_absolute() { + PathBuf::from(&self.manifest_path) + } else { + self.cwd.join(&self.manifest_path) + } } /// Build [`ApiClientEnvOverrides`] from the CLI flags. @@ -346,7 +344,7 @@ impl GlobalArgs { /// without this mirror a bare `--offline` flag (or a truthy spelling like /// `SOCKET_OFFLINE=yes` that core's `"1" | "true"` match doesn't recognize) /// still let telemetry fire a network request. -pub fn apply_env_toggles(common: &GlobalArgs) { +pub(crate) fn apply_env_toggles(common: &GlobalArgs) { if common.offline { std::env::set_var("SOCKET_OFFLINE", "1"); } @@ -359,7 +357,7 @@ pub fn apply_env_toggles(common: &GlobalArgs) { } /// Every env var `GlobalArgs` binds (one per `env = "..."` attribute above). -/// Single source of truth for [`scrub_empty_global_env_vars`] and the +/// Single source of truth for [`scrub_empty_env_vars`] and the /// clean-environment test harnesses. pub const GLOBAL_ARG_ENV_VARS: &[&str] = &[ "SOCKET_CWD", @@ -374,6 +372,7 @@ pub const GLOBAL_ARG_ENV_VARS: &[&str] = &[ "SOCKET_VENDOR_URL", "SOCKET_PATCH_SERVER_URL", "SOCKET_OFFLINE", + "SOCKET_STRICT", "SOCKET_GLOBAL", "SOCKET_GLOBAL_PREFIX", "SOCKET_JSON", @@ -387,21 +386,48 @@ pub const GLOBAL_ARG_ENV_VARS: &[&str] = &[ "SOCKET_TELEMETRY_DISABLED", ]; -/// Remove exported-but-**empty** `GlobalArgs` env vars before clap parses. +/// Every env var a **subcommand-local** flag binds (one per `env = "..."` +/// attribute in `commands/*.rs`). Same contract as [`GLOBAL_ARG_ENV_VARS`]: +/// single source of truth for [`scrub_empty_env_vars`] and the +/// clean-environment test harnesses. A flag added with an `env` binding but +/// missing here escapes the empty-var scrub — the invariant tests below +/// parse every entry against its owning subcommand to keep this honest. +pub const LOCAL_ARG_ENV_VARS: &[&str] = &[ + "SOCKET_FORCE", + "SOCKET_SAVE_ONLY", + "SOCKET_ONE_OFF", + "SOCKET_ALL_RELEASES", + "SOCKET_SKIP_ROLLBACK", + "SOCKET_DOWNLOAD_ONLY", + "SOCKET_SETUP_EXCLUDE", + "SOCKET_VENDOR_REVERT", + "SOCKET_UNLOCK_RELEASE", + "SOCKET_BATCH_SIZE", + "SOCKET_VEX", + "SOCKET_VEX_OUTPUT", + "SOCKET_VEX_PRODUCT", + "SOCKET_VEX_NO_VERIFY", + "SOCKET_VEX_DOC_ID", + "SOCKET_VEX_COMPACT", +]; + +/// Remove exported-but-**empty** flag-bound env vars before clap parses. /// /// `SOCKET_CWD=` — the conventional shell/CI idiom for blanking a variable /// without unsetting it — must mean "unset, fall back to the default", not /// abort the command. [`parse_bool_flag`] already gives the bool flags that /// semantic, but clap rejects an empty `SOCKET_CWD` / `SOCKET_GLOBAL_PREFIX` -/// ("a value is required"), `SOCKET_LOCK_TIMEOUT` ("cannot parse integer -/// from empty string") and `SOCKET_ECOSYSTEMS` (the per-token validator) -/// outright — a single stray blank var crashed every subcommand — and an -/// empty `SOCKET_DOWNLOAD_MODE` / `SOCKET_MANIFEST_PATH` leaked `""` past -/// the documented defaults. Called from `main` after legacy-name promotion -/// and before clap runs. Only exactly-empty values are scrubbed; whitespace -/// is significant in paths, so it is left for the parsers to judge. -pub fn scrub_empty_global_env_vars() { - for &var in GLOBAL_ARG_ENV_VARS { +/// ("a value is required"), `SOCKET_LOCK_TIMEOUT` / `SOCKET_BATCH_SIZE` +/// ("cannot parse integer from empty string") and `SOCKET_ECOSYSTEMS` (the +/// per-token validator) outright — a single stray blank var crashed every +/// subcommand — and an empty `SOCKET_DOWNLOAD_MODE` / `SOCKET_MANIFEST_PATH` +/// (or `SOCKET_VEX_OUTPUT`, which would silently target `""`) leaked `""` +/// past the documented defaults. Called from `main` after legacy-name +/// promotion and before clap runs. Only exactly-empty values are scrubbed; +/// whitespace is significant in paths, so it is left for the parsers to +/// judge. +pub fn scrub_empty_env_vars() { + for &var in GLOBAL_ARG_ENV_VARS.iter().chain(LOCAL_ARG_ENV_VARS) { if matches!(std::env::var(var).as_deref(), Ok("")) { std::env::remove_var(var); } @@ -466,19 +492,12 @@ mod tests { common: GlobalArgs, } - /// Full list of env vars `GlobalArgs` reads, so each clap-parse test starts - /// from a known-clean environment (no ambient `SOCKET_*` bleed-through). - /// Aliases the production list so the scrub and the harness can't drift. - const SOCKET_ENV_VARS: &[&str] = GLOBAL_ARG_ENV_VARS; - - /// Snapshot/clear every `SOCKET_*` var, run `f`, then restore. Keeps the + /// Snapshot/clear each var in `vars`, run `f`, then restore. Keeps the /// env-mutating clap tests hermetic and reversible. - fn with_clean_socket_env(f: impl FnOnce()) { - let saved: Vec<(&str, Option)> = SOCKET_ENV_VARS - .iter() - .map(|&k| (k, std::env::var(k).ok())) - .collect(); - for &k in SOCKET_ENV_VARS { + fn with_env_cleared(vars: &[&str], f: impl FnOnce()) { + let saved: Vec<(&str, Option)> = + vars.iter().map(|&k| (k, std::env::var(k).ok())).collect(); + for &k in vars { std::env::remove_var(k); } f(); @@ -490,24 +509,22 @@ mod tests { } } + /// Clear every env var a flag reads — global and subcommand-local (the + /// production lists, so the scrub and the harness can't drift), giving + /// each clap-parse test a known-clean environment with no ambient + /// `SOCKET_*` bleed-through. + fn with_clean_socket_env(f: impl FnOnce()) { + with_env_cleared(GLOBAL_ARG_ENV_VARS, || { + with_env_cleared(LOCAL_ARG_ENV_VARS, f); + }); + } + /// Clear the extra env the core telemetry gate reads beyond the /// `SOCKET_*` set (`is_telemetry_disabled` also consults `VITEST` and the /// legacy `SOCKET_PATCH_TELEMETRY_DISABLED` name), so the airgap tests /// below can't pass or fail vacuously. Restores afterwards. fn with_clean_telemetry_env(f: impl FnOnce()) { - const EXTRA: &[&str] = &["VITEST", "SOCKET_PATCH_TELEMETRY_DISABLED"]; - let saved: Vec<(&str, Option)> = - EXTRA.iter().map(|&k| (k, std::env::var(k).ok())).collect(); - for &k in EXTRA { - std::env::remove_var(k); - } - f(); - for (k, v) in saved { - match v { - Some(v) => std::env::set_var(k, v), - None => std::env::remove_var(k), - } - } + with_env_cleared(&["VITEST", "SOCKET_PATCH_TELEMETRY_DISABLED"], f); } /// `--offline` promises "never contact the network", but the telemetry @@ -556,14 +573,14 @@ mod tests { }); } - /// `scrub_empty_global_env_vars` removes exactly-empty `SOCKET_*` globals - /// (the `VAR=` blank-without-unsetting idiom) and nothing else: set, - /// non-empty values — even whitespace-only ones, which are significant in - /// paths — survive, and the previously-crashing parse then sees plain - /// defaults. + /// `scrub_empty_env_vars` removes exactly-empty `SOCKET_*` flag vars + /// (the `VAR=` blank-without-unsetting idiom) — global and local — and + /// nothing else: set, non-empty values — even whitespace-only ones, + /// which are significant in paths — survive, and the + /// previously-crashing parse then sees plain defaults. #[test] #[serial_test::serial] - fn scrub_empty_global_env_vars_unsets_only_empties() { + fn scrub_empty_env_vars_unsets_only_empties() { with_clean_socket_env(|| { std::env::set_var("SOCKET_CWD", ""); std::env::set_var("SOCKET_LOCK_TIMEOUT", ""); @@ -571,16 +588,23 @@ mod tests { std::env::set_var("SOCKET_ECOSYSTEMS", ""); std::env::set_var("SOCKET_DOWNLOAD_MODE", ""); std::env::set_var("SOCKET_VENDOR_SOURCE", ""); + std::env::set_var("SOCKET_BATCH_SIZE", ""); + std::env::set_var("SOCKET_VEX_OUTPUT", ""); std::env::set_var("SOCKET_MANIFEST_PATH", "keep.json"); std::env::set_var("SOCKET_ORG_SLUG", " "); - scrub_empty_global_env_vars(); + scrub_empty_env_vars(); assert!( std::env::var("SOCKET_CWD").is_err(), "empty var is scrubbed" ); assert!(std::env::var("SOCKET_LOCK_TIMEOUT").is_err()); + assert!( + std::env::var("SOCKET_BATCH_SIZE").is_err(), + "empty subcommand-local vars are scrubbed too" + ); + assert!(std::env::var("SOCKET_VEX_OUTPUT").is_err()); assert_eq!( std::env::var("SOCKET_MANIFEST_PATH").as_deref(), Ok("keep.json"), @@ -876,27 +900,43 @@ mod tests { /// An absolute `manifest_path` ignores `cwd` and passes through unchanged. #[test] fn resolved_manifest_path_passes_absolute_through() { + let absolute = if cfg!(windows) { + r"C:\etc\socket\manifest.json" + } else { + "/etc/socket/manifest.json" + }; let args = GlobalArgs { cwd: PathBuf::from("/work/project"), - manifest_path: "/etc/socket/manifest.json".to_string(), + manifest_path: absolute.to_string(), + ..GlobalArgs::default() + }; + assert_eq!(args.resolved_manifest_path(), PathBuf::from(absolute)); + } + + /// A dotted relative `manifest_path` is joined verbatim, not normalized. + #[test] + fn resolved_manifest_path_joins_dotted_relative_verbatim() { + let args = GlobalArgs { + cwd: PathBuf::from("/work/project"), + manifest_path: "../manifest.json".to_string(), ..GlobalArgs::default() }; assert_eq!( args.resolved_manifest_path(), - PathBuf::from("/etc/socket/manifest.json"), + PathBuf::from("/work/project/../manifest.json"), ); } - /// `parse_supported_ecosystem` accepts every name this build compiles in + /// `parse_supported_ecosystem` accepts every supported ecosystem name /// and returns it verbatim. #[test] - fn parse_supported_ecosystem_accepts_compiled_in_names() { + fn parse_supported_ecosystem_accepts_supported_names() { for e in Ecosystem::all() { let name = e.cli_name(); assert_eq!( parse_supported_ecosystem(name), Ok(name.to_string()), - "{name:?} is compiled in and must be accepted", + "{name:?} is a supported ecosystem and must be accepted", ); } } @@ -971,42 +1011,182 @@ mod tests { }); } + /// The mirror only works if every subcommand's `run` actually calls + /// `apply_env_toggles`. `list` and `setup` fire telemetry + /// (`track_patch_listed` / `track_patch_setup`) whose kill-switch reads + /// `SOCKET_TELEMETRY_DISABLED` / `SOCKET_OFFLINE` from the env only — a + /// run entry point that skips the mirror silently ignores + /// `--no-telemetry` and lets `--offline` (strict airgap: never contact + /// the network) still fire the telemetry HTTP request. + #[test] + #[serial_test::serial] + fn list_and_setup_run_mirror_global_toggles_for_airgap() { + with_clean_socket_env(|| { + with_clean_telemetry_env(|| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let toggles_on = |cwd: &Path| GlobalArgs { + cwd: cwd.to_path_buf(), + offline: true, + no_telemetry: true, + silent: true, + ..GlobalArgs::default() + }; + + // Guard against a vacuous pass: the gate must start open. + assert!(!socket_patch_core::utils::telemetry::is_telemetry_disabled()); + + let tmp = tempfile::tempdir().unwrap(); + rt.block_on(crate::commands::list::run( + crate::commands::list::ListArgs { + common: toggles_on(tmp.path()), + }, + )); + assert!( + socket_patch_core::utils::telemetry::is_telemetry_disabled(), + "`list --offline --no-telemetry` must mirror the toggles into the \ + env — its telemetry kill-switch reads only SOCKET_OFFLINE / \ + SOCKET_TELEMETRY_DISABLED", + ); + + // Reset the mirrored vars so setup can't pass on list's leftovers. + std::env::remove_var("SOCKET_OFFLINE"); + std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); + + let tmp = tempfile::tempdir().unwrap(); + rt.block_on(crate::commands::setup::run( + crate::commands::setup::SetupArgs { + check: false, + remove: false, + exclude: Vec::new(), + common: GlobalArgs { + // `setup` must not write anything from a unit test. + dry_run: true, + ..toggles_on(tmp.path()) + }, + }, + )); + assert!( + socket_patch_core::utils::telemetry::is_telemetry_disabled(), + "`setup --offline --no-telemetry` must mirror the toggles into the env", + ); + }); + }); + } + /// `apply_env_toggles` mirrors `--debug` / `--no-telemetry` into the env /// vars core code reads directly, and is a no-op when the flags are off. /// `#[serial]` because it mutates process-global env state. #[test] #[serial_test::serial] fn apply_env_toggles_mirrors_flags_into_env() { - let saved_debug = std::env::var("SOCKET_DEBUG").ok(); - let saved_telemetry = std::env::var("SOCKET_TELEMETRY_DISABLED").ok(); - std::env::remove_var("SOCKET_DEBUG"); - std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); + with_env_cleared(&["SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED"], || { + // Flags off: no-op, env stays unset. + apply_env_toggles(&GlobalArgs::default()); + assert!(std::env::var("SOCKET_DEBUG").is_err()); + assert!(std::env::var("SOCKET_TELEMETRY_DISABLED").is_err()); + + // Flags on: mirrored into the env. + let args = GlobalArgs { + debug: true, + no_telemetry: true, + ..GlobalArgs::default() + }; + apply_env_toggles(&args); + assert_eq!(std::env::var("SOCKET_DEBUG").as_deref(), Ok("1")); + assert_eq!( + std::env::var("SOCKET_TELEMETRY_DISABLED").as_deref(), + Ok("1") + ); + }); + } - // Flags off: no-op, env stays unset. - apply_env_toggles(&GlobalArgs::default()); - assert!(std::env::var("SOCKET_DEBUG").is_err()); - assert!(std::env::var("SOCKET_TELEMETRY_DISABLED").is_err()); + /// Policy invariant: EVERY env-bound boolean flag on every subcommand + /// parses with [`parse_bool_flag`] semantics — the boolish vocabulary is + /// accepted, exported-but-empty means false, garbage is a parse error. + /// Table-driven against the real `Cli` so a new flag added with clap's + /// default bool-from-env parser (accepts only `true`/`false` — the + /// recurring "`SOCKET_X=1` aborts the parse" bug class) or with + /// `BoolishValueParser` (rejects `VAR=`) fails here, not in the field. + #[test] + #[serial_test::serial] + fn every_env_bound_bool_flag_parses_boolishly_and_tolerates_empty() { + use clap::Parser as _; + + // (env var, argv of a subcommand that binds it) — every bool entry + // of `LOCAL_ARG_ENV_VARS`, on each subcommand that binds it. + const BOOL_BINDINGS: &[(&str, &[&str])] = &[ + ("SOCKET_FORCE", &["socket-patch", "apply"]), + ("SOCKET_FORCE", &["socket-patch", "vendor"]), + ("SOCKET_SAVE_ONLY", &["socket-patch", "get", "x"]), + ("SOCKET_ONE_OFF", &["socket-patch", "get", "x"]), + ("SOCKET_ONE_OFF", &["socket-patch", "rollback"]), + ("SOCKET_ALL_RELEASES", &["socket-patch", "get", "x"]), + ("SOCKET_ALL_RELEASES", &["socket-patch", "scan"]), + ("SOCKET_SKIP_ROLLBACK", &["socket-patch", "remove", "x"]), + ("SOCKET_DOWNLOAD_ONLY", &["socket-patch", "repair"]), + ("SOCKET_VENDOR_REVERT", &["socket-patch", "vendor"]), + ("SOCKET_UNLOCK_RELEASE", &["socket-patch", "unlock"]), + ("SOCKET_VEX_NO_VERIFY", &["socket-patch", "vex"]), + ("SOCKET_VEX_COMPACT", &["socket-patch", "vex"]), + // The embedded `--vex-*` twins share the same env vars and must + // not abort host commands (e.g. apply from a postinstall hook). + ("SOCKET_VEX_NO_VERIFY", &["socket-patch", "apply"]), + ("SOCKET_VEX_COMPACT", &["socket-patch", "scan"]), + ]; - // Flags on: mirrored into the env. - let args = GlobalArgs { - debug: true, - no_telemetry: true, - ..GlobalArgs::default() - }; - apply_env_toggles(&args); - assert_eq!(std::env::var("SOCKET_DEBUG").as_deref(), Ok("1")); - assert_eq!( - std::env::var("SOCKET_TELEMETRY_DISABLED").as_deref(), - Ok("1") - ); + with_clean_socket_env(|| { + for &(var, argv) in BOOL_BINDINGS { + for (val, should_parse) in + [("", true), ("1", true), ("yes", true), ("garbage", false)] + { + std::env::set_var(var, val); + let result = crate::Cli::try_parse_from(argv.iter().copied()); + assert_eq!( + result.is_ok(), + should_parse, + "{var}={val:?} on {argv:?} — expected parse {}: {:?}", + if should_parse { "success" } else { "failure" }, + result.err().map(|e| e.to_string()), + ); + std::env::remove_var(var); + } + } + }); + } - match saved_debug { - Some(v) => std::env::set_var("SOCKET_DEBUG", v), - None => std::env::remove_var("SOCKET_DEBUG"), - } - match saved_telemetry { - Some(v) => std::env::set_var("SOCKET_TELEMETRY_DISABLED", v), - None => std::env::remove_var("SOCKET_TELEMETRY_DISABLED"), - } + /// Companion invariant for the **value-typed** local env vars: an + /// exported-but-empty value (`VAR=`) must not crash its subcommand — + /// [`scrub_empty_env_vars`] (run by `main` before clap) removes it, and + /// the parse then sees plain defaults. + #[test] + #[serial_test::serial] + fn empty_value_typed_local_env_vars_are_rescued_by_the_scrub() { + use clap::Parser as _; + + const VALUE_BINDINGS: &[(&str, &[&str])] = &[ + ("SOCKET_BATCH_SIZE", &["socket-patch", "scan"]), + ("SOCKET_SETUP_EXCLUDE", &["socket-patch", "setup"]), + ("SOCKET_VEX", &["socket-patch", "apply"]), + ("SOCKET_VEX_OUTPUT", &["socket-patch", "vex"]), + ("SOCKET_VEX_PRODUCT", &["socket-patch", "vex"]), + ("SOCKET_VEX_DOC_ID", &["socket-patch", "vex"]), + ]; + + with_clean_socket_env(|| { + for &(var, argv) in VALUE_BINDINGS { + std::env::set_var(var, ""); + scrub_empty_env_vars(); + let result = crate::Cli::try_parse_from(argv.iter().copied()); + assert!( + result.is_ok(), + "{var}= (exported empty) on {argv:?} must be scrubbed, not abort: {:?}", + result.err().map(|e| e.to_string()), + ); + std::env::remove_var(var); + } + }); } } diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index f839b7fc..aebbaf4e 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -4,15 +4,35 @@ use socket_patch_core::crawlers::{ detect_npm_pkg_manager, CrawlerOptions, Ecosystem, NpmPkgManager, }; use socket_patch_core::manifest::operations::read_manifest; -use socket_patch_core::manifest::schema::PatchRecord; +use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{ apply_package_patch, verify_file_patch, ApplyResult, MismatchPolicy, PatchSources, VerifyStatus, }; +use socket_patch_core::patch::go_redirect::{ + apply_go_redirect, reconcile_go_redirects, verify_go_redirect_state, +}; +use socket_patch_core::utils::purl::parse_golang_purl; +use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; +use socket_patch_core::utils::telemetry::{track_patch_applied, track_patch_apply_failed}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::args::{apply_env_toggles, GlobalArgs}; +use crate::commands::fetch_stage::{stage_patch_sources, StageOutcome, StagedSources}; +use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event}; +use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; +use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls}; +use crate::json_envelope::{ + AppliedVia, Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, Status, + VexSummary, +}; + /// Files whose pre-apply content matched NEITHER hash and were (or would /// be) overwritten with the verified patched content — the promoted /// verify signature `apply_package_patch` leaves behind under the default /// mismatch policy. -pub(crate) fn mismatch_overwritten_files(result: &ApplyResult) -> Vec { +fn mismatch_overwritten_files(result: &ApplyResult) -> Vec { result .files_verified .iter() @@ -35,7 +55,7 @@ fn warn_mismatch_overwrites(result: &ApplyResult, common: &GlobalArgs) { "Warning (content_mismatch_overwritten): {} {file} did not match the patch's \ expected original content; applied the full verified patched content instead \ (pass --strict to fail on mismatches)", - socket_patch_core::utils::purl::normalize_purl(&result.package_key) + normalize_purl(&result.package_key) ); } } @@ -47,32 +67,14 @@ fn warn_mismatch_overwrites(result: &ApplyResult, common: &GlobalArgs) { /// by hash (online only) so the apply below can fall through diff → blob. async fn ensure_blobs_for_mismatches( args: &ApplyArgs, - manifest: &socket_patch_core::manifest::schema::PatchManifest, - all_packages: &HashMap, - blobs_path: &Path, + manifest: &PatchManifest, + all_packages: &HashMap, + staged: &mut StagedSources, ) { if args.common.strict && !args.force { return; // strict fails on mismatch — nothing to fetch } - let mut needed: std::collections::HashSet = std::collections::HashSet::new(); - for (purl, pkg_path) in all_packages { - let Some(record) = manifest.patches.get(purl) else { - continue; - }; - for (file_name, info) in &record.files { - if info.before_hash.is_empty() { - continue; - } - let verify = verify_file_patch(pkg_path, file_name, info).await; - if verify.status == socket_patch_core::patch::apply::VerifyStatus::HashMismatch - && tokio::fs::metadata(blobs_path.join(&info.after_hash)) - .await - .is_err() - { - needed.insert(info.after_hash.clone()); - } - } - } + let needed = mismatch_blob_gaps(manifest, all_packages, &staged.blobs, args.force).await; if needed.is_empty() { return; } @@ -92,6 +94,19 @@ async fn ensure_blobs_for_mismatches( needed.len() ); } + // Apply is read-only against `.socket/`: when the stage step returned + // direct `.socket/` paths (everything had a local source), the on-demand + // blobs must go to a transient overlay, never `.socket/blobs/`. + let Some(blobs_path) = staged.writable_blobs().await else { + if !args.common.silent && !args.common.json { + eprintln!( + "Warning: could not stage a transient blob directory; {} mismatched file(s) \ + will fail to apply", + needed.len() + ); + } + return; + }; let (client, _) = get_api_client_with_overrides(args.common.api_client_overrides()).await; let _ = socket_patch_core::api::blob_fetcher::fetch_blobs_by_hash( &needed, blobs_path, &client, None, @@ -99,9 +114,63 @@ async fn ensure_blobs_for_mismatches( .await; } +/// Probe the crawled packages for `beforeHash` mismatches whose +/// `afterHash` blob is not already staged, returning the missing blob +/// hashes [`ensure_blobs_for_mismatches`] should fetch. +/// +/// The crawler keys `all_packages` by BASE purl, but release-variant +/// ecosystems (PyPI `?artifact_id=`, RubyGems `?platform=`, Maven +/// `?classifier=&ext=`) key the manifest by QUALIFIED purls — an +/// exact-key lookup misses every one of them. Match records by +/// qualifier-stripped key, and probe only the variants the apply loop +/// will actually attempt (its representative-file installed-distribution +/// gate, bypassed by `--force`) so a skipped sibling variant's files +/// don't trigger spurious fetches or `--offline` warnings. +async fn mismatch_blob_gaps( + manifest: &PatchManifest, + all_packages: &HashMap, + blobs_path: &Path, + force: bool, +) -> HashSet { + let mut needed: HashSet = HashSet::new(); + for (purl, pkg_path) in all_packages { + let variant_eco = Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()); + let stripped = strip_purl_qualifiers(purl); + for (key, record) in &manifest.patches { + if key != purl && strip_purl_qualifiers(key) != stripped { + continue; + } + if variant_eco && !force { + if let Some((file_name, file_info)) = representative_file(&record.files) { + let status = verify_file_patch(pkg_path, file_name, file_info) + .await + .status; + if !variant_matches_installed(Some(&status)) { + continue; + } + } + } + for (file_name, info) in &record.files { + if info.before_hash.is_empty() { + continue; + } + let verify = verify_file_patch(pkg_path, file_name, info).await; + if verify.status == VerifyStatus::HashMismatch + && tokio::fs::metadata(blobs_path.join(&info.after_hash)) + .await + .is_err() + { + needed.insert(info.after_hash.clone()); + } + } + } + } + needed +} + /// The mismatch policy this run applies with: `--force` ⊃ default /// (adds the missing-file skip), `--strict` restores fail-closed. -pub(crate) fn mismatch_policy(force: bool, strict: bool) -> MismatchPolicy { +fn mismatch_policy(force: bool, strict: bool) -> MismatchPolicy { if force { MismatchPolicy::Force } else if strict { @@ -111,29 +180,6 @@ pub(crate) fn mismatch_policy(force: bool, strict: bool) -> MismatchPolicy { } } -#[cfg(feature = "golang")] -use socket_patch_core::patch::go_redirect::{ - apply_go_redirect, reconcile_go_redirects, verify_go_redirect_state, -}; -#[cfg(feature = "golang")] -use socket_patch_core::utils::purl::parse_golang_purl; - -use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event}; -use socket_patch_core::utils::purl::strip_purl_qualifiers; -use socket_patch_core::utils::telemetry::{track_patch_applied, track_patch_apply_failed}; -use std::collections::{HashMap, HashSet}; -use std::path::Path; -use std::time::Duration; - -use crate::args::{apply_env_toggles, GlobalArgs}; -use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; -use crate::json_envelope::{ - AppliedVia, Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, Status, - VexSummary, -}; - -use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls}; - #[derive(Args)] pub struct ApplyArgs { #[command(flatten)] @@ -155,7 +201,7 @@ pub struct ApplyArgs { #[arg( long = "check", default_value_t = false, - value_parser = clap::builder::BoolishValueParser::new(), + value_parser = crate::args::parse_bool_flag, )] pub check: bool, @@ -170,11 +216,11 @@ pub struct ApplyArgs { // ── local-go redirect helpers ──────────────────────────────────────────────── // The Go analog of the cargo helpers above: in local mode a `pkg:golang/…` PURL // redirects to a project-local patched copy under `.socket/go-patches/` wired via -// a `go.mod` `replace` directive. Inert stubs without the `golang` feature. +// a `go.mod` `replace` directive. /// True for a golang PURL in local mode (no `--global` / `--global-prefix`). -#[cfg(feature = "golang")] -fn is_local_go(purl: &str, common: &GlobalArgs) -> bool { +/// Shared with `rollback`, which drops the same redirects this creates. +pub(crate) fn is_local_go(purl: &str, common: &GlobalArgs) -> bool { !common.global && common.global_prefix.is_none() && Ecosystem::from_purl(purl) == Some(Ecosystem::Golang) @@ -182,7 +228,6 @@ fn is_local_go(purl: &str, common: &GlobalArgs) -> bool { /// Whether local-go redirects are in scope (local mode + golang not filtered out /// by `--ecosystems`). Gates reconcile / `--check`. -#[cfg(feature = "golang")] fn go_in_local_scope(common: &GlobalArgs) -> bool { if common.global || common.global_prefix.is_some() { return false; @@ -198,7 +243,6 @@ fn go_in_local_scope(common: &GlobalArgs) -> bool { /// Materialise a local-go redirect for `purl`, or `None` if `purl` isn't a /// local-go target (the caller then falls back to in-place apply, i.e. the /// `--global` module-cache path). -#[cfg(feature = "golang")] async fn try_local_go_apply( purl: &str, pkg_path: &Path, @@ -235,21 +279,8 @@ async fn try_local_go_apply( ) } -#[cfg(not(feature = "golang"))] -async fn try_local_go_apply( - _purl: &str, - _pkg_path: &Path, - _patch: &PatchRecord, - _sources: &PatchSources<'_>, - _common: &GlobalArgs, - _policy: MismatchPolicy, -) -> Option { - None -} - /// After the apply loop: prune local-go redirects whose patches were dropped /// from the manifest. No-op unless local go is in scope. -#[cfg(feature = "golang")] async fn reconcile_local_go(common: &GlobalArgs, target_manifest_purls: &HashSet) { if !go_in_local_scope(common) { return; @@ -273,14 +304,10 @@ async fn reconcile_local_go(common: &GlobalArgs, target_manifest_purls: &HashSet } } -#[cfg(not(feature = "golang"))] -async fn reconcile_local_go(_common: &GlobalArgs, _target_manifest_purls: &HashSet) {} - /// Read-only verification of the committed Go `replace`-redirects for CI / /// GitHub-App auditing. Lock-free, crawl-free, offline-safe. Exits 0 when in /// sync, 1 on drift. Cargo patches in place (no redirect to audit), so `--check` /// covers Go only. -#[cfg(feature = "golang")] async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { let manifest = match read_manifest(manifest_path).await { Ok(Some(m)) => m, @@ -290,11 +317,18 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { // silently passing — the guard treats exit 0 as "in sync". Ok(None) => return 0, Err(e) => { - if !args.common.silent && !args.common.json { - eprintln!( - "Patch redirect check could not read the manifest ({e}); \ - treating as drift (fail-closed)." - ); + let msg = format!( + "Patch redirect check could not read the manifest ({e}); \ + treating as drift (fail-closed)." + ); + if args.common.json { + let mut env = Envelope::new(Command::Apply); + env.mark_error(EnvelopeError::new("manifest_unreadable", msg)); + println!("{}", env.to_pretty_json()); + } else { + // Errors print even under --silent ("errors only", never + // "nothing"): exit 1 with no message would be undiagnosable. + eprintln!("{msg}"); } return 1; } @@ -361,7 +395,9 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { } env.mark_partial_failure(); println!("{}", env.to_pretty_json()); - } else if !args.common.silent { + } else { + // Drift IS the error the exit code signals — it prints even + // under --silent ("errors only", never "nothing"). eprintln!("Patch redirects are OUT OF SYNC:"); for (_, _, detail) in &drifts { eprintln!(" {detail}"); @@ -372,28 +408,13 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { } } -#[cfg(not(feature = "golang"))] -async fn run_check(args: &ApplyArgs, _manifest_path: &Path) -> i32 { - // Fail-closed: `--check` is the Go `replace`-redirect audit. A socket-patch - // built WITHOUT the `golang` feature cannot verify those redirects, so it - // must NOT report "in sync" (exit 0). Exit non-zero with a clear reason. - if !args.common.silent && !args.common.json { - eprintln!( - "socket-patch: this build has no golang support, so it cannot verify \ - Go patch redirects (`--check`). Install a socket-patch built with the \ - `golang` feature, or point SOCKET_PATCH_BIN at one." - ); - } - 2 -} - /// True when every file the engine verified for this package is already /// at its `afterHash` — i.e. the patch is a complete no-op on disk. /// /// Sentinel `package_path` for a result synthesized because the purl is /// owned by `socket-patch vendor` (recorded in `.socket/vendor/state.json`). /// `result_to_event` routes it to `Skipped`/`vendored` by exact equality. -pub(crate) const VENDOR_OWNED_MARKER: &str = "managed by socket-patch vendor"; +const VENDOR_OWNED_MARKER: &str = "managed by socket-patch vendor"; /// Single source of truth for the `already_patched` classification, shared /// by [`result_to_event`] (which feeds the JSON envelope) and the @@ -414,15 +435,16 @@ fn all_files_already_patched(result: &ApplyResult) -> bool { /// Decide whether a release variant describes the distribution that is /// actually installed on disk, based on the verification status of its -/// first patched file. +/// representative patched file (see [`representative_file`]). /// /// This is the apply-side mirror of /// [`select_installed_variants`](socket_patch_core::patch::apply::select_installed_variants), -/// which `rollback` and `get` use: a variant matches only when its first -/// file is [`Ready`](VerifyStatus::Ready) (its `beforeHash` matches the -/// on-disk bytes) or [`AlreadyPatched`](VerifyStatus::AlreadyPatched) -/// (its `afterHash` already matches). A variant with no files (`None`) -/// has nothing to disqualify it and is treated as a match. +/// which `rollback` and `get` use: a variant matches only when its +/// representative file is [`Ready`](VerifyStatus::Ready) (its +/// `beforeHash` matches the on-disk bytes) or +/// [`AlreadyPatched`](VerifyStatus::AlreadyPatched) +/// (its `afterHash` already matches). A variant with no representative +/// (`None`) has nothing to disqualify it and is treated as a match. /// /// Crucially, both [`HashMismatch`](VerifyStatus::HashMismatch) **and** /// [`NotFound`](VerifyStatus::NotFound) mean "this variant's @@ -439,6 +461,28 @@ pub(crate) fn variant_matches_installed(first_file_status: Option<&VerifyStatus> } } +/// The file whose verify status decides whether a release variant +/// describes the installed distribution (fed to +/// [`variant_matches_installed`]). +/// +/// Only a file that modifies existing content (non-empty `beforeHash`) +/// can discriminate between distributions — a NEW file (empty +/// `beforeHash`) verifies `Ready` against any environment, so it can +/// neither identify nor disqualify a variant. Take the lexicographically +/// smallest such key so the choice is deterministic (`HashMap` iteration +/// order is randomized per instance). `None` (no files, or only new +/// files) means nothing can disqualify the variant. Mirrors the +/// representative pick in core's +/// [`select_installed_variants`](socket_patch_core::patch::apply::select_installed_variants). +fn representative_file( + files: &HashMap, +) -> Option<(&String, &PatchFileInfo)> { + files + .iter() + .filter(|(_, info)| !info.before_hash.is_empty()) + .min_by(|(a, _), (b, _)| a.cmp(b)) +} + /// Translate the core engine's per-package [`ApplyResult`] into a single /// patch-level [`PatchEvent`] for the unified envelope. /// @@ -508,9 +552,9 @@ pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent .collect(); // Sidecar data is NOT attached here — it's surfaced at the // envelope level under `Envelope.sidecars[]` by the run loop. - // See `Envelope::record_sidecar`. Keeping events clean of - // sidecar info means each event describes only the apply - // action; sidecar reporting is a separate, JOIN-able list. + // Keeping events clean of sidecar info means each event describes + // only the apply action; sidecar reporting is a separate, + // JOIN-able list. PatchEvent::new(PatchAction::Applied, purl).with_files(files) } @@ -569,8 +613,7 @@ pub async fn run(args: ApplyArgs) -> i32 { // different operation entirely. Refuse with a clear pointer to // `yarn patch`. pnpm gets an informational event; the CoW guard // in `apply_file_patch` does the substantive safety work. - let pkg_manager = detect_npm_pkg_manager(&args.common.cwd); - match pkg_manager { + match detect_npm_pkg_manager(&args.common.cwd) { NpmPkgManager::YarnBerryPnP => { if args.common.json { let mut env = Envelope::new(Command::Apply); @@ -580,7 +623,9 @@ pub async fn run(args: ApplyArgs) -> i32 { "yarn-berry Plug'n'Play layout is not supported by socket-patch (packages live inside .yarn/cache zips). Use `yarn patch ` instead.", )); println!("{}", env.to_pretty_json()); - } else if !args.common.silent { + } else { + // Errors print even under --silent ("errors only", never + // "nothing"): exit 1 with no message would be undiagnosable. eprintln!("Error: yarn-berry Plug'n'Play layout is not supported."); eprintln!( " Packages live inside .yarn/cache/*.zip — socket-patch cannot rewrite them in place." @@ -625,7 +670,13 @@ pub async fn run(args: ApplyArgs) -> i32 { // result is folded into the JSON envelope / human output // below and flips the exit code on failure (per the // fail-the-command contract). `None` => not requested. - let vex_result = if success && args.vex.vex.is_some() { + // + // A dry run applies nothing, so there is no just-applied + // state to attest: generating here verified the deliberately + // unapplied tree, spuriously failed the whole command with + // `no_applicable_patches`, and would write an attestation + // file during --dry-run. Skip instead. + let vex_result = if success && !args.common.dry_run && args.vex.vex.is_some() { let params = args.vex.to_build_params(); Some(generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await) } else { @@ -661,7 +712,7 @@ pub async fn run(args: ApplyArgs) -> i32 { // `envelope.sidecars[]` and JOIN against // `events[]` by `purl` for per-package context. if let Some(ref sidecar) = result.sidecar { - env.record_sidecar(sidecar.clone()); + env.sidecars.push(sidecar.clone()); } } // Manifest entries that targeted in-scope ecosystems but @@ -693,7 +744,14 @@ pub async fn run(args: ApplyArgs) -> i32 { } println!("{}", env.to_pretty_json()); } else if !args.common.silent && !results.is_empty() { - let patched: Vec<_> = results.iter().filter(|r| r.success).collect(); + // Vendor-owned synthesized results are `Skipped`/`vendored` + // in the JSON envelope — not appliable work — so keep them + // out of the human counts too ("N package(s) can be + // patched" must not count them). + let patched: Vec<_> = results + .iter() + .filter(|r| r.success && r.package_path != VENDOR_OWNED_MARKER) + .collect(); let already_patched: Vec<_> = results .iter() .filter(|r| all_files_already_patched(r)) @@ -727,15 +785,11 @@ pub async fn run(args: ApplyArgs) -> i32 { } else { format!(" (via {})", tags.join("+")) }; - println!( - " {}{}", - socket_patch_core::utils::purl::normalize_purl(&result.package_key), - suffix - ); + println!(" {}{}", normalize_purl(&result.package_key), suffix); } else if all_files_already_patched(result) { println!( " {} (already patched)", - socket_patch_core::utils::purl::normalize_purl(&result.package_key) + normalize_purl(&result.package_key) ); } } @@ -756,16 +810,14 @@ pub async fn run(args: ApplyArgs) -> i32 { if let Some(ref msg) = f.message { println!(" message: {msg}"); } - if args.common.verbose { - if let Some(ref h) = f.current_hash { - println!(" current: {h}"); - } - if let Some(ref h) = f.expected_hash { - println!(" expected: {h}"); - } - if let Some(ref h) = f.target_hash { - println!(" target: {h}"); - } + if let Some(ref h) = f.current_hash { + println!(" current: {h}"); + } + if let Some(ref h) = f.expected_hash { + println!(" expected: {h}"); + } + if let Some(ref h) = f.target_hash { + println!(" target: {h}"); } } } @@ -774,19 +826,28 @@ pub async fn run(args: ApplyArgs) -> i32 { // Human-readable VEX status (JSON mode already folded the // outcome into the envelope above). - if !args.common.json && !args.common.silent { + if !args.common.json { match &vex_result { Some(Ok(summary)) => { - println!( - "Wrote OpenVEX document with {} statement(s) to {}", - summary.statements, - args.vex.vex.as_ref().unwrap().display(), - ); + if !args.common.silent { + println!( + "Wrote OpenVEX document with {} statement(s) to {}", + summary.statements, + args.vex.vex.as_ref().unwrap().display(), + ); + } } Some(Err(e)) => { + // Errors print even under --silent ("errors only", + // never "nothing"): exit 1 with no message would be + // undiagnosable. eprintln!("Error: VEX generation failed: {}", e.message); } - None => {} + None => { + if !args.common.silent && args.common.dry_run && args.vex.vex.is_some() { + println!("Skipping VEX generation (--dry-run: nothing was applied)."); + } + } } } @@ -830,7 +891,9 @@ pub async fn run(args: ApplyArgs) -> i32 { env.dry_run = args.common.dry_run; env.mark_error(EnvelopeError::new("apply_failed", e.clone())); println!("{}", env.to_pretty_json()); - } else if !args.common.silent { + } else { + // Errors print even under --silent ("errors only", never + // "nothing"): exit 1 with no message would be undiagnosable. eprintln!("Error: {e}"); } 1 @@ -885,6 +948,21 @@ fn synthesize_vendor_owned_results( (results, matched, vendored_bases) } +/// Targeted manifest purls that matched nothing: not attempted (or +/// vendor-synthesized) and not a qualified sibling of a vendored variant — +/// those are accounted for by the vendored base, not "not installed". +fn unmatched_purls( + targets: &HashSet, + matched: &HashSet, + vendored_bases: &HashSet, +) -> Vec { + targets + .iter() + .filter(|p| !matched.contains(*p) && !vendored_bases.contains(strip_purl_qualifiers(p))) + .cloned() + .collect() +} + async fn apply_patches_inner( args: &ApplyArgs, manifest_path: &Path, @@ -900,7 +978,7 @@ async fn apply_patches_inner( // Partition manifest PURLs by ecosystem up front. The source probes, // the offline guard, and the download planner in `fetch_stage` must only // consider patches this run can actually apply — the `--ecosystems` - // filter plus the ecosystems compiled into this build. An out-of-scope + // filter. An out-of-scope // patch with no local source must not fail (or trigger fetches for) a // run that will never apply it. let manifest_purls: Vec = manifest.patches.keys().cloned().collect(); @@ -919,21 +997,10 @@ async fn apply_patches_inner( .patches .retain(|purl, _| target_manifest_purls.contains(purl)); - let staged = match crate::commands::fetch_stage::stage_patch_sources( - &args.common, - &scoped_manifest, - socket_dir, - ) - .await? - { - crate::commands::fetch_stage::StageOutcome::Ready(s) => s, - crate::commands::fetch_stage::StageOutcome::Unavailable => { - return Ok((false, Vec::new(), Vec::new())) - } + let mut staged = match stage_patch_sources(&args.common, &scoped_manifest, socket_dir).await? { + StageOutcome::Ready(s) => s, + StageOutcome::Unavailable => return Ok((false, Vec::new(), Vec::new())), }; - let blobs_path = staged.blobs.clone(); - let diffs_path = staged.diffs.clone(); - let packages_path = staged.packages.clone(); // Vendor ownership wins for EVERY ecosystem: a purl recorded in // `.socket/vendor/state.json` is managed by the explicit `vendor` @@ -946,9 +1013,8 @@ async fn apply_patches_inner( socket_patch_core::patch::vendor::vendored_purl_keys(&args.common.cwd).await; let is_vendored = |p: &str| vendored_purls.contains(p) || vendored_purls.contains(strip_purl_qualifiers(p)); - let (mut results, matched_manifest_purls, vendored_bases) = + let (mut results, mut matched_manifest_purls, vendored_bases) = synthesize_vendor_owned_results(&target_manifest_purls, &vendored_purls); - let mut matched_manifest_purls = matched_manifest_purls; // Local go: prune `replace`-redirects whose patches were dropped from the // manifest (orphans). Done here — before the crawl + the "no packages @@ -961,7 +1027,6 @@ async fn apply_patches_inner( cwd: args.common.cwd.clone(), global: args.common.global, global_prefix: args.common.global_prefix.clone(), - batch_size: 100, }; let all_packages = find_packages_for_purls( @@ -971,9 +1036,7 @@ async fn apply_patches_inner( ) .await; - let has_any_purls = !partitioned.is_empty(); - - if all_packages.is_empty() && !has_any_purls { + if all_packages.is_empty() && partitioned.is_empty() { // Nothing in scope: the manifest lists no patches (or every patch was // filtered out by `--ecosystems`). There is genuinely no work to do, // so this is a clean no-op SUCCESS — not a failure. Returning `false` @@ -991,14 +1054,11 @@ async fn apply_patches_inner( // vendored results above); only the remainder is genuinely // unmatched. An all-vendored manifest with an absent installed // tree is a SUCCESS — the committed artifacts are the patch. - let unmatched: Vec = target_manifest_purls - .iter() - .filter(|p| { - !matched_manifest_purls.contains(*p) - && !vendored_bases.contains(strip_purl_qualifiers(p)) - }) - .cloned() - .collect(); + let unmatched = unmatched_purls( + &target_manifest_purls, + &matched_manifest_purls, + &vendored_bases, + ); if !unmatched.is_empty() && !args.common.silent && !args.common.json { eprintln!("Warning: No packages found that match available patches"); eprintln!( @@ -1013,7 +1073,9 @@ async fn apply_patches_inner( } // Apply patches - ensure_blobs_for_mismatches(args, &manifest, &all_packages, &blobs_path).await; + ensure_blobs_for_mismatches(args, &manifest, &all_packages, &mut staged).await; + let sources = staged.as_patch_sources(); + let policy = mismatch_policy(args.force, args.common.strict); let mut has_errors = false; // Group release-variant PURLs by base. PyPI (`?artifact_id=`), @@ -1071,13 +1133,13 @@ async fn apply_patches_inner( None => continue, }; - // Check the first file's status (skip when --force). A - // mismatch *or* a missing file means this variant's - // distribution isn't the one on disk, so skip it — + // Check the representative file's status (skip when + // --force). A mismatch *or* a missing file means this + // variant's distribution isn't the one on disk, so skip it — // attempting it would only produce a spurious failure. // Mirrors `select_installed_variants`, used by rollback/get. if !args.force { - let first_status = match patch.files.iter().next() { + let first_status = match representative_file(&patch.files) { Some((file_name, file_info)) => Some( verify_file_patch(pkg_path, file_name, file_info) .await @@ -1091,12 +1153,6 @@ async fn apply_patches_inner( } attempted = true; - let sources = PatchSources { - blobs_path: &blobs_path, - packages_path: Some(&packages_path), - diffs_path: Some(&diffs_path), - mem_blobs: None, - }; let result = apply_package_patch( variant_purl, pkg_path, @@ -1104,7 +1160,7 @@ async fn apply_patches_inner( &sources, Some(&patch.uuid), args.common.dry_run, - mismatch_policy(args.force, args.common.strict), + policy, ) .await; @@ -1176,42 +1232,29 @@ async fn apply_patches_inner( None => continue, }; - let sources = PatchSources { - blobs_path: &blobs_path, - packages_path: Some(&packages_path), - diffs_path: Some(&diffs_path), - mem_blobs: None, - }; // Local go redirects to a project-local patched copy under // `.socket/go-patches/` wired via a `go.mod` `replace` (the module // cache is `go.sum`-verified, so in-place patching can't build). // Everything else — npm/pypi/gem and cargo (vendored or registry - // cache) — patches in place via `apply_package_patch`. Without the - // `golang` feature `try_local_go_apply` is an inert `None`. - let result = match try_local_go_apply( - purl, - pkg_path, - patch, - &sources, - &args.common, - mismatch_policy(args.force, args.common.strict), - ) - .await - { - Some(r) => r, - None => { - apply_package_patch( - purl, - pkg_path, - &patch.files, - &sources, - Some(&patch.uuid), - args.common.dry_run, - mismatch_policy(args.force, args.common.strict), - ) + // cache) — patches in place via `apply_package_patch`. + let result = + match try_local_go_apply(purl, pkg_path, patch, &sources, &args.common, policy) .await - } - }; + { + Some(r) => r, + None => { + apply_package_patch( + purl, + pkg_path, + &patch.files, + &sources, + Some(&patch.uuid), + args.common.dry_run, + policy, + ) + .await + } + }; warn_mismatch_overwrites(&result, &args.common); if !result.success { @@ -1229,17 +1272,12 @@ async fn apply_patches_inner( } } - // Check if targeted manifest entries had no matches. Qualified siblings - // of a vendored variant are accounted for by the vendored base, not - // "not installed". - let unmatched: Vec = target_manifest_purls - .iter() - .filter(|p| { - !matched_manifest_purls.contains(*p) - && !vendored_bases.contains(strip_purl_qualifiers(p)) - }) - .cloned() - .collect(); + // Check if targeted manifest entries had no matches. + let unmatched = unmatched_purls( + &target_manifest_purls, + &matched_manifest_purls, + &vendored_bases, + ); if !unmatched.is_empty() && !args.common.silent && !args.common.json { eprintln!( @@ -1247,10 +1285,7 @@ async fn apply_patches_inner( unmatched.len() ); for purl in &unmatched { - eprintln!( - " - {}", - socket_patch_core::utils::purl::normalize_purl(purl) - ); + eprintln!(" - {}", normalize_purl(purl)); } } @@ -1504,6 +1539,229 @@ mod tests { assert!(variant_matches_installed(None)); } + /// Regression (twin of core's `select_installed_variants` fix): the + /// representative file that decides "is this variant the installed + /// distribution?" must never be a NEW file (empty `beforeHash`) — a + /// new file verifies `Ready` against ANY environment, so a + /// `HashMap`-iteration-ordered pick let a variant describing a + /// different, NOT-installed distribution randomly match and get + /// attempted (nondeterministic spurious failures, or wrong-variant + /// content overwrites under the default mismatch policy). 64 rounds + /// with fresh maps so the randomized per-instance iteration order is + /// actually exercised. + #[tokio::test] + async fn representative_never_picks_new_file() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("mod.py"), b"installed wheel content\n") + .await + .unwrap(); + + for round in 0..64 { + let mut files: HashMap = HashMap::new(); + // NEW file (empty beforeHash): verifies Ready everywhere; must + // never drive selection. Name varies per round so hash order + // varies too. + files.insert( + format!("aaa_new_{round}.py"), + PatchFileInfo { + before_hash: String::new(), + after_hash: "1".repeat(64), + }, + ); + // Content-modifying file whose beforeHash does NOT match the + // on-disk bytes: the discriminating evidence that this variant + // is NOT the installed distribution. + files.insert( + "mod.py".to_string(), + PatchFileInfo { + before_hash: "2".repeat(64), + after_hash: "3".repeat(64), + }, + ); + + let status = match representative_file(&files) { + Some((name, info)) => Some(verify_file_patch(dir.path(), name, info).await.status), + None => None, + }; + assert!( + !variant_matches_installed(status.as_ref()), + "round {round}: non-installed variant matched — the representative \ + pick selected the new file instead of the discriminating one" + ); + } + } + + /// One-record manifest fixture for the `mismatch_blob_gaps` tests. + fn manifest_with_record(key: &str, files: HashMap) -> PatchManifest { + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + key.to_string(), + PatchRecord { + uuid: "11111111-1111-4111-8111-111111111111".to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: "fixture".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }, + ); + manifest + } + + /// Regression: release-variant ecosystems key the manifest by + /// QUALIFIED purl (`?artifact_id=`…) while the crawler keys + /// `all_packages` by BASE purl, so the exact-key lookup in the + /// mismatch-blob probe missed every PyPI/Gem/Maven record — the + /// afterHash blobs that the default (Warn) mismatch policy needs were + /// never prefetched, and a locally-modified file in a variant package + /// failed to apply under the default diff download mode instead of + /// being warn-overwritten. + #[tokio::test] + async fn mismatch_blob_gaps_matches_qualified_variant_keys() { + use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; + + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("pkg"); + tokio::fs::create_dir_all(&pkg).await.unwrap(); + // Representative file (lex-smallest, non-empty beforeHash) matches + // the installed distribution, so the apply loop WILL attempt this + // variant... + tokio::fs::write(pkg.join("aaa.py"), b"pristine\n") + .await + .unwrap(); + // ...but a second file was locally modified: under the default + // Warn policy it is overwritten with the full afterHash blob, so + // that blob must be prefetched. + tokio::fs::write(pkg.join("zzz.py"), b"locally modified\n") + .await + .unwrap(); + let blobs = dir.path().join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "aaa.py".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(b"pristine\n"), + after_hash: "1".repeat(64), + }, + ); + files.insert( + "zzz.py".to_string(), + PatchFileInfo { + before_hash: "2".repeat(64), + after_hash: "3".repeat(64), + }, + ); + let manifest = manifest_with_record( + "pkg:pypi/foo@1.0.0?artifact_id=foo-1.0.0-py3-none-any.whl", + files, + ); + let mut all_packages = HashMap::new(); + all_packages.insert("pkg:pypi/foo@1.0.0".to_string(), pkg.clone()); + + let needed = mismatch_blob_gaps(&manifest, &all_packages, &blobs, false).await; + assert_eq!( + needed, + HashSet::from(["3".repeat(64)]), + "the qualified variant's mismatched file must have its afterHash blob queued" + ); + } + + /// The counterpart guard: a sibling variant that does NOT describe the + /// installed distribution (its representative file mismatches) is + /// skipped by the apply loop, so its blobs must not be queued — that + /// would mean spurious downloads and spurious `--offline` "will fail + /// to apply" warnings on every run. Under `--force` every variant IS + /// attempted, so then its blob must be queued. + #[tokio::test] + async fn mismatch_blob_gaps_skips_non_installed_variant_unless_forced() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("pkg"); + tokio::fs::create_dir_all(&pkg).await.unwrap(); + tokio::fs::write(pkg.join("aaa.py"), b"pristine\n") + .await + .unwrap(); + let blobs = dir.path().join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + + // The sdist variant's only file has a different base than the + // on-disk bytes: representative mismatch → not installed. + let mut files = HashMap::new(); + files.insert( + "aaa.py".to_string(), + PatchFileInfo { + before_hash: "4".repeat(64), + after_hash: "5".repeat(64), + }, + ); + let manifest = + manifest_with_record("pkg:pypi/foo@1.0.0?artifact_id=foo-1.0.0.tar.gz", files); + let mut all_packages = HashMap::new(); + all_packages.insert("pkg:pypi/foo@1.0.0".to_string(), pkg.clone()); + + let needed = mismatch_blob_gaps(&manifest, &all_packages, &blobs, false).await; + assert!( + needed.is_empty(), + "a non-installed variant is never attempted, so its blobs must not be queued: {needed:?}" + ); + + let needed = mismatch_blob_gaps(&manifest, &all_packages, &blobs, true).await; + assert_eq!( + needed, + HashSet::from(["5".repeat(64)]), + "--force attempts every variant, so the mismatch blob is needed" + ); + } + + /// Exact-key (npm-shaped) probing keeps working: unqualified manifest + /// keys match the crawled purl directly, with no installed-variant + /// gate (the npm branch always attempts). + #[tokio::test] + async fn mismatch_blob_gaps_exact_key_still_probed() { + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("pkg"); + tokio::fs::create_dir_all(&pkg).await.unwrap(); + tokio::fs::write(pkg.join("index.js"), b"locally modified\n") + .await + .unwrap(); + let blobs = dir.path().join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + + let mut files = HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: "6".repeat(64), + after_hash: "7".repeat(64), + }, + ); + let manifest = manifest_with_record("pkg:npm/foo@1.0.0", files); + let mut all_packages = HashMap::new(); + all_packages.insert("pkg:npm/foo@1.0.0".to_string(), pkg.clone()); + + let needed = mismatch_blob_gaps(&manifest, &all_packages, &blobs, false).await; + assert_eq!(needed, HashSet::from(["7".repeat(64)])); + } + + /// A variant with no content-modifying files (only new files) has + /// nothing to disqualify it: no representative, treated as a match — + /// the same no-files contract as core's `select_installed_variants`. + #[test] + fn representative_none_when_only_new_files() { + let mut files: HashMap = HashMap::new(); + files.insert( + "new.py".to_string(), + PatchFileInfo { + before_hash: String::new(), + after_hash: "1".repeat(64), + }, + ); + assert!(representative_file(&files).is_none()); + assert!(representative_file(&HashMap::new()).is_none()); + } + /// Regression: a freshly-applied result with an empty `files_verified` /// must map to `Applied`, never `Skipped`/`already_patched`. This is /// the same classification the human-readable summary relies on via diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index 17db89be..b081b31c 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -19,20 +19,21 @@ use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::patch::apply::PatchSources; use tempfile::TempDir; +use super::get::{base64_decode, is_valid_blob_hash}; use crate::args::GlobalArgs; /// Resolved artifact locations for the patch pipeline. Holds the overlay /// `TempDir` alive — sources become invalid when this is dropped. -pub struct StagedSources { - pub blobs: PathBuf, - pub diffs: PathBuf, - pub packages: PathBuf, +pub(crate) struct StagedSources { + pub(crate) blobs: PathBuf, + diffs: PathBuf, + packages: PathBuf, _stage: Option, } impl StagedSources { /// Borrow as the core pipeline's source set. - pub fn as_patch_sources(&self) -> PatchSources<'_> { + pub(crate) fn as_patch_sources(&self) -> PatchSources<'_> { PatchSources { blobs_path: &self.blobs, packages_path: Some(&self.packages), @@ -40,10 +41,29 @@ impl StagedSources { mem_blobs: None, } } + + /// Blob destination for post-stage, on-demand fetches (apply's mismatch + /// blob top-up). When sources are read directly from `.socket/` (no + /// overlay was staged), promote `blobs` to a transient overlay tempdir + /// first — a late download must never land in the persistent + /// `.socket/blobs/` cache (this module's read-only contract). `None` + /// when the overlay cannot be created; the caller skips the fetch and + /// the affected files fail as they would offline. + pub(crate) async fn writable_blobs(&mut self) -> Option<&Path> { + if self._stage.is_none() { + let stage = tempfile::tempdir().ok()?; + let blobs = stage.path().join("blobs"); + tokio::fs::create_dir_all(&blobs).await.ok()?; + overlay_dir(&self.blobs, &blobs).await; + self.blobs = blobs; + self._stage = Some(stage); + } + Some(&self.blobs) + } } /// The staging outcome. -pub enum StageOutcome { +pub(crate) enum StageOutcome { /// Every patch has a readable source at the returned paths. Ready(StagedSources), /// Sources are unavailable (offline with missing artifacts, or downloads @@ -52,6 +72,25 @@ pub enum StageOutcome { Unavailable, } +/// Shared offline diagnostic: patches with no usable local source while +/// `--offline` is set (first five PURLs, then the `repair` hint). +fn report_offline_missing(common: &GlobalArgs, purls: &[&str]) { + if common.silent || common.json { + return; + } + eprintln!( + "Error: {} patch(es) have no local source and --offline is set:", + purls.len() + ); + for purl in purls.iter().take(5) { + eprintln!(" - {}", purl); + } + if purls.len() > 5 { + eprintln!(" ... and {} more", purls.len() - 5); + } + eprintln!("Run \"socket-patch repair\" to download missing artifacts."); +} + /// Mirror `src`'s files into `dst` by hardlink (copy fallback). Pre-seeds the /// overlay tempdir with everything already cached so only the gap downloads. async fn overlay_dir(src: &Path, dst: &Path) { @@ -83,11 +122,12 @@ async fn overlay_dir(src: &Path, dst: &Path) { /// tempdir and fetch the gap. `Err` is a hard setup failure (bad /// `--download-mode`, tempdir creation); `Ok(Unavailable)` is the soft /// "cannot proceed" path with diagnostics already printed. -pub async fn stage_patch_sources( +pub(crate) async fn stage_patch_sources( common: &GlobalArgs, manifest: &PatchManifest, socket_dir: &Path, ) -> Result { + let quiet = common.silent || common.json; let socket_blobs_path = socket_dir.join("blobs"); let socket_diffs_path = socket_dir.join("diffs"); let socket_packages_path = socket_dir.join("packages"); @@ -130,19 +170,7 @@ pub async fn stage_patch_sources( // verification on its own; we still surface the no-source // diagnosis so the user runs `repair` before retrying. if !patches_without_source.is_empty() { - if !common.silent && !common.json { - eprintln!( - "Error: {} patch(es) have no local source and --offline is set:", - patches_without_source.len() - ); - for purl in patches_without_source.iter().take(5) { - eprintln!(" - {}", purl); - } - if patches_without_source.len() > 5 { - eprintln!(" ... and {} more", patches_without_source.len() - 5); - } - eprintln!("Run \"socket-patch repair\" to download missing artifacts."); - } + report_offline_missing(common, &patches_without_source); return Ok(StageOutcome::Unavailable); } } @@ -177,19 +205,22 @@ pub async fn stage_patch_sources( // exclusively from the tempdir; `.socket/` is never mutated. Dropping // `StagedSources` removes the directory and any downloaded bytes. let stage = tempfile::tempdir().map_err(|e| e.to_string())?; - let stage_blobs = stage.path().join("blobs"); - let stage_diffs = stage.path().join("diffs"); - let stage_packages = stage.path().join("packages"); - for dir in [&stage_blobs, &stage_diffs, &stage_packages] { + let staged = StagedSources { + blobs: stage.path().join("blobs"), + diffs: stage.path().join("diffs"), + packages: stage.path().join("packages"), + _stage: Some(stage), + }; + for dir in [&staged.blobs, &staged.diffs, &staged.packages] { tokio::fs::create_dir_all(dir) .await .map_err(|e| e.to_string())?; } - overlay_dir(&socket_blobs_path, &stage_blobs).await; - overlay_dir(&socket_diffs_path, &stage_diffs).await; - overlay_dir(&socket_packages_path, &stage_packages).await; + overlay_dir(&socket_blobs_path, &staged.blobs).await; + overlay_dir(&socket_diffs_path, &staged.diffs).await; + overlay_dir(&socket_packages_path, &staged.packages).await; - if !common.silent && !common.json { + if !quiet { println!( "Downloading missing patch artifacts (mode: {})...", download_mode.as_tag() @@ -197,16 +228,11 @@ pub async fn stage_patch_sources( } let (client, _) = get_api_client_with_overrides(common.api_client_overrides()).await; - let sources = PatchSources { - blobs_path: &stage_blobs, - packages_path: Some(&stage_packages), - diffs_path: Some(&stage_diffs), - mem_blobs: None, - }; + let sources = staged.as_patch_sources(); let fetch_result = fetch_missing_sources(manifest, &sources, download_mode, &client, None).await; - if !common.silent && !common.json { + if !quiet { println!("{}", format_fetch_result(&fetch_result)); } @@ -214,38 +240,33 @@ pub async fn stage_patch_sources( // a fallback. Patches that lack the requested mode on the server will // still apply via the legacy blob path. if download_mode != DownloadMode::File { - let still_missing_blobs = get_missing_blobs(manifest, &stage_blobs).await; + let still_missing_blobs = get_missing_blobs(manifest, &staged.blobs).await; if !still_missing_blobs.is_empty() { - if !common.silent && !common.json { + if !quiet { println!( "Falling back to per-file blob downloads for {} blob(s)...", still_missing_blobs.len() ); } - let blob_result = fetch_missing_blobs(manifest, &stage_blobs, &client, None).await; - if !common.silent && !common.json { + let blob_result = fetch_missing_blobs(manifest, &staged.blobs, &client, None).await; + if !quiet { println!("{}", format_fetch_result(&blob_result)); } if blob_result.failed > 0 && fetch_result.failed > 0 { - if !common.silent && !common.json { + if !quiet { eprintln!("Some artifacts could not be downloaded. Cannot apply patches."); } return Ok(StageOutcome::Unavailable); } } } else if fetch_result.failed > 0 { - if !common.silent && !common.json { + if !quiet { eprintln!("Some blobs could not be downloaded. Cannot apply patches."); } return Ok(StageOutcome::Unavailable); } - Ok(StageOutcome::Ready(StagedSources { - blobs: stage_blobs, - diffs: stage_diffs, - packages: stage_packages, - _stage: Some(stage), - })) + Ok(StageOutcome::Ready(staged)) } /// In-memory staged sources for the VENDOR flows. @@ -256,7 +277,7 @@ pub async fn stage_patch_sources( /// `.socket/blobs` entries and no temporary files. The committed /// `.socket/vendor/` artifact is the patch; nothing else should land on /// disk. -pub struct MemStagedSources { +pub(crate) struct MemStagedSources { blobs: PathBuf, diffs: PathBuf, packages: PathBuf, @@ -266,7 +287,7 @@ pub struct MemStagedSources { impl MemStagedSources { /// Borrow as the core pipeline's source set (memory overlay first, /// on-disk artifacts as the read-only fallback). - pub fn as_patch_sources(&self) -> PatchSources<'_> { + pub(crate) fn as_patch_sources(&self) -> PatchSources<'_> { PatchSources { blobs_path: &self.blobs, packages_path: Some(&self.packages), @@ -277,7 +298,7 @@ impl MemStagedSources { } /// The in-memory staging outcome (mirror of [`StageOutcome`]). -pub enum MemStageOutcome { +pub(crate) enum MemStageOutcome { Ready(MemStagedSources), Unavailable, } @@ -290,12 +311,13 @@ pub enum MemStageOutcome { /// memory from the patch view endpoint (`blobContent`), preceded by the /// committed-artifact harvest. Offline runs with missing sources are /// `Unavailable` with the same diagnostics as the disk stager. -pub async fn stage_vendor_sources_in_memory( +pub(crate) async fn stage_vendor_sources_in_memory( common: &GlobalArgs, manifest: &PatchManifest, socket_dir: &Path, project_root: &Path, ) -> Result { + let quiet = common.silent || common.json; let blobs = socket_dir.join("blobs"); let diffs = socket_dir.join("diffs"); let packages = socket_dir.join("packages"); @@ -326,111 +348,90 @@ pub async fn stage_vendor_sources_in_memory( }) .collect(); - if to_fetch.is_empty() { - return Ok(MemStageOutcome::Ready(MemStagedSources { - blobs, - diffs, - packages, - mem: HashMap::new(), - })); - } - - // The committed vendor artifact IS the patched content: harvest its - // afterHash blobs into memory so in-sync re-runs and fresh clones of - // already-vendored projects stage with no network and no disk blobs. - let mut mem = - socket_patch_core::patch::vendor::harvest_artifact_blobs(project_root, &manifest.patches) - .await; - if !mem.is_empty() { - to_fetch.retain(|(purl, _)| { - manifest.patches.get(*purl).is_none_or(|record| { - !record.files.values().all(|f| { - !missing_blobs.contains(&f.after_hash) || mem.contains_key(&f.after_hash) + let mut mem = HashMap::new(); + if !to_fetch.is_empty() { + // The committed vendor artifact IS the patched content: harvest its + // afterHash blobs into memory so in-sync re-runs and fresh clones of + // already-vendored projects stage with no network and no disk blobs. + mem = socket_patch_core::patch::vendor::harvest_artifact_blobs( + project_root, + &manifest.patches, + ) + .await; + if !mem.is_empty() { + to_fetch.retain(|(purl, _)| { + manifest.patches.get(*purl).is_none_or(|record| { + !record.files.values().all(|f| { + !missing_blobs.contains(&f.after_hash) || mem.contains_key(&f.after_hash) + }) }) - }) - }); - if to_fetch.is_empty() { - return Ok(MemStageOutcome::Ready(MemStagedSources { - blobs, - diffs, - packages, - mem, - })); + }); } } - if common.offline { - if !common.silent && !common.json { - eprintln!( - "Error: {} patch(es) have no local source and --offline is set:", + if !to_fetch.is_empty() { + if common.offline { + let purls: Vec<&str> = to_fetch.iter().map(|(purl, _)| *purl).collect(); + report_offline_missing(common, &purls); + return Ok(MemStageOutcome::Unavailable); + } + + if !quiet { + println!( + "Fetching {} patch(es)' content (kept in memory)...", to_fetch.len() ); - for (purl, _) in to_fetch.iter().take(5) { - eprintln!(" - {}", purl); - } - if to_fetch.len() > 5 { - eprintln!(" ... and {} more", to_fetch.len() - 5); - } - eprintln!("Run \"socket-patch repair\" to download missing artifacts."); } - return Ok(MemStageOutcome::Unavailable); - } - - if !common.silent && !common.json { - println!( - "Fetching {} patch(es)' content (kept in memory)...", - to_fetch.len() - ); - } - let (client, _) = get_api_client_with_overrides(common.api_client_overrides()).await; - let mut failed: Vec<&str> = Vec::new(); - for (purl, uuid) in &to_fetch { - match client.fetch_patch(common.org.as_deref(), uuid).await { - Ok(Some(patch)) => { - let mut complete = true; - for (file, info) in &patch.files { - let (Some(b64), Some(hash)) = (&info.blob_content, &info.after_hash) else { - if !common.silent && !common.json { - eprintln!(" [error] {purl}: no blob content served for {file}"); - } - complete = false; - break; - }; - // Same key guard as the disk writer: the hash names the - // lookup key the apply pipeline gates writes on. - if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { - complete = false; - break; - } - match super::get::base64_decode(b64) { - Ok(bytes) => { - mem.insert(hash.clone(), bytes); - } - Err(_) => { + let (client, _) = get_api_client_with_overrides(common.api_client_overrides()).await; + let mut failed: Vec<&str> = Vec::new(); + for (purl, uuid) in &to_fetch { + match client.fetch_patch(common.org.as_deref(), uuid).await { + Ok(Some(patch)) => { + let mut complete = true; + for (file, info) in &patch.files { + let (Some(b64), Some(hash)) = (&info.blob_content, &info.after_hash) else { + if !quiet { + eprintln!(" [error] {purl}: no blob content served for {file}"); + } + complete = false; + break; + }; + // Same key guard as the disk writer: the hash names the + // lookup key the apply pipeline gates writes on. + if !is_valid_blob_hash(hash) { complete = false; break; } + match base64_decode(b64) { + Ok(bytes) => { + mem.insert(hash.clone(), bytes); + } + Err(_) => { + complete = false; + break; + } + } + } + if !complete { + failed.push(purl); } } - if !complete { - failed.push(purl); - } + _ => failed.push(purl), } - _ => failed.push(purl), } - } - if !failed.is_empty() { - if !common.silent && !common.json { - eprintln!( - "Error: could not fetch patch content for {} patch(es):", - failed.len() - ); - for purl in failed.iter().take(5) { - eprintln!(" - {}", purl); + if !failed.is_empty() { + if !quiet { + eprintln!( + "Error: could not fetch patch content for {} patch(es):", + failed.len() + ); + for purl in failed.iter().take(5) { + eprintln!(" - {}", purl); + } } + return Ok(MemStageOutcome::Unavailable); } - return Ok(MemStageOutcome::Unavailable); } Ok(MemStageOutcome::Ready(MemStagedSources { diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 0ba397f6..e22eb5d1 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -58,7 +58,7 @@ pub(crate) enum PatchAction { /// historical bug was a `status` of `success` (keyed only on download /// failures) sitting next to an exit code of `1` produced by a failed /// *apply* step. -pub(crate) fn run_outcome(patches_failed: bool, apply_failed: bool) -> (&'static str, i32) { +fn run_outcome(patches_failed: bool, apply_failed: bool) -> (&'static str, i32) { if patches_failed || apply_failed { ("partial_failure", 1) } else { @@ -85,7 +85,7 @@ pub(crate) fn decide_patch_action( /// Ordinal rank for severity strings. Higher = worse. Unknown labels /// (including GHSA's `moderate` which maps to `medium`) get sensible /// defaults so the max-severity selector still works. -pub(crate) fn severity_rank(severity: &str) -> u8 { +fn severity_rank(severity: &str) -> u8 { match severity.to_ascii_lowercase().as_str() { "critical" => 4, "high" => 3, @@ -99,7 +99,7 @@ pub(crate) fn severity_rank(severity: &str) -> u8 { /// Return the highest-severity label from a vulnerabilities map. /// Returns `None` when the map is empty or every entry's severity is /// unrecognized. -pub(crate) fn max_vuln_severity(vulns: &HashMap) -> Option { +fn max_vuln_severity(vulns: &HashMap) -> Option { vulns .values() .max_by_key(|v| severity_rank(&v.severity)) @@ -123,7 +123,7 @@ pub(crate) fn max_vuln_severity(vulns: &HashMap) /// /// Output keys are JSON-camelCase to match the rest of the envelope. /// The vulnerability list is sorted by ID for stable test snapshots. -pub(crate) fn patch_event_metadata(patch: &PatchResponse) -> serde_json::Value { +fn patch_event_metadata(patch: &PatchResponse) -> serde_json::Value { let mut vulns: Vec = patch .vulnerabilities .iter() @@ -199,12 +199,12 @@ pub(crate) fn truncate_with_ellipsis(s: &str, limit: usize) -> String { } } -/// Short, display-only prefix of a UUID for `[update]` log lines. Returns +/// Short, display-only prefix of a UUID for log lines. Returns /// the first 8 bytes when they fall on a char boundary, otherwise the /// whole string. A naive `&uuid[..8]` panics on a malformed/short UUID in /// the manifest (out-of-bounds or mid-codepoint); this never does. Pure /// so the no-panic guarantee is unit-testable. -fn short_uuid(uuid: &str) -> &str { +pub(crate) fn short_uuid(uuid: &str) -> &str { uuid.get(..8).unwrap_or(uuid) } @@ -254,7 +254,7 @@ fn report_error(json: bool, message: impl std::fmt::Display) { /// hash comes from an untrusted API response and is used as a filesystem /// path component: anything else (`../../x`, an absolute path) would /// escape the blobs directory via `Path::join`. -fn is_valid_blob_hash(hash: &str) -> bool { +pub(crate) fn is_valid_blob_hash(hash: &str) -> bool { hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) } @@ -288,21 +288,21 @@ async fn write_all_patch_blobs( quiet: bool, ) -> Result<(), ()> { for (file_path, file_info) in &patch.files { - if let (Some(blob), Some(hash)) = (&file_info.blob_content, &file_info.after_hash) { - if let Err(e) = write_blob_entry(blobs_dir, blob, hash, file_path, "blob").await { - if !quiet { - eprintln!(" [error] {e}"); - } - return Err(()); - } - } - if let (Some(blob), Some(hash)) = (&file_info.before_blob_content, &file_info.before_hash) { - if let Err(e) = write_blob_entry(blobs_dir, blob, hash, file_path, "before-blob").await - { - if !quiet { - eprintln!(" [error] {e}"); + for (blob, hash, label) in [ + (&file_info.blob_content, &file_info.after_hash, "blob"), + ( + &file_info.before_blob_content, + &file_info.before_hash, + "before-blob", + ), + ] { + if let (Some(blob), Some(hash)) = (blob, hash) { + if let Err(e) = write_blob_entry(blobs_dir, blob, hash, file_path, label).await { + if !quiet { + eprintln!(" [error] {e}"); + } + return Err(()); } - return Err(()); } } } @@ -346,10 +346,14 @@ fn build_patch_record(patch: &PatchResponse, files: HashMap (String, PatchRecord) { +/// Build the manifest-shaped `files` map from a fetched patch view, +/// keeping only files that carry BOTH hashes — the download-flow rule +/// shared by the record builders and installed-distribution matching +/// (new files with no `beforeHash` are excluded; `save_and_apply_patch` +/// has the new-file-tolerant variant). A file with an empty-string +/// `beforeHash` is still kept so first-file verification can treat it +/// as Ready. +fn files_with_both_hashes(patch: &PatchResponse) -> HashMap { let mut files = HashMap::new(); for (file_path, file_info) in &patch.files { if let (Some(before), Some(after)) = (&file_info.before_hash, &file_info.after_hash) { @@ -362,7 +366,17 @@ pub(crate) fn record_from_patch_response(patch: &PatchResponse) -> (String, Patc ); } } - (patch.purl.clone(), build_patch_record(patch, files)) + files +} + +/// `(purl, manifest record)` from a fetched patch view — the both-hashes +/// file rule shared with the download flows (new files with no beforeHash +/// are not part of the record). +pub(crate) fn record_from_patch_response(patch: &PatchResponse) -> (String, PatchRecord) { + ( + patch.purl.clone(), + build_patch_record(patch, files_with_both_hashes(patch)), + ) } #[derive(Args)] @@ -390,16 +404,32 @@ pub struct GetArgs { pub package: bool, /// Download patch without applying it. + /// + /// `value_parser = parse_bool_flag` matches the `GlobalArgs` bool flags: + /// clap's default bool parser accepts only the literal strings + /// `true`/`false` from the env binding, so `SOCKET_SAVE_ONLY=1` (or an + /// exported-but-empty `SOCKET_SAVE_ONLY=`) aborted every `get` + /// invocation. #[arg( long = "save-only", alias = "no-apply", env = "SOCKET_SAVE_ONLY", - default_value_t = false + default_value_t = false, + value_parser = crate::args::parse_bool_flag, )] pub save_only: bool, /// Apply patch immediately without saving to .socket folder. - #[arg(long = "one-off", env = "SOCKET_ONE_OFF", default_value_t = false)] + /// + /// `value_parser = parse_bool_flag`: same env-crash fix as `--save-only` + /// above — and `SOCKET_ONE_OFF` is shared with `rollback --one-off`, + /// which already parses boolishly; the two must not diverge. + #[arg( + long = "one-off", + env = "SOCKET_ONE_OFF", + default_value_t = false, + value_parser = crate::args::parse_bool_flag, + )] pub one_off: bool, /// Download patches for every release/distribution variant of a @@ -412,7 +442,7 @@ pub struct GetArgs { long = "all-releases", env = "SOCKET_ALL_RELEASES", default_value_t = false, - value_parser = clap::builder::BoolishValueParser::new(), + value_parser = crate::args::parse_bool_flag, )] pub all_releases: bool, } @@ -465,7 +495,7 @@ fn detect_identifier_type(identifier: &str) -> Option { /// - JSON mode with multiple free patches: returns an error with options list. /// /// Returns `Ok(selected_patches)` or `Err(exit_code)` if selection fails. -pub fn select_patches( +pub(crate) fn select_patches( patches: &[PatchSearchResult], can_access_paid: bool, is_json: bool, @@ -557,7 +587,7 @@ pub fn select_patches( "{}", serde_json::to_string_pretty(&serde_json::json!({ "status": "selection_required", - "error": format!("Multiple patches available for {purl}. Specify --id to select one."), + "error": format!("Multiple patches available for {purl}. Re-run with the chosen UUID as the identifier (`socket-patch get `) to select one."), "purl": purl, "options": options_json, })) @@ -579,9 +609,14 @@ pub fn select_patches( /// Download parameters shared between get and scan commands. pub struct DownloadParams { pub cwd: PathBuf, + /// Resolved manifest location (`GlobalArgs::resolved_manifest_path`). + /// The blobs directory is its parent's `blobs/` — the same layout + /// apply/rollback resolve from — so `--manifest-path` is honored here + /// like on every other command, not silently replaced with + /// `/.socket/manifest.json`. + pub manifest_path: PathBuf, pub org: Option, pub save_only: bool, - pub one_off: bool, pub global: bool, pub global_prefix: Option, pub json: bool, @@ -630,13 +665,18 @@ pub struct DownloadParams { /// /// Both fallbacks push a human-readable warning. /// -/// Returns the kept patches plus any warnings to surface to the caller. +/// Returns the kept patches plus any warnings to surface to the caller +/// (also printed to stderr here, in human mode). With `--all-releases` +/// set this is a verbatim pass-through. async fn filter_to_installed_releases( selected: &[PatchSearchResult], params: &DownloadParams, api_client: &socket_patch_core::api::client::ApiClient, - org: Option<&str>, ) -> (Vec, Vec) { + if params.all_releases { + return (selected.to_vec(), Vec::new()); + } + // Group release-variant ecosystem selections (PyPI / RubyGems / Maven) // by their base PURL (qualifiers stripped). Anything that can't have // release variants, or whose base has a single variant, is kept @@ -686,7 +726,6 @@ async fn filter_to_installed_releases( cwd: params.cwd.clone(), global: params.global, global_prefix: params.global_prefix.clone(), - batch_size: 100, }; let paths = find_packages_for_rollback(&partitioned, &crawler_options, true).await; @@ -709,9 +748,10 @@ async fn filter_to_installed_releases( // can hash-match against the installed distribution. let mut candidates: Vec<(String, HashMap)> = Vec::new(); for s in &variants { - match api_client.fetch_patch(org, &s.uuid).await { + // org slug is already stored in the client. + match api_client.fetch_patch(None, &s.uuid).await { Ok(Some(patch)) => { - candidates.push((s.purl.clone(), files_for_selection(&patch))); + candidates.push((s.purl.clone(), files_with_both_hashes(&patch))); } // On a fetch error/miss, keep the variant so the main // download loop can record the failure as it would today. @@ -744,27 +784,22 @@ async fn filter_to_installed_releases( } } + if !params.json && !params.silent { + for w in &warnings { + eprintln!(" [note] {w}"); + } + } (kept, warnings) } -/// Build the before/after-hash map used for installed-distribution -/// matching. Mirrors the download flow's requirement that a patchable -/// file carry both hashes (new files, with an empty `beforeHash`, are -/// still kept so first-file verification can treat them as Ready). -fn files_for_selection(patch: &PatchResponse) -> HashMap { - let mut files = HashMap::new(); - for (file_path, file_info) in &patch.files { - if let (Some(before), Some(after)) = (&file_info.before_hash, &file_info.after_hash) { - files.insert( - file_path.clone(), - PatchFileInfo { - before_hash: before.clone(), - after_hash: after.clone(), - }, - ); - } +/// Build the API client for a download run, defaulting the override org +/// slug to the caller's `--org` when no explicit override was given. +async fn api_client_for(params: &DownloadParams) -> socket_patch_core::api::client::ApiClient { + let mut overrides = params.api_overrides.clone(); + if overrides.org_slug.is_none() { + overrides.org_slug = params.org.clone(); } - files + get_api_client_with_overrides(overrides).await.0 } /// Download and apply a set of selected patches. @@ -782,15 +817,13 @@ pub(crate) async fn download_patch_records( selected: &[PatchSearchResult], params: &DownloadParams, ) -> (i32, serde_json::Value, HashMap) { - let mut overrides = params.api_overrides.clone(); - if overrides.org_slug.is_none() { - overrides.org_slug = params.org.clone(); - } - let (api_client, _) = - socket_patch_core::api::client::get_api_client_with_overrides(overrides).await; - let effective_org: Option<&str> = None; + let api_client = api_client_for(params).await; - let socket_dir = params.cwd.join(".socket"); + let socket_dir = params + .manifest_path + .parent() + .unwrap_or(Path::new(".")) + .to_path_buf(); let blobs_dir = socket_dir.join("blobs"); if params.persist_blobs { if let Err(e) = tokio::fs::create_dir_all(&blobs_dir).await { @@ -804,22 +837,8 @@ pub(crate) async fn download_patch_records( } } - let mut narrow_warnings: Vec = Vec::new(); - let selected_owned: Vec; - let selected: &[PatchSearchResult] = if params.all_releases { - selected - } else { - let (kept, warns) = - filter_to_installed_releases(selected, params, &api_client, effective_org).await; - if !params.json && !params.silent { - for w in &warns { - eprintln!(" [note] {w}"); - } - } - narrow_warnings = warns; - selected_owned = kept; - &selected_owned - }; + let (selected, narrow_warnings) = + filter_to_installed_releases(selected, params, &api_client).await; let vendor_state = socket_patch_core::patch::vendor::load_state(¶ms.cwd) .await @@ -831,19 +850,14 @@ pub(crate) async fn download_patch_records( let mut failed = 0usize; let mut patch_records_json: Vec = Vec::new(); - for search_result in selected { + for search_result in &selected { // Idempotency: a detached entry already at this uuid carries its // own record — no view fetch needed. - let existing = vendor_state - .entries - .get(&search_result.purl) - .or_else(|| { - vendor_state - .entries - .values() - .find(|e| e.base_purl == search_result.purl) - }) - .filter(|e| e.detached && e.uuid == search_result.uuid); + let existing = socket_patch_core::patch::vendor::lookup_entry( + &vendor_state.entries, + &search_result.purl, + ) + .filter(|e| e.detached && e.uuid == search_result.uuid); if let Some(record) = existing.and_then(|e| e.record.clone()) { if !params.json && !params.silent { eprintln!(" [skip] {} (already vendored)", search_result.purl); @@ -858,27 +872,12 @@ pub(crate) async fn download_patch_records( continue; } - match api_client - .fetch_patch(effective_org, &search_result.uuid) - .await - { + // org slug is already stored in the client. + match api_client.fetch_patch(None, &search_result.uuid).await { Ok(Some(patch)) => { // Same both-hashes rule as the download flow: new files // (no beforeHash) are skipped from the record. - let mut files = HashMap::new(); - for (file_path, file_info) in &patch.files { - if let (Some(before), Some(after)) = - (&file_info.before_hash, &file_info.after_hash) - { - files.insert( - file_path.clone(), - PatchFileInfo { - before_hash: before.clone(), - after_hash: after.clone(), - }, - ); - } - } + let files = files_with_both_hashes(&patch); let quiet = params.json || params.silent; // Vendor flows keep blob content in memory (the vendor // step re-fetches what it needs); persisting blobs here @@ -911,6 +910,9 @@ pub(crate) async fn download_patch_records( downloaded += 1; } Ok(None) => { + if !params.json && !params.silent { + eprintln!(" [fail] {} (could not fetch details)", search_result.purl); + } failed += 1; patch_records_json.push(serde_json::json!({ "purl": search_result.purl, @@ -920,6 +922,9 @@ pub(crate) async fn download_patch_records( })); } Err(e) => { + if !params.json && !params.silent { + eprintln!(" [fail] {} ({e})", search_result.purl); + } failed += 1; patch_records_json.push(serde_json::json!({ "purl": search_result.purl, @@ -954,11 +959,12 @@ pub(crate) async fn download_patch_records( /// function sits on the in-process scan→download→apply chain, whose summed /// poll frames must fit Windows' 1 MiB main-thread stack in debug builds. async fn warn_on_vendored_uuid_drift( - params: &DownloadParams, + cwd: &Path, + quiet: bool, downloaded_patches: &[serde_json::Value], warnings: &mut Vec, ) { - let Ok(vendor_state) = socket_patch_core::patch::vendor::load_state(¶ms.cwd).await else { + let Ok(vendor_state) = socket_patch_core::patch::vendor::load_state(cwd).await else { return; }; if vendor_state.entries.is_empty() { @@ -971,17 +977,14 @@ async fn warn_on_vendored_uuid_drift( if !matches!(rec["action"].as_str(), Some("added" | "updated")) { continue; } - let entry = vendor_state - .entries - .get(purl) - .or_else(|| vendor_state.entries.values().find(|e| e.base_purl == purl)); + let entry = socket_patch_core::patch::vendor::lookup_entry(&vendor_state.entries, purl); if let Some(entry) = entry.filter(|e| e.uuid != uuid) { let w = format!( "{purl} is vendored at patch {} but the manifest now records {uuid}; \ run `socket-patch vendor` to refresh the committed artifact", entry.uuid ); - if !params.json && !params.silent { + if !quiet { eprintln!(" [note] {w}"); } warnings.push(w); @@ -989,21 +992,62 @@ async fn warn_on_vendored_uuid_drift( } } +/// Run the nested `apply` step over the manifest under `cwd`. Returns +/// whether apply exited 0. Callers print their own "Applying patches..." +/// line (they differ on stdout vs stderr). `get` drives apply internally: +/// the read-only cargo-redirect verifier stays off and embedded VEX is +/// opt-in on the top-level command only, never on this internal +/// invocation. +async fn run_nested_apply( + cwd: &Path, + manifest_path: &Path, + global: bool, + global_prefix: Option, + quiet: bool, + download_mode: String, + strict: bool, +) -> bool { + // Apply re-resolves a relative manifest path against ITS `--cwd` + // (`resolved_manifest_path`), but ours is already cwd-resolved — + // passing it through relative double-joins the cwd (`proj/proj/...`), + // and apply then no-ops on the missing manifest while reporting + // success. Absolutize so it passes through verbatim. + let manifest_path = + std::path::absolute(manifest_path).unwrap_or_else(|_| manifest_path.to_path_buf()); + let apply_args = super::apply::ApplyArgs { + common: crate::args::GlobalArgs { + manifest_path: manifest_path.display().to_string(), + cwd: cwd.to_path_buf(), + global, + global_prefix, + silent: quiet, + download_mode, + strict, + ..crate::args::GlobalArgs::default() + }, + force: false, + check: false, + vex: Default::default(), + }; + let code = super::apply::run(apply_args).await; + if code != 0 && !quiet { + eprintln!("\nSome patches could not be applied."); + } + code == 0 +} + pub async fn download_and_apply_patches( selected: &[PatchSearchResult], params: &DownloadParams, ) -> (i32, serde_json::Value) { - let mut overrides = params.api_overrides.clone(); - if overrides.org_slug.is_none() { - overrides.org_slug = params.org.clone(); - } - let (api_client, _) = - socket_patch_core::api::client::get_api_client_with_overrides(overrides).await; - let effective_org: Option<&str> = None; + let api_client = api_client_for(params).await; - let socket_dir = params.cwd.join(".socket"); + let manifest_path = params.manifest_path.clone(); + let socket_dir = manifest_path + .parent() + .unwrap_or(Path::new(".")) + .to_path_buf(); let blobs_dir = socket_dir.join("blobs"); - let manifest_path = socket_dir.join("manifest.json"); if let Err(e) = tokio::fs::create_dir_all(&socket_dir).await { let err = format!("Failed to create .socket directory: {}", e); @@ -1020,28 +1064,22 @@ pub async fn download_and_apply_patches( let mut manifest = match read_manifest(&manifest_path).await { Ok(Some(m)) => m, - _ => PatchManifest::new(), + Ok(None) => PatchManifest::new(), + // Fail closed on a manifest that exists but can't be read/parsed: + // treating it as empty would let the unconditional write below + // replace the file and destroy every tracked patch record. + Err(e) => { + let err = format!("Failed to read manifest: {e}"); + report_error(params.json, &err); + return (1, serde_json::json!({"status": "error", "error": err})); + } }; - // Narrow PyPI multi-release selections to the installed distribution + // Narrow multi-release selections to the installed distribution // unless --all-releases was passed. `filter_to_installed_releases` - // is a no-op for non-PyPI ecosystems and single-variant packages. - let mut narrow_warnings: Vec = Vec::new(); - let selected_owned: Vec; - let selected: &[PatchSearchResult] = if params.all_releases { - selected - } else { - let (kept, warns) = - filter_to_installed_releases(selected, params, &api_client, effective_org).await; - if !params.json && !params.silent { - for w in &warns { - eprintln!(" [note] {w}"); - } - } - narrow_warnings = warns; - selected_owned = kept; - &selected_owned - }; + // is a no-op for non-variant ecosystems and single-variant packages. + let (selected, mut narrow_warnings) = + filter_to_installed_releases(selected, params, &api_client).await; if !params.json && !params.silent { eprintln!("\nDownloading {} patch(es)...", selected.len()); @@ -1053,11 +1091,9 @@ pub async fn download_and_apply_patches( let mut patches_updated = 0; let mut downloaded_patches: Vec = Vec::new(); - for search_result in selected { - match api_client - .fetch_patch(effective_org, &search_result.uuid) - .await - { + for search_result in &selected { + // org slug is already stored in the client. + match api_client.fetch_patch(None, &search_result.uuid).await { Ok(Some(patch)) => { // Classify against the manifest state BEFORE we touch it. // `Skipped` early-returns; `Updated` is preserved so the @@ -1082,20 +1118,7 @@ pub async fn download_and_apply_patches( // Build the manifest `files` map. Download flow requires // BOTH before+after hash (skips new files); see // `save_and_apply_patch` for the new-file-tolerant variant. - let mut files = HashMap::new(); - for (file_path, file_info) in &patch.files { - if let (Some(before), Some(after)) = - (&file_info.before_hash, &file_info.after_hash) - { - files.insert( - file_path.clone(), - PatchFileInfo { - before_hash: before.clone(), - after_hash: after.clone(), - }, - ); - } - } + let files = files_with_both_hashes(&patch); let quiet = params.json || params.silent; // Vendor flows keep blob content in memory (the vendor @@ -1205,7 +1228,13 @@ pub async fn download_and_apply_patches( // uuid — tell the operator now instead of letting VEX surprise them // later. (`scan` never hits this: it filters vendored purls before // download.) The nested apply below skips the vendored purl either way. - warn_on_vendored_uuid_drift(params, &downloaded_patches, &mut narrow_warnings).await; + warn_on_vendored_uuid_drift( + ¶ms.cwd, + params.json || params.silent, + &downloaded_patches, + &mut narrow_warnings, + ) + .await; if !params.json && !params.silent { eprintln!("\nPatches saved to {}", manifest_path.display()); @@ -1227,29 +1256,16 @@ pub async fn download_and_apply_patches( if !params.json && !params.silent { eprintln!("\nApplying patches..."); } - let apply_args = super::apply::ApplyArgs { - common: crate::args::GlobalArgs { - cwd: params.cwd.clone(), - manifest_path: manifest_path.display().to_string(), - global: params.global, - global_prefix: params.global_prefix.clone(), - silent: params.json || params.silent, - download_mode: params.download_mode.clone(), - strict: params.strict, - ..crate::args::GlobalArgs::default() - }, - force: false, - // get never runs the read-only cargo-redirect verifier. - check: false, - // get drives apply internally; embedded VEX is opt-in on the - // top-level command, never on this internal invocation. - vex: Default::default(), - }; - let code = super::apply::run(apply_args).await; - apply_succeeded = code == 0; - if code != 0 && !params.json && !params.silent { - eprintln!("\nSome patches could not be applied."); - } + apply_succeeded = run_nested_apply( + ¶ms.cwd, + &manifest_path, + params.global, + params.global_prefix.clone(), + params.json || params.silent, + params.download_mode.clone(), + params.strict, + ) + .await; } // An apply step that ran (patches were added, not --save-only) but @@ -1294,14 +1310,33 @@ pub async fn run(args: GetArgs) -> i32 { return 1; } if args.one_off && args.save_only { - if args.common.json { - print_json(&serde_json::json!({ - "status": "error", - "error": "--one-off and --save-only cannot be used together", - })); - } else { - eprintln!("Error: --one-off and --save-only cannot be used together"); - } + report_error( + args.common.json, + "--one-off and --save-only cannot be used together", + ); + return 1; + } + if args.one_off { + // Honest failure instead of the historical silent no-op: the flag + // parsed but was never implemented, so the patch was saved to the + // manifest anyway — lying to the user about persistence. Mirrors + // `rollback --one-off`'s not-yet-implemented contract; rejected + // before any network or disk activity. + report_error(args.common.json, "One-off get mode is not yet implemented"); + return 1; + } + // Strict airgap (CLI_CONTRACT.md `--offline`: never contact the + // network; operations that need remote data fail loudly). Every `get` + // mode fetches remote patch data — proceeding would hit the API (and + // save the fetched patch into the manifest) — so refuse before the + // client is built (org auto-resolve is itself a network call). No + // telemetry fires here: offline gates `is_telemetry_disabled` too. + if args.common.offline { + report_error( + args.common.json, + "get requires network access to fetch patches and cannot run with \ + --offline/SOCKET_OFFLINE (strict airgap)", + ); return 1; } @@ -1509,7 +1544,6 @@ pub async fn run(args: GetArgs) -> i32 { cwd: args.common.cwd.clone(), global: args.common.global, global_prefix: args.common.global_prefix.clone(), - batch_size: 100, }; let (all_packages, _) = crawl_all_ecosystems(&crawler_options).await; @@ -1522,13 +1556,9 @@ pub async fn run(args: GetArgs) -> i32 { } else { #[allow(unused_mut)] let mut install_cmds = String::from("npm/yarn/pnpm/pip"); - #[cfg(feature = "cargo")] install_cmds.push_str("/cargo"); - #[cfg(feature = "golang")] install_cmds.push_str("/go"); - #[cfg(feature = "maven")] install_cmds.push_str("/mvn"); - #[cfg(feature = "composer")] install_cmds.push_str("/composer"); println!("No packages found. Run {install_cmds} install first."); } @@ -1654,9 +1684,9 @@ pub async fn run(args: GetArgs) -> i32 { // Download and apply let params = DownloadParams { cwd: args.common.cwd.clone(), + manifest_path: args.common.resolved_manifest_path(), org: args.common.org.clone(), save_only: args.save_only, - one_off: args.one_off, global: args.common.global, global_prefix: args.common.global_prefix.clone(), json: args.common.json, @@ -1729,9 +1759,11 @@ async fn save_and_apply_patch(args: &GetArgs, patch: &PatchResponse) -> i32 { // Same "errors only" gate as `run` — informational prints respect // `--silent`; errors and the JSON envelope do not. let quiet = args.common.json || args.common.silent; - let socket_dir = args.common.cwd.join(".socket"); - let blobs_dir = socket_dir.join("blobs"); - let manifest_path = socket_dir.join("manifest.json"); + let manifest_path = args.common.resolved_manifest_path(); + let blobs_dir = manifest_path + .parent() + .unwrap_or(Path::new(".")) + .join("blobs"); if let Err(e) = tokio::fs::create_dir_all(&blobs_dir).await { report_error( @@ -1743,7 +1775,14 @@ async fn save_and_apply_patch(args: &GetArgs, patch: &PatchResponse) -> i32 { let mut manifest = match read_manifest(&manifest_path).await { Ok(Some(m)) => m, - _ => PatchManifest::new(), + Ok(None) => PatchManifest::new(), + // Fail closed like the download flow: an unreadable manifest + // treated as empty would be rewritten below with only this one + // patch, destroying every tracked record. + Err(e) => { + report_error(args.common.json, format!("Failed to read manifest: {e}")); + return 1; + } }; // Build the manifest `files` map. UUID flow is more permissive than @@ -1790,10 +1829,17 @@ async fn save_and_apply_patch(args: &GetArgs, patch: &PatchResponse) -> i32 { return 1; } - let added = manifest - .patches - .get(&patch.purl) - .is_none_or(|p| p.uuid != patch.uuid); + // Classify against the manifest state BEFORE the insert, with the same + // vocabulary `download_and_apply_patches` emits (CLI_CONTRACT.md): a + // different uuid already recorded at this purl is `updated` (+`oldUuid`), + // not `added` — consumers diff manifest replacements on that action. + let action = decide_patch_action(&manifest, &patch.purl, &patch.uuid); + let changed = action != PatchAction::Skipped; + let action_label = match &action { + PatchAction::Added => "added", + PatchAction::Updated { .. } => "updated", + PatchAction::Skipped => "skipped", + }; manifest .patches @@ -1809,74 +1855,53 @@ async fn save_and_apply_patch(args: &GetArgs, patch: &PatchResponse) -> i32 { // different one, VEX verification fails closed (`vendor_uuid_mismatch`) // until a `vendor` run refreshes the committed artifact. let mut warnings: Vec = Vec::new(); - if added { - if let Ok(vendor_state) = - socket_patch_core::patch::vendor::load_state(&args.common.cwd).await - { - let entry = vendor_state.entries.get(&patch.purl).or_else(|| { - vendor_state - .entries - .values() - .find(|e| e.base_purl == patch.purl) - }); - if let Some(entry) = entry.filter(|e| e.uuid != patch.uuid) { - let w = format!( - "{} is vendored at patch {} but the manifest now records {}; run \ - `socket-patch vendor` to refresh the committed artifact", - patch.purl, entry.uuid, patch.uuid - ); - if !quiet { - eprintln!(" [note] {w}"); - } - warnings.push(w); - } - } + if changed { + warn_on_vendored_uuid_drift( + &args.common.cwd, + quiet, + &[serde_json::json!({ + "purl": patch.purl, + "uuid": patch.uuid, + "action": action_label, + })], + &mut warnings, + ) + .await; } if !quiet { println!("\nPatch saved to {}", manifest_path.display()); - if added { - println!(" Added: 1"); - } else { - println!(" Skipped: 1 (already exists)"); + match &action { + PatchAction::Added => println!(" Added: 1"), + PatchAction::Updated { old_uuid } => { + println!(" Updated: 1 (replacing {})", short_uuid(old_uuid)); + } + PatchAction::Skipped => println!(" Skipped: 1 (already exists)"), } } let mut apply_succeeded = false; - if !args.save_only && added { + if !args.save_only && changed { if !quiet { println!("\nApplying patches..."); } - let apply_args = super::apply::ApplyArgs { - common: crate::args::GlobalArgs { - cwd: args.common.cwd.clone(), - manifest_path: manifest_path.display().to_string(), - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - silent: quiet, - download_mode: args.common.download_mode.clone(), - strict: args.common.strict, - ..crate::args::GlobalArgs::default() - }, - force: false, - // get never runs the read-only cargo-redirect verifier. - check: false, - // get drives apply internally; embedded VEX is opt-in on the - // top-level command, never on this internal invocation. - vex: Default::default(), - }; - let code = super::apply::run(apply_args).await; - apply_succeeded = code == 0; - if code != 0 && !quiet { - eprintln!("\nSome patches could not be applied."); - } + apply_succeeded = run_nested_apply( + &args.common.cwd, + &manifest_path, + args.common.global, + args.common.global_prefix.clone(), + quiet, + args.common.download_mode.clone(), + args.common.strict, + ) + .await; } // The apply step ran (patch added, not --save-only) but failed → // partial failure. The `status` field must agree with the exit code // returned below; a hardcoded `success` alongside a non-zero exit // misleads JSON consumers. - let apply_failed = !apply_succeeded && added && !args.save_only; + let apply_failed = !apply_succeeded && changed && !args.save_only; // No "download failed" concept here — a blob failure early-returns // with status `error` above — so only the apply step can degrade us. let (status, exit_code) = run_outcome(false, apply_failed); @@ -1885,17 +1910,20 @@ async fn save_and_apply_patch(args: &GetArgs, patch: &PatchResponse) -> i32 { let mut patch_record = serde_json::json!({ "purl": patch.purl, "uuid": patch.uuid, - "action": if added { "added" } else { "skipped" }, + "action": action_label, }); - if added { - // Only enrich when the patch was actually added — a `skipped` - // record means the consumer already saw the metadata last time. + if let PatchAction::Updated { old_uuid } = &action { + patch_record["oldUuid"] = serde_json::json!(old_uuid); + } + if changed { + // Only enrich added/updated records — a `skipped` record means + // the consumer already saw the metadata last time. merge_metadata(&mut patch_record, patch_event_metadata(patch)); } let mut result_json = serde_json::json!({ "status": status, "found": 1, - "downloaded": if added { 1 } else { 0 }, + "downloaded": if changed { 1 } else { 0 }, "applied": if apply_succeeded { 1 } else { 0 }, "patches": [patch_record], }); diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index abbb258e..6d01c02e 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -3,7 +3,7 @@ use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::utils::telemetry::track_patch_listed; -use crate::args::GlobalArgs; +use crate::args::{apply_env_toggles, GlobalArgs}; use crate::json_envelope::{ Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, }; @@ -94,6 +94,7 @@ fn emit_error(args: &ListArgs, code: &str, message: String) { } pub async fn run(args: ListArgs) -> i32 { + apply_env_toggles(&args.common); let manifest_path = args.common.resolved_manifest_path(); // `read_manifest` is the single source of truth for the three error diff --git a/crates/socket-patch-cli/src/commands/lock_cli.rs b/crates/socket-patch-cli/src/commands/lock_cli.rs index 82f68235..0347b9d8 100644 --- a/crates/socket-patch-cli/src/commands/lock_cli.rs +++ b/crates/socket-patch-cli/src/commands/lock_cli.rs @@ -1,11 +1,11 @@ //! Envelope-aware wrapper around the //! `socket_patch_core::patch::apply_lock` advisory lock. //! -//! Mutating subcommands (`apply`, `rollback`, `repair`, `remove`) all -//! need the same shape: acquire the lock at the top of `run`, on -//! contention emit a JSON envelope with `errorCode: "lock_held"` (or -//! stderr in human mode) and exit 1. This module centralises that -//! emission so the four call sites stay one line each. +//! Mutating subcommands (`apply`, `rollback`, `repair`, `remove`, +//! `vendor`) all need the same shape: acquire the lock at the top of +//! `run`, on contention emit a JSON envelope with `errorCode: +//! "lock_held"` (or stderr in human mode) and exit 1. This module +//! centralises that emission so the call sites stay one line each. //! //! The lock itself is in `socket-patch-core` (cross-crate, also used //! by tests). This module is the CLI-side glue that knows how to @@ -20,9 +20,9 @@ use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchE /// Stable `errorCode` tag emitted as a `Skipped` warning event when /// `--break-lock` actually reclaims a stale pre-existing lock file. -/// Exposed for downstream consumers and integration tests that -/// pattern-match on it. -pub const LOCK_BROKEN_CODE: &str = "lock_broken"; +/// Integration tests and downstream consumers pattern-match on the +/// literal string. +pub(crate) const LOCK_BROKEN_CODE: &str = "lock_broken"; /// Outcome of a successful lock acquisition. Callers attach a /// `lock_broken` event to their own envelope when [`broke_lock`] is @@ -31,13 +31,13 @@ pub const LOCK_BROKEN_CODE: &str = "lock_broken"; /// /// [`broke_lock`]: LockAcquired::broke_lock #[derive(Debug)] -pub struct LockAcquired { - pub guard: LockGuard, +pub(crate) struct LockAcquired { + pub(crate) guard: LockGuard, /// True iff `--break-lock` was set AND a pre-existing /// `apply.lock` file (with no live holder) was reclaimed. /// False when the file didn't exist (nothing to break) — the /// flag was a no-op in that case so no warning is warranted. - pub broke_lock: bool, + pub(crate) broke_lock: bool, } /// Try to acquire `/apply.lock` and return the guard, or @@ -66,7 +66,7 @@ pub struct LockAcquired { /// pre-existing file is reclaimed with no live holder the return /// value's `broke_lock` is true and the caller should attach a /// `lock_broken` warning event to their envelope. -pub fn acquire_or_emit( +pub(crate) fn acquire_or_emit( socket_dir: &Path, command: Command, json: bool, @@ -75,109 +75,52 @@ pub fn acquire_or_emit( timeout: Duration, break_lock: bool, ) -> Result { - if break_lock { - let path = socket_dir.join("apply.lock"); - - // Snapshot whether a lock file existed *before* we touch - // anything. The probe `acquire` below opens with `create(true)`, - // so afterwards the file always exists — even on a clean tree. - // We only want to report `broke_lock` (and emit the warning - // event) when a *pre-existing* leftover was actually reclaimed, - // mirroring `unlock`'s `lock_existed` source-of-truth pattern. - let lock_existed = path.exists(); - - // CRITICAL: never steal a lock from a *live* holder, and never - // unlink the lock file at all. Removal defeats mutual exclusion - // in two ways: - // * unlinking a *held* file leaves the holder flocking the - // old (unlinked) inode while we lock a fresh one — both - // processes then believe they hold the lock and race on - // every file write, which is exactly what the lock exists - // to prevent; - // * even when a probe proves no holder at unlink time, a - // competitor mid-`acquire` that opened the file *before* - // the unlink can flock the orphaned inode *after* it, with - // the same double-hold result. - // A leftover file from a crashed run needs no removal anyway: - // the kernel released the dead process's advisory lock along - // with its file handle, so a non-blocking acquire simply - // reclaims the file in place. - // * Ok => no live holder — the probe guard IS the lock. - // Return it; `broke_lock` reports whether a - // pre-existing leftover was reclaimed so callers - // attach the warning event. - // * Held => a live holder exists. Refuse rather than steal — - // this is the case `--break-lock` was *wrongly* - // relied on for, and the only one where it mattered. - // * Io => surface the real fault. - match acquire(socket_dir, Duration::ZERO) { - Ok(guard) => { - // Only a *pre-existing* leftover counts as "broke a - // lock". If the probe itself just created the file on a - // clean tree, the flag was a no-op for the warning - // surface — `broke_lock` stays false so callers don't - // emit a spurious event. - let broke_lock = lock_existed; - if broke_lock && !silent && !json { - eprintln!( - "Warning: --break-lock reclaimed stale {} (no live holder).", - path.display() - ); - } - return Ok(LockAcquired { guard, broke_lock }); - } - Err(LockError::Held) => { - // The probe above is a *non-blocking* try-once - // (`Duration::ZERO`), so report a zero wait. Threading - // the caller's `timeout` here would claim a "(waited …)" - // that never happened — the probe refuses a live holder - // immediately, it does not wait out the budget first. - // `break_probe_held_message` takes no timeout precisely so - // the wrong value can't be passed back in. - let msg = break_probe_held_message(); - // The user already passed --break-lock and it was refused: - // advising it again would be self-defeating. Only the - // inspect path remains actionable. - emit( - command, - json, - silent, - dry_run, - "lock_held", - &msg, - Hint::UnlockOnly, + // `--break-lock` is a *non-blocking* try-once probe (the caller's + // `timeout` never applies to it) that never unlinks the lock file — + // see the doc comment for why removal would defeat mutual + // exclusion. Snapshot whether a lock file existed *before* the + // acquire: it opens with `create(true)`, so afterwards the file + // always exists, and only a *pre-existing* leftover counts as + // "broke a lock" (mirroring `unlock`'s `lock_existed` + // source-of-truth pattern). + let lock_existed = break_lock && socket_dir.join("apply.lock").exists(); + let acquire_timeout = if break_lock { Duration::ZERO } else { timeout }; + + match acquire(socket_dir, acquire_timeout) { + Ok(guard) => { + // No live holder — the acquire's guard IS the lock; a + // crashed run's leftover needs no removal (the kernel + // already released the dead holder's advisory lock). + if lock_existed && !silent && !json { + eprintln!( + "Warning: --break-lock reclaimed stale {} (no live holder).", + socket_dir.join("apply.lock").display() ); - return Err(1); - } - Err(LockError::Io { path, source }) => { - let msg = format!("failed to open lock file at {}: {}", path.display(), source); - emit(command, json, silent, dry_run, "lock_io", &msg, Hint::None); - return Err(1); } + Ok(LockAcquired { + guard, + broke_lock: lock_existed, + }) } - } - - match acquire(socket_dir, timeout) { - Ok(guard) => Ok(LockAcquired { - guard, - broke_lock: false, - }), Err(LockError::Held) => { - let msg = held_message(timeout); - emit( - command, - json, - silent, - dry_run, - "lock_held", - &msg, - Hint::UnlockOrBreakLock, - ); + // A live holder exists. For `--break-lock` that means + // refuse rather than steal, with two consequences for the + // message: the probe never waited, so it must not claim a + // "(waited …)" (`break_probe_held_message` takes no timeout + // precisely so the wrong value can't be passed back in); + // and re-advising --break-lock — which was just refused — + // would be self-defeating, so only the inspect hint remains. + let (msg, hint) = if break_lock { + (break_probe_held_message(), Hint::UnlockOnly) + } else { + (held_message(timeout), Hint::UnlockOrBreakLock) + }; + emit(command, json, dry_run, "lock_held", &msg, hint); Err(1) } Err(LockError::Io { path, source }) => { let msg = format!("failed to open lock file at {}: {}", path.display(), source); - emit(command, json, silent, dry_run, "lock_io", &msg, Hint::None); + emit(command, json, dry_run, "lock_io", &msg, Hint::None); Err(1) } } @@ -187,7 +130,7 @@ pub fn acquire_or_emit( /// when [`LockAcquired::broke_lock`] is true. Artifact-level (no /// PURL) since the action targets the `.socket/` directory itself, /// not a specific package. -pub fn lock_broken_event(socket_dir: &Path) -> PatchEvent { +pub(crate) fn lock_broken_event(socket_dir: &Path) -> PatchEvent { PatchEvent::artifact(PatchAction::Skipped).with_reason( LOCK_BROKEN_CODE, format!( @@ -197,13 +140,6 @@ pub fn lock_broken_event(socket_dir: &Path) -> PatchEvent { ) } -/// Convenience: record the `lock_broken` warning event on an -/// envelope. Mirrors the inline pattern at each call site so we -/// don't drift on the action / errorCode pair. -pub fn record_lock_broken(env: &mut Envelope, socket_dir: &Path) { - env.record(lock_broken_event(socket_dir)); -} - /// Contention message for the `--break-lock` pre-acquire probe. That /// probe is hard-wired to a non-blocking try-once (`Duration::ZERO`), so /// the message must never claim a wait, regardless of the caller's @@ -240,11 +176,17 @@ fn fmt_duration(d: Duration) -> String { } } -/// Build the top-level error envelope emitted in `--json` mode when -/// lock acquisition fails. Split out from [`emit`] so the serialized -/// shape (status / error.code / command / dryRun) is unit-testable -/// without capturing stdout. -fn error_envelope(command: Command, dry_run: bool, code: &str, message: &str) -> Envelope { +/// Build the top-level error envelope emitted in `--json` mode when a +/// command fails before doing real work (lock acquisition here; `repair` +/// reuses it for its early error exits). Split out from [`emit`] so the +/// serialized shape (status / error.code / command / dryRun) is +/// unit-testable without capturing stdout. +pub(crate) fn error_envelope( + command: Command, + dry_run: bool, + code: &str, + message: &str, +) -> Envelope { let mut env = Envelope::new(command); env.dry_run = dry_run; env.mark_error(EnvelopeError::new(code, message)); @@ -261,21 +203,17 @@ enum Hint { UnlockOrBreakLock, } -fn emit( - command: Command, - json: bool, - silent: bool, - dry_run: bool, - code: &str, - message: &str, - hint: Hint, -) { +fn emit(command: Command, json: bool, dry_run: bool, code: &str, message: &str, hint: Hint) { if json { println!( "{}", error_envelope(command, dry_run, code, message).to_pretty_json() ); - } else if !silent { + } else { + // Errors print even under --silent ("errors only", never "nothing" + // — CLI_CONTRACT.md): exit 1 with no message would be + // undiagnosable. The remediation hint is part of the error report, + // not informational chatter, so it prints with the error. eprintln!("Error: {message}."); match hint { Hint::None => {} diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 51b18294..d813d4ac 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -1,11 +1,11 @@ pub mod apply; -pub mod fetch_stage; +pub(crate) mod fetch_stage; pub mod get; pub mod list; -pub mod lock_cli; +pub(crate) mod lock_cli; pub mod remove; pub mod repair; -pub mod repair_vendor; +pub(crate) mod repair_vendor; pub mod rollback; pub mod scan; pub mod setup; diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 460f9e4c..fa616778 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -9,6 +9,7 @@ use socket_patch_core::utils::telemetry::{track_patch_remove_failed, track_patch use std::path::Path; use std::time::Duration; +use super::get::short_uuid; use super::rollback::{all_files_already_original, rollback_patches}; use super::vendor::dispatch_revert_one; use crate::args::{apply_env_toggles, GlobalArgs}; @@ -16,21 +17,28 @@ use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event}; use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status}; use crate::output::confirm; +/// A remove/rollback identifier matches a patch by PURL for `pkg:` +/// identifiers (a base PURL matches every release variant of that +/// package@version; a qualified PURL targets a single patch), or by patch +/// uuid otherwise. +pub(crate) fn patch_matches(purl: &str, uuid: &str, identifier: &str) -> bool { + if identifier.starts_with("pkg:") { + purl_matches_identifier(purl, identifier) + } else { + uuid == identifier + } +} + /// Vendor-ledger entries matching a remove identifier: by ledger key or -/// base purl for `pkg:` identifiers (a base PURL matches every release -/// variant, mirroring the manifest matching), or by patch uuid otherwise. -/// Sorted by key for deterministic event order. +/// base purl (mirroring the manifest matching). Sorted by key for +/// deterministic event order. fn vendor_entries_matching(state: &VendorState, identifier: &str) -> Vec<(String, VendorEntry)> { let mut matches: Vec<(String, VendorEntry)> = state .entries .iter() .filter(|(key, entry)| { - if identifier.starts_with("pkg:") { - purl_matches_identifier(key, identifier) - || purl_matches_identifier(&entry.base_purl, identifier) - } else { - entry.uuid == identifier - } + patch_matches(key, &entry.uuid, identifier) + || patch_matches(&entry.base_purl, &entry.uuid, identifier) }) .map(|(k, e)| (k.clone(), e.clone())) .collect(); @@ -38,11 +46,38 @@ fn vendor_entries_matching(state: &VendorState, identifier: &str) -> Vec<(String matches } +/// Emit the `not_found` envelope (or stderr line) for an identifier that +/// matched nothing, tracking the failure. Both the pre-flight match and +/// the post-rollback manifest mutation share this exit path. `dry_run` +/// rides the envelope so a preview's failures still report `dryRun: true` +/// (matching apply's error envelopes and remove's own success envelope). +async fn emit_not_found( + json: bool, + dry_run: bool, + identifier: &str, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + let msg = format!("No patch found matching identifier: {identifier}"); + track_patch_remove_failed(&msg, api_token, org_slug).await; + if json { + let mut env = Envelope::new(Command::Remove); + env.dry_run = dry_run; + env.status = Status::NotFound; + env.error = Some(EnvelopeError::new("not_found", msg)); + println!("{}", env.to_pretty_json()); + } else { + eprintln!("{msg}"); + } +} + /// Emit a `remove` error envelope and return. Used by the many error -/// paths in `run` so they all share the same JSON shape. -fn emit_error_envelope(json: bool, code: &str, message: String) { +/// paths in `run` so they all share the same JSON shape. `dry_run` rides +/// the envelope so preview failures report `dryRun: true`. +fn emit_error_envelope(json: bool, dry_run: bool, code: &str, message: String) { if json { let mut env = Envelope::new(Command::Remove); + env.dry_run = dry_run; env.mark_error(EnvelopeError::new(code, message)); println!("{}", env.to_pretty_json()); } else { @@ -59,10 +94,17 @@ pub struct RemoveArgs { pub common: GlobalArgs, /// Skip rolling back files before removing (only update manifest). + /// + /// `value_parser = parse_bool_flag` matches the `GlobalArgs` bool flags: + /// clap's default bool parser accepts only the literal strings + /// `true`/`false` from the env binding, so `SOCKET_SKIP_ROLLBACK=1` (or + /// an exported-but-empty `SOCKET_SKIP_ROLLBACK=`) aborted every + /// `remove` invocation. #[arg( long = "skip-rollback", env = "SOCKET_SKIP_ROLLBACK", - default_value_t = false + default_value_t = false, + value_parser = crate::args::parse_bool_flag, )] pub skip_rollback: bool, } @@ -76,13 +118,31 @@ pub async fn run(args: RemoveArgs) -> i32 { let manifest_path = args.common.resolved_manifest_path(); - if tokio::fs::metadata(&manifest_path).await.is_err() { - emit_error_envelope( - args.common.json, - "manifest_not_found", - format!("Manifest not found at {}", manifest_path.display()), - ); - return 1; + let manifest_missing = tokio::fs::metadata(&manifest_path).await.is_err(); + if manifest_missing { + // A pure-detached project (`scan --vendor --detached`) has a + // vendor ledger but deliberately no manifest, and `remove` is the + // per-purl exit path for its entries — so a missing manifest is + // only fatal when the ledger has no detached match either. An + // unreadable ledger falls through to the error: nothing is + // mutated on that path. + let has_detached_match = load_state(&args.common.cwd) + .await + .map(|s| { + vendor_entries_matching(&s, &args.identifier) + .iter() + .any(|(_, e)| e.detached) + }) + .unwrap_or(false); + if !has_detached_match { + emit_error_envelope( + args.common.json, + args.common.dry_run, + "manifest_not_found", + format!("Manifest not found at {}", manifest_path.display()), + ); + return 1; + } } // Serialize against concurrent socket-patch runs targeting the @@ -96,7 +156,7 @@ pub async fn run(args: RemoveArgs) -> i32 { Command::Remove, args.common.json, args.common.silent, - false, // remove has no --dry-run + args.common.dry_run, Duration::from_secs(args.common.lock_timeout.unwrap_or(0)), args.common.break_lock, ) { @@ -106,40 +166,41 @@ pub async fn run(args: RemoveArgs) -> i32 { let _lock = acquired.guard; let lock_was_broken = acquired.broke_lock; - // Read manifest to show what will be removed and confirm - let manifest = match read_manifest(&manifest_path).await { - Ok(Some(m)) => m, - Ok(None) => { - emit_error_envelope( - args.common.json, - "manifest_invalid", - "Invalid manifest".to_string(), - ); - return 1; - } - Err(e) => { - emit_error_envelope(args.common.json, "manifest_unreadable", e.to_string()); - return 1; + // Read manifest to show what will be removed and confirm. On the + // pure-detached path there is no manifest to read or mutate; an empty + // view routes the flow to the detached-only removal below. + let manifest = if manifest_missing { + PatchManifest::new() + } else { + match read_manifest(&manifest_path).await { + Ok(Some(m)) => m, + Ok(None) => { + emit_error_envelope( + args.common.json, + args.common.dry_run, + "manifest_invalid", + "Invalid manifest".to_string(), + ); + return 1; + } + Err(e) => { + emit_error_envelope( + args.common.json, + args.common.dry_run, + "manifest_unreadable", + e.to_string(), + ); + return 1; + } } }; - // Find matching patches to show what will be removed. A base PURL - // (no `?`) matches every release variant of that package@version; a - // qualified PURL or a UUID targets a single patch. - let matching: Vec<(&String, &socket_patch_core::manifest::schema::PatchRecord)> = - if args.identifier.starts_with("pkg:") { - manifest - .patches - .iter() - .filter(|(purl, _)| purl_matches_identifier(purl, &args.identifier)) - .collect() - } else { - manifest - .patches - .iter() - .filter(|(_, patch)| patch.uuid == args.identifier) - .collect() - }; + // Find matching patches to show what will be removed. + let matching: Vec<_> = manifest + .patches + .iter() + .filter(|(purl, patch)| patch_matches(purl, &patch.uuid, &args.identifier)) + .collect(); if matching.is_empty() { // Detached vendored patches (`scan --vendor --detached`) have no @@ -165,16 +226,14 @@ pub async fn run(args: RemoveArgs) -> i32 { .await; } - let msg = format!("No patch found matching identifier: {}", args.identifier); - track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; - if args.common.json { - let mut env = Envelope::new(Command::Remove); - env.status = Status::NotFound; - env.error = Some(EnvelopeError::new("not_found", msg)); - println!("{}", env.to_pretty_json()); - } else { - eprintln!("No patch found matching identifier: {}", args.identifier); - } + emit_not_found( + args.common.json, + args.common.dry_run, + &args.identifier, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; return 1; } @@ -196,21 +255,21 @@ pub async fn run(args: RemoveArgs) -> i32 { eprintln!("The following patch(es) will be removed:"); } for (purl, patch) in &matching { - let file_count = patch.files.len(); - // Short-UUID for display only. Slice on a char boundary and - // tolerate UUIDs shorter than 8 chars — a malformed manifest - // must not panic the whole command in the display path. - let short_uuid = patch.uuid.get(..8).unwrap_or(patch.uuid.as_str()); eprintln!( " - {} (UUID: {}, {} file(s))", - purl, short_uuid, file_count + purl, + short_uuid(&patch.uuid), + patch.files.len() ); } eprintln!(); } + // `--dry-run` previews without mutating, so there is nothing to + // confirm — skip the prompt (matching the global contract row: + // "Preview, no mutations"). let prompt = format!("Remove {} patch(es) and rollback files?", matching.len()); - if !confirm(&prompt, true, args.common.yes, args.common.json) { + if !args.common.dry_run && !confirm(&prompt, true, args.common.yes, args.common.json) { if !args.common.json && !args.common.silent { println!("Removal cancelled."); } @@ -224,14 +283,11 @@ pub async fn run(args: RemoveArgs) -> i32 { println!("Rolling back patch before removal..."); } match rollback_patches( - &args.common.cwd, + &args.common, &manifest_path, Some(&args.identifier), - false, + args.common.dry_run, args.common.json || args.common.silent, - args.common.offline, - args.common.global, - args.common.global_prefix.clone(), None, ) .await @@ -245,7 +301,8 @@ pub async fn run(args: RemoveArgs) -> i32 { ) .await; emit_error_envelope( - args.common.json, + args.common.json, + args.common.dry_run, "rollback_failed", "Rollback failed during patch removal. Use --skip-rollback to remove from manifest without restoring files.".to_string(), ); @@ -283,7 +340,8 @@ pub async fn run(args: RemoveArgs) -> i32 { Err(e) => { track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; emit_error_envelope( - args.common.json, + args.common.json, + args.common.dry_run, "rollback_failed", format!("Error during rollback: {e}. Use --skip-rollback to remove from manifest without restoring files."), ); @@ -310,6 +368,7 @@ pub async fn run(args: RemoveArgs) -> i32 { Err(e) => { emit_error_envelope( args.common.json, + args.common.dry_run, "vendor_state_unreadable", format!("cannot read .socket/vendor/state.json: {e}"), ); @@ -326,7 +385,7 @@ pub async fn run(args: RemoveArgs) -> i32 { if !vendored_matches.is_empty() { if args.skip_rollback { for (key, _) in &vendored_matches { - if !args.common.json { + if !args.common.json && !args.common.silent { eprintln!( "Note: {key} is vendored; --skip-rollback leaves the vendor wiring and \ artifact in place (the next `vendor` run will reconcile-revert it)." @@ -341,9 +400,10 @@ pub async fn run(args: RemoveArgs) -> i32 { } } else { for (key, entry) in &vendored_matches { - let outcome = dispatch_revert_one(entry, &args.common.cwd, false).await; + let outcome = + dispatch_revert_one(entry, &args.common.cwd, args.common.dry_run).await; for w in &outcome.warnings { - if !args.common.json { + if !args.common.json && !args.common.silent { eprintln!("Warning ({}): {}", w.code, w.detail); } vendor_skipped_events.push( @@ -360,6 +420,7 @@ pub async fn run(args: RemoveArgs) -> i32 { .await; emit_error_envelope( args.common.json, + args.common.dry_run, "vendor_revert_failed", format!( "could not revert vendoring for {key}: {}. The manifest was not \ @@ -369,16 +430,31 @@ pub async fn run(args: RemoveArgs) -> i32 { ); return 1; } + if args.common.dry_run { + if !args.common.json && !args.common.silent { + println!("Would revert vendoring for {key}"); + } + // Dry-run flips the would-be Removed to a Verified + // preview, same convention as apply/vendor/repair. + vendor_reverted_events.push( + PatchEvent::new(PatchAction::Verified, key.clone()).with_reason( + "vendor_would_revert", + "vendoring would be reverted on remove", + ), + ); + continue; + } vendor_state.entries.remove(key); if let Err(e) = save_state(&args.common.cwd, &vendor_state).await { emit_error_envelope( args.common.json, + args.common.dry_run, "vendor_state_write_failed", e.to_string(), ); return 1; } - if !args.common.json { + if !args.common.json && !args.common.silent { println!("Reverted vendoring for {key}"); } vendor_reverted_events.push( @@ -389,44 +465,74 @@ pub async fn run(args: RemoveArgs) -> i32 { } } - // Now remove from manifest - match remove_patch_from_manifest(&args.identifier, &manifest_path).await { + // Now remove from manifest. On --dry-run the removal is simulated in + // memory (manifest untouched) so the blob sweep below can still + // preview against the post-removal reference set. + let removal = if args.common.dry_run { + let removed: Vec = matching.iter().map(|(purl, _)| (*purl).clone()).collect(); + let mut simulated = manifest.clone(); + simulated.patches.retain(|purl, _| !removed.contains(purl)); + Ok((removed, simulated)) + } else { + remove_patch_from_manifest(&args.identifier, &manifest_path).await + }; + match removal { Ok((removed, manifest)) => { if removed.is_empty() { - let msg = format!("No patch found matching identifier: {}", args.identifier); - track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; - if args.common.json { - let mut env = Envelope::new(Command::Remove); - env.status = Status::NotFound; - env.error = Some(EnvelopeError::new("not_found", msg)); - println!("{}", env.to_pretty_json()); - } else { - eprintln!("No patch found matching identifier: {}", args.identifier); - } + emit_not_found( + args.common.json, + args.common.dry_run, + &args.identifier, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; return 1; } if !args.common.json && !args.common.silent { - println!("Removed {} patch(es) from manifest:", removed.len()); + if args.common.dry_run { + println!("Would remove {} patch(es) from manifest:", removed.len()); + } else { + println!("Removed {} patch(es) from manifest:", removed.len()); + } for purl in &removed { println!(" - {purl}"); } - println!("\nManifest updated at {}", manifest_path.display()); + if args.common.dry_run { + println!("\nDry run — nothing was changed."); + } else { + println!("\nManifest updated at {}", manifest_path.display()); + } } - // Clean up unused blobs - let socket_dir = manifest_path.parent().unwrap(); + // Clean up unused blobs (previewed, not deleted, on --dry-run). let blobs_path = socket_dir.join("blobs"); let mut blobs_removed = 0; - if let Ok(cleanup_result) = cleanup_unused_blobs(&manifest, &blobs_path, false).await { + if let Ok(cleanup_result) = + cleanup_unused_blobs(&manifest, &blobs_path, args.common.dry_run).await + { blobs_removed = cleanup_result.blobs_removed; if !args.common.json && !args.common.silent && cleanup_result.blobs_removed > 0 { - println!("\n{}", format_cleanup_result(&cleanup_result, false)); + println!( + "\n{}", + format_cleanup_result(&cleanup_result, args.common.dry_run) + ); } } if args.common.json { let mut env = Envelope::new(Command::Remove); + env.dry_run = args.common.dry_run; + // Dry-run flips would-be Removed events to Verified + // previews (the apply/vendor/repair convention), so + // `summary.removed` stays "manifest entries actually + // deleted" — zero on a preview. + let removal_action = if args.common.dry_run { + PatchAction::Verified + } else { + PatchAction::Removed + }; if lock_was_broken { env.record(lock_broken_event(socket_dir)); } @@ -442,9 +548,10 @@ pub async fn run(args: RemoveArgs) -> i32 { for ev in vendor_skipped_events { env.record(ev); } - // One Removed event per purl whose manifest entry was deleted. + // One Removed event per purl whose manifest entry was + // deleted (Verified on --dry-run). for purl in &removed { - env.record(PatchEvent::new(PatchAction::Removed, purl.clone())); + env.record(PatchEvent::new(removal_action, purl.clone())); } // One artifact-level Removed event carrying the // blob-sweep and rollback counts. Emitted whenever either @@ -463,7 +570,7 @@ pub async fn run(args: RemoveArgs) -> i32 { // totals from `details`, never from `summary.removed`. if blobs_removed > 0 || rollback_count > 0 { env.events - .push(PatchEvent::artifact(PatchAction::Removed).with_details( + .push(PatchEvent::artifact(removal_action).with_details( serde_json::json!({ "blobsRemoved": blobs_removed, "rolledBack": rollback_count, @@ -473,12 +580,14 @@ pub async fn run(args: RemoveArgs) -> i32 { println!("{}", env.to_pretty_json()); } - track_patch_removed(removed.len(), api_token.as_deref(), org_slug.as_deref()).await; + if !args.common.dry_run { + track_patch_removed(removed.len(), api_token.as_deref(), org_slug.as_deref()).await; + } 0 } Err(e) => { track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; - emit_error_envelope(args.common.json, "remove_failed", e); + emit_error_envelope(args.common.json, args.common.dry_run, "remove_failed", e); 1 } } @@ -503,6 +612,7 @@ async fn remove_detached_only( if args.skip_rollback { emit_error_envelope( args.common.json, + args.common.dry_run, "vendor_state_retained", format!( "{} matches only detached vendored patch(es); removing one means reverting \ @@ -513,19 +623,19 @@ async fn remove_detached_only( return 1; } - if !args.common.json { + if !args.common.json && !args.common.silent { eprintln!("The following detached vendored patch(es) will be reverted and removed:"); for (key, entry) in &detached { - let short_uuid = entry.uuid.get(..8).unwrap_or(entry.uuid.as_str()); - eprintln!(" - {key} (UUID: {short_uuid})"); + eprintln!(" - {key} (UUID: {})", short_uuid(&entry.uuid)); } eprintln!(); } + // `--dry-run` previews without mutating — nothing to confirm. let prompt = format!( "Remove {} vendored patch(es) and revert their vendoring?", detached.len() ); - if !confirm(&prompt, true, args.common.yes, args.common.json) { + if !args.common.dry_run && !confirm(&prompt, true, args.common.yes, args.common.json) { if !args.common.json { println!("Removal cancelled."); } @@ -533,13 +643,14 @@ async fn remove_detached_only( } let mut env = Envelope::new(Command::Remove); + env.dry_run = args.common.dry_run; if lock_was_broken { env.record(lock_broken_event(socket_dir)); } for (key, entry) in &detached { - let outcome = dispatch_revert_one(entry, &args.common.cwd, false).await; + let outcome = dispatch_revert_one(entry, &args.common.cwd, args.common.dry_run).await; for w in &outcome.warnings { - if !args.common.json { + if !args.common.json && !args.common.silent { eprintln!("Warning ({}): {}", w.code, w.detail); } env.record( @@ -556,6 +667,7 @@ async fn remove_detached_only( .await; emit_error_envelope( args.common.json, + args.common.dry_run, "vendor_revert_failed", format!( "could not revert vendoring for {key}: {}", @@ -564,12 +676,31 @@ async fn remove_detached_only( ); return 1; } + if args.common.dry_run { + if !args.common.json && !args.common.silent { + println!("Would revert vendoring for {key}"); + } + // Verified preview (the dry-run convention); still recorded + // so `summary.verified` counts the would-be removals. + env.record( + PatchEvent::new(PatchAction::Verified, key.clone()).with_reason( + "vendor_would_revert", + "vendoring would be reverted on remove", + ), + ); + continue; + } state.entries.remove(key); if let Err(e) = save_state(&args.common.cwd, &state).await { - emit_error_envelope(args.common.json, "vendor_state_write_failed", e.to_string()); + emit_error_envelope( + args.common.json, + args.common.dry_run, + "vendor_state_write_failed", + e.to_string(), + ); return 1; } - if !args.common.json { + if !args.common.json && !args.common.silent { println!("Reverted vendoring for {key}"); } env.record( @@ -580,7 +711,9 @@ async fn remove_detached_only( if args.common.json { println!("{}", env.to_pretty_json()); } - track_patch_removed(detached.len(), api_token, org_slug).await; + if !args.common.dry_run { + track_patch_removed(detached.len(), api_token, org_slug).await; + } 0 } @@ -593,28 +726,15 @@ async fn remove_patch_from_manifest( .map_err(|e| e.to_string())? .ok_or_else(|| "Invalid manifest".to_string())?; - let mut removed = Vec::new(); - - let purls_to_remove: Vec = if identifier.starts_with("pkg:") { - // Base PURL removes every release variant; qualified PURL removes one. - manifest - .patches - .keys() - .filter(|purl| purl_matches_identifier(purl, identifier)) - .cloned() - .collect() - } else { - manifest - .patches - .iter() - .filter(|(_, patch)| patch.uuid == identifier) - .map(|(purl, _)| purl.clone()) - .collect() - }; + let removed: Vec = manifest + .patches + .iter() + .filter(|(purl, patch)| patch_matches(purl, &patch.uuid, identifier)) + .map(|(purl, _)| purl.clone()) + .collect(); - for purl in purls_to_remove { - manifest.patches.remove(&purl); - removed.push(purl); + for purl in &removed { + manifest.patches.remove(purl); } if !removed.is_empty() { diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index b20e796a..96dd4a94 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -14,8 +14,8 @@ use std::path::Path; use std::time::Duration; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; -use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event}; -use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status}; +use crate::commands::lock_cli::{acquire_or_emit, error_envelope, lock_broken_event}; +use crate::json_envelope::{Command, Envelope, PatchAction, PatchEvent, Status}; #[derive(Args)] pub struct RepairArgs { @@ -46,11 +46,9 @@ pub async fn run(args: RepairArgs) -> i32 { // --offline implies strict airgap: no network calls. `--download-only` // is the inverse (network-only). The two are now mutually exclusive. if args.common.offline && args.download_only { - let msg = "--offline and --download-only are mutually exclusive".to_string(); + let msg = "--offline and --download-only are mutually exclusive"; if args.common.json { - let mut env = Envelope::new(Command::Repair); - env.dry_run = args.common.dry_run; - env.mark_error(EnvelopeError::new("invalid_args", msg)); + let env = error_envelope(Command::Repair, args.common.dry_run, "invalid_args", msg); println!("{}", env.to_pretty_json()); } else { eprintln!("Error: {msg}"); @@ -58,6 +56,17 @@ pub async fn run(args: RepairArgs) -> i32 { return 2; } + // Resolve telemetry credentials through the API client the way + // apply/rollback/remove do: passing the raw `--api-token`/`--org` flag + // values meant env-provided SOCKET_API_TOKEN/SOCKET_ORG_SLUG (the + // standard configuration) never reached telemetry, which then fell + // back to the anonymous public-proxy endpoint instead of the + // org-scoped one. + let (telemetry_client, _) = + get_api_client_with_overrides(args.common.api_client_overrides()).await; + let api_token = telemetry_client.api_token().cloned(); + let org_slug = telemetry_client.org_slug().cloned(); + let manifest_path = args.common.resolved_manifest_path(); if tokio::fs::metadata(&manifest_path).await.is_err() { @@ -96,16 +105,17 @@ pub async fn run(args: RepairArgs) -> i32 { } return 0; } + let msg = format!("Manifest not found at {}", manifest_path.display()); if args.common.json { - let mut env = Envelope::new(Command::Repair); - env.dry_run = args.common.dry_run; - env.mark_error(EnvelopeError::new( + let env = error_envelope( + Command::Repair, + args.common.dry_run, "manifest_not_found", - format!("Manifest not found at {}", manifest_path.display()), - )); + &msg, + ); println!("{}", env.to_pretty_json()); } else { - eprintln!("Manifest not found at {}", manifest_path.display()); + eprintln!("{msg}"); } return 1; } @@ -152,8 +162,8 @@ pub async fn run(args: RepairArgs) -> i32 { if had_failure { track_patch_repair_failed( "One or more artifacts failed to download", - args.common.api_token.as_deref(), - args.common.org.as_deref(), + api_token.as_deref(), + org_slug.as_deref(), ) .await; } else { @@ -161,8 +171,8 @@ pub async fn run(args: RepairArgs) -> i32 { counts.downloaded, counts.cleaned, counts.bytes_freed, - args.common.api_token.as_deref(), - args.common.org.as_deref(), + api_token.as_deref(), + org_slug.as_deref(), ) .await; } @@ -176,16 +186,9 @@ pub async fn run(args: RepairArgs) -> i32 { } } Err(e) => { - track_patch_repair_failed( - &e, - args.common.api_token.as_deref(), - args.common.org.as_deref(), - ) - .await; + track_patch_repair_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; if args.common.json { - let mut env = Envelope::new(Command::Repair); - env.dry_run = args.common.dry_run; - env.mark_error(EnvelopeError::new("repair_failed", e)); + let env = error_envelope(Command::Repair, args.common.dry_run, "repair_failed", &e); println!("{}", env.to_pretty_json()); } else { eprintln!("Error: {e}"); @@ -196,13 +199,13 @@ pub async fn run(args: RepairArgs) -> i32 { } /// Aggregate counts surfaced by `repair_inner` for telemetry use. -pub(crate) struct RepairCounts { +struct RepairCounts { downloaded: usize, cleaned: usize, bytes_freed: u64, } -pub(crate) async fn repair_inner( +async fn repair_inner( args: &RepairArgs, manifest_path: &Path, ) -> Result<(Envelope, RepairCounts), String> { @@ -268,15 +271,7 @@ pub(crate) async fn repair_inner( .iter() .filter(|(purl, rec)| { !referenced_uuids.contains(&rec.uuid) - && vendor_state - .entries - .get(*purl) - .or_else(|| { - vendor_state - .entries - .values() - .find(|e| &e.base_purl == *purl) - }) + && socket_patch_core::patch::vendor::lookup_entry(&vendor_state.entries, purl) .is_none_or(|e| e.uuid != rec.uuid) }) .map(|(k, v)| (k.clone(), v.clone())) @@ -303,58 +298,14 @@ pub(crate) async fn repair_inner( }; let missing_count = missing_artifacts.len(); - if !args.common.offline { - if !missing_artifacts.is_empty() { - if !quiet { - println!( - "Found {} missing {} artifact(s)", - missing_artifacts.len(), - download_mode.as_tag() - ); - } - - if args.common.dry_run { - if !quiet { - println!("\nDry run - would download:"); - for id in missing_artifacts.iter().take(10) { - println!(" - {}...", &id[..12.min(id.len())]); - } - if missing_artifacts.len() > 10 { - println!(" ... and {} more", missing_artifacts.len() - 10); - } - } - } else { - if !quiet { - println!("\nDownloading missing {}s...", download_mode.as_tag()); - } - let (client, _) = - get_api_client_with_overrides(args.common.api_client_overrides()).await; - let sources = PatchSources { - blobs_path: &blobs_path, - packages_path: Some(&packages_path), - diffs_path: Some(&diffs_path), - mem_blobs: None, - }; - // Step 1 only runs with a manifest (missing_artifacts is - // empty otherwise), so the expect is unreachable. - let m = scoped_manifest - .as_ref() - .expect("step 1 requires a manifest"); - let fetch_result = - fetch_missing_sources(m, &sources, download_mode, &client, None).await; - downloaded_count = fetch_result.downloaded; - download_failed_count = fetch_result.failed; - if !quiet { - println!("{}", format_fetch_result(&fetch_result)); - } - } - } else if !quiet { + if missing_artifacts.is_empty() { + if !quiet { println!( "All {} artifacts are present locally.", download_mode.as_tag() ); } - } else if !missing_artifacts.is_empty() { + } else if args.common.offline { if !quiet { println!( "Warning: {} {} artifact(s) are missing (offline mode - not downloading)", @@ -362,41 +313,85 @@ pub(crate) async fn repair_inner( download_mode.as_tag() ); for id in missing_artifacts.iter().take(5) { - println!(" - {}...", &id[..12.min(id.len())]); + // Truncate by characters, not bytes: manifest hashes are + // unvalidated strings, and a byte slice panics when index + // 12 lands inside a multibyte char (see format_fetch_result). + let short: String = id.chars().take(12).collect(); + println!(" - {short}..."); } if missing_artifacts.len() > 5 { println!(" ... and {} more", missing_artifacts.len() - 5); } } - } else if !quiet { - println!( - "All {} artifacts are present locally.", - download_mode.as_tag() - ); + } else { + if !quiet { + println!( + "Found {} missing {} artifact(s)", + missing_artifacts.len(), + download_mode.as_tag() + ); + } + + if args.common.dry_run { + if !quiet { + println!("\nDry run - would download:"); + for id in missing_artifacts.iter().take(10) { + // Chars, not bytes — same constraint as the offline list. + let short: String = id.chars().take(12).collect(); + println!(" - {short}..."); + } + if missing_artifacts.len() > 10 { + println!(" ... and {} more", missing_artifacts.len() - 10); + } + } + } else { + if !quiet { + println!("\nDownloading missing {}s...", download_mode.as_tag()); + } + let (client, _) = + get_api_client_with_overrides(args.common.api_client_overrides()).await; + let sources = PatchSources { + blobs_path: &blobs_path, + packages_path: Some(&packages_path), + diffs_path: Some(&diffs_path), + mem_blobs: None, + }; + // Step 1 only runs with a manifest (missing_artifacts is + // empty otherwise), so the expect is unreachable. + let m = scoped_manifest + .as_ref() + .expect("step 1 requires a manifest"); + let fetch_result = + fetch_missing_sources(m, &sources, download_mode, &client, None).await; + downloaded_count = fetch_result.downloaded; + download_failed_count = fetch_result.failed; + if !quiet { + println!("{}", format_fetch_result(&fetch_result)); + } + } } // Step 1.5: vendored artifacts — health-check the ledger (and any // lockfile vendor references with no ledger coverage) and rebuild // missing/corrupt artifacts. Runs under `--download-only` too: // restoring artifacts IS repair's download half. - let vendor_counts = crate::commands::repair_vendor::repair_vendored_artifacts( + let vendor_rebuilt = crate::commands::repair_vendor::repair_vendored_artifacts( &args.common, manifest.as_ref(), socket_dir, &mut env, ) .await; - if !quiet && vendor_counts.rebuilt > 0 { - println!("Rebuilt {} vendored artifact(s).", vendor_counts.rebuilt); + if !quiet && vendor_rebuilt > 0 { + println!("Rebuilt {} vendored artifact(s).", vendor_rebuilt); } // Step 2: Clean up unused artifacts across all three directories. if let (false, Some(manifest)) = (args.download_only, manifest.as_ref()) { - let manifest = manifest.clone(); if !quiet { println!(); } - match cleanup_unused_blobs(&manifest, &blobs_path, args.common.dry_run).await { + match cleanup_unused_blobs(manifest, &blobs_path, args.common.dry_run).await { Ok(cleanup_result) => { blobs_checked += cleanup_result.blobs_checked; blobs_cleaned += cleanup_result.blobs_removed; @@ -418,50 +413,47 @@ pub(crate) async fn repair_inner( } } Err(e) => { - if !quiet { + // A failed cleanup is error output: `--silent` (suppress + // NON-error output) must not mute it, and the JSON envelope + // must carry it — a bare `status: success` with no events is + // indistinguishable from "nothing to clean". Recorded as an + // informational skip (not `Failed`) to preserve the human + // path's warn-and-continue contract: status stays success, + // exit stays 0. + if !args.common.json { eprintln!("Warning: blob cleanup failed: {e}"); } + env.record( + PatchEvent::artifact(PatchAction::Skipped) + .with_reason("cleanup_failed", format!("blob cleanup failed: {e}")), + ); } } - // Diff archives. - match cleanup_unused_archives(&manifest, &diffs_path, args.common.dry_run).await { - Ok(cleanup_result) => { - blobs_checked += cleanup_result.blobs_checked; - blobs_cleaned += cleanup_result.blobs_removed; - bytes_freed += cleanup_result.bytes_freed; - if !quiet && cleanup_result.blobs_removed > 0 { - println!( - "{}", - format_cleanup_result(&cleanup_result, args.common.dry_run) - .replace("blob(s)", "diff archive(s)") - ); - } - } - Err(e) => { - if !quiet { - eprintln!("Warning: diff cleanup failed: {e}"); - } - } - } - - // Package archives. - match cleanup_unused_archives(&manifest, &packages_path, args.common.dry_run).await { - Ok(cleanup_result) => { - blobs_checked += cleanup_result.blobs_checked; - blobs_cleaned += cleanup_result.blobs_removed; - bytes_freed += cleanup_result.bytes_freed; - if !quiet && cleanup_result.blobs_removed > 0 { - println!( - "{}", - format_cleanup_result(&cleanup_result, args.common.dry_run) - .replace("blob(s)", "package archive(s)") - ); + // Diff and package archives. + for (path, label) in [(&diffs_path, "diff"), (&packages_path, "package")] { + match cleanup_unused_archives(manifest, path, args.common.dry_run).await { + Ok(cleanup_result) => { + blobs_checked += cleanup_result.blobs_checked; + blobs_cleaned += cleanup_result.blobs_removed; + bytes_freed += cleanup_result.bytes_freed; + if !quiet && cleanup_result.blobs_removed > 0 { + println!( + "{}", + format_cleanup_result(&cleanup_result, args.common.dry_run) + .replace("blob(s)", &format!("{label} archive(s)")) + ); + } } - } - Err(e) => { - if !quiet { - eprintln!("Warning: package cleanup failed: {e}"); + Err(e) => { + // Same contract as the blob-cleanup arm above. + if !args.common.json { + eprintln!("Warning: {label} cleanup failed: {e}"); + } + env.record(PatchEvent::artifact(PatchAction::Skipped).with_reason( + "cleanup_failed", + format!("{label} cleanup failed: {e}"), + )); } } } @@ -474,23 +466,19 @@ pub(crate) async fn repair_inner( // Translate the aggregate counts into envelope events. `repair` // operates on artifacts (not specific patches), so events use the // `PatchEvent::artifact` form (no PURL/UUID). - let action_for_repair = if args.common.dry_run { - PatchAction::Verified - } else { - PatchAction::Downloaded - }; + // // Only the online path downloads (or, in dry-run, *would* download). // In offline mode nothing is fetched even when artifacts are missing, // so don't record a download/would-download event there — that would // contradict the human-readable path, which only prints a warning. if downloaded_count > 0 || (!args.common.offline && args.common.dry_run && missing_count > 0) { - let count = if args.common.dry_run { - missing_count + let (action, count) = if args.common.dry_run { + (PatchAction::Verified, missing_count) } else { - downloaded_count + (PatchAction::Downloaded, downloaded_count) }; env.record( - PatchEvent::artifact(action_for_repair).with_details(serde_json::json!({ + PatchEvent::artifact(action).with_details(serde_json::json!({ "count": count, "mode": download_mode.as_tag(), })), @@ -790,6 +778,68 @@ mod tests { .exists()); } + /// A manifest "hash" that is NOT a hex digest: manifest hashes are + /// unvalidated strings (serde only), and byte index 12 of this one lands + /// inside a multibyte char — so a byte slice `&id[..12]` panics on it. + /// 1 ASCII byte + 8×2-byte `é` = 17 bytes; boundaries at 11 and 13. + const MULTIBYTE_HASH: &str = "aéééééééé"; + + /// Write a `.socket/manifest.json` whose afterHash is `MULTIBYTE_HASH`. + fn make_socket_multibyte(root: &Path) -> PathBuf { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write( + socket.join("manifest.json"), + MANIFEST_JSON.replace(REFERENCED_HASH, MULTIBYTE_HASH), + ) + .unwrap(); + socket + } + + /// Regression: the human-readable offline warning truncates each missing + /// artifact id for display. Truncation must be by characters, not bytes — + /// `&id[..12]` panics when byte 12 falls inside a multibyte char, so a + /// corrupt or hand-edited manifest crashed `repair --offline` instead of + /// warning. (Same class as the `format_fetch_result` fix in blob_fetcher.) + #[tokio::test] + async fn offline_warning_survives_multibyte_manifest_hash() { + let tmp = tempfile::tempdir().unwrap(); + let socket = make_socket_multibyte(tmp.path()); + // The truncating print only runs on the human-readable path. + let mut args = offline_args(tmp.path()); + args.common.json = false; + + let (env, counts) = repair_inner(&args, &socket.join("manifest.json")) + .await + .expect("repair_inner"); + + assert_eq!(counts.downloaded, 0); + assert_eq!(env.status, Status::Success); + } + + /// Regression twin for the dry-run preview print, which truncated ids the + /// same byte-sliced way (its list caps at 10 instead of 5). + #[tokio::test] + async fn dry_run_preview_survives_multibyte_manifest_hash() { + let tmp = tempfile::tempdir().unwrap(); + let socket = make_socket_multibyte(tmp.path()); + let mut args = offline_args(tmp.path()); + args.common.offline = false; + args.common.dry_run = true; + args.common.json = false; + + let (env, _counts) = repair_inner(&args, &socket.join("manifest.json")) + .await + .expect("repair_inner"); + + // The preview event is still recorded once the print survives. + assert!( + has_download_event(&env), + "dry-run must still preview the download; events={:?}", + env.events + ); + } + /// Offline mode with a missing artifact: the run must succeed (a warning, /// not a failure), record NO download event, and report zero downloads — /// nothing is fetched and the airgap is honoured. Cleanup still runs. diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index 8a361018..734057c4 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -26,11 +26,14 @@ use socket_patch_core::api::client::get_api_client_with_overrides; use socket_patch_core::crawlers::CrawlerOptions; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::copy_tree::remove_tree; +use socket_patch_core::patch::vendor::state::VendorArtifact; use socket_patch_core::patch::vendor::{ self, check_vendored_artifact, file_sha256_hex, load_state, lock_inventory, parse_vendor_path, - registry_fetch, ArtifactHealth, VendorEntry, + registry_fetch, ArtifactHealth, VendorEntry, VendorOutcome, +}; +use socket_patch_core::utils::purl::{ + normalize_purl, percent_decode_purl_component, strip_purl_qualifiers, }; -use socket_patch_core::utils::purl::strip_purl_qualifiers; use socket_patch_core::vex::time::now_rfc3339; use crate::args::GlobalArgs; @@ -42,14 +45,6 @@ use crate::commands::vendor::{ use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls}; use crate::json_envelope::{Envelope, PatchAction, PatchEvent}; -/// Counts surfaced to `repair_inner` for telemetry/human output. -#[derive(Default)] -pub(crate) struct RepairVendorCounts { - pub rebuilt: usize, - pub failed: usize, - pub healthy: usize, -} - /// One broken vendored unit queued for rebuild. struct Candidate { purl: String, @@ -131,7 +126,7 @@ fn synth_entry(eco: &str, uuid: &str, artifact_path: &str, base_purl: &str) -> V ecosystem: eco.to_string(), base_purl: base_purl.to_string(), uuid: uuid.to_string(), - artifact: socket_patch_core::patch::vendor::state::VendorArtifact { + artifact: VendorArtifact { path: artifact_path.to_string(), sha256: String::new(), size: None, @@ -151,37 +146,40 @@ fn synth_entry(eco: &str, uuid: &str, artifact_path: &str, base_purl: &str) -> V } } -fn fail( - env: &mut Envelope, - counts: &mut RepairVendorCounts, - quiet: bool, - purl: &str, - code: &str, - detail: String, -) { +fn fail(env: &mut Envelope, quiet: bool, purl: &str, code: &str, detail: String) { if !quiet { eprintln!( "Cannot repair vendored artifact for {}: {detail}", - socket_patch_core::utils::purl::normalize_purl(purl) + normalize_purl(purl) ); } env.record(PatchEvent::new(PatchAction::Failed, purl.to_string()).with_error(code, detail)); env.mark_partial_failure(); - counts.failed += 1; +} + +/// Best-effort removal of a vendored uuid dir — ahead of a rebuild (corrupt +/// bytes must never blend into one) or after a failed post-verify (never +/// leave unverifiable bytes behind). +async fn remove_vendor_dir(cwd: &Path, eco: &str, uuid: &str) { + if let Some(rel) = vendor::path::vendor_uuid_dir_rel(eco, uuid) { + let _ = remove_tree(&cwd.join(rel)).await; + } } /// The vendored-artifact phase of `repair`. Runs between the download and /// cleanup phases (and under `--download-only` — restoring artifacts IS /// repair's job). `manifest` is `None` when the project has no /// `.socket/manifest.json` (detached/reconstruction-only repairs). +/// Returns the number of artifacts rebuilt (for the human summary line); +/// failures are carried by `env` (`Failed` events + partial-failure status). pub(crate) async fn repair_vendored_artifacts( common: &GlobalArgs, manifest: Option<&PatchManifest>, socket_dir: &Path, env: &mut Envelope, -) -> RepairVendorCounts { +) -> usize { let quiet = common.json || common.silent; - let mut counts = RepairVendorCounts::default(); + let mut rebuilt = 0usize; let mut state = match load_state(&common.cwd).await { Ok(s) => s, @@ -191,8 +189,7 @@ pub(crate) async fn repair_vendored_artifacts( .with_error("vendor_state_unreadable", e.to_string()), ); env.mark_partial_failure(); - counts.failed += 1; - return counts; + return rebuilt; } }; @@ -227,7 +224,6 @@ pub(crate) async fn repair_vendored_artifacts( None => { fail( env, - &mut counts, quiet, purl, "vendor_artifact_unrepairable", @@ -252,7 +248,7 @@ pub(crate) async fn repair_vendored_artifacts( continue; } match check_vendored_artifact(&common.cwd, &entry, &record).await { - ArtifactHealth::Healthy => counts.healthy += 1, + ArtifactHealth::Healthy => {} ArtifactHealth::StaleUuid => { env.record( PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason( @@ -264,25 +260,18 @@ pub(crate) async fn repair_vendored_artifacts( ArtifactHealth::Unverifiable { reason } => { fail( env, - &mut counts, quiet, purl, "vendor_artifact_unrepairable", format!("the ledger entry cannot be verified ({reason}); fix state.json"), ); } - ArtifactHealth::Missing => { - let detached = entry.detached; - candidates.push(Candidate { - purl: purl.clone(), - entry, - record, - detached, - reconstructed: false, - reason: "vendor_artifact_missing", - }); - } - ArtifactHealth::Corrupt { .. } => { + health @ (ArtifactHealth::Missing | ArtifactHealth::Corrupt { .. }) => { + let reason = if matches!(health, ArtifactHealth::Missing) { + "vendor_artifact_missing" + } else { + "vendor_artifact_corrupt" + }; let detached = entry.detached; candidates.push(Candidate { purl: purl.clone(), @@ -290,7 +279,7 @@ pub(crate) async fn repair_vendored_artifacts( record, detached, reconstructed: false, - reason: "vendor_artifact_corrupt", + reason, }); } } @@ -316,7 +305,6 @@ pub(crate) async fn repair_vendored_artifacts( None => { fail( env, - &mut counts, quiet, &format!("pkg:{eco}/unknown@{uuid}"), "vendor_artifact_missing", @@ -338,6 +326,39 @@ pub(crate) async fn repair_vendored_artifacts( } match check_vendored_artifact(&common.cwd, &entry, &record).await { ArtifactHealth::Healthy => { + // The re-synthesized entry records no sha256, so the health + // check above verified only the patched members — whole-file + // drift (an altered UNPATCHED member) is invisible to it. + // The rewired lockfile integrity is the trust anchor for + // these exact bytes: a "surviving" artifact that no longer + // matches it leaves the package manager broken, so it must + // be rebuilt, never blessed into the reconstructed ledger. + if let Some(wired) = + lock_inventory::wired_vendor_integrity(&common.cwd, &entry.artifact.path).await + { + let name = npm_coords(&entry.base_purl) + .map(|(n, _)| n) + .unwrap_or_default(); + let intact = match tokio::fs::read(common.cwd.join(&entry.artifact.path)).await + { + Ok(bytes) => { + registry_fetch::artifact_matches_integrity(&bytes, &name, &wired) + .is_ok() + } + Err(_) => false, + }; + if !intact { + candidates.push(Candidate { + purl, + entry, + record, + detached, + reconstructed: true, + reason: "vendor_artifact_corrupt", + }); + continue; + } + } // The artifact survived; only the ledger was lost. Restore // the entry (sha/size recomputed) so GC/sweep/revert know // the artifact again — without it the next `scan --prune` @@ -359,7 +380,6 @@ pub(crate) async fn repair_vendored_artifacts( persist_vendor_entry(common, env, &mut state, &purl, entry, detached, &record) .await; if save_failed { - counts.failed += 1; continue; } env.record( @@ -371,7 +391,7 @@ pub(crate) async fn repair_vendored_artifacts( }), ), ); - counts.rebuilt += 1; + rebuilt += 1; } _ => { candidates.push(Candidate { @@ -387,7 +407,7 @@ pub(crate) async fn repair_vendored_artifacts( } if candidates.is_empty() { - return counts; + return rebuilt; } // ── Dry run: preview only ──────────────────────────────────────────── @@ -404,7 +424,7 @@ pub(crate) async fn repair_vendored_artifacts( ), ); } - return counts; + return rebuilt; } if !quiet { @@ -420,10 +440,7 @@ pub(crate) async fn repair_vendored_artifacts( // never leaves tampered bytes to be blended into a rebuild). for c in &candidates { if c.reason == "vendor_artifact_corrupt" { - if let Some(rel) = vendor::path::vendor_uuid_dir_rel(&c.entry.ecosystem, &c.entry.uuid) - { - let _ = remove_tree(&common.cwd.join(rel)).await; - } + remove_vendor_dir(&common.cwd, &c.entry.ecosystem, &c.entry.uuid).await; } } @@ -443,7 +460,6 @@ pub(crate) async fn repair_vendored_artifacts( for c in &candidates { fail( env, - &mut counts, quiet, &c.purl, c.reason, @@ -459,13 +475,12 @@ pub(crate) async fn repair_vendored_artifacts( ), ); } - return counts; + return rebuilt; } Err(e) => { env.record(PatchEvent::artifact(PatchAction::Failed).with_error("stage_failed", e)); env.mark_partial_failure(); - counts.failed += candidates.len(); - return counts; + return rebuilt; } }; let sources = staged.as_patch_sources(); @@ -477,7 +492,6 @@ pub(crate) async fn repair_vendored_artifacts( cwd: common.cwd.clone(), global: common.global, global_prefix: common.global_prefix.clone(), - batch_size: 100, }; let mut all_packages = find_packages_for_purls(&partitioned, &crawler_options, quiet).await; let inventory = lock_inventory::inventory_project(&common.cwd).await; @@ -490,12 +504,27 @@ pub(crate) async fn repair_vendored_artifacts( let mut must_verify: HashMap = HashMap::new(); for c in &candidates { if all_packages.contains_key(&c.purl) { - continue; // installed copy: works offline too + // Installed copy: works offline too. But for a RECONSTRUCTED + // entry the copy is an unverified source — the ledger that + // recorded the artifact sha is gone, so the rewired lockfile's + // integrity is the ONLY trust anchor. A copy that drifted since + // vendoring (build-tool artifacts, edited unpatched files) packs + // into a tarball the package manager would reject on its next + // install; register the wired integrity so the rebuilt artifact + // is verified below, exactly like the unverified-registry rung. + if c.reconstructed { + if let Some(wired) = + lock_inventory::wired_vendor_integrity(&common.cwd, &c.entry.artifact.path) + .await + { + must_verify.insert(c.purl.clone(), wired); + } + } + continue; } if common.offline { fail( env, - &mut counts, quiet, &c.purl, c.reason, @@ -539,14 +568,7 @@ pub(crate) async fn repair_vendored_artifacts( } Err(registry_fetch::FetchError::Failed(d)) | Err(registry_fetch::FetchError::Unverifiable(d)) => { - fail( - env, - &mut counts, - quiet, - &c.purl, - "vendor_fetch_failed", - d, - ); + fail(env, quiet, &c.purl, "vendor_fetch_failed", d); unrebuildable.insert(c.purl.clone()); continue; } @@ -554,31 +576,22 @@ pub(crate) async fn repair_vendored_artifacts( } } } - let detail = fetch_pristine_unrepairable_detail(c).unwrap_or_else(|| { + let detail = if c.entry.artifact.platform_locked == Some(true) { + "the vendored wheel is platform-locked (compiled); reinstall the \ + package on this platform and re-run repair, or run `socket-patch \ + vendor` to rebuild it" + .to_string() + } else { "no verifiable pristine source: the package is not installed, the \ lockfile is rewired to the (broken) vendored artifact, and the \ ledger records no recoverable registry fragment" .to_string() - }); - fail( - env, - &mut counts, - quiet, - &c.purl, - "vendor_artifact_unrepairable", - detail, - ); + }; + fail(env, quiet, &c.purl, "vendor_artifact_unrepairable", detail); unrebuildable.insert(c.purl.clone()); } PristineFetch::Failed(detail) => { - fail( - env, - &mut counts, - quiet, - &c.purl, - "vendor_fetch_failed", - detail, - ); + fail(env, quiet, &c.purl, "vendor_fetch_failed", detail); unrebuildable.insert(c.purl.clone()); } } @@ -593,6 +606,31 @@ pub(crate) async fn repair_vendored_artifacts( let Some(pkg_path) = all_packages.get(&c.purl).cloned() else { continue; // failed above }; + // For an unverified-source rebuild the rewired lockfile is the trust + // anchor: snapshot the wiring files so a failed post-verify can put + // them back byte-for-byte. The backend's re-wire may refresh the + // recorded integrity/checksum to the rebuilt tarball's — blessing + // exactly the drifted bytes the verify below is about to reject. + let wiring_snapshot: Option)>> = + if must_verify.contains_key(&c.purl) { + let mut snap = Vec::new(); + for name in [ + "package-lock.json", + "npm-shrinkwrap.json", + "pnpm-lock.yaml", + "yarn.lock", + "bun.lock", + "package.json", + ] { + let p = common.cwd.join(name); + if let Ok(bytes) = tokio::fs::read(&p).await { + snap.push((p, bytes)); + } + } + Some(snap) + } else { + None + }; let outcome = dispatch_vendor_one( &c.purl, &pkg_path, @@ -610,17 +648,16 @@ pub(crate) async fn repair_vendored_artifacts( None => { fail( env, - &mut counts, quiet, &c.purl, "vendor_artifact_unrepairable", "no vendor backend for this ecosystem in this build".to_string(), ); } - Some(socket_patch_core::patch::vendor::VendorOutcome::Refused { code, detail }) => { - fail(env, &mut counts, quiet, &c.purl, code, detail); + Some(VendorOutcome::Refused { code, detail }) => { + fail(env, quiet, &c.purl, code, detail); } - Some(socket_patch_core::patch::vendor::VendorOutcome::Done { + Some(VendorOutcome::Done { result, entry, warnings, @@ -628,7 +665,6 @@ pub(crate) async fn repair_vendored_artifacts( if !result.success { fail( env, - &mut counts, quiet, &c.purl, "vendor_artifact_rebuild_failed", @@ -656,14 +692,17 @@ pub(crate) async fn repair_vendored_artifacts( Err(e) => Err(format!("cannot read the rebuilt artifact: {e}")), }; if let Err(detail) = verdict { - if let Some(rel) = - vendor::path::vendor_uuid_dir_rel(&c.entry.ecosystem, &c.entry.uuid) - { - let _ = remove_tree(&common.cwd.join(rel)).await; + remove_vendor_dir(&common.cwd, &c.entry.ecosystem, &c.entry.uuid).await; + // Put the trust anchor back exactly as it was: the + // backend's re-wire may have refreshed the recorded + // integrity to the rejected rebuild's. + if let Some(snap) = &wiring_snapshot { + for (path, bytes) in snap { + let _ = tokio::fs::write(path, bytes).await; + } } fail( env, - &mut counts, quiet, &c.purl, "vendor_artifact_rebuild_failed", @@ -680,20 +719,13 @@ pub(crate) async fn repair_vendored_artifacts( // match: a backend-returned entry (drift healed / wiring // re-recorded) wins; a reconstructed entry gets its // fingerprint computed from the rebuilt bytes. - let mut check_entry = c.entry.clone(); - if let Some(e) = entry { - check_entry = e.clone(); - if persist_vendor_entry( - common, env, &mut state, &c.purl, e, c.detached, &c.record, - ) - .await - { - counts.failed += 1; - continue; - } - } else if c.reconstructed { + let from_backend = entry.is_some(); + let mut check_entry = entry.unwrap_or_else(|| c.entry.clone()); + if !from_backend && c.reconstructed { fill_artifact_fingerprint(&common.cwd, &mut check_entry).await; - if persist_vendor_entry( + } + if (from_backend || c.reconstructed) + && persist_vendor_entry( common, env, &mut state, @@ -703,10 +735,8 @@ pub(crate) async fn repair_vendored_artifacts( &c.record, ) .await - { - counts.failed += 1; - continue; - } + { + continue; } // ── Fail-closed post-verify ────────────────────────────── match check_vendored_artifact(&common.cwd, &check_entry, &c.record).await { @@ -714,7 +744,7 @@ pub(crate) async fn repair_vendored_artifacts( if !quiet { println!( "Rebuilt {} ({})", - socket_patch_core::utils::purl::normalize_purl(&c.purl), + normalize_purl(&c.purl), check_entry.artifact.path ); } @@ -726,21 +756,16 @@ pub(crate) async fn repair_vendored_artifacts( }), ), ); - counts.rebuilt += 1; + rebuilt += 1; } other => { // The deterministic rebuild did not reproduce the // recorded artifact (e.g. a tampered ledger sha): // remove it rather than leave unverifiable bytes. - if let Some(rel) = vendor::path::vendor_uuid_dir_rel( - &check_entry.ecosystem, - &check_entry.uuid, - ) { - let _ = remove_tree(&common.cwd.join(rel)).await; - } + remove_vendor_dir(&common.cwd, &check_entry.ecosystem, &check_entry.uuid) + .await; fail( env, - &mut counts, quiet, &c.purl, "vendor_artifact_rebuild_failed", @@ -756,7 +781,7 @@ pub(crate) async fn repair_vendored_artifacts( } } drop(holders); - counts + rebuilt } /// Compute and record the artifact fingerprint (sha256 + size for @@ -790,26 +815,23 @@ async fn fetch_record_by_uuid(common: &GlobalArgs, uuid: &str) -> Option<(String } /// `pkg:npm/@` → (name, version); the name may be scoped. +/// `base_purl` is stored verbatim percent-encoded (`pkg:npm/%40scope/…`), +/// so each component is decoded like the npm backend's own coordinate +/// parser — the registry fetch and the berry cache-checksum recipe both +/// need the decoded name. fn npm_coords(base_purl: &str) -> Option<(String, String)> { let rest = strip_purl_qualifiers(base_purl).strip_prefix("pkg:npm/")?; - let (name, version) = rest.rsplit_once('@')?; - if name.is_empty() || version.is_empty() { + let (name_raw, version_raw) = rest.rsplit_once('@')?; + if name_raw.is_empty() || version_raw.is_empty() { return None; } - Some((name.to_string(), version.to_string())) -} - -/// A more specific unrepairable detail when one is knowable from the entry. -fn fetch_pristine_unrepairable_detail(c: &Candidate) -> Option { - if c.entry.artifact.platform_locked == Some(true) { - Some( - "the vendored wheel is platform-locked (compiled); reinstall the package on \ - this platform and re-run repair, or run `socket-patch vendor` to rebuild it" - .to_string(), - ) - } else { - None - } + let name = name_raw + .split('/') + .map(percent_decode_purl_component) + .collect::>() + .join("/"); + let version = percent_decode_purl_component(version_raw).into_owned(); + Some((name, version)) } #[cfg(test)] @@ -852,4 +874,26 @@ mod tests { "trailing colon must be cut: {refs:?}" ); } + + /// `base_purl` is stored VERBATIM percent-encoded (`pkg:npm/%40scope/…`, + /// manifest/ledger key parity — see npm_common's coordinate tests), but + /// the registry fetch and the berry cache-checksum recipe both need the + /// DECODED npm name. + #[test] + fn npm_coords_percent_decodes_scoped_names() { + assert_eq!( + npm_coords("pkg:npm/%40scope/sdk@1.12.0"), + Some(("@scope/sdk".to_string(), "1.12.0".to_string())) + ); + // Already-decoded and unscoped spellings pass through unchanged. + assert_eq!( + npm_coords("pkg:npm/@scope/sdk@1.12.0"), + Some(("@scope/sdk".to_string(), "1.12.0".to_string())) + ); + assert_eq!( + npm_coords("pkg:npm/left-pad@1.3.0?foo=bar"), + Some(("left-pad".to_string(), "1.3.0".to_string())) + ); + assert_eq!(npm_coords("pkg:npm/left-pad"), None); + } } diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index c6bf2036..65e2b0ca 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -2,20 +2,22 @@ use clap::Args; use socket_patch_core::api::blob_fetcher::{fetch_blobs_by_hash, format_fetch_result}; use socket_patch_core::api::client::get_api_client_with_overrides; use socket_patch_core::crawlers::CrawlerOptions; -use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::manifest::operations::{get_before_hash_blobs, read_manifest}; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::select_installed_variants; use socket_patch_core::patch::rollback::{ rollback_package_patch, RollbackResult, VerifyRollbackStatus, }; -use socket_patch_core::utils::purl::{purl_matches_identifier, strip_purl_qualifiers}; +use socket_patch_core::utils::purl::strip_purl_qualifiers; use socket_patch_core::utils::telemetry::{track_patch_rollback_failed, track_patch_rolled_back}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::Duration; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; +use crate::commands::apply::is_local_go; use crate::commands::lock_cli::{acquire_or_emit, LOCK_BROKEN_CODE}; +use crate::commands::remove::patch_matches; use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls}; use crate::json_envelope::Command as EnvelopeCommand; @@ -54,22 +56,13 @@ struct PatchToRollback { // directive) + the patched copy — no in-place restore, no before-blob. Cargo // patches in place (vendored or registry cache), so it rolls back in place from // before-blobs like npm/pypi. The helper is an inert stub without `golang`. - -/// True for a golang PURL in local mode (no `--global` / `--global-prefix`). -#[cfg(feature = "golang")] -fn is_local_go(purl: &str, common: &GlobalArgs) -> bool { - use socket_patch_core::crawlers::Ecosystem; - !common.global - && common.global_prefix.is_none() - && Ecosystem::from_purl(purl) == Some(Ecosystem::Golang) -} +// `is_local_go` is shared with `apply`, which creates the same redirects. /// True when `purl` rolls back by dropping a project-local redirect (local-mode /// go) rather than restoring bytes from a before-blob. The before-blob gate uses /// this to skip those PURLs — they read no blobs, so a missing before-blob must /// not block (or trigger a needless download for) an offline redirect rollback. fn is_local_redirect(purl: &str, common: &GlobalArgs) -> bool { - #[cfg(feature = "golang")] if is_local_go(purl, common) { return true; } @@ -98,7 +91,6 @@ fn exclude_local_redirects(manifest: &PatchManifest, common: &GlobalArgs) -> Pat /// is left pristine by the redirect, so there is no before-blob to restore; /// mirrors apply's `try_local_go_apply`. Go has no `vendor/` fallthrough (apply /// always redirects local go), so there is no vendored discriminator here. -#[cfg(feature = "golang")] async fn try_rollback_local_go( purl: &str, pkg_path: &Path, @@ -144,70 +136,19 @@ async fn try_rollback_local_go( Some(result) } -#[cfg(not(feature = "golang"))] -async fn try_rollback_local_go( - _purl: &str, - _pkg_path: &Path, - _patch: &PatchRecord, - _common: &GlobalArgs, -) -> Option { - None -} - fn find_patches_to_rollback( manifest: &PatchManifest, identifier: Option<&str>, ) -> Vec { - match identifier { - None => manifest - .patches - .iter() - .map(|(purl, patch)| PatchToRollback { - purl: purl.clone(), - patch: patch.clone(), - }) - .collect(), - Some(id) => { - let mut patches = Vec::new(); - if id.starts_with("pkg:") { - // A base PURL (no `?`) matches every release variant of - // that package@version; a qualified PURL targets one. - for (purl, patch) in &manifest.patches { - if purl_matches_identifier(purl, id) { - patches.push(PatchToRollback { - purl: purl.clone(), - patch: patch.clone(), - }); - } - } - } else { - for (purl, patch) in &manifest.patches { - if patch.uuid == id { - patches.push(PatchToRollback { - purl: purl.clone(), - patch: patch.clone(), - }); - } - } - } - patches - } - } -} - -fn get_before_hash_blobs(manifest: &PatchManifest) -> HashSet { - let mut blobs = HashSet::new(); - for patch in manifest.patches.values() { - for file_info in patch.files.values() { - // An empty beforeHash is the "file created by the patch" sentinel, - // not a blob: rollback deletes the file instead of restoring - // content, so there is nothing to download or gate on. - if !file_info.before_hash.is_empty() { - blobs.insert(file_info.before_hash.clone()); - } - } - } - blobs + manifest + .patches + .iter() + .filter(|(purl, patch)| identifier.is_none_or(|id| patch_matches(purl, &patch.uuid, id))) + .map(|(purl, patch)| PatchToRollback { + purl: purl.clone(), + patch: patch.clone(), + }) + .collect() } async fn get_missing_before_blobs(manifest: &PatchManifest, blobs_path: &Path) -> HashSet { @@ -292,45 +233,36 @@ fn result_to_json(result: &RollbackResult) -> serde_json::Value { pub async fn run(args: RollbackArgs) -> i32 { apply_env_toggles(&args.common); - let (telemetry_client, _) = - get_api_client_with_overrides(args.common.api_client_overrides()).await; - let api_token = telemetry_client.api_token().cloned(); - let org_slug = telemetry_client.org_slug().cloned(); - - // Validate one-off requires identifier - if args.one_off && args.identifier.is_none() { - if args.common.json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "status": "error", - "error": "--one-off requires an identifier (UUID or PURL)", - })) - .unwrap() - ); - } else { - eprintln!("Error: --one-off requires an identifier (UUID or PURL)"); - } - return 1; - } - - // Handle one-off mode + // Bail on the unimplemented flag BEFORE constructing the API client: + // client construction can auto-resolve the org slug over the network, + // and the contract promises the one-off stub fails before any network + // or disk activity. if args.one_off { + let msg = if args.identifier.is_none() { + "--one-off requires an identifier (UUID or PURL)" + } else { + "One-off rollback mode is not yet implemented" + }; if args.common.json { println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ "status": "error", - "error": "One-off rollback mode is not yet implemented", + "error": msg, })) .unwrap() ); } else { - eprintln!("Error: One-off rollback mode is not yet implemented"); + eprintln!("Error: {msg}"); } return 1; } + let (telemetry_client, _) = + get_api_client_with_overrides(args.common.api_client_overrides()).await; + let api_token = telemetry_client.api_token().cloned(); + let org_slug = telemetry_client.org_slug().cloned(); + let manifest_path = args.common.resolved_manifest_path(); if tokio::fs::metadata(&manifest_path).await.is_err() { @@ -344,7 +276,9 @@ pub async fn run(args: RollbackArgs) -> i32 { })) .unwrap() ); - } else if !args.common.silent { + } else { + // Errors print even under --silent ("errors only", never + // "nothing"): exit 1 with no message would be undiagnosable. eprintln!("Manifest not found at {}", manifest_path.display()); } return 1; @@ -467,13 +401,11 @@ pub async fn run(args: RollbackArgs) -> i32 { for result in &results { println!(" {}:", result.package_key); for f in &result.files_verified { - let status_str = match f.status { - VerifyRollbackStatus::Ready => "ready", - VerifyRollbackStatus::AlreadyOriginal => "already original", - VerifyRollbackStatus::HashMismatch => "hash mismatch", - VerifyRollbackStatus::NotFound => "not found", - VerifyRollbackStatus::MissingBlob => "missing blob", - }; + // Same labels as the JSON status strings, with the + // underscores humanized (`already_original` → + // `already original`). + let status_str = + verify_rollback_status_str(&f.status).replace('_', " "); println!(" {} [{}]", f.file, status_str); if let Some(ref msg) = f.message { println!(" message: {msg}"); @@ -542,7 +474,9 @@ pub async fn run(args: RollbackArgs) -> i32 { })) .unwrap() ); - } else if !args.common.silent { + } else { + // Errors print even under --silent ("errors only", never + // "nothing"): exit 1 with no message would be undiagnosable. eprintln!("Error: {e}"); } 1 @@ -560,10 +494,14 @@ async fn rollback_patches_inner( .ok_or_else(|| "Invalid manifest".to_string())?; let socket_dir = manifest_path.parent().unwrap(); - let blobs_path = socket_dir.join("blobs"); - tokio::fs::create_dir_all(&blobs_path) - .await - .map_err(|e| e.to_string())?; + let mut blobs_path = socket_dir.join("blobs"); + // `--dry-run` must not mutate `.socket/` ("Preview, no mutations"): + // don't create the blobs dir; a throwaway stage replaces it below. + if !args.common.dry_run { + tokio::fs::create_dir_all(&blobs_path) + .await + .map_err(|e| e.to_string())?; + } let patches_to_rollback = find_patches_to_rollback(&manifest, args.identifier.as_deref()); @@ -614,8 +552,8 @@ async fn rollback_patches_inner( // Partition PURLs by ecosystem up front. The before-blob gate and the // download below must only consider patches this run can actually roll - // back — the `--ecosystems` filter plus the ecosystems compiled into this - // build. An out-of-scope patch with an absent before-blob must not abort + // back — the `--ecosystems` filter. An out-of-scope patch with an + // absent before-blob must not abort // (or trigger fetches for) a run that will never restore it. Mirrors // apply's `scoped_manifest`. let rollback_purls: Vec = patches_to_rollback.iter().map(|p| p.purl.clone()).collect(); @@ -634,10 +572,49 @@ async fn rollback_patches_inner( // and reads no blobs, so a missing before-blob must not block an offline // redirect rollback. let gate_manifest = exclude_local_redirects(&scoped_manifest, &args.common); + + // `--dry-run`: verification needs real blob content for an accurate + // preview, but the preview must not leave new files in the committable + // `.socket/blobs` (a wet run's sweep would have removed them) — so stage + // blob reads in a throwaway sibling dir: hardlink (or copy) the + // already-cached before-blobs in, and let any download below land there + // too. `tempdir_in(socket_dir)` keeps it on the same filesystem for + // hardlinks and is auto-removed on drop, like the `.socket-stage-*` + // atomic-write siblings. + let _dry_run_blob_stage: Option = if args.common.dry_run { + let stage = tempfile::Builder::new() + .prefix(".socket-stage-dryrun-blobs-") + .tempdir_in(socket_dir) + .map_err(|e| e.to_string())?; + let staged_path = stage.path().to_path_buf(); + for patch in gate_manifest.patches.values() { + for info in patch.files.values() { + if info.before_hash.is_empty() { + continue; // created-by-patch marker: no blob to read + } + let src = blobs_path.join(&info.before_hash); + let dst = staged_path.join(&info.before_hash); + if tokio::fs::metadata(&src).await.is_ok() + && !dst.exists() + && tokio::fs::hard_link(&src, &dst).await.is_err() + { + let _ = tokio::fs::copy(&src, &dst).await; + } + } + } + blobs_path = staged_path; + Some(stage) + } else { + None + }; + let missing_blobs = get_missing_before_blobs(&gate_manifest, &blobs_path).await; if !missing_blobs.is_empty() { if args.common.offline { - if !args.common.silent && !args.common.json { + // Errors print even under --silent ("errors only", never + // "nothing"): this bail is the run's ONLY diagnostic — the JSON + // envelope carries a contentless partial_failure. + if !args.common.json { eprintln!( "Error: {} blob(s) are missing and --offline mode is enabled.", missing_blobs.len() @@ -665,7 +642,9 @@ async fn rollback_patches_inner( // blobs and spuriously abort a mixed local-go rollback. let still_missing = get_missing_before_blobs(&gate_manifest, &blobs_path).await; if !still_missing.is_empty() { - if !args.common.silent && !args.common.json { + // Errors print even under --silent — same contract as the + // offline bail above. + if !args.common.json { eprintln!( "{} blob(s) could not be downloaded. Cannot rollback.", still_missing.len() @@ -679,7 +658,6 @@ async fn rollback_patches_inner( cwd: args.common.cwd.clone(), global: args.common.global, global_prefix: args.common.global_prefix.clone(), - batch_size: 100, }; let all_packages = find_packages_for_rollback( @@ -759,8 +737,7 @@ async fn rollback_patches_inner( // Local go drops the project-local `replace`-redirect; everything // else — npm/pypi/gem and cargo (vendored or registry cache) — - // restores in place from before-blobs. Without the `golang` feature - // `try_rollback_local_go` is an inert `None`. + // restores in place from before-blobs. let result = match try_rollback_local_go(purl, pkg_path, patch, &args.common).await { Some(r) => r, None => { @@ -777,7 +754,10 @@ async fn rollback_patches_inner( if !result.success { has_errors = true; - if !args.common.silent && !args.common.json { + // Errors print even under --silent ("errors only", never + // "nothing"): with the summary muted, this line is the + // silent run's only failure diagnostic. + if !args.common.json { eprintln!( "Failed to rollback {}: {}", purl, @@ -794,30 +774,31 @@ async fn rollback_patches_inner( // Export for use by remove command. The third tuple element lists // vendor-owned purls that were excluded from in-place rollback (benign). -#[allow(clippy::too_many_arguments)] -pub async fn rollback_patches( - cwd: &Path, +// +// Takes the caller's `GlobalArgs` as the base (only the per-call fields are +// overridden): the nested missing-blob download builds its API client from +// `api_client_overrides()`, so flag-passed `--api-url` / `--api-token` / +// `--org` / `--proxy-url` must flow through. A from-scratch +// `GlobalArgs::default()` here silently dropped them — with credentials +// passed as flags the nested client was unauthenticated and pointed at the +// public proxy, so the download failed and the whole `remove` aborted with +// `rollback_failed` (see tests/remove_rollback_api_overrides.rs). +pub(crate) async fn rollback_patches( + common: &crate::args::GlobalArgs, manifest_path: &Path, identifier: Option<&str>, dry_run: bool, silent: bool, - offline: bool, - global: bool, - global_prefix: Option, ecosystems: Option>, ) -> Result<(bool, Vec, Vec), String> { let args = RollbackArgs { identifier: identifier.map(String::from), common: crate::args::GlobalArgs { - cwd: cwd.to_path_buf(), manifest_path: manifest_path.display().to_string(), - offline, - global, - global_prefix, ecosystems, silent, dry_run, - ..crate::args::GlobalArgs::default() + ..common.clone() }, one_off: false, }; @@ -1118,7 +1099,6 @@ mod tests { /// must NOT be excluded by the before-blob gate: a missing cargo before-blob /// IS a real problem the gate should surface. This guards against cargo /// being mistakenly reclassified as a redirect again. - #[cfg(feature = "cargo")] #[tokio::test] async fn gate_manifest_keeps_cargo_before_blobs_in_missing_check() { let mut patches = HashMap::new(); @@ -1165,7 +1145,6 @@ mod tests { /// an offline local-go rollback. Before the fix only cargo was excluded, so /// a local-go patch with an absent before-blob aborted the whole rollback /// under `--offline`. - #[cfg(feature = "golang")] #[tokio::test] async fn gate_manifest_excludes_local_go_before_blobs_from_missing_check() { let mut patches = HashMap::new(); @@ -1233,7 +1212,6 @@ mod tests { /// cache, every file verified `AlreadyOriginal`, and the redirect was left /// active — a silent no-op that reported "already original" while the build /// kept using the patched copy. - #[cfg(feature = "golang")] #[tokio::test] async fn try_rollback_local_go_drops_redirect_and_copy() { use socket_patch_core::patch::go_mod_edit::{ @@ -1319,7 +1297,6 @@ mod tests { /// it unconditionally, so `rollback --dry-run --json` reported /// `rolledBack: 1` (with the files listed in `filesRolledBack`) for a run /// that mutated nothing. - #[cfg(feature = "golang")] #[tokio::test] async fn try_rollback_local_go_dry_run_reports_no_files_rolled_back() { use socket_patch_core::patch::go_mod_edit::{ @@ -1382,7 +1359,6 @@ mod tests { /// A go PURL under `--global` is an in-place module-cache rollback, NOT a /// redirect — `try_rollback_local_go` must decline it so the caller falls /// through to `rollback_package_patch`. - #[cfg(feature = "golang")] #[tokio::test] async fn try_rollback_local_go_declines_global() { let patch = record_with_file("uuid-go", "errors.go", "go_before"); @@ -1407,7 +1383,7 @@ mod tests { // // Twin of apply's (fixed) "offline guard unscoped" bug: the gate must // only consider patches this run can actually roll back — the - // `--ecosystems` filter plus the ecosystems compiled into this build. + // `--ecosystems` filter. /// Regression: an out-of-scope patch's missing before-blob must not abort /// an `--ecosystems`-scoped rollback. Before the fix the gate ran on the @@ -1449,15 +1425,17 @@ mod tests { // With no npm package installed under the tempdir the run finds // nothing to do — but it must get past the gate and report success, // not abort over a blob it would never read. + let common = crate::args::GlobalArgs { + cwd: root.to_path_buf(), + offline: true, + ..crate::args::GlobalArgs::default() + }; let (success, results, _vendored_skipped) = rollback_patches( - root, + &common, &manifest_path, None, false, // dry_run true, // silent - true, // offline - false, // global - None, Some(vec!["npm".to_string()]), ) .await @@ -1496,16 +1474,18 @@ mod tests { .unwrap(); // The npm before-blob is deliberately absent. + let common = crate::args::GlobalArgs { + cwd: root.to_path_buf(), + offline: true, + ..crate::args::GlobalArgs::default() + }; let (success, results, _vendored_skipped) = rollback_patches( - root, + &common, &manifest_path, None, false, // dry_run true, // silent - true, // offline - false, // global - None, - None, // no ecosystem filter — the npm patch is in scope + None, // no ecosystem filter — the npm patch is in scope ) .await .expect("rollback must not error"); diff --git a/crates/socket-patch-cli/src/commands/scan.rs b/crates/socket-patch-cli/src/commands/scan.rs deleted file mode 100644 index fb56a0bb..00000000 --- a/crates/socket-patch-cli/src/commands/scan.rs +++ /dev/null @@ -1,3663 +0,0 @@ -use clap::Args; -use socket_patch_core::api::client::{ - build_proxy_fallback_client, get_api_client_with_overrides, is_fallback_candidate, -}; -use socket_patch_core::api::types::{BatchPackagePatches, PatchSearchResult}; -use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; -use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; -use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; -use socket_patch_core::patch::apply_lock; -use socket_patch_core::utils::cleanup_blobs::{ - cleanup_unused_archives, cleanup_unused_blobs, CleanupResult, -}; -use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; -use socket_patch_core::utils::telemetry::{ - track_patch_scan_failed, track_patch_scanned, track_patch_vendor_failed, track_patch_vendored, -}; -use std::collections::{HashMap, HashSet}; -use std::path::Path; -use std::time::Duration; - -use crate::args::{apply_env_toggles, GlobalArgs}; -use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; -use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; -use crate::ecosystem_dispatch::crawl_all_ecosystems; -use crate::json_envelope::{Command as EnvelopeCommand, Envelope}; -use crate::output::{color, confirm, format_severity, stderr_is_tty, stdout_is_tty}; - -use super::get::{ - download_and_apply_patches, download_patch_records, select_patches, truncate_with_ellipsis, - DownloadParams, -}; -use super::vendor::{reconcile_dropped, vendor_records}; - -const DEFAULT_BATCH_SIZE: usize = 100; - -/// Surfaced in `scan --json` output. Tells a bot which PURLs in the discovery -/// would replace an existing manifest entry with a newer UUID. Stable schema — -/// see CLI_CONTRACT.md (`scan` JSON output / `updates` field). -#[derive(Debug, PartialEq, Eq, Clone)] -pub(crate) struct UpdateInfo { - pub purl: String, - pub old_uuid: String, - pub new_uuid: String, -} - -/// Aggregated outcome of a GC pass (or preview). Serialized into the -/// `scan --json` output's `gc` sub-object. See CLI_CONTRACT.md for the -/// stable schema. -#[derive(Debug, Default)] -pub(crate) struct GcSummary { - /// PURLs removed from the manifest (apply mode) or eligible to be - /// removed (preview mode). - pub pruned: Vec, - pub blobs: CleanupResult, - pub diffs: CleanupResult, - pub packages: CleanupResult, - /// Vendored entries reverted (or revertable, preview mode) because - /// their patch is gone from the manifest or their dependency left the - /// lockfile graph — see `vendor::run_vendor_gc`. Sorted. - pub vendored_reverted: Vec, - /// Orphan `.socket/vendor//` dirs swept (or sweepable). - pub vendor_orphan_dirs: usize, - /// `true` when `--no-prune` was set; the sub-object only carries the - /// `skipped: true` field in that case. - pub skipped: bool, -} - -impl GcSummary { - fn total_bytes(&self) -> u64 { - self.blobs.bytes_freed + self.diffs.bytes_freed + self.packages.bytes_freed - } - - /// Fold a vendored-state GC pass into this summary. - fn absorb_vendor_gc(&mut self, v: super::vendor::VendorGcSummary) { - self.vendored_reverted = v - .dropped_reverted - .into_iter() - .chain(v.unused_reverted) - .collect(); - self.vendored_reverted.sort(); - self.vendor_orphan_dirs = v.orphan_dirs; - } - - /// Serialize for a *mutating* GC pass (post-apply). - fn to_apply_json(&self) -> serde_json::Value { - if self.skipped { - return serde_json::json!({ "skipped": true }); - } - serde_json::json!({ - "prunedManifestEntries": self.pruned, - "removedBlobs": self.blobs.blobs_removed, - "removedDiffArchives": self.diffs.blobs_removed, - "removedPackageArchives": self.packages.blobs_removed, - "revertedVendoredEntries": self.vendored_reverted, - "removedVendorOrphanDirs": self.vendor_orphan_dirs, - "bytesFreed": self.total_bytes(), - }) - } - - /// Serialize for a *non-mutating* GC pass (read-only preview). - fn to_preview_json(&self) -> serde_json::Value { - if self.skipped { - return serde_json::json!({ "skipped": true }); - } - serde_json::json!({ - "prunableManifestEntries": self.pruned, - "orphanBlobs": self.blobs.blobs_removed, - "orphanDiffArchives": self.diffs.blobs_removed, - "orphanPackageArchives": self.packages.blobs_removed, - "revertableVendoredEntries": self.vendored_reverted, - "vendorOrphanDirs": self.vendor_orphan_dirs, - "bytesReclaimable": self.total_bytes(), - }) - } -} - -/// Compute GC actions without performing them. `dry_run = true` for the -/// preview path; `dry_run = false` for the apply path. The cleanup helpers -/// from `socket_patch_core::utils::cleanup_blobs` natively support dry-run, -/// so the same function works for both. -async fn run_gc( - manifest: &PatchManifest, - pruned: Vec, - socket_dir: &Path, - dry_run: bool, -) -> GcSummary { - let blobs = cleanup_unused_blobs(manifest, &socket_dir.join("blobs"), dry_run) - .await - .unwrap_or_default(); - let diffs = cleanup_unused_archives(manifest, &socket_dir.join("diffs"), dry_run) - .await - .unwrap_or_default(); - let packages = cleanup_unused_archives(manifest, &socket_dir.join("packages"), dry_run) - .await - .unwrap_or_default(); - GcSummary { - pruned, - blobs, - diffs, - packages, - skipped: false, - ..Default::default() - } -} - -/// Apply-mode GC: re-read the manifest written by `download_and_apply_patches`, -/// prune manifest entries for PURLs not in `scanned_purls`, write the manifest -/// back, then sweep orphan blob/diff/package files. Callers must gate on the -/// `prune` flag — when GC isn't requested, simply don't call this function and -/// don't emit a `gc` sub-object. -async fn run_apply_gc( - common: &crate::args::GlobalArgs, - manifest_path: &Path, - socket_dir: &Path, - scanned_purls: &HashSet, - vendored: &HashSet, -) -> GcSummary { - // Vendored-state GC FIRST: it reverts manifest-dropped and - // lockfile-unused vendored entries, dropping the latter's manifest - // entries — so the manifest prune + blob sweep below reclaims their - // blobs in this same pass (and the stale `vendored` exemption set is - // harmless: the entries it would exempt are already gone). - let vendor_gc = super::vendor::run_vendor_gc(common, manifest_path, /*dry_run=*/ false).await; - - // Re-read the just-written manifest (the apply step may have added - // or updated entries we now want to consider for pruning). - let mut manifest = match read_manifest(manifest_path).await { - Ok(Some(m)) => m, - _ => { - let mut gc = GcSummary::default(); - gc.absorb_vendor_gc(vendor_gc); - return gc; - } - }; - let prunable = detect_prunable(&manifest, scanned_purls, vendored); - for purl in &prunable { - manifest.patches.remove(purl); - } - if !prunable.is_empty() { - // If pruning failed mid-write the manifest may be stale, but the - // file-level cleanup below still operates on the in-memory copy. - let _ = write_manifest(manifest_path, &manifest).await; - } - let mut gc = run_gc(&manifest, prunable, socket_dir, /*dry_run=*/ false).await; - gc.absorb_vendor_gc(vendor_gc); - gc -} - -/// Dry-run preview of the apply-mode GC pass. Same shape as -/// [`run_apply_gc`] but emits `prunable*`/`orphan*` field names and -/// performs no mutation. -async fn preview_apply_gc( - common: &crate::args::GlobalArgs, - manifest_path: &Path, - socket_dir: &Path, - scanned_purls: &HashSet, - vendored: &HashSet, -) -> GcSummary { - // Read-only preview of the vendored-state GC (lists, never reverts). - let vendor_gc = super::vendor::run_vendor_gc(common, manifest_path, /*dry_run=*/ true).await; - - let mut manifest = match read_manifest(manifest_path).await { - Ok(Some(m)) => m, - _ => { - let mut gc = GcSummary::default(); - gc.absorb_vendor_gc(vendor_gc); - return gc; - } - }; - // Mirror the wet pass: an unused vendored entry's manifest keys are - // dropped before the blob sweep, so drop them from the in-memory copy - // too — otherwise the preview under-reports orphan blobs/bytes - // relative to what the real `--prune` run frees. - for purl in &vendor_gc.unused_reverted { - let base = strip_purl_qualifiers(purl).to_string(); - manifest - .patches - .retain(|k, _| k != purl && strip_purl_qualifiers(k) != base); - } - let prunable = detect_prunable(&manifest, scanned_purls, vendored); - // Mirror `run_apply_gc`: drop the prunable entries from the manifest - // *before* computing orphans (no write — this is the preview). The - // cleanup helpers derive the "referenced" blob/archive set from the - // manifest they're handed, so leaving the prunable entries in place - // would keep their blobs marked as used and the preview would - // under-report `orphan*`/`bytesReclaimable` relative to what the real - // `--prune`/`--sync` run actually frees. - for purl in &prunable { - manifest.patches.remove(purl); - } - let mut gc = run_gc(&manifest, prunable, socket_dir, /*dry_run=*/ true).await; - gc.absorb_vendor_gc(vendor_gc); - gc -} - -/// PURL strings present in the manifest but absent from `scanned_purls`. -/// These are candidates for pruning during `scan`'s GC pass — they -/// correspond to packages that were once patched but are no longer -/// installed (or no longer reachable to the crawler). Pure / no I/O so -/// it's unit-testable. -/// -/// Comparison is on the **base** PURL (qualifiers stripped) on both -/// sides: the pypi crawler reports base PURLs, but a manifest may hold -/// several qualified release variants (`?artifact_id=...`) of one -/// installed package. Matching on the base keeps every variant of an -/// installed package while still pruning all variants of one that is -/// gone — otherwise `scan --all-releases --sync` would prune the very -/// variants it just downloaded. -/// -/// `vendored` (the ledger's purl-key set, see `vendored_purl_keys`) is -/// always exempt: a vendored package is consumed from the committed -/// `.socket/vendor/` artifact, so the crawler not finding an installed -/// copy is its NORMAL state, not "no longer installed". Without this, a -/// wiped node_modules would prune the manifest entry — and the next -/// `vendor` run would then reconcile-revert the vendoring itself. -/// -/// Both sides are compared in percent-DECODED form (`normalize_purl`): -/// manifest keys come from the API encoded (`pkg:npm/%40scope/x@1`) while -/// crawler purls carry the literal `@scope` — comparing the raw strings -/// would make every encoded scoped entry look prunable and `--prune`/ -/// `--sync` would GC the very patch it just downloaded. -pub(crate) fn detect_prunable( - manifest: &PatchManifest, - scanned_purls: &HashSet, - vendored: &HashSet, -) -> Vec { - let scanned_bases: HashSet = scanned_purls - .iter() - .map(|p| normalize_purl(strip_purl_qualifiers(p)).into_owned()) - .collect(); - manifest - .patches - .keys() - .filter(|p| { - let base = normalize_purl(strip_purl_qualifiers(p)); - !scanned_bases.contains(base.as_ref()) - && !vendored.contains(p.as_str()) - && !vendored.contains(strip_purl_qualifiers(p)) - }) - .cloned() - .collect() -} - -/// Lockfile-only packages: dependencies the project's lockfile resolves -/// that have no crawled (installed) counterpart. -#[derive(Default)] -struct LockfileSupplement { - packages: Vec, - /// Literal crawler-form purls, for fast membership tests. - purls: HashSet, - /// The lockfile the entries came from, for messages. - source: &'static str, -} - -/// Inventory the project's lockfile(s) and fabricate crawl entries for -/// dependencies that are not installed. The fabricated `path` is the -/// WOULD-BE install dir — every consumer degrades safely on a nonexistent -/// path (hash verify → NotFound, apply → partitioned skip, vendor → -/// auto-fetch). Global scans target the machine's global tree, not this -/// project's lockfile, so they get no supplement. -async fn lockfile_supplement( - common: &GlobalArgs, - crawled: &[socket_patch_core::crawlers::types::CrawledPackage], -) -> LockfileSupplement { - use socket_patch_core::patch::vendor::lock_inventory; - - let mut out = LockfileSupplement { - source: "project lockfiles", - ..Default::default() - }; - if common.global || common.global_prefix.is_some() { - return out; - } - let entries = lock_inventory::inventory_project(&common.cwd).await; - if entries.is_empty() { - return out; - } - let crawled_purls: HashSet<&str> = crawled.iter().map(|p| p.purl.as_str()).collect(); - for entry in entries { - if crawled_purls.contains(entry.purl.as_str()) { - continue; - } - let Some(pkg) = crawled_from_purl(&entry.purl, &common.cwd) else { - continue; - }; - out.purls.insert(entry.purl.clone()); - out.packages.push(pkg); - } - out -} - -/// A displayable crawl entry fabricated from a purl (decoded form). The -/// path is a placeholder consumers degrade safely on. -fn crawled_from_purl( - purl: &str, - cwd: &std::path::Path, -) -> Option { - let decoded = normalize_purl(strip_purl_qualifiers(purl)).into_owned(); - let rest = decoded.strip_prefix("pkg:")?; - let (_eco, rest) = rest.split_once('/')?; - let at = rest.rfind('@').filter(|&i| i > 0)?; - let (name_part, version) = (&rest[..at], &rest[at + 1..]); - let (namespace, name) = match name_part.rsplit_once('/') { - Some((ns, n)) => (Some(ns.to_string()), n.to_string()), - None => (None, name_part.to_string()), - }; - Some(socket_patch_core::crawlers::types::CrawledPackage { - name, - version: version.to_string(), - namespace, - purl: decoded.clone(), - path: cwd.join("node_modules").join(name_part), - }) -} - -/// Vendored-ledger packages with no crawled counterpart: on a fresh clone -/// the committed artifact IS the dependency, so these stay discoverable -/// (updates[] detection, the table, and `scan --vendor` re-vendor/in-sync -/// runs all keep working before any install). They are NOT "lockfile-only" -/// — nothing needs installing; the artifact satisfies the lock. -async fn vendored_ledger_supplement( - common: &GlobalArgs, - crawled: &[socket_patch_core::crawlers::types::CrawledPackage], -) -> Vec { - if common.global || common.global_prefix.is_some() { - return Vec::new(); - } - let Ok(state) = socket_patch_core::patch::vendor::load_state(&common.cwd).await else { - return Vec::new(); - }; - let crawled_norm: HashSet = crawled - .iter() - .map(|p| normalize_purl(&p.purl).into_owned()) - .collect(); - let mut seen: HashSet = HashSet::new(); - let mut out = Vec::new(); - for entry in state.entries.values() { - let base = strip_purl_qualifiers(&entry.base_purl); - let norm = normalize_purl(base).into_owned(); - if crawled_norm.contains(&norm) || !seen.insert(norm) { - continue; - } - if let Some(pkg) = crawled_from_purl(base, &common.cwd) { - out.push(pkg); - } - } - out.sort_by(|a, b| a.purl.cmp(&b.purl)); - out -} - -/// Vendor-mode pre-prompt check: uuids of selected patches whose installed -/// files match NEITHER beforeHash nor afterHash — the patch was built -/// against different bytes than the installed artifact. Vendoring still -/// succeeds for these (the vendor stage force-applies the verified patched -/// content; see `force_apply_staged`), but the user should learn it BEFORE -/// the confirm prompt, not from a post-hoc warning event. -/// -/// Best-effort and read-only: a detail-fetch failure or an unresolvable -/// installed path just skips the annotation — it never blocks the flow and -/// writes nothing (unlike `download_patch_records`, which stages blobs). -async fn preverify_vendor_baselines( - api_client: &socket_patch_core::api::client::ApiClient, - org_slug: Option<&str>, - selected: &[PatchSearchResult], - crawled: &[socket_patch_core::crawlers::types::CrawledPackage], - lockfile_only: &HashSet, -) -> HashSet { - use socket_patch_core::manifest::schema::PatchFileInfo; - use socket_patch_core::patch::apply::{verify_file_patch, VerifyStatus}; - use socket_patch_core::utils::purl::purl_eq; - - let mut mismatched: HashSet = HashSet::new(); - for patch in selected { - // API purls come percent-encoded, crawler purls literal — purl_eq - // bridges the two spellings. - let base = strip_purl_qualifiers(&patch.purl); - // Lockfile-only packages have no installed bytes to compare — the - // vendor engine fetches them pristine (nothing to annotate). - if lockfile_only.contains(normalize_purl(base).as_ref()) { - continue; - } - let Some(pkg) = crawled.iter().find(|c| purl_eq(&c.purl, base)) else { - continue; - }; - let Ok(Some(detail)) = api_client.fetch_patch(org_slug, &patch.uuid).await else { - continue; - }; - for (file, info) in &detail.files { - let info = PatchFileInfo { - before_hash: info.before_hash.clone().unwrap_or_default(), - after_hash: info.after_hash.clone().unwrap_or_default(), - }; - if info.before_hash.is_empty() { - continue; // a new file has no baseline to compare - } - if verify_file_patch(&pkg.path, file, &info).await.status == VerifyStatus::HashMismatch - { - mismatched.insert(patch.uuid.clone()); - break; - } - } - } - mismatched -} - -/// Cross-reference an existing manifest against discovery results to find -/// PURLs whose newest available patch UUID differs from the locally-recorded -/// one. Used by both the discovery JSON path and the table-print path. -/// Pure / no I/O so it's unit-testable. -pub(crate) fn detect_updates( - existing_manifest: Option<&PatchManifest>, - packages: &[BatchPackagePatches], -) -> Vec { - let Some(manifest) = existing_manifest else { - return Vec::new(); - }; - let mut updates = Vec::new(); - for pkg in packages { - let Some(existing) = manifest.patches.get(&pkg.purl) else { - continue; - }; - // Treat the first patch in the batch as the candidate the apply path - // would resolve to (mirrors `select_patches` ordering — newest-first - // for paid users, single-patch auto-select for free). - let Some(candidate) = pkg.patches.first() else { - continue; - }; - if candidate.uuid != existing.uuid { - updates.push(UpdateInfo { - purl: pkg.purl.clone(), - old_uuid: existing.uuid.clone(), - new_uuid: candidate.uuid.clone(), - }); - } - } - updates -} - -/// Collect the deduplicated CVE and GHSA identifiers across every patch of -/// a package, for the scan table's VULNERABILITIES column. CVEs are listed -/// before GHSAs and each group is sorted, so the rendered output is stable — -/// the per-patch ID lists and set-based dedup are otherwise nondeterministic -/// in order. Pure / no I/O so it's unit-testable. -pub(crate) fn collect_vuln_ids(pkg: &BatchPackagePatches) -> Vec { - let mut cves: HashSet = HashSet::new(); - let mut ghsas: HashSet = HashSet::new(); - for patch in &pkg.patches { - for cve in &patch.cve_ids { - cves.insert(cve.clone()); - } - for ghsa in &patch.ghsa_ids { - ghsas.insert(ghsa.clone()); - } - } - let mut cves: Vec = cves.into_iter().collect(); - cves.sort(); - let mut ghsas: Vec = ghsas.into_iter().collect(); - ghsas.sort(); - cves.into_iter().chain(ghsas).collect() -} - -/// The three patch-application modes `scan` can drive, selectable via -/// `--mode` (the documented spelling). Each variant is equivalent to one -/// legacy boolean flag, which remains supported as an alias. -#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] -pub enum ScanMode { - /// Rewrite lockfiles so ONLY patched dependencies resolve to Socket's - /// hosted patch server (== `--redirect`): no artifact bytes land in the - /// repo, but installs must reach the patch server. - Hosted, - /// Commit patched artifacts to `.socket/vendor/` (== `--vendor`): - /// hermetic, offline-safe installs at the cost of repo size. - Vendored, - /// Record patches in `.socket/manifest.json` + blobs and re-apply them - /// in place, e.g. from CI (== `--apply`): smallest repo footprint, but - /// every install environment must run the agent. - Agent, -} - -impl ScanMode { - /// The CLI spelling of the variant (`--mode `), for error messages. - fn cli_name(self) -> &'static str { - match self { - ScanMode::Hosted => "hosted", - ScanMode::Vendored => "vendored", - ScanMode::Agent => "agent", - } - } -} - -/// Fold `--mode` into the legacy boolean spellings (`--redirect` / -/// `--vendor` / `--apply`) so everything downstream keeps a single source -/// of truth, and enforce the cross-flag rules clap cannot express: -/// -/// * `--mode X` combined with a boolean belonging to a DIFFERENT mode is a -/// contradiction → `Err`. Clap's `conflicts_with` is value-independent — -/// it could not allow `--mode vendored --vendor` while rejecting -/// `--mode hosted --vendor` — so the check lives here. -/// * The same mode spelled both ways (`--mode vendored --vendor`) is -/// redundant but accepted: both spellings mean one thing. -/// * `--sync` implies `--apply`, so it counts as an agent-mode spelling; -/// `--prune` is an orthogonal GC knob and never conflicts. -/// * `--detached` requires vendored mode in either spelling. The former -/// clap-level `requires = "vendor"` couldn't see `--mode vendored`, so -/// the requirement moved here too. -/// -/// Public (not `pub(crate)`) so the CLI-contract tests can exercise the -/// fold without driving a full `run()`. -pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { - if let Some(mode) = args.mode { - // First boolean that selects a mode OTHER than the requested one. - let mut conflicting: Option<&'static str> = None; - if args.redirect && mode != ScanMode::Hosted { - conflicting = Some("--redirect"); - } - if args.vendor && mode != ScanMode::Vendored { - conflicting = Some("--vendor"); - } - if args.apply && mode != ScanMode::Agent { - conflicting = Some("--apply"); - } - if args.sync && mode != ScanMode::Agent { - conflicting = Some("--sync"); - } - if let Some(flag) = conflicting { - // "cannot be used with" phrasing matches clap's conflict errors — - // the scan_vendor_e2e contract test accepts exactly that shape. - return Err(format!( - "--mode {} cannot be used with {flag}: the flags select different \ - modes (hosted == --redirect, vendored == --vendor, agent == --apply/--sync)", - mode.cli_name(), - )); - } - match mode { - ScanMode::Hosted => args.redirect = true, - ScanMode::Vendored => args.vendor = true, - ScanMode::Agent => args.apply = true, - } - } - if args.detached && !args.vendor { - // "required" phrasing matches clap's requires errors — the - // scan_vendor_e2e contract test accepts exactly that shape. - return Err( - "--detached requires vendored mode: --mode vendored or --vendor is required" - .to_string(), - ); - } - Ok(()) -} - -#[derive(Args)] -pub struct ScanArgs { - #[command(flatten)] - pub common: GlobalArgs, - - /// Number of packages to query per API request. - #[arg(long = "batch-size", env = "SOCKET_BATCH_SIZE", default_value_t = DEFAULT_BATCH_SIZE)] - pub batch_size: usize, - - /// Download and apply selected patches in JSON mode (non-interactive). - /// Without this flag, `scan --json` is read-only — it lists available - /// patches plus an `updates` array but does not mutate the manifest. - /// Designed for unattended workflows (cron jobs, bots that open PRs); - /// pair with `--yes` for clarity though `--json` already implies non- - /// interactive confirmation. No effect outside `--json` mode (the - /// non-JSON path always prompts the user). `--mode agent` is the - /// documented spelling of this mode. - #[arg(long, default_value_t = false)] - pub apply: bool, - - /// Garbage-collect after the scan: prune manifest entries for - /// packages no longer present in the crawl, then delete orphan - /// blob, diff, and package-archive files from `.socket/`. Off by - /// default to preserve manifest state across temporary uninstalls; - /// pair with `--apply` (or use `--sync`) for the auto-update - /// workflow. - #[arg(long, default_value_t = false)] - pub prune: bool, - - /// Convenience flag for the auto-update workflow: implies both - /// `--apply` and `--prune`. Designed so a cron job or CI workflow - /// can run `socket-patch scan --json --sync --yes` and end up in a - /// fully-reconciled state in one invocation. - #[arg(long, default_value_t = false)] - pub sync: bool, - - /// Vendor every patched dependency into the committable - /// `.socket/vendor/` tree instead of applying patches in place: - /// download the selected patches, record them in the manifest, then - /// build + wire the vendored artifacts (the whole manifest is - /// vendored, so a package vendored at an older patch uuid is - /// re-vendored automatically). Conflicts with `--apply`/`--sync` - /// (vendoring replaces the in-place apply); combine with `--prune` - /// to drop uninstalled entries before they fail vendoring. JSON mode - /// is non-interactive like `--apply`; the interactive path prompts - /// before downloading. `--mode vendored` is the documented spelling - /// of this mode. - #[arg(long, default_value_t = false, conflicts_with_all = ["apply", "sync"])] - pub vendor: bool, - - /// With vendored mode (`--mode vendored` / `--vendor`): do not write - /// `.socket/manifest.json` entries — the vendor ledger - /// (`.socket/vendor/state.json`) carries an embedded copy of each - /// patch record instead. Detached patches are invisible to - /// apply/rollback/repair (nothing is in the manifest); they are - /// undone per-purl via `remove ` or wholesale via - /// `vendor --revert`, and are exempt from `vendor`'s manifest - /// reconcile. The vendored-mode requirement is enforced in - /// `resolve_mode_flags` (not clap `requires`) so `--mode vendored` - /// satisfies it too. - #[arg(long, default_value_t = false)] - pub detached: bool, - - /// Redirect every patched dependency to Socket's HOSTED vendored patches - /// by rewriting lockfiles/registry configs so ONLY the patched dependency - /// points at the patch-server (`--patch-server-url`), instead of applying - /// patches in place or ejecting local artifacts. This is the remote - /// counterpart of `--vendor`: no artifact bytes land in the repo — the - /// lockfile pins the hosted URL + integrity (npm/pypi/composer) or a - /// per-dependency registry override (cargo/nuget/gem/…). Conflicts with - /// `--apply`/`--sync`/`--vendor`. Hidden from help: the flag is - /// unreleased and `--mode hosted` is the documented spelling. - #[arg(long, default_value_t = false, hide = true, conflicts_with_all = ["apply", "sync", "vendor"])] - pub redirect: bool, - - /// How discovered patches are consumed — the documented selector for - /// the three modes (each is equivalent to one boolean flag, kept as an - /// alias): - /// - /// * `hosted` (== `--redirect`): rewrite lockfiles so only patched - /// dependencies resolve to Socket's hosted patch server — no - /// artifact bytes in the repo, but installs must reach the server. - /// * `vendored` (== `--vendor`): commit patched artifacts under - /// `.socket/vendor/` — hermetic, offline-safe installs at the cost - /// of repo size. - /// * `agent` (== `--apply`): record patches in `.socket/manifest.json` - /// plus blobs and re-apply in place — smallest repo footprint, but - /// every environment must run the agent. - /// - /// Combining `--mode` with a boolean flag from a DIFFERENT mode is - /// rejected (see `resolve_mode_flags`); the same mode spelled both - /// ways is accepted. - #[arg(long = "mode", value_enum)] - pub mode: Option, - - /// Download patches for every release/distribution variant of a - /// matched package, not just the one(s) matching the locally- - /// installed distribution. Affects ecosystems with per-release - /// variants — PyPI (wheel/sdist via `artifact_id`), RubyGems - /// (`platform`), and Maven (`classifier`). Off by default: narrow - /// scans store only the patch(es) for the installed dist, keeping - /// `.socket/` small; `--all-releases` makes the manifest portable - /// across environments (e.g. cross-platform CI caches). - #[arg( - long = "all-releases", - env = "SOCKET_ALL_RELEASES", - default_value_t = false, - value_parser = clap::builder::BoolishValueParser::new(), - )] - pub all_releases: bool, - - /// On a successful scan, also generate an OpenVEX 0.2.0 document. - /// `--vex ` is the trigger; the `--vex-*` knobs mirror the - /// standalone `vex` command. The document is built from the manifest - /// as it stands after the scan (including any `--apply`/`--sync` - /// writes) and verified against on-disk state. A requested-but-failed - /// VEX makes the command exit non-zero. - #[command(flatten)] - pub vex: VexEmbedArgs, -} - -/// Embedded-VEX side-effect for `scan`'s JSON terminal returns. When -/// `--vex` was requested and `base_code` is 0, generate the OpenVEX -/// document from the post-scan manifest and fold the outcome into -/// `result` — a `vex` object on success, or `status: "error"` + `error` -/// on failure (per the fail-the-command contract). Returns the final exit -/// code: `base_code` when not requested / skipped / on VEX success, `1` -/// when VEX generation failed. Caller prints `result` after this returns. -async fn embed_vex_into_json( - common: &GlobalArgs, - vex_args: &VexEmbedArgs, - manifest_path: &Path, - base_code: i32, - result: &mut serde_json::Value, -) -> i32 { - if vex_args.vex.is_none() || base_code != 0 { - return base_code; - } - let params = vex_args.to_build_params(); - match generate_vex_from_manifest_path(common, ¶ms, manifest_path).await { - Ok(summary) => { - result["vex"] = serde_json::json!({ - "path": vex_args.vex.as_ref().unwrap().display().to_string(), - "statements": summary.statements, - "format": "openvex-0.2.0", - }); - 0 - } - Err(e) => { - result["status"] = serde_json::json!("error"); - result["error"] = serde_json::json!({ - "code": e.code, - "message": e.message, - }); - 1 - } - } -} - -/// Embedded-VEX side-effect for `scan`'s human-readable terminal returns. -/// Prints a one-line note (or error) and returns the final exit code: -/// `base_code` when not requested / skipped / on VEX success, `1` on VEX -/// failure. No-op unless `--vex` was set and `base_code` is 0. -async fn embed_vex_human( - common: &GlobalArgs, - vex_args: &VexEmbedArgs, - manifest_path: &Path, - base_code: i32, -) -> i32 { - if vex_args.vex.is_none() || base_code != 0 { - return base_code; - } - let params = vex_args.to_build_params(); - match generate_vex_from_manifest_path(common, ¶ms, manifest_path).await { - Ok(summary) => { - if !common.silent { - println!( - "Wrote OpenVEX document with {} statement(s) to {}", - summary.statements, - vex_args.vex.as_ref().unwrap().display(), - ); - } - 0 - } - Err(e) => { - if !common.silent { - eprintln!("Error: VEX generation failed: {}", e.message); - } - 1 - } - } -} - -/// Dry-run preview for `scan --vendor`: classify each selected patch -/// against the vendor ledger without touching disk or the network beyond -/// discovery. Action values are part of the CLI contract: -/// `would_vendor` (no ledger entry), `already_vendored` (entry at this -/// uuid), `would_revendor` + `oldUuid` (entry at an older uuid). -async fn preview_vendor_json(cwd: &Path, selected: &[PatchSearchResult]) -> serde_json::Value { - let state = socket_patch_core::patch::vendor::load_state(cwd) - .await - .unwrap_or_default(); - let mut patches: Vec = selected - .iter() - .map(|p| { - let entry = state - .entries - .get(&p.purl) - .or_else(|| state.entries.values().find(|e| e.base_purl == p.purl)); - match entry { - Some(e) if e.uuid == p.uuid => serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "already_vendored", - }), - Some(e) => serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, - "action": "would_revendor", "oldUuid": e.uuid, - }), - None => serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "would_vendor", - }), - } - }) - .collect(); - patches.sort_by(|a, b| a["purl"].as_str().cmp(&b["purl"].as_str())); - serde_json::json!({ "dryRun": true, "patches": patches }) -} - -/// The vendor step shared by `scan --vendor`'s JSON and interactive -/// paths: acquire the apply lock, stage patch sources, and drive -/// [`vendor_records`] — manifest mode (`detached_records: None`, records -/// come from re-reading the manifest, preceded by the same reconcile as -/// the `vendor` command) or detached mode (`Some(records)` from -/// [`download_patch_records`]; no manifest involvement at all). -/// -/// `Ok((has_errors, envelope))` on a run that reached the engine; -/// `Err((code, message))` for the lock/stage/manifest failures the -/// caller folds into its own output shape (scan's ad-hoc JSON can't use -/// `acquire_or_emit`, which prints an Envelope). -async fn run_scan_vendor_step( - common: &GlobalArgs, - manifest_path: &Path, - socket_dir: &Path, - detached_records: Option<&HashMap>, -) -> Result<(bool, Envelope), (&'static str, String)> { - // The download phase created `.socket/` already in every flow that - // reaches here, but `acquire` deliberately refuses to mkdir. - if let Err(e) = tokio::fs::create_dir_all(socket_dir).await { - return Err(("socket_dir_unwritable", e.to_string())); - } - let guard = apply_lock::acquire( - socket_dir, - Duration::from_secs(common.lock_timeout.unwrap_or(0)), - ) - .map_err(|e| match e { - apply_lock::LockError::Held => ( - "lock_held", - "another socket-patch process is operating in this directory".to_string(), - ), - apply_lock::LockError::Io { .. } => ("lock_io", e.to_string()), - })?; - - let mut env = Envelope::new(EnvelopeCommand::Vendor); - env.dry_run = common.dry_run; - let has_errors = match detached_records { - Some(records) => { - // Staging probes blobs by the records' hashes; a synthetic - // manifest view is all it needs. - let synth = PatchManifest { - patches: records.clone(), - setup: None, - }; - let staged = - match stage_vendor_sources_in_memory(common, &synth, socket_dir, &common.cwd).await - { - Ok(MemStageOutcome::Ready(s)) => s, - Ok(MemStageOutcome::Unavailable) => { - return Err(( - "no_local_source", - "patch artifacts unavailable (offline or download failure)".to_string(), - )) - } - Err(e) => return Err(("stage_failed", e)), - }; - let sources = staged.as_patch_sources(); - boxed_vendor_records(common, records, &sources, true, &mut env).await - } - None => { - let manifest = match read_manifest(manifest_path).await { - Ok(Some(m)) => m, - Ok(None) => { - // No manifest ⇒ nothing downloaded and nothing - // pre-existing to vendor: a clean no-op. - drop(guard); - return Ok((false, env)); - } - Err(e) => return Err(("invalid_manifest", e.to_string())), - }; - // Same placement as the `vendor` command: dropped entries - // are reverted even when zero in-scope patches remain. - let mut has_errors = reconcile_dropped(&manifest, common, &mut env).await; - let staged = - match stage_vendor_sources_in_memory(common, &manifest, socket_dir, &common.cwd) - .await - { - Ok(MemStageOutcome::Ready(s)) => s, - Ok(MemStageOutcome::Unavailable) => { - return Err(( - "no_local_source", - "patch artifacts unavailable (offline or download failure)".to_string(), - )) - } - Err(e) => return Err(("stage_failed", e)), - }; - let sources = staged.as_patch_sources(); - has_errors |= - boxed_vendor_records(common, &manifest.patches, &sources, false, &mut env).await; - has_errors - } - }; - drop(guard); - if has_errors { - env.mark_partial_failure(); - } - Ok((has_errors, env)) -} - -/// The `scan --vendor` JSON path: discovery → (dry-run preview | download -/// → GC → vendor engine) → embedded VEX → print `result` → exit code. -/// -/// Extracted from `run` (and called through `Box::pin`) so its sizeable -/// temporaries get their own poll frame, entered only when `--vendor` is -/// actually requested — in debug builds the enclosing frame retains stack -/// slots for never-taken branches, and `run`'s frame must fit Windows' -/// 1 MiB main-thread stack. -#[allow(clippy::too_many_arguments)] -async fn run_vendor_json_path( - args: &ScanArgs, - api_client: &socket_patch_core::api::client::ApiClient, - effective_org_slug: Option<&str>, - all_packages_with_patches: &[BatchPackagePatches], - can_access_paid_patches: bool, - result: &mut serde_json::Value, - manifest_path: &Path, - socket_dir: &Path, - scanned_purls: &HashSet, - vendored_purls: &HashSet, - prune: bool, - telemetry_token: Option<&str>, - telemetry_org: Option<&str>, -) -> i32 { - // Same discovery as `--apply`: per-package search, then the - // selection logic that resolves the newest accessible patch. - // Vendored purls are NOT filtered here — re-vendoring a stale - // uuid is the point of the flag (same-uuid re-runs land on the - // backend's `already_vendored` skip). - let mut all_search_results: Vec = Vec::new(); - for pkg in all_packages_with_patches { - match api_client - .search_patches_by_package(effective_org_slug, &pkg.purl) - .await - { - Ok(response) => all_search_results.extend(response.patches), - Err(_) => continue, - } - } - let selected = if all_search_results.is_empty() { - Vec::new() - } else { - match select_patches(&all_search_results, can_access_paid_patches, false) { - Ok(s) => s, - Err(code) => return code, - } - }; - - if args.common.dry_run { - // No downloads, no backends: classify against the ledger - // and preview the GC, exactly like `--apply`'s dry run. - result["vendor"] = preview_vendor_json(&args.common.cwd, &selected).await; - if prune { - let gc = preview_apply_gc( - &args.common, - manifest_path, - socket_dir, - scanned_purls, - vendored_purls, - ) - .await; - result["gc"] = gc.to_preview_json(); - } - let final_code = - embed_vex_into_json(&args.common, &args.vex, manifest_path, 0, result).await; - println!("{}", serde_json::to_string_pretty(&result).unwrap()); - return final_code; - } - - // 1) Download phase. Manifest mode reuses the `--apply` - // download (with `save_only` — the nested apply::run never - // fires); detached mode fetches records without touching - // the manifest. Either way the vendor step still runs when - // zero patches were downloaded (re-vendor after a wipe). - let params = DownloadParams { - cwd: args.common.cwd.clone(), - org: args.common.org.clone(), - save_only: true, - one_off: false, - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - json: true, - silent: true, - download_mode: args.common.download_mode.clone(), - api_overrides: args.common.api_client_overrides(), - all_releases: args.all_releases, - strict: args.common.strict, - persist_blobs: !args.vendor, - }; - let mut has_errors = false; - let detached_records: Option> = if args.detached { - let (code, mut dl_json, records) = boxed_download_patch_records(&selected, ¶ms).await; - has_errors |= code != 0; - if let Some(obj) = dl_json.as_object_mut() { - obj.remove("status"); - } - result["download"] = dl_json; - Some(records) - } else if selected.is_empty() { - result["download"] = serde_json::json!({ - "found": 0, "downloaded": 0, "skipped": 0, - "failed": 0, "patches": [], - }); - None - } else { - let (code, mut dl_json) = boxed_download_and_apply(&selected, ¶ms).await; - has_errors |= code != 0; - if let Some(obj) = dl_json.as_object_mut() { - obj.remove("status"); - // save_only: the nested apply never ran, so the - // `applied` count is structurally zero — drop it - // rather than report a misleading 0-applied. - obj.remove("applied"); - } - result["download"] = dl_json; - None - }; - - // 2) GC BEFORE the vendor step (when --prune): stale manifest - // entries would otherwise fail vendoring with - // package_not_installed; vendored entries are exempt from - // the prune itself. - if prune { - let gc = run_apply_gc( - &args.common, - manifest_path, - socket_dir, - scanned_purls, - vendored_purls, - ) - .await; - result["gc"] = gc.to_apply_json(); - } - - // 3) The vendor engine, under the same lock as apply/vendor. - let vendor_code = match boxed_scan_vendor_step( - &args.common, - manifest_path, - socket_dir, - detached_records.as_ref(), - ) - .await - { - Ok((vendor_errors, venv)) => { - has_errors |= vendor_errors; - track_outcomes_for_vendor( - vendor_errors, - &venv, - args.common.dry_run, - telemetry_token, - telemetry_org, - ) - .await; - result["vendor"] = - serde_json::to_value(&venv).unwrap_or_else(|_| serde_json::json!({})); - i32::from(has_errors) - } - Err((code, message)) => { - track_patch_vendor_failed( - &message, - args.common.dry_run, - telemetry_token, - telemetry_org, - ) - .await; - result["status"] = serde_json::json!("error"); - result["error"] = serde_json::json!({ - "code": code, - "message": message, - }); - println!("{}", serde_json::to_string_pretty(&result).unwrap()); - return 1; - } - }; - if vendor_code != 0 { - result["status"] = serde_json::json!("partial_failure"); - } - - let final_code = - embed_vex_into_json(&args.common, &args.vex, manifest_path, vendor_code, result).await; - println!("{}", serde_json::to_string_pretty(&result).unwrap()); - final_code -} - -/// The `scan --vendor` interactive arm: download (manifest or detached -/// mode) → pre-vendor GC → vendor engine, with human-readable output. -/// Extracted + boxed for the same Windows-1-MiB-poll-frame reason as -/// [`run_vendor_json_path`]. -#[allow(clippy::too_many_arguments)] -async fn run_vendor_interactive_path( - args: &ScanArgs, - selected: &[PatchSearchResult], - params: &DownloadParams, - manifest_path: &Path, - socket_dir: &Path, - scanned_purls: &HashSet, - vendored_purls: &HashSet, - prune: bool, - telemetry_token: Option<&str>, - telemetry_org: Option<&str>, -) -> i32 { - let mut has_errors = false; - let detached_records: Option> = if args.detached { - let (dl_code, _, records) = boxed_download_patch_records(selected, params).await; - has_errors |= dl_code != 0; - Some(records) - } else { - if !selected.is_empty() { - let (dl_code, _) = boxed_download_and_apply(selected, params).await; - has_errors |= dl_code != 0; - } - None - }; - // GC before the vendor step (see the JSON path): stale manifest - // entries would fail vendoring with package_not_installed. - if prune { - let gc = run_apply_gc( - &args.common, - manifest_path, - socket_dir, - scanned_purls, - vendored_purls, - ) - .await; - if !gc.pruned.is_empty() { - println!("GC: pruned {} manifest entr{}.", gc.pruned.len(), { - if gc.pruned.len() == 1 { - "y" - } else { - "ies" - } - }); - } - if !gc.vendored_reverted.is_empty() || gc.vendor_orphan_dirs > 0 { - println!( - "GC: reverted {} vendored entr{}; swept {} orphan vendor dir{}.", - gc.vendored_reverted.len(), - if gc.vendored_reverted.len() == 1 { - "y" - } else { - "ies" - }, - gc.vendor_orphan_dirs, - if gc.vendor_orphan_dirs == 1 { "" } else { "s" }, - ); - } - } - match boxed_scan_vendor_step( - &args.common, - manifest_path, - socket_dir, - detached_records.as_ref(), - ) - .await - { - Ok((vendor_errors, venv)) => { - has_errors |= vendor_errors; - track_outcomes_for_vendor( - vendor_errors, - &venv, - args.common.dry_run, - telemetry_token, - telemetry_org, - ) - .await; - i32::from(has_errors) - } - Err((code, message)) => { - track_patch_vendor_failed( - &message, - args.common.dry_run, - telemetry_token, - telemetry_org, - ) - .await; - eprintln!("Error ({code}): {message}"); - 1 - } - } -} - -/// Partition vendor-owned purls out of the selected set and pre-render -/// their `skipped`/`vendored` JSON records (sorted by purl). A plain fn -/// (not inlined into `run`) so the json! temporaries don't ride `run`'s -/// async poll frame — see [`run_vendor_json_path`]'s Windows-stack note. -fn partition_vendored_selected( - selected: Vec, - vendored_purls: &HashSet, -) -> (Vec, Vec) { - let is_vendored = - |p: &str| vendored_purls.contains(p) || vendored_purls.contains(strip_purl_qualifiers(p)); - let (vendored_selected, kept): (Vec<_>, Vec<_>) = - selected.into_iter().partition(|p| is_vendored(&p.purl)); - let mut vendored_records: Vec = vendored_selected - .iter() - .map(|p| { - serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, - "action": "skipped", "errorCode": "vendored", - }) - }) - .collect(); - vendored_records.sort_by(|a, b| a["purl"].as_str().cmp(&b["purl"].as_str())); - (kept, vendored_records) -} - -/// Lockfile-only patches are skipped BEFORE download in apply mode: the -/// package is not on disk to patch in place, and downloading its patch -/// into the manifest would create a not-yet-appliable entry (and flip the -/// apply path's exit code). `scan --vendor` is the route that handles them -/// (the vendor engine auto-fetches lockfile-resolved packages). Matching -/// bridges API purl encoding via `normalize_purl`. Same shape/mechanics as -/// [`partition_vendored_selected`]. -fn partition_not_installed_selected( - selected: Vec, - lockfile_only: &HashSet, -) -> (Vec, Vec) { - if lockfile_only.is_empty() { - return (selected, Vec::new()); - } - let is_lockfile_only = - |p: &str| lockfile_only.contains(normalize_purl(strip_purl_qualifiers(p)).as_ref()); - let (not_installed, kept): (Vec<_>, Vec<_>) = selected - .into_iter() - .partition(|p| is_lockfile_only(&p.purl)); - let mut records: Vec = not_installed - .iter() - .map(|p| { - serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, - "action": "skipped", "errorCode": "package_not_installed", - }) - }) - .collect(); - records.sort_by(|a, b| a["purl"].as_str().cmp(&b["purl"].as_str())); - (kept, records) -} - -/// Fold the pre-download vendored skips into the apply report returned by -/// `download_and_apply_patches`: they were "found" by discovery and -/// skipped here, never downloaded. Also strips the inner `status` (scan -/// recomputes its own). Plain fn for the same poll-frame reason as -/// [`partition_vendored_selected`]. -fn fold_vendored_skips_into_apply( - apply_obj: &mut serde_json::Value, - vendored_records: &[serde_json::Value], -) { - let Some(obj) = apply_obj.as_object_mut() else { - return; - }; - obj.remove("status"); - if vendored_records.is_empty() { - return; - } - let n = vendored_records.len() as u64; - for key in ["found", "skipped"] { - let bumped = obj.get(key).and_then(|v| v.as_u64()).unwrap_or(0) + n; - obj.insert(key.to_string(), serde_json::json!(bumped)); - } - if let Some(patches) = obj.get_mut("patches").and_then(|p| p.as_array_mut()) { - patches.extend(vendored_records.iter().cloned()); - } -} - -/// Construct the (large) vendor-JSON-path future on THIS transient frame -/// and hand `run` only the heap pointer. Writing -/// `Box::pin(run_vendor_json_path(..))` inline in `run` materializes the -/// future — which embeds the whole vendor engine — as a stack temporary in -/// `run`'s poll frame: debug builds allocate slots even for never-taken -/// branches, and that frame has to fit Windows' 1 MiB main-thread stack -/// (every plain `scan` was overflowing there). -#[allow(clippy::too_many_arguments)] -fn boxed_vendor_json_path<'a>( - args: &'a ScanArgs, - api_client: &'a socket_patch_core::api::client::ApiClient, - effective_org_slug: Option<&'a str>, - all_packages_with_patches: &'a [BatchPackagePatches], - can_access_paid_patches: bool, - result: &'a mut serde_json::Value, - manifest_path: &'a Path, - socket_dir: &'a Path, - scanned_purls: &'a HashSet, - vendored_purls: &'a HashSet, - prune: bool, - telemetry_token: Option<&'a str>, - telemetry_org: Option<&'a str>, -) -> std::pin::Pin + 'a>> { - Box::pin(run_vendor_json_path( - args, - api_client, - effective_org_slug, - all_packages_with_patches, - can_access_paid_patches, - result, - manifest_path, - socket_dir, - scanned_purls, - vendored_purls, - prune, - telemetry_token, - telemetry_org, - )) -} - -/// The interactive twin of [`boxed_vendor_json_path`] — same transient- -/// frame indirection, same Windows-stack rationale. -#[allow(clippy::too_many_arguments)] -fn boxed_vendor_interactive_path<'a>( - args: &'a ScanArgs, - selected: &'a [PatchSearchResult], - params: &'a DownloadParams, - manifest_path: &'a Path, - socket_dir: &'a Path, - scanned_purls: &'a HashSet, - vendored_purls: &'a HashSet, - prune: bool, - telemetry_token: Option<&'a str>, - telemetry_org: Option<&'a str>, -) -> std::pin::Pin + 'a>> { - Box::pin(run_vendor_interactive_path( - args, - selected, - params, - manifest_path, - socket_dir, - scanned_purls, - vendored_purls, - prune, - telemetry_token, - telemetry_org, - )) -} - -/// Transient-frame boxed constructor for [`run_scan_vendor_step`] — the -/// future embeds the entire vendor engine, and the vendor-path frames it -/// would otherwise ride must themselves fit Windows' 1 MiB main-thread -/// stack (same rationale as [`boxed_vendor_json_path`], one level down). -#[allow(clippy::type_complexity)] -fn boxed_scan_vendor_step<'a>( - common: &'a GlobalArgs, - manifest_path: &'a Path, - socket_dir: &'a Path, - detached_records: Option<&'a HashMap>, -) -> std::pin::Pin< - Box> + 'a>, -> { - Box::pin(run_scan_vendor_step( - common, - manifest_path, - socket_dir, - detached_records, - )) -} - -/// Transient-frame boxed constructors for the download-phase futures used -/// inside the vendor paths — `download_and_apply_patches`'s future embeds -/// the in-process `apply::run`, and these frames must fit Windows' 1 MiB -/// main-thread stack (same rationale as [`boxed_vendor_json_path`]). -fn boxed_download_and_apply<'a>( - selected: &'a [PatchSearchResult], - params: &'a DownloadParams, -) -> std::pin::Pin + 'a>> { - Box::pin(download_and_apply_patches(selected, params)) -} - -/// See [`boxed_download_and_apply`]. -#[allow(clippy::type_complexity)] -fn boxed_download_patch_records<'a>( - selected: &'a [PatchSearchResult], - params: &'a DownloadParams, -) -> std::pin::Pin< - Box< - dyn std::future::Future)> - + 'a, - >, -> { - Box::pin(download_patch_records(selected, params)) -} - -/// Transient-frame boxed constructor for the vendor engine itself -/// ([`vendor_records`]) — the deepest, largest future on the scan-vendor -/// chain. See [`boxed_vendor_json_path`] for the Windows-stack rationale. -fn boxed_vendor_records<'a>( - common: &'a GlobalArgs, - records: &'a HashMap, - sources: &'a socket_patch_core::patch::apply::PatchSources<'a>, - detached: bool, - env: &'a mut Envelope, -) -> std::pin::Pin + 'a>> { - // `scan --vendor` builds locally (no vendoring-service config); the - // `vendor` command is the service-download entry point. - Box::pin(vendor_records( - common, records, sources, detached, false, env, None, - )) -} - -/// Telemetry for the scan-driven vendor step (mirrors `vendor::run`'s -/// success/failure split). -async fn track_outcomes_for_vendor( - has_errors: bool, - env: &Envelope, - dry_run: bool, - token: Option<&str>, - org: Option<&str>, -) { - if has_errors { - track_patch_vendor_failed("vendor completed with failures", dry_run, token, org).await; - } else { - track_patch_vendored(env.summary.applied, dry_run, token, org).await; - } -} - -/// Candidate lockfiles / registry configs the redirect rewriters may touch — -/// read from the project when present and handed to `rewrite_registry_redirect`. -const REDIRECT_CANDIDATE_FILES: &[&str] = &[ - "package-lock.json", - "npm-shrinkwrap.json", - "pnpm-lock.yaml", - "yarn.lock", - // A berry lock's cache-config gate reads `.yarnrc.yml`; bun's text lock is - // `bun.lock` (its binary `bun.lockb` is auto-migrated in `run_redirect`). - ".yarnrc.yml", - "bun.lock", - "requirements.txt", - "uv.lock", - "Cargo.toml", - "Cargo.lock", - ".cargo/config.toml", - "composer.lock", - "nuget.config", - "packages.lock.json", - "Gemfile", - "Gemfile.lock", - "pom.xml", - // Maven Trusted Checksums files the fail-closed maven rewriter merges into - // (read so an existing user config / checksum set is preserved, not - // clobbered). - ".mvn/maven.config", - ".mvn/checksums/checksums.sha256", - // Gradle build scripts are never edited — their presence only feeds the - // maven rewriter's paste-able `exclusiveContent` snippet warning. - "settings.gradle", - "settings.gradle.kts", - "build.gradle", - "build.gradle.kts", -]; - -/// `pkg:/@` → `(type, coordinate, version)`. The -/// coordinate keeps its full slash-bearing form (npm `@scope/name`, composer -/// `vendor/pkg`, golang module path) — the rewriters treat that as the `name` -/// (their `full_name()` is `name` when `namespace` is `None`). -fn parse_purl_simple(purl: &str) -> Option<(String, String, String)> { - let stripped = socket_patch_core::utils::purl::strip_purl_qualifiers(purl); - let rest = stripped.strip_prefix("pkg:")?; - let (typ, after) = rest.split_once('/')?; - let (coord, version) = after.rsplit_once('@')?; - let name = socket_patch_core::utils::purl::percent_decode_purl_component(coord).into_owned(); - Some((typ.to_string(), name, version.to_string())) -} - -/// `scan --redirect`: resolve hosted-patch references for the selected patches, -/// then rewrite ONLY those dependencies' lockfile/registry-config entries to -/// point at the hosted vendored patches (the byte-identical counterpart of the -/// GitHub-app registry mode). No artifact bytes land in the repo. -async fn run_redirect( - args: &ScanArgs, - api_client: &socket_patch_core::api::client::ApiClient, - effective_org_slug: Option<&str>, - all_packages_with_patches: &[BatchPackagePatches], - can_access_paid_patches: bool, -) -> i32 { - use socket_patch_core::manifest::schema::PatchRecord; - use socket_patch_core::patch::redirect::{ - rewrite_registry_redirect, DepOverride, RedirectState, - }; - - // Same discovery/selection as `--apply`/`--vendor`. - let mut all_search_results: Vec = Vec::new(); - for pkg in all_packages_with_patches { - if let Ok(response) = api_client - .search_patches_by_package(effective_org_slug, &pkg.purl) - .await - { - all_search_results.extend(response.patches); - } - } - let selected = if all_search_results.is_empty() { - Vec::new() - } else { - match select_patches(&all_search_results, can_access_paid_patches, false) { - Ok(s) => s, - Err(code) => return code, - } - }; - - let mut skipped: Vec = Vec::new(); - let mut overrides: Vec = Vec::new(); - // (purl, uuid, artifact_url, registry index_url, maven suffixed version) - // per granted reference — used AFTER the rewrite to decide which deps were - // actually redirected (their target URL / index / suffixed version landed - // in a file) before persisting records or attesting anything. The last - // element is Some only for fail-closed maven overrides. - type RedirectCandidate = (String, String, String, Option, Option); - let mut candidates: Vec = Vec::new(); - - if !selected.is_empty() { - let uuids: Vec = selected.iter().map(|s| s.uuid.clone()).collect(); - let references = match api_client.fetch_registry_references(&uuids).await { - Ok(r) => r, - Err(e) => { - eprintln!("failed to resolve patch references: {e}"); - return 1; - } - }; - for sel in &selected { - let Some(reference) = references.get(&sel.uuid) else { - skipped.push(serde_json::json!({ "purl": sel.purl, "uuid": sel.uuid, "reason": "not_found" })); - continue; - }; - if reference.status != "granted" && reference.status != "reused" { - skipped.push(serde_json::json!({ "purl": sel.purl, "uuid": sel.uuid, "reason": reference.status })); - continue; - } - let purl = reference.purl.as_deref().unwrap_or(&sel.purl); - let Some((ecosystem, name, version)) = parse_purl_simple(purl) else { - skipped.push( - serde_json::json!({ "purl": purl, "uuid": sel.uuid, "reason": "bad_purl" }), - ); - continue; - }; - let Some(url) = reference.url.clone() else { - skipped.push( - serde_json::json!({ "purl": purl, "uuid": sel.uuid, "reason": "no_url" }), - ); - continue; - }; - let mut integrity = reference - .artifacts - .iter() - .find(|a| a.kind == "tarball") - .map(|a| a.integrity.clone()) - .unwrap_or_default(); - // The yarn-berry cache zip carries the `yarnBerry10c0` checksum the - // berry rewriter pins (berry verifies the zip, not the tarball). - // Merge it in and carry the zip URL (None when not stored yet). - let berry_zip = reference - .artifacts - .iter() - .find(|a| a.kind == "yarn-berry-zip"); - if let Some(c) = berry_zip.and_then(|a| a.integrity.yarn_berry10c0.clone()) { - integrity.yarn_berry10c0 = Some(c); - } - candidates.push(( - purl.to_string(), - sel.uuid.clone(), - url.clone(), - reference - .registry_override - .as_ref() - .map(|o| o.index_url.clone()), - reference - .registry_override - .as_ref() - .and_then(|o| o.identifiers.maven_suffixed_version.clone()), - )); - overrides.push(DepOverride { - ecosystem, - name, - namespace: None, - version, - token: String::new(), - patch_uuid: sel.uuid.clone(), - artifact_url: url, - berry_zip_url: berry_zip.and_then(|a| a.url.clone()), - registry_override: reference.registry_override.clone(), - integrity, - }); - } - } - - // bun.lockb auto-migration: the redirect rewriter only edits the TEXT - // lockfile, so a project locked to a binary `bun.lockb` must be re-locked - // to `bun.lock` first. `bun install --save-text-lockfile --frozen-lockfile - // --lockfile-only` writes bun.lock, DELETES bun.lockb, needs no network, - // and fails closed on drift. Dry-run only warns; a failure degrades to the - // rewriter's own presence-only refusal (the .lockb stays a candidate file). - let mut migration_warnings: Vec = Vec::new(); - let mut migration_edits: Vec = Vec::new(); - let has_lockb = args.common.cwd.join("bun.lockb").exists(); - let has_bun_lock = args.common.cwd.join("bun.lock").exists(); - if has_lockb && !has_bun_lock { - if args.common.dry_run { - migration_warnings.push(serde_json::json!({ - "code": "redirect_bun_lockb_would_migrate", - "detail": "bun.lockb would be migrated to a text bun.lock \ - (`bun install --save-text-lockfile`) before redirecting; \ - re-run without --dry-run to apply", - })); - } else { - let status = std::process::Command::new("bun") - .args([ - "install", - "--save-text-lockfile", - "--frozen-lockfile", - "--lockfile-only", - ]) - .current_dir(&args.common.cwd) - .status(); - let migrated = - matches!(status, Ok(s) if s.success()) && args.common.cwd.join("bun.lock").exists(); - if migrated { - // bun deleted bun.lockb itself. Record the removal so `--revert` - // knows the file was replaced (binary — git history is the - // restore path, so no `original` bytes are captured). - migration_edits.push(socket_patch_core::patch::redirect::FileEdit { - path: "bun.lockb".into(), - kind: "redirect_bun_lockb_migrated".into(), - action: "removed".into(), - key: None, - original: None, - new: None, - }); - } else { - migration_warnings.push(serde_json::json!({ - "code": "redirect_bun_lockb_unsupported", - "detail": "bun.lockb could not be migrated to a text bun.lock \ - (`bun install --save-text-lockfile` failed or is unavailable); \ - the redirect cannot pin a binary lockfile", - })); - } - } - } - - // Read the project's candidate files, run the rewriters. - let mut files: std::collections::BTreeMap = std::collections::BTreeMap::new(); - for name in REDIRECT_CANDIDATE_FILES { - if let Ok(content) = std::fs::read_to_string(args.common.cwd.join(name)) { - files.insert((*name).to_string(), content); - } - } - - // Rush monorepos have no root package.json/lock pair: the single pnpm - // source-of-truth lock lives at common/config/rush/pnpm-lock.yaml, and - // (when subspaces are enabled) one lock per subspace under - // common/config/subspaces//. Add them under their repo-relative - // keys — the pnpm rewriter is basename-generalized, so nested keys are - // rewritten in place, and the write-back below is already path-generic. - let mut rush_warnings: Vec = Vec::new(); - if args.common.cwd.join("rush.json").is_file() { - let mut added_rush_lock = false; - let common_lock = "common/config/rush/pnpm-lock.yaml"; - if let Ok(content) = std::fs::read_to_string(args.common.cwd.join(common_lock)) { - files.insert(common_lock.to_string(), content); - added_rush_lock = true; - } - let subspaces_dir = args.common.cwd.join("common/config/subspaces"); - if let Ok(read_dir) = std::fs::read_dir(&subspaces_dir) { - // read_dir order is unspecified — sort for deterministic output. - let mut subspace_dirs: Vec = read_dir - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) - .map(|e| e.path()) - .collect(); - subspace_dirs.sort(); - for dir in subspace_dirs { - let Some(name) = dir.file_name().and_then(|n| n.to_str()) else { - continue; - }; - let key = format!("common/config/subspaces/{name}/pnpm-lock.yaml"); - if let Ok(content) = std::fs::read_to_string(dir.join("pnpm-lock.yaml")) { - files.insert(key, content); - added_rush_lock = true; - } - } - } - - // Editing a Rush lock outside `rush update` desyncs the - // pnpmShrinkwrapHash recorded in repo-state.json. When - // preventManualShrinkwrapChanges is enabled, `rush install` then - // refuses until `rush update` refreshes that hash — but the redirect - // survives `rush update` (pnpm preserves locked resolutions for - // unchanged specifiers). Warn only when we actually touched a lock and - // the repo-state file that carries the hash is present. - if added_rush_lock - && args - .common - .cwd - .join("common/config/rush/repo-state.json") - .is_file() - { - rush_warnings.push(serde_json::json!({ - "code": "redirect_rush_repo_state_stale", - "detail": - "pnpm-lock.yaml was edited outside `rush update`; if \ - preventManualShrinkwrapChanges is enabled, `rush install` fails until \ - `rush update` refreshes repo-state.json (the redirect survives `rush \ - update`)", - })); - } - } - - let rewrite = rewrite_registry_redirect(&files, &overrides); - let rewritten: Vec = rewrite.files.keys().cloned().collect(); - - // A dep counts as REDIRECTED only if its hosted-artifact URL (or its - // per-dependency registry index URL) actually landed in the project's - // files — either written by this run or already present from an earlier - // one. A granted reference whose rewriter found nothing to edit (e.g. no - // lockfile) must NOT be recorded or attested: nothing pins the patch. - let final_texts: Vec<&String> = files - .iter() - .map(|(name, content)| rewrite.files.get(name).unwrap_or(content)) - .chain( - rewrite - .files - .iter() - .filter(|(name, _)| !files.contains_key(*name)) - .map(|(_, content)| content), - ) - .collect(); - let confirmed: Vec<(String, String)> = candidates - .iter() - .filter(|(_, _, artifact_url, index_url, suffixed_version)| { - let encoded = socket_patch_core::utils::uri::encode_uri_component(artifact_url); - final_texts.iter().any(|text| { - text.contains(artifact_url.as_str()) - // The berry rewriter writes the URL percent-encoded into the - // lock's `::__archiveUrl=` binding, so the raw form is absent. - || text.contains(encoded.as_str()) - || index_url.as_deref().is_some_and(|iu| text.contains(iu)) - // Fail-closed maven pins the globally-unique - // `-socket.` suffixed version (never the `.pom` URL), - // so match on that string. - || suffixed_version - .as_deref() - .is_some_and(|sv| text.contains(sv)) - }) - }) - .map(|(purl, uuid, _, _, _)| (purl.clone(), uuid.clone())) - .collect(); - - // Fetch the full patch view (file hashes + vulnerabilities) for each - // CONFIRMED redirect and persist it so a post-install `socket-patch vex` - // can attest the patch. A fetch failure does not undo the redirect, but - // it leaves the patch unattestable — surface it as a warning (JSON + - // stderr) so CI can detect the attestation gap and re-run. - let mut records: std::collections::BTreeMap = - std::collections::BTreeMap::new(); - let mut record_warnings: Vec = Vec::new(); - if !args.common.dry_run { - for (purl, uuid) in &confirmed { - match api_client.fetch_patch(effective_org_slug, uuid).await { - Ok(Some(resp)) => { - let (rec_purl, record) = - crate::commands::get::record_from_patch_response(&resp); - records.insert(rec_purl, record); - } - Ok(None) | Err(_) => { - record_warnings.push(serde_json::json!({ - "code": "record_fetch_failed", - "detail": format!( - "{purl} redirected, but its patch record could not be fetched; \ - it will be missing from VEX until `scan --redirect` is re-run" - ), - })); - } - } - } - } - - if !args.common.dry_run { - for (rel, content) in &rewrite.files { - let path = args.common.cwd.join(rel); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Err(e) = std::fs::write(&path, content) { - eprintln!("failed to write {rel}: {e}"); - return 1; - } - } - // Ledger (mirrors the vendor state.json shape): recorded edits for a - // future revert + the patch records (file hashes + vulnerabilities) so - // a post-install `socket-patch vex` can attest the redirected patches. - // MERGE with any existing ledger rather than overwriting: an idempotent - // re-run produces no new edits (the lockfile already points at the - // hosted patch), and clobbering the file would lose the original - // pre-redirect values a future revert needs. New edits APPEND (revert - // walks them in reverse); records are keyed by PURL, newest wins. - if !rewrite.edits.is_empty() || !records.is_empty() || !migration_edits.is_empty() { - let vendor_dir = args.common.cwd.join(".socket").join("vendor"); - let _ = std::fs::create_dir_all(&vendor_dir); - let mut ledger = - socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd) - .await - .unwrap_or_else(RedirectState::new); - // Ledgers written before the mode-string rename carry - // `"mode": "redirect"`; normalize on rewrite so the on-disk - // ledger converges on the documented "hosted" name (the - // loader accepts either — mode is an opaque string to it). - ledger.mode = "hosted".to_string(); - // The bun.lockb→bun.lock migration removal precedes the rewrite - // edits so `--revert` unwinds it last (after restoring bun.lock). - ledger.edits.extend(migration_edits.iter().cloned()); - ledger.edits.extend(rewrite.edits.iter().cloned()); - ledger.records.extend(records.clone()); - let _ = std::fs::write( - vendor_dir.join("redirect-state.json"), - format!("{}\n", serde_json::to_string_pretty(&ledger).unwrap()), - ); - } - } - - // Emit an OpenVEX attestation when `--vex` was requested. The redirected - // bytes are fetched from the hosted patch server at install time, so the - // PURLs CONFIRMED REDIRECTED BY THIS RUN are attested from the ledger - // records WITHOUT hash verification (`assume_applied` — the integrity - // pins written into the lockfile are the evidence), while any OTHER - // manifest patches (previously applied / vendored — and any stale ledger - // records this run did not confirm) still verify normally. A post-install - // `socket-patch vex` hash-verifies the redirected patches against the - // installed tree (it reads the records back from the redirect ledger via - // augment_with_redirect). Requested-but-failed VEX (including "nothing to - // attest") flips the exit code, matching `scan --vex`. - let mut vex_statements: Option = None; - let mut vex_error: Option<(&'static str, String)> = None; - let mut vex_code = 0; - if args.vex.vex.is_some() && !args.common.dry_run { - let mut params = args.vex.to_build_params(); - params.assume_applied = confirmed.iter().map(|(purl, _)| purl.clone()).collect(); - let manifest_path = args.common.resolved_manifest_path(); - match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await { - Ok(summary) => vex_statements = Some(summary.statements), - Err(e) => { - vex_code = 1; - vex_error = Some((e.code, e.message)); - } - } - } - - if args.common.json { - let mut warnings: Vec = rewrite - .warnings - .iter() - .map(|w| { - serde_json::json!({ - "code": w.code, "detail": w.detail, - }) - }) - .collect(); - warnings.extend(record_warnings.iter().cloned()); - warnings.extend(migration_warnings.iter().cloned()); - warnings.extend(rush_warnings.iter().cloned()); - let mut result = serde_json::json!({ - "status": "success", - "redirect": { - // Final mode naming: `--redirect` IS hosted mode. Additive - // key so JSON consumers can dispatch on the mode without - // inferring it from which sub-object is present. - "mode": "hosted", - "redirected": confirmed.len(), - "rewrittenFiles": rewritten, - "skipped": skipped, - "warnings": warnings, - "dryRun": args.common.dry_run, - } - }); - if let Some(statements) = vex_statements { - result["vex"] = serde_json::json!({ - "path": args.vex.vex.as_ref().unwrap().display().to_string(), - "statements": statements, - "format": "openvex-0.2.0", - "verified": false, - }); - } else if let Some((code, message)) = &vex_error { - result["status"] = serde_json::json!("error"); - result["error"] = serde_json::json!({ "code": code, "message": message }); - } - println!("{}", serde_json::to_string_pretty(&result).unwrap()); - } else if !args.common.silent { - let verb = if args.common.dry_run { - "would rewrite" - } else { - "rewrote" - }; - println!( - "Redirected {} package(s); {verb} {} file(s).", - confirmed.len(), - rewritten.len() - ); - for s in &skipped { - eprintln!(" skipped {} ({})", s["purl"], s["reason"]); - } - for w in &record_warnings { - eprintln!(" warning: {}", w["detail"]); - } - for w in &migration_warnings { - eprintln!(" warning: {}", w["detail"]); - } - for w in &rush_warnings { - eprintln!(" warning: {}", w["detail"]); - } - if let Some(statements) = vex_statements { - eprintln!( - "Wrote OpenVEX document with {} statement(s) to {} (redirected patches are \ - attested from the ledger, not hash-verified — their bytes are fetched at \ - install time; run `socket-patch vex` after installing to verify against \ - the installed tree).", - statements, - args.vex.vex.as_ref().unwrap().display(), - ); - } else if let Some((_, message)) = &vex_error { - eprintln!("Error: VEX generation failed: {message}"); - } else if args.vex.vex.is_some() && args.common.dry_run { - eprintln!("Skipping VEX generation (--dry-run)."); - } - } - vex_code -} - -pub async fn run(mut args: ScanArgs) -> i32 { - apply_env_toggles(&args.common); - - // Fold `--mode` into the legacy mode booleans before anything reads - // them, so every branch below keeps a single source of truth. Cross- - // mode combinations get a usage-style error (exit 2, matching clap's - // conflict exit code) — see `resolve_mode_flags` for why clap itself - // can't express them. - if let Err(message) = resolve_mode_flags(&mut args) { - eprintln!("error: {message}"); - return 2; - } - - // `--sync` is sugar for `--apply --prune`. Derive locals once and - // use them everywhere downstream so the flag interactions are - // expressed in one place. `--apply --prune --sync` is redundant - // but legal (all three end up true). - let apply = args.apply || args.sync; - let prune = args.prune || args.sync; - - // A zero batch size would panic the API-query loop below: both - // `all_purls.len().div_ceil(batch_size)` and `all_purls.chunks(batch_size)` - // abort the process on a divisor/chunk-size of 0. `--batch-size 0` - // (or `SOCKET_BATCH_SIZE=0`) is otherwise unvalidated, so clamp to a - // floor of 1 — degrade to one-package batches rather than crash. - let batch_size = args.batch_size.max(1); - - // Resolved up-front (rather than at the GC site) because the embedded - // `--vex` side-effect reads the manifest at several terminal returns, - // including the early "no packages" exit before the GC block. - let manifest_path = args.common.resolved_manifest_path(); - let socket_dir = manifest_path.parent().unwrap().to_path_buf(); - - let overrides = args.common.api_client_overrides(); - let (mut api_client, mut use_public_proxy) = - get_api_client_with_overrides(overrides.clone()).await; - let telemetry_token = api_client.api_token().cloned(); - let telemetry_org = api_client.org_slug().cloned(); - // Tracks whether scan was downgraded from the authenticated - // endpoint to the public proxy mid-run after a 401/403. Surfaces - // in the final `patch_scanned` telemetry event so we can measure - // how often stale-token fallbacks fire in the wild. - let mut fallback_to_proxy = false; - - // org slug is already stored in the client - let effective_org_slug: Option<&str> = None; - - let crawler_options = CrawlerOptions { - cwd: args.common.cwd.clone(), - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - batch_size, - }; - - let scan_target = if args.common.global || args.common.global_prefix.is_some() { - "global packages" - } else { - "packages" - }; - - // `--silent` is "errors only" (CLI_CONTRACT.md): progress, the crawl - // summary, the results table, and the per-patch listing are all - // suppressed below, mirroring `list`/`get`/`repair`/`remove`. Errors - // and the JSON envelope are unaffected. - let show_progress = !args.common.json && !args.common.silent && stderr_is_tty(); - - if show_progress { - eprint!("Scanning {scan_target}..."); - } - - // Crawl packages - let (mut all_crawled, mut eco_counts) = crawl_all_ecosystems(&crawler_options).await; - - // Lockfile supplement: dependencies the project's lockfile resolves - // that have NO installed copy (fresh clone, partial install). They join - // discovery — counts, API lookup, table, the prune "scanned" set — and - // are flagged "not yet installed" everywhere a user could act on them. - let lockfile_only = lockfile_supplement(&args.common, &all_crawled).await; - if !lockfile_only.packages.is_empty() { - for pkg in &lockfile_only.packages { - if let Some(eco) = Ecosystem::from_purl(&pkg.purl) { - *eco_counts.entry(eco).or_insert(0) += 1; - } - } - all_crawled.extend(lockfile_only.packages.iter().cloned()); - } - let ledger_supplement = vendored_ledger_supplement(&args.common, &all_crawled).await; - for pkg in &ledger_supplement { - if let Some(eco) = Ecosystem::from_purl(&pkg.purl) { - *eco_counts.entry(eco).or_insert(0) += 1; - } - } - all_crawled.extend(ledger_supplement); - - // Every PURL the crawl found, captured BEFORE the `--ecosystems` - // display/query filter is applied. Prune (below) must reference the - // full installed set: `--ecosystems npm` narrows what we *query and - // show*, but packages of other ecosystems are still installed. If - // prune used the filtered set instead, `scan --ecosystems npm --prune` - // would treat every cargo/go/pypi/gem manifest entry as "uninstalled" - // and delete it (plus its blobs) — silent cross-ecosystem data loss. - // Lockfile-only purls are deliberately included: a dependency the - // lockfile still resolves must not be pruned just because node_modules - // is wiped or partially installed. - let installed_purls: HashSet = all_crawled.iter().map(|p| p.purl.clone()).collect(); - - // Vendor-ledger purl keys, loaded once and shared by the prune - // exemption (a vendored package is consumed from the committed - // artifact, so "absent from the crawl" is its normal state, not - // grounds for pruning) and the vendored-skip in the apply path. - let vendored_purls = - socket_patch_core::patch::vendor::vendored_purl_keys(&args.common.cwd).await; - - // Filter by --ecosystems if provided - let filtered_crawled: Vec<_> = if let Some(ref allowed) = args.common.ecosystems { - all_crawled - .into_iter() - .filter(|pkg| { - if let Some(eco) = Ecosystem::from_purl(&pkg.purl) { - allowed.iter().any(|a| a == eco.cli_name()) - } else { - false - } - }) - .collect() - } else { - all_crawled - }; - - let all_purls: Vec = filtered_crawled.iter().map(|p| p.purl.clone()).collect(); - let package_count = all_purls.len(); - - if package_count == 0 { - if show_progress { - eprintln!(); - } - // Telemetry: empty-scan still counts as a successful scan. - track_patch_scanned( - 0, - 0, - 0, - false, - args.common - .ecosystems - .clone() - .unwrap_or_default() - .as_slice(), - false, - telemetry_token.as_deref(), - telemetry_org.as_deref(), - ) - .await; - if args.common.json { - // When the crawler finds nothing, GC is intentionally skipped - // — pruning every manifest entry on the assumption that the - // user "uninstalled everything" is too destructive. Bots - // that need full cleanup can call `repair` explicitly. No - // `gc` field emitted because the user didn't request one. - let mut result = serde_json::json!({ - "status": "success", - "scannedPackages": 0, - "lockfileOnlyPackages": 0, - "packagesWithPatches": 0, - "totalPatches": 0, - "freePatches": 0, - "paidPatches": 0, - "canAccessPaidPatches": false, - "packages": [], - "updates": [], - }); - let code = - embed_vex_into_json(&args.common, &args.vex, &manifest_path, 0, &mut result).await; - println!("{}", serde_json::to_string_pretty(&result).unwrap()); - return code; - } else if args.common.silent { - // Errors only: the empty-scan hint is informational. - } else if args.common.global || args.common.global_prefix.is_some() { - println!("No global packages found."); - } else { - #[allow(unused_mut)] - let mut install_cmds = String::from("npm/yarn/pnpm/pip"); - #[cfg(feature = "cargo")] - install_cmds.push_str("/cargo"); - #[cfg(feature = "golang")] - install_cmds.push_str("/go"); - #[cfg(feature = "maven")] - install_cmds.push_str("/mvn"); - #[cfg(feature = "composer")] - install_cmds.push_str("/composer"); - println!("No packages found. Run {install_cmds} install first."); - } - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; - } - - // Build ecosystem summary - let mut eco_parts = Vec::new(); - for eco in Ecosystem::all() { - let count = if args.common.ecosystems.is_some() { - // When filtering, count the filtered packages - filtered_crawled - .iter() - .filter(|p| Ecosystem::from_purl(&p.purl) == Some(*eco)) - .count() - } else { - eco_counts.get(eco).copied().unwrap_or(0) - }; - if count > 0 { - eco_parts.push(format!("{count} {}", eco.display_name())); - } - } - let eco_summary = if eco_parts.is_empty() { - String::new() - } else { - format!(" ({})", eco_parts.join(", ")) - }; - - if !args.common.json && !args.common.silent { - if show_progress { - eprintln!("\rFound {package_count} packages{eco_summary}"); - } else { - eprintln!("Found {package_count} packages{eco_summary}"); - } - if !lockfile_only.purls.is_empty() { - eprintln!( - "Note: {} package(s) from {} are not yet installed (lockfile-only).", - lockfile_only.purls.len(), - lockfile_only.source, - ); - } - } - - // Query API in batches - let mut all_packages_with_patches: Vec = Vec::new(); - let mut can_access_paid_patches = false; - let total_batches = all_purls.len().div_ceil(batch_size); - let mut batch_error_count = 0usize; - let mut last_batch_error: Option = None; - - if show_progress { - eprint!("Querying API for patches... (batch 1/{total_batches})"); - } - - for (batch_idx, chunk) in all_purls.chunks(batch_size).enumerate() { - if show_progress { - eprint!( - "\rQuerying API for patches... (batch {}/{})", - batch_idx + 1, - total_batches - ); - } - - let purls: Vec = chunk.to_vec(); - let mut result = api_client - .search_patches_batch(effective_org_slug, &purls) - .await; - - // Fallback: a 401/403 against the authenticated endpoint can - // mean a stale/revoked token. Retry against the public proxy - // (free patches only) once, then continue the rest of the - // loop with the downgraded client. Only triggers on the - // first authenticated batch; subsequent iterations are - // already on the proxy. - if !use_public_proxy { - if let Err(ref e) = result { - if is_fallback_candidate(e) { - eprintln!( - "Warning: authenticated API returned {e}; \ - falling back to public patch API proxy (free patches only)." - ); - api_client = build_proxy_fallback_client(&overrides); - use_public_proxy = true; - fallback_to_proxy = true; - result = api_client - .search_patches_batch(effective_org_slug, &purls) - .await; - } - } - } - - match result { - Ok(response) => { - if response.can_access_paid_patches { - can_access_paid_patches = true; - } - for pkg in response.packages { - if !pkg.patches.is_empty() { - all_packages_with_patches.push(pkg); - } - } - } - Err(e) => { - batch_error_count += 1; - last_batch_error = Some(e.to_string()); - if !args.common.json { - eprintln!("\nError querying batch {}: {e}", batch_idx + 1); - } - } - } - } - - // If every batch errored, surface this as a full scan failure rather - // than silently reporting zero patches (which historically looked - // identical to "no patches for these packages"). - if total_batches > 0 && batch_error_count == total_batches { - let err = last_batch_error.unwrap_or_else(|| "all batches failed".to_string()); - track_patch_scan_failed( - &err, - fallback_to_proxy, - telemetry_token.as_deref(), - telemetry_org.as_deref(), - ) - .await; - - // A scan in which *every* batch failed produced no trustworthy - // patch data. Surfacing `status: "success"` / exit 0 here would be - // indistinguishable from a genuine "no patches" result and would - // mask a total API outage. Report the failure explicitly and bail - // before writing any manifest or attempting apply/prune. - if args.common.json { - let result = serde_json::json!({ - "status": "error", - "error": err, - "scannedPackages": package_count, - "packagesWithPatches": 0, - "totalPatches": 0, - "freePatches": 0, - "paidPatches": 0, - "canAccessPaidPatches": false, - "packages": [], - "updates": [], - }); - println!("{}", serde_json::to_string_pretty(&result).unwrap()); - } else { - eprintln!("Error: all {total_batches} API batch queries failed: {err}"); - } - return 1; - } - - let total_patches_found: usize = all_packages_with_patches - .iter() - .map(|p| p.patches.len()) - .sum(); - - if !args.common.json && !args.common.silent { - if total_patches_found > 0 { - if show_progress { - eprintln!( - "\rFound {total_patches_found} patches for {} packages", - all_packages_with_patches.len() - ); - } else { - eprintln!( - "Found {total_patches_found} patches for {} packages", - all_packages_with_patches.len() - ); - } - } else if show_progress { - eprintln!("\rAPI query complete"); - } else { - eprintln!("API query complete"); - } - } - - // Calculate patch counts - let mut free_patches = 0usize; - let mut paid_patches = 0usize; - for pkg in &all_packages_with_patches { - for patch in &pkg.patches { - if patch.tier == "free" { - free_patches += 1; - } else { - paid_patches += 1; - } - } - } - let total_patches = free_patches + paid_patches; - - // Telemetry: record the scan outcome once we have the canonical - // per-tier counts. `fallback_to_proxy` is `true` iff the batch - // loop downgraded from the authenticated endpoint to the public - // proxy after a 401/403. - track_patch_scanned( - package_count, - free_patches, - paid_patches, - can_access_paid_patches, - args.common - .ecosystems - .clone() - .unwrap_or_default() - .as_slice(), - fallback_to_proxy, - telemetry_token.as_deref(), - telemetry_org.as_deref(), - ) - .await; - - // Registry-redirect mode is a distinct, self-contained flow (rewrite - // lockfiles → hosted vendored patches). It reuses discovery above, then - // returns — it must NOT fall through to the apply/vendor branches. - if args.redirect { - return run_redirect( - &args, - &api_client, - effective_org_slug, - &all_packages_with_patches, - can_access_paid_patches, - ) - .await; - } - - // Read existing manifest once for update detection. Used by both the - // JSON-mode emission (always includes an `updates` array) and the - // non-JSON table-print path (counts `updates_available`). - // (`manifest_path`/`socket_dir` are resolved at the top of `run`.) - let existing_manifest = read_manifest(&manifest_path).await.ok().flatten(); - let updates = detect_updates(existing_manifest.as_ref(), &all_packages_with_patches); - - // Crawl PURLs as a set for prunable detection (manifest entries whose - // PURL is not installed). Uses `installed_purls` — the UNFILTERED crawl - // — not the `--ecosystems`-narrowed `all_purls`, so a display/query - // filter never makes an installed package look prunable. - let scanned_purls: HashSet = installed_purls; - - if args.common.json { - let mut result = serde_json::json!({ - "status": "success", - "scannedPackages": package_count, - "lockfileOnlyPackages": lockfile_only.purls.len(), - "packagesWithPatches": all_packages_with_patches.len(), - "totalPatches": total_patches, - "freePatches": free_patches, - "paidPatches": paid_patches, - "canAccessPaidPatches": can_access_paid_patches, - "packages": all_packages_with_patches, - "updates": updates.iter().map(|u| serde_json::json!({ - "purl": u.purl, - "oldUuid": u.old_uuid, - "newUuid": u.new_uuid, - })).collect::>(), - }); - // Flag lockfile-only packages so JSON consumers can tell "patch - // available but not installed" from the installed case. Additive - // field; absent means installed. - if let Some(packages) = result["packages"].as_array_mut() { - for pkg in packages { - let is_lockfile_only = pkg["purl"] - .as_str() - .is_some_and(|p| lockfile_only.purls.contains(p)); - if is_lockfile_only { - pkg["notInstalled"] = serde_json::json!(true); - } - } - } - - // `apply` and `prune` are computed once at the top of run() - // (factoring in --sync, which implies both). They're independent - // here: a bot can `--apply` without `--prune`, or `--prune` - // without `--apply` (just GC-sweep), or both (full sync). - let dry = args.common.dry_run; - - // --- Apply path (if requested) ----------------------------------- - if apply { - let mut all_search_results: Vec = Vec::new(); - for pkg in &all_packages_with_patches { - match api_client - .search_patches_by_package(effective_org_slug, &pkg.purl) - .await - { - Ok(response) => all_search_results.extend(response.patches), - Err(_) => continue, - } - } - - // For scan-driven bot workflows there's no "specify --id" - // option — we're scanning the whole project. Pass - // `is_json = false` so `select_one` auto-selects the newest - // patch in non-TTY mode rather than erroring with - // `selection_required`. - let selected = if all_search_results.is_empty() { - Vec::new() - } else { - match select_patches(&all_search_results, can_access_paid_patches, false) { - Ok(s) => s, - Err(code) => return code, - } - }; - - // Vendor-owned purls are skipped BEFORE download (any uuid): - // the patch is consumed from the committed artifact, and - // moving the manifest past the vendored uuid would break VEX - // verification (`vendor_uuid_mismatch`) until a vendor run. - // A newer patch still surfaces in `updates[]` — the - // operator's signal to run `scan --vendor` (or `vendor`). - let (selected, vendored_records) = - partition_vendored_selected(selected, &vendored_purls); - // Lockfile-only purls leave the apply selection here (calm - // skip records, never an error); the union rides the same - // bookkeeping as the vendored skips. - let (selected, vendored_records) = { - let (kept, not_installed) = - partition_not_installed_selected(selected, &lockfile_only.purls); - let mut all = vendored_records; - all.extend(not_installed); - all.sort_by(|a, b| a["purl"].as_str().cmp(&b["purl"].as_str())); - (kept, all) - }; - - let mut apply_code = 0i32; - if dry { - // Synthesize the per-patch outcome without touching disk. - // `decide_patch_action` consults the existing manifest, - // so it accurately reports what `--apply` *would* do. - let manifest_for_preview = - existing_manifest.clone().unwrap_or_else(PatchManifest::new); - let mut patches: Vec = selected - .iter() - .map(|p| { - match super::get::decide_patch_action( - &manifest_for_preview, - &p.purl, - &p.uuid, - ) { - super::get::PatchAction::Added => serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "added", - }), - super::get::PatchAction::Updated { old_uuid } => serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, - "action": "updated", "oldUuid": old_uuid, - }), - super::get::PatchAction::Skipped => serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "skipped", - }), - } - }) - .collect(); - patches.extend(vendored_records.iter().cloned()); - let added = patches.iter().filter(|p| p["action"] == "added").count(); - let updated = patches.iter().filter(|p| p["action"] == "updated").count(); - let skipped = patches.iter().filter(|p| p["action"] == "skipped").count(); - result["apply"] = serde_json::json!({ - "found": selected.len() + vendored_records.len(), - "downloaded": 0, - "skipped": skipped, - "failed": 0, - "applied": 0, - "updated": updated, - "added": added, - "patches": patches, - "dryRun": true, - }); - } else if selected.is_empty() { - // No patches left to download (e.g. all paid for a free - // user, no packages had patches, or everything selected is - // vendor-owned). Emit a stable-shape `apply` carrying any - // vendored skips, then fall through to GC if requested. - result["apply"] = serde_json::json!({ - "found": vendored_records.len(), - "downloaded": 0, - "skipped": vendored_records.len(), - "failed": 0, "applied": 0, "updated": 0, - "patches": vendored_records, - }); - } else { - let params = DownloadParams { - cwd: args.common.cwd.clone(), - org: args.common.org.clone(), - save_only: false, - one_off: false, - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - json: true, - silent: true, - download_mode: args.common.download_mode.clone(), - api_overrides: args.common.api_client_overrides(), - all_releases: args.all_releases, - strict: args.common.strict, - persist_blobs: !args.vendor, - }; - let (code, apply_json) = download_and_apply_patches(&selected, ¶ms).await; - apply_code = code; - let mut apply_obj = apply_json; - fold_vendored_skips_into_apply(&mut apply_obj, &vendored_records); - result["apply"] = apply_obj; - if apply_code != 0 { - result["status"] = serde_json::json!("partial_failure"); - } - } - - // --- GC (if requested) -------------------------------------- - if prune { - let gc = if dry { - preview_apply_gc( - &args.common, - &manifest_path, - &socket_dir, - &scanned_purls, - &vendored_purls, - ) - .await - } else { - run_apply_gc( - &args.common, - &manifest_path, - &socket_dir, - &scanned_purls, - &vendored_purls, - ) - .await - }; - result["gc"] = if dry { - gc.to_preview_json() - } else { - gc.to_apply_json() - }; - } - - let final_code = embed_vex_into_json( - &args.common, - &args.vex, - &manifest_path, - apply_code, - &mut result, - ) - .await; - println!("{}", serde_json::to_string_pretty(&result).unwrap()); - return final_code; - } - - // --- Vendor path (if requested) ---------------------------------- - if args.vendor { - // Extracted into its own boxed fn — and it must STAY extracted: - // this branch's temporaries (json! trees, DownloadParams, the - // engine dispatch) live in the enclosing poll frame in debug - // builds even when the branch is never taken, and that frame - // has to fit Windows' 1 MiB main-thread stack (regression- - // pinned by `scan_run_fits_windows_main_thread_stack`). - return boxed_vendor_json_path( - &args, - &api_client, - effective_org_slug, - &all_packages_with_patches, - can_access_paid_patches, - &mut result, - &manifest_path, - &socket_dir, - &scanned_purls, - &vendored_purls, - prune, - telemetry_token.as_deref(), - telemetry_org.as_deref(), - ) - .await; - } - - // --- GC-only path (no --apply, just --prune) -------------------- - if prune { - let gc = if dry { - preview_apply_gc( - &args.common, - &manifest_path, - &socket_dir, - &scanned_purls, - &vendored_purls, - ) - .await - } else { - run_apply_gc( - &args.common, - &manifest_path, - &socket_dir, - &scanned_purls, - &vendored_purls, - ) - .await - }; - result["gc"] = if dry { - gc.to_preview_json() - } else { - gc.to_apply_json() - }; - } - - let final_code = - embed_vex_into_json(&args.common, &args.vex, &manifest_path, 0, &mut result).await; - println!("{}", serde_json::to_string_pretty(&result).unwrap()); - return final_code; - } - - let use_color = stdout_is_tty(); - - if all_packages_with_patches.is_empty() { - if !args.common.silent { - println!("\nNo patches available for installed packages."); - } - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; - } - - // The whole table + summary section is presentational only (nothing - // computed inside is consumed downstream), so `--silent` skips it - // wholesale. - if !args.common.silent { - let mut updates_available = 0usize; - - // Canonical set of PURLs with a newer patch available, computed once via - // `detect_updates` (the same source the JSON `updates` array uses). The - // table path MUST agree with the JSON path, so reuse that result rather - // than re-deriving it: comparing against *any* batch patch (instead of the - // first/candidate one `select_patches` would resolve to) over-reports - // updates whenever the manifest already holds the newest patch but older - // patches also appear in the batch. - let update_purls: HashSet<&str> = updates.iter().map(|u| u.purl.as_str()).collect(); - - // Print table - println!("\n{}", "=".repeat(100)); - println!( - "{} {} {} VULNERABILITIES", - "PACKAGE".to_string() + &" ".repeat(33), - "PATCHES".to_string() + " ", - "SEVERITY".to_string() + &" ".repeat(8), - ); - println!("{}", "=".repeat(100)); - - for pkg in &all_packages_with_patches { - // Char-safe truncation: a byte slice (`&pkg.purl[..37]`) panics - // when the cut lands mid-codepoint. PURLs can carry non-ASCII - // names/qualifiers, so route through the shared helper. - let display_purl = truncate_with_ellipsis(&pkg.purl, 40); - - let pkg_free = pkg.patches.iter().filter(|p| p.tier == "free").count(); - let pkg_paid = pkg.patches.iter().filter(|p| p.tier == "paid").count(); - - let count_str = if pkg_paid > 0 { - if can_access_paid_patches { - format!("{}+{}", pkg_free, pkg_paid) - } else { - format!( - "{}+{}", - pkg_free, - color(&pkg_paid.to_string(), "33", use_color) - ) - } - } else { - format!("{}", pkg_free) - }; - - // Get highest severity - let severity = pkg - .patches - .iter() - .filter_map(|p| p.severity.as_deref()) - .min_by_key(|s| severity_order(s)) - .unwrap_or("unknown"); - - // Collect vuln IDs (deterministic: deduped, CVEs then GHSAs, - // each group sorted — see collect_vuln_ids). - let vuln_ids = collect_vuln_ids(pkg); - let vuln_str = if vuln_ids.len() > 2 { - format!("{} (+{})", vuln_ids[..2].join(", "), vuln_ids.len() - 2) - } else if vuln_ids.is_empty() { - "-".to_string() - } else { - vuln_ids.join(", ") - }; - - // Check for updates — consult the canonical `detect_updates` result - // (mirrored into `update_purls`) so the human table and JSON `updates` - // array never disagree. - let has_update = update_purls.contains(pkg.purl.as_str()); - if has_update { - updates_available += 1; - } - - let update_marker = if has_update { - color(" [UPDATE]", "33", use_color) - } else { - String::new() - }; - // Lockfile-only packages can be patched by `scan --vendor` - // (which fetches them pristine) but not applied in place. - let not_installed_marker = if lockfile_only.purls.contains(pkg.purl.as_str()) { - color(" [NOT INSTALLED]", "33", use_color) - } else { - String::new() - }; - - println!( - "{:<40} {:>8} {:<16} {}{}{}", - display_purl, - count_str, - format_severity(severity, use_color), - vuln_str, - update_marker, - not_installed_marker, - ); - } - - println!("{}", "=".repeat(100)); - - // Summary - if can_access_paid_patches { - println!( - "\nSummary: {} package(s) with {} available patch(es)", - all_packages_with_patches.len(), - total_patches, - ); - } else { - println!( - "\nSummary: {} package(s) with {} free patch(es)", - all_packages_with_patches.len(), - free_patches, - ); - if paid_patches > 0 { - println!( - "{}", - color( - &format!( - " + {} additional patch(es) available with paid subscription", - paid_patches - ), - "33", - use_color, - ), - ); - println!( - "\nUpgrade to Socket's paid plan to access all patches: https://socket.dev/pricing" - ); - } - } - - if updates_available > 0 { - println!( - "\n{}", - color( - &format!("{updates_available} package(s) have newer patches available."), - "33", - use_color, - ), - ); - } - } - - // Count downloadable patches - let downloadable_count = if can_access_paid_patches { - all_packages_with_patches.len() - } else { - all_packages_with_patches - .iter() - .filter(|pkg| pkg.patches.iter().any(|p| p.tier == "free")) - .count() - }; - - if downloadable_count == 0 { - if !args.common.silent { - println!("\nNo downloadable patches (paid subscription required)."); - } - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; - } - - // Fetch full PatchSearchResult for each package that has patches - if show_progress { - eprint!("\nFetching patch details..."); - } - - let mut all_search_results: Vec = Vec::new(); - for (i, pkg) in all_packages_with_patches.iter().enumerate() { - if show_progress { - eprint!( - "\rFetching patch details... ({}/{})", - i + 1, - all_packages_with_patches.len() - ); - } - match api_client - .search_patches_by_package(effective_org_slug, &pkg.purl) - .await - { - Ok(response) => { - all_search_results.extend(response.patches); - } - Err(e) => { - if !args.common.silent { - eprintln!("\n Warning: could not fetch details for {}: {e}", pkg.purl); - } - } - } - } - - if show_progress { - eprintln!(); - } - - if all_search_results.is_empty() { - eprintln!("Could not fetch patch details."); - return 1; - } - - // Smart selection - let selected: Vec = - match select_patches(&all_search_results, can_access_paid_patches, false) { - Ok(s) => s, - Err(code) => return code, - }; - - // Vendor-owned purls never download/apply here (mirrors the JSON - // path): the committed artifact is the patch, and a manifest moved - // past the vendored uuid would break VEX verification until a vendor - // run refreshes the artifact. In `--vendor` mode the partition is a - // no-op — re-vendoring a stale uuid is exactly what the flag is for. - let is_vendored = - |p: &str| vendored_purls.contains(p) || vendored_purls.contains(strip_purl_qualifiers(p)); - let (vendored_selected, selected): (Vec<_>, Vec<_>) = if args.vendor { - (Vec::new(), selected) - } else { - selected.into_iter().partition(|p| is_vendored(&p.purl)) - }; - if !args.common.silent { - for p in &vendored_selected { - println!( - " [skip] {} (vendored — run scan --vendor to update)", - normalize_purl(&p.purl) - ); - } - } - - // Lockfile-only purls leave the in-place apply selection (calm skip, - // mirrors the JSON path). In `--vendor` mode they stay: the vendor - // engine fetches lockfile-resolved packages pristine. - let (selected, not_installed_selected): (Vec<_>, Vec) = if args.vendor { - (selected, Vec::new()) - } else { - let (kept, skipped) = partition_not_installed_selected(selected, &lockfile_only.purls); - let printed: Vec = skipped - .iter() - .filter_map(|r| r["purl"].as_str().map(str::to_string)) - .collect(); - (kept, printed) - }; - if !args.common.silent { - for purl in ¬_installed_selected { - println!( - " [skip] {} (not installed — run your package manager's install first, \ - or `scan --vendor` to vendor it from the lockfile)", - normalize_purl(purl) - ); - } - } - - if selected.is_empty() && !args.vendor { - if !args.common.silent { - println!("No patches selected."); - } - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; - } - - // Vendor mode: pre-verify baselines so a content mismatch surfaces - // BEFORE the confirm prompt (vendoring still proceeds for these — - // the stage force-applies the verified patched content). - let mismatched_baselines: HashSet = if args.vendor && !args.common.silent { - preverify_vendor_baselines( - &api_client, - effective_org_slug, - &selected, - &filtered_crawled, - &lockfile_only.purls, - ) - .await - } else { - HashSet::new() - }; - - // Display detailed summary of selected patches before confirming - // (presentational only — skipped wholesale under --silent). - if !args.common.silent { - if args.vendor { - println!("\nPatches to vendor:\n"); - } else { - println!("\nPatches to apply:\n"); - } - for patch in &selected { - // Collect CVE/GHSA IDs and highest severity from vulnerabilities - let mut vuln_ids: Vec = Vec::new(); - let mut highest_severity: Option<&str> = None; - for (id, vuln) in &patch.vulnerabilities { - if vuln.cves.is_empty() { - vuln_ids.push(id.clone()); - } else { - for cve in &vuln.cves { - vuln_ids.push(cve.clone()); - } - } - let sev = vuln.severity.as_str(); - if highest_severity.is_none_or(|cur| severity_order(sev) < severity_order(cur)) { - highest_severity = Some(sev); - } - } - - let sev_display = highest_severity.unwrap_or("unknown"); - let sev_colored = format_severity(sev_display, use_color); - - // Char-safe: descriptions come straight from the API and routinely - // contain non-ASCII text; a `&desc[..69]` byte slice would panic. - let desc = truncate_with_ellipsis(&patch.description, 72); - - println!( - " {} [{}] {}", - // Human display only: show the decoded form of an - // API-encoded purl (`%40scope` → `@scope`). JSON output - // keeps the verbatim key. - normalize_purl(&patch.purl), - patch.tier.to_uppercase(), - sev_colored, - ); - if mismatched_baselines.contains(&patch.uuid) { - println!( - " (installed content differs from patch baseline — will vendor patched content)" - ); - } - if !vuln_ids.is_empty() { - println!(" Fixes: {}", vuln_ids.join(", ")); - } - // Show per-vulnerability summaries - for vuln in patch.vulnerabilities.values() { - if !vuln.summary.is_empty() { - // Char-safe: vulnerability summaries are API-sourced free - // text; a `&summary[..73]` byte slice would panic mid-codepoint. - let summary = truncate_with_ellipsis(&vuln.summary, 76); - let cve_label = if vuln.cves.is_empty() { - String::new() - } else { - format!("{}: ", vuln.cves.join(", ")) - }; - println!(" - {cve_label}{summary}"); - } - } - if !desc.is_empty() { - println!(" {desc}"); - } - println!(); - } - } - - // `--dry-run` is a non-mutating preview (see the global flag's doc and - // the JSON path's `dryRun` envelope). The interactive path must honor it - // too: stop here, having printed the table and the per-patch plan above, - // before the confirm prompt, the download/apply, and the prune GC — all - // of which mutate the manifest and `.socket/` on disk. - if args.common.dry_run { - if !args.common.silent { - let action = if args.vendor { - "download and vendor" - } else { - "download and apply" - }; - println!( - "\n[dry-run] Would {action} {} patch(es). No changes made.", - selected.len() - ); - } - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; - } - - // Prompt to download - let verb = if args.vendor { "vendor" } else { "apply" }; - let prompt = format!("Download and {verb} {} patch(es)?", selected.len()); - if !confirm(&prompt, true, args.common.yes, args.common.json) { - if !args.common.silent { - println!("\nTo apply a patch, run:"); - println!(" socket-patch get "); - println!(" socket-patch get "); - } - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; - } - - // Download, then apply in place — or vendor (`--vendor`). - let params = DownloadParams { - cwd: args.common.cwd.clone(), - org: args.common.org.clone(), - // Vendor mode downloads only; the vendor step below does the rest. - save_only: args.vendor, - one_off: false, - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - json: false, - silent: args.common.silent, - download_mode: args.common.download_mode.clone(), - api_overrides: args.common.api_client_overrides(), - all_releases: args.all_releases, - strict: args.common.strict, - persist_blobs: !args.vendor, - }; - - let code = if args.vendor { - // Extracted + boxed for the same Windows-1-MiB-frame reason as the - // JSON path (see `run_vendor_json_path`). - boxed_vendor_interactive_path( - &args, - &selected, - ¶ms, - &manifest_path, - &socket_dir, - &scanned_purls, - &vendored_purls, - prune, - telemetry_token.as_deref(), - telemetry_org.as_deref(), - ) - .await - } else { - let (code, _) = download_and_apply_patches(&selected, ¶ms).await; - code - }; - - // Post-apply GC: only runs when the user opted in via `--prune` or - // `--sync`. Default `scan --yes` no longer touches the manifest - // beyond what `--apply` added — users wanting to clean up should - // run `socket-patch gc` (or `repair`) explicitly. (Vendor mode - // already ran its GC before the vendor step.) - if prune && !args.vendor { - let gc = run_apply_gc( - &args.common, - &manifest_path, - &socket_dir, - &scanned_purls, - &vendored_purls, - ) - .await; - let total = gc.blobs.blobs_removed + gc.diffs.blobs_removed + gc.packages.blobs_removed; - if !args.common.silent && (!gc.pruned.is_empty() || total > 0) { - println!( - "\nGC: pruned {} manifest entr{} and removed {} orphan file{} ({}).", - gc.pruned.len(), - if gc.pruned.len() == 1 { "y" } else { "ies" }, - total, - if total == 1 { "" } else { "s" }, - socket_patch_core::utils::cleanup_blobs::format_bytes(gc.total_bytes()), - ); - } - if !args.common.silent && (!gc.vendored_reverted.is_empty() || gc.vendor_orphan_dirs > 0) { - println!( - "GC: reverted {} vendored entr{}; swept {} orphan vendor dir{}.", - gc.vendored_reverted.len(), - if gc.vendored_reverted.len() == 1 { - "y" - } else { - "ies" - }, - gc.vendor_orphan_dirs, - if gc.vendor_orphan_dirs == 1 { "" } else { "s" }, - ); - } - } - - embed_vex_human(&args.common, &args.vex, &manifest_path, code).await -} - -pub(crate) fn severity_order(s: &str) -> u8 { - match s.to_lowercase().as_str() { - "critical" => 0, - "high" => 1, - "medium" => 2, - "low" => 3, - _ => 4, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use socket_patch_core::api::types::{BatchPackagePatches, BatchPatchInfo}; - use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; - use std::collections::HashMap; - - // ---- severity_order ---------------------------------------------------- - - #[test] - fn severity_order_critical_is_zero() { - assert_eq!(severity_order("critical"), 0); - } - - #[test] - fn severity_order_is_case_insensitive() { - assert_eq!(severity_order("Critical"), 0); - assert_eq!(severity_order("CRITICAL"), 0); - assert_eq!(severity_order("High"), 1); - } - - #[test] - fn severity_order_known_levels() { - assert_eq!(severity_order("high"), 1); - assert_eq!(severity_order("medium"), 2); - assert_eq!(severity_order("low"), 3); - } - - #[test] - fn severity_order_unknown_is_four() { - assert_eq!(severity_order("unknown"), 4); - assert_eq!(severity_order(""), 4); - assert_eq!(severity_order("informational"), 4); - } - - // ---- detect_updates ----------------------------------------------------- - - fn manifest_with(entries: &[(&str, &str)]) -> PatchManifest { - let mut m = PatchManifest::new(); - for (purl, uuid) in entries { - m.patches.insert( - (*purl).to_string(), - PatchRecord { - uuid: (*uuid).to_string(), - exported_at: String::new(), - files: HashMap::new(), - vulnerabilities: HashMap::new(), - description: String::new(), - license: String::new(), - tier: "free".to_string(), - }, - ); - } - m - } - - fn batch_with(purl: &str, uuids: &[&str]) -> BatchPackagePatches { - BatchPackagePatches { - purl: purl.to_string(), - patches: uuids - .iter() - .map(|u| BatchPatchInfo { - uuid: (*u).to_string(), - purl: purl.to_string(), - tier: "free".to_string(), - cve_ids: Vec::new(), - ghsa_ids: Vec::new(), - severity: None, - title: String::new(), - }) - .collect(), - } - } - - #[test] - fn detect_updates_returns_empty_when_no_manifest() { - let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-a"])]; - assert!(detect_updates(None, &pkgs).is_empty()); - } - - #[test] - fn detect_updates_returns_empty_for_empty_packages() { - let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); - assert!(detect_updates(Some(&m), &[]).is_empty()); - } - - #[test] - fn detect_updates_returns_empty_when_no_overlap() { - let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); - let pkgs = vec![batch_with("pkg:npm/bar@2.0", &["uuid-z"])]; - assert!(detect_updates(Some(&m), &pkgs).is_empty()); - } - - #[test] - fn detect_updates_skips_same_uuid() { - let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); - let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-a"])]; - assert!(detect_updates(Some(&m), &pkgs).is_empty()); - } - - #[test] - fn detect_updates_flags_different_uuid() { - let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); - let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-b"])]; - let updates = detect_updates(Some(&m), &pkgs); - assert_eq!(updates.len(), 1); - assert_eq!(updates[0].purl, "pkg:npm/foo@1.0"); - assert_eq!(updates[0].old_uuid, "uuid-a"); - assert_eq!(updates[0].new_uuid, "uuid-b"); - } - - #[test] - fn detect_updates_reports_multiple_updates() { - let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a"), ("pkg:npm/bar@2.0", "uuid-c")]); - let pkgs = vec![ - batch_with("pkg:npm/foo@1.0", &["uuid-b"]), - batch_with("pkg:npm/bar@2.0", &["uuid-d"]), - ]; - let updates = detect_updates(Some(&m), &pkgs); - assert_eq!(updates.len(), 2); - } - - #[test] - fn detect_updates_skips_packages_with_empty_patch_list() { - let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); - // No candidate patches means we can't tell what the new UUID would - // be, so there's nothing to compare against. Correct behavior is to - // skip these silently. - let pkgs = vec![batch_with("pkg:npm/foo@1.0", &[])]; - assert!(detect_updates(Some(&m), &pkgs).is_empty()); - } - - #[test] - fn detect_updates_uses_first_patch_as_candidate() { - // `detect_updates` mirrors `select_patches` by picking the first - // patch in the batch as the candidate UUID. Locking this in so a - // future select_patches refactor doesn't silently drift the two. - let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); - let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-b", "uuid-c"])]; - let updates = detect_updates(Some(&m), &pkgs); - assert_eq!(updates.len(), 1); - assert_eq!(updates[0].new_uuid, "uuid-b"); - } - - #[test] - fn detect_updates_no_update_when_manifest_holds_candidate_despite_other_patches() { - // Regression: the human-readable table once flagged `[UPDATE]` (and - // bumped `updates_available`) whenever *any* batch patch differed from - // the manifest UUID. But the apply path resolves to the FIRST patch, - // so a manifest already holding that candidate is up to date even when - // the batch also lists older patches. The table and the JSON `updates` - // array must agree; both derive from this function, which compares the - // candidate (first) patch only. - let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-newest")]); - let pkgs = vec![batch_with( - "pkg:npm/foo@1.0", - &["uuid-newest", "uuid-older", "uuid-oldest"], - )]; - assert!( - detect_updates(Some(&m), &pkgs).is_empty(), - "manifest already holds the candidate (first) patch — no update" - ); - } - - // ---- detect_prunable --------------------------------------------------- - - fn scanned(purls: &[&str]) -> HashSet { - purls.iter().map(|s| (*s).to_string()).collect() - } - - /// The "nothing vendored" set most prune tests run with. - fn no_vendored() -> HashSet { - HashSet::new() - } - - /// GlobalArgs rooted at the test project dir (the vendored-state GC - /// loads `.socket/vendor/state.json` from `cwd`; these fixtures have - /// none, so the vendor pass is a no-op). - fn gc_common(cwd: &Path) -> crate::args::GlobalArgs { - crate::args::GlobalArgs { - cwd: cwd.to_path_buf(), - ..Default::default() - } - } - - #[test] - fn detect_prunable_empty_manifest_empty_scanned() { - let m = PatchManifest::new(); - assert!(detect_prunable(&m, &scanned(&[]), &no_vendored()).is_empty()); - } - - #[test] - fn detect_prunable_empty_manifest_nonempty_scanned() { - let m = PatchManifest::new(); - // No manifest entries → nothing to prune even if the crawl found - // packages that don't appear in the manifest. - assert!(detect_prunable(&m, &scanned(&["pkg:npm/foo@1"]), &no_vendored()).is_empty()); - } - - #[test] - fn detect_prunable_all_entries_present_in_scan() { - let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a"), ("pkg:npm/bar@2.0", "uuid-b")]); - let s = scanned(&["pkg:npm/foo@1.0", "pkg:npm/bar@2.0"]); - assert!(detect_prunable(&m, &s, &no_vendored()).is_empty()); - } - - #[test] - fn detect_prunable_returns_missing_entries() { - let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a"), ("pkg:npm/bar@2.0", "uuid-b")]); - // foo is still installed, bar is gone. - let s = scanned(&["pkg:npm/foo@1.0"]); - let mut out = detect_prunable(&m, &s, &no_vendored()); - out.sort(); - assert_eq!(out, vec!["pkg:npm/bar@2.0".to_string()]); - } - - #[test] - fn detect_prunable_returns_everything_when_scan_is_empty() { - let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a"), ("pkg:npm/bar@2.0", "uuid-b")]); - let mut out = detect_prunable(&m, &scanned(&[]), &no_vendored()); - out.sort(); - assert_eq!( - out, - vec!["pkg:npm/bar@2.0".to_string(), "pkg:npm/foo@1.0".to_string()], - ); - } - - #[test] - fn detect_prunable_keeps_pypi_variants_of_installed_base() { - // Manifest holds three qualified release variants; the crawler - // reports only the base PURL. None should be pruned — they all - // belong to the installed package. - let m = manifest_with(&[ - ("pkg:pypi/six@1.16.0?artifact_id=wheel-a", "uuid-a"), - ("pkg:pypi/six@1.16.0?artifact_id=wheel-b", "uuid-b"), - ("pkg:pypi/six@1.16.0?artifact_id=sdist", "uuid-c"), - ]); - let out = detect_prunable(&m, &scanned(&["pkg:pypi/six@1.16.0"]), &no_vendored()); - assert!( - out.is_empty(), - "variants of an installed base must not be pruned; got {out:?}" - ); - } - - #[test] - fn detect_prunable_removes_all_variants_of_uninstalled_base() { - // The package is no longer installed (empty crawl): every - // release variant is prunable. - let m = manifest_with(&[ - ("pkg:pypi/six@1.16.0?artifact_id=wheel-a", "uuid-a"), - ("pkg:pypi/six@1.16.0?artifact_id=sdist", "uuid-c"), - ]); - let out = detect_prunable(&m, &scanned(&[]), &no_vendored()); - assert_eq!(out.len(), 2, "all variants of a gone package should prune"); - } - - #[test] - fn detect_prunable_exempts_vendored_purls() { - // A vendored package is consumed from the committed artifact — - // the crawler not seeing an installed copy (wiped node_modules) - // is its normal state. Pruning it would orphan the manifest - // entry and let the next `vendor` run reconcile-revert the - // vendoring itself. - let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a"), ("pkg:npm/bar@2.0", "uuid-b")]); - let vendored: HashSet = ["pkg:npm/foo@1.0".to_string()].into_iter().collect(); - let out = detect_prunable(&m, &scanned(&[]), &vendored); - assert_eq!( - out, - vec!["pkg:npm/bar@2.0".to_string()], - "vendored foo exempt, non-vendored bar prunable" - ); - } - - #[test] - fn detect_prunable_encoded_manifest_key_not_pruned() { - // The API serves scoped purls percent-encoded and they land in the - // manifest verbatim; the crawler reports the literal `@scope` form. - // Comparing raw strings would make every encoded scoped entry look - // prunable — `scan --prune` would GC the patch it just downloaded. - let m = manifest_with(&[("pkg:npm/%40scope/x@1.0.0", "uuid-a")]); - let s = scanned(&["pkg:npm/@scope/x@1.0.0"]); - assert!( - detect_prunable(&m, &s, &no_vendored()).is_empty(), - "encoded manifest key must match the decoded scanned purl" - ); - // A genuinely-gone encoded entry still prunes. - let out = detect_prunable(&m, &scanned(&[]), &no_vendored()); - assert_eq!(out, vec!["pkg:npm/%40scope/x@1.0.0".to_string()]); - } - - #[test] - fn detect_prunable_exempts_qualified_variant_of_vendored_base() { - // The ledger key set carries qualifier-stripped bases (see - // `vendored_purl_keys`), so a qualified manifest variant of a - // vendored package is exempt via its base purl. - let m = manifest_with(&[("pkg:pypi/six@1.16.0?artifact_id=wheel-a", "uuid-a")]); - let vendored: HashSet = ["pkg:pypi/six@1.16.0".to_string()].into_iter().collect(); - let out = detect_prunable(&m, &scanned(&[]), &vendored); - assert!( - out.is_empty(), - "qualified variant of a vendored base must not prune; got {out:?}" - ); - } - - // ---- preview_apply_gc / run_apply_gc parity ---------------------------- - // The dry-run preview MUST report the same orphan blobs/archives the real - // (wet) prune would remove. Both delete the prunable manifest entries - // first, then sweep; the cleanup helpers derive the "still referenced" - // blob set from the manifest they're handed, so a preview that swept - // against the un-pruned manifest would keep the prunable entries' blobs - // marked "used" and under-report `orphan*`/`bytesReclaimable`. - - /// Write a manifest holding a single entry that references one afterHash - /// blob, plant that blob on disk, and return `(manifest_path, socket_dir, - /// blob_path)`. - fn seed_manifest_with_blob( - tmp: &std::path::Path, - purl: &str, - after_hash: &str, - ) -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) { - let socket_dir = tmp.join(".socket"); - let blobs_dir = socket_dir.join("blobs"); - std::fs::create_dir_all(&blobs_dir).unwrap(); - let blob_path = blobs_dir.join(after_hash); - // Non-trivial size so `bytesReclaimable`/`bytesFreed` is observably > 0. - std::fs::write(&blob_path, vec![0u8; 64]).unwrap(); - - let manifest_path = socket_dir.join("manifest.json"); - let manifest = serde_json::json!({ - "patches": { - purl: { - "uuid": "11111111-1111-4111-8111-111111111111", - "exportedAt": "2024-01-01T00:00:00Z", - "files": { - "package/index.js": { - "beforeHash": "0".repeat(64), - "afterHash": after_hash, - } - }, - "vulnerabilities": {}, - "description": "seed", - "license": "MIT", - "tier": "free", - } - } - }); - std::fs::write( - &manifest_path, - serde_json::to_string_pretty(&manifest).unwrap(), - ) - .unwrap(); - (manifest_path, socket_dir, blob_path) - } - - #[tokio::test] - async fn preview_apply_gc_reports_blobs_of_prunable_entry() { - // The package is not installed (empty scan), so its entry is prunable - // and its only blob is reclaimable. A correct PREVIEW must count that - // blob even though it is still referenced by the not-yet-pruned entry. - let tmp = tempfile::tempdir().unwrap(); - let after_hash = "a".repeat(64); - let (manifest_path, socket_dir, blob_path) = - seed_manifest_with_blob(tmp.path(), "pkg:npm/gone@1.0.0", &after_hash); - - let scanned: HashSet = HashSet::new(); - let preview = preview_apply_gc( - &gc_common(tmp.path()), - &manifest_path, - &socket_dir, - &scanned, - &no_vendored(), - ) - .await; - - assert_eq!( - preview.pruned, - vec!["pkg:npm/gone@1.0.0".to_string()], - "preview must list the uninstalled entry as prunable" - ); - assert_eq!( - preview.blobs.blobs_removed, 1, - "preview must count the prunable entry's blob as an orphan \ - (regression: it was masked because the entry still referenced it)" - ); - assert!( - preview.total_bytes() > 0, - "bytesReclaimable must be > 0 when an orphan blob would be freed" - ); - // Preview is non-mutating: blob and manifest untouched. - assert!( - blob_path.exists(), - "dry-run preview must not delete the blob" - ); - let m = read_manifest(&manifest_path).await.unwrap().unwrap(); - assert!( - m.patches.contains_key("pkg:npm/gone@1.0.0"), - "dry-run preview must not prune the manifest entry" - ); - } - - #[tokio::test] - async fn preview_and_apply_gc_agree_on_orphan_counts() { - // The preview's reclaimable counts must equal what the wet run frees. - let after_hash = "b".repeat(64); - - let tmp_preview = tempfile::tempdir().unwrap(); - let (mp_p, sd_p, blob_p) = - seed_manifest_with_blob(tmp_preview.path(), "pkg:npm/gone@1.0.0", &after_hash); - let scanned: HashSet = HashSet::new(); - let preview = preview_apply_gc( - &gc_common(tmp_preview.path()), - &mp_p, - &sd_p, - &scanned, - &no_vendored(), - ) - .await; - assert!(blob_p.exists(), "preview must not mutate"); - - let tmp_wet = tempfile::tempdir().unwrap(); - let (mp_w, sd_w, blob_w) = - seed_manifest_with_blob(tmp_wet.path(), "pkg:npm/gone@1.0.0", &after_hash); - let wet = run_apply_gc( - &gc_common(tmp_wet.path()), - &mp_w, - &sd_w, - &scanned, - &no_vendored(), - ) - .await; - - assert_eq!( - preview.blobs.blobs_removed, wet.blobs.blobs_removed, - "preview and wet run must agree on the orphan-blob count" - ); - assert_eq!( - preview.total_bytes(), - wet.total_bytes(), - "preview and wet run must agree on reclaimable bytes" - ); - assert_eq!(preview.pruned, wet.pruned, "prunable set must match"); - // The wet run actually removed the blob and pruned the entry. - assert!(!blob_w.exists(), "wet run must delete the orphan blob"); - let m = read_manifest(&mp_w).await.unwrap().unwrap(); - assert!( - !m.patches.contains_key("pkg:npm/gone@1.0.0"), - "wet run must prune the entry" - ); - } - - // ---- collect_vuln_ids -------------------------------------------------- - - /// Build a single-patch package whose patch carries the given CVE and - /// GHSA identifier lists. - fn batch_with_vulns(purl: &str, cves: &[&str], ghsas: &[&str]) -> BatchPackagePatches { - BatchPackagePatches { - purl: purl.to_string(), - patches: vec![BatchPatchInfo { - uuid: "uuid".to_string(), - purl: purl.to_string(), - tier: "free".to_string(), - cve_ids: cves.iter().map(|s| (*s).to_string()).collect(), - ghsa_ids: ghsas.iter().map(|s| (*s).to_string()).collect(), - severity: None, - title: String::new(), - }], - } - } - - #[test] - fn collect_vuln_ids_empty_when_no_vulns() { - let pkg = batch_with_vulns("pkg:npm/foo@1.0", &[], &[]); - assert!(collect_vuln_ids(&pkg).is_empty()); - } - - #[test] - fn collect_vuln_ids_lists_cves_before_ghsas_each_sorted() { - // Deliberately unsorted input; output must be CVEs (sorted) then - // GHSAs (sorted) so the rendered table column is deterministic. - let pkg = batch_with_vulns( - "pkg:npm/foo@1.0", - &["CVE-2024-2", "CVE-2024-1"], - &["GHSA-zzzz-zzzz-zzzz", "GHSA-aaaa-aaaa-aaaa"], - ); - assert_eq!( - collect_vuln_ids(&pkg), - vec![ - "CVE-2024-1".to_string(), - "CVE-2024-2".to_string(), - "GHSA-aaaa-aaaa-aaaa".to_string(), - "GHSA-zzzz-zzzz-zzzz".to_string(), - ], - ); - } - - #[test] - fn collect_vuln_ids_dedups_across_patches() { - // The same CVE appears on two patches of one package; it must be - // reported once. - let pkg = BatchPackagePatches { - purl: "pkg:npm/foo@1.0".to_string(), - patches: vec![ - BatchPatchInfo { - uuid: "u1".to_string(), - purl: "pkg:npm/foo@1.0".to_string(), - tier: "free".to_string(), - cve_ids: vec!["CVE-2024-1".to_string()], - ghsa_ids: vec![], - severity: None, - title: String::new(), - }, - BatchPatchInfo { - uuid: "u2".to_string(), - purl: "pkg:npm/foo@1.0".to_string(), - tier: "free".to_string(), - cve_ids: vec!["CVE-2024-1".to_string()], - ghsa_ids: vec!["GHSA-aaaa-aaaa-aaaa".to_string()], - severity: None, - title: String::new(), - }, - ], - }; - assert_eq!( - collect_vuln_ids(&pkg), - vec!["CVE-2024-1".to_string(), "GHSA-aaaa-aaaa-aaaa".to_string(),], - ); - } - - // ---- truncate_with_ellipsis (scan's display columns) ------------------- - // scan.rs renders PURLs, descriptions, and vulnerability summaries — all - // API-sourced and potentially non-ASCII — into fixed-width columns. These - // pin scan's use of the char-safe helper; a raw `&s[..n]` byte slice - // would panic when the cut lands mid-codepoint. - - #[test] - fn truncate_multibyte_purl_does_not_panic() { - // 30 three-byte chars (90 bytes, 30 chars). The old purl path sliced - // `&purl[..37]` once `len() > 40`; byte 37 splits a codepoint here. - let purl = format!("pkg:npm/{}", "日".repeat(30)); - let out = truncate_with_ellipsis(&purl, 40); - assert!(out.chars().count() <= 40); - } - - #[test] - fn truncate_multibyte_description_truncates_on_char_boundary() { - // 100 two-byte chars; description column truncates at 72. - let desc = "é".repeat(100); - let out = truncate_with_ellipsis(&desc, 72); - assert_eq!(out.chars().count(), 72); - assert!(out.ends_with("...")); - } - - #[test] - fn truncate_multibyte_summary_truncates_on_char_boundary() { - // Summary column truncates at 76. - let summary = "—".repeat(100); // em dash, 3 bytes each - let out = truncate_with_ellipsis(&summary, 76); - assert_eq!(out.chars().count(), 76); - assert!(out.ends_with("...")); - } -} diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs new file mode 100644 index 00000000..07bcd03f --- /dev/null +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -0,0 +1,481 @@ +//! Discovery-side helpers for `scan`: lockfile / vendored-ledger crawl +//! supplements, update detection against the existing manifest, vendor +//! baseline pre-verification, and the table's vuln-ID / severity helpers. + +use socket_patch_core::api::types::{BatchPackagePatches, PatchSearchResult}; +use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; +use std::collections::HashSet; + +use crate::args::GlobalArgs; + +/// Surfaced in `scan --json` output. Tells a bot which PURLs in the discovery +/// would replace an existing manifest entry with a newer UUID. Stable schema — +/// see CLI_CONTRACT.md (`scan` JSON output / `updates` field). +#[derive(Debug, PartialEq, Eq, Clone)] +pub(super) struct UpdateInfo { + pub(super) purl: String, + pub(super) old_uuid: String, + pub(super) new_uuid: String, +} + +/// Lockfile-only packages: dependencies the project's lockfile resolves +/// that have no crawled (installed) counterpart. +#[derive(Default)] +pub(super) struct LockfileSupplement { + pub(super) packages: Vec, + /// Literal crawler-form purls, for fast membership tests. + pub(super) purls: HashSet, +} + +/// Inventory the project's lockfile(s) and fabricate crawl entries for +/// dependencies that are not installed. The fabricated `path` is the +/// WOULD-BE install dir — every consumer degrades safely on a nonexistent +/// path (hash verify → NotFound, apply → partitioned skip, vendor → +/// auto-fetch). Global scans target the machine's global tree, not this +/// project's lockfile, so they get no supplement. +pub(super) async fn lockfile_supplement( + common: &GlobalArgs, + crawled: &[socket_patch_core::crawlers::types::CrawledPackage], +) -> LockfileSupplement { + use socket_patch_core::patch::vendor::lock_inventory; + + let mut out = LockfileSupplement::default(); + if common.global || common.global_prefix.is_some() { + return out; + } + let entries = lock_inventory::inventory_project(&common.cwd).await; + if entries.is_empty() { + return out; + } + let crawled_purls: HashSet<&str> = crawled.iter().map(|p| p.purl.as_str()).collect(); + for entry in entries { + if crawled_purls.contains(entry.purl.as_str()) { + continue; + } + let Some(pkg) = crawled_from_purl(&entry.purl, &common.cwd) else { + continue; + }; + out.purls.insert(entry.purl.clone()); + out.packages.push(pkg); + } + out +} + +/// A displayable crawl entry fabricated from a purl (decoded form). The +/// path is a placeholder consumers degrade safely on. +fn crawled_from_purl( + purl: &str, + cwd: &std::path::Path, +) -> Option { + let decoded = normalize_purl(strip_purl_qualifiers(purl)).into_owned(); + let rest = decoded.strip_prefix("pkg:")?; + let (_eco, rest) = rest.split_once('/')?; + let at = rest.rfind('@').filter(|&i| i > 0)?; + let (name_part, version) = (&rest[..at], &rest[at + 1..]); + let (namespace, name) = match name_part.rsplit_once('/') { + Some((ns, n)) => (Some(ns.to_string()), n.to_string()), + None => (None, name_part.to_string()), + }; + Some(socket_patch_core::crawlers::types::CrawledPackage { + name, + version: version.to_string(), + namespace, + purl: decoded.clone(), + path: cwd.join("node_modules").join(name_part), + }) +} + +/// Vendored-ledger packages with no crawled counterpart: on a fresh clone +/// the committed artifact IS the dependency, so these stay discoverable +/// (updates[] detection, the table, and `scan --vendor` re-vendor/in-sync +/// runs all keep working before any install). They are NOT "lockfile-only" +/// — nothing needs installing; the artifact satisfies the lock. +pub(super) async fn vendored_ledger_supplement( + common: &GlobalArgs, + crawled: &[socket_patch_core::crawlers::types::CrawledPackage], +) -> Vec { + if common.global || common.global_prefix.is_some() { + return Vec::new(); + } + let Ok(state) = socket_patch_core::patch::vendor::load_state(&common.cwd).await else { + return Vec::new(); + }; + let crawled_norm: HashSet = crawled + .iter() + .map(|p| normalize_purl(&p.purl).into_owned()) + .collect(); + let mut seen: HashSet = HashSet::new(); + let mut out = Vec::new(); + for entry in state.entries.values() { + let base = strip_purl_qualifiers(&entry.base_purl); + let norm = normalize_purl(base).into_owned(); + if crawled_norm.contains(&norm) || !seen.insert(norm) { + continue; + } + if let Some(pkg) = crawled_from_purl(base, &common.cwd) { + out.push(pkg); + } + } + out.sort_by(|a, b| a.purl.cmp(&b.purl)); + out +} + +/// Vendor-mode pre-prompt check: uuids of selected patches whose installed +/// files match NEITHER beforeHash nor afterHash — the patch was built +/// against different bytes than the installed artifact. Vendoring still +/// succeeds for these (the vendor stage force-applies the verified patched +/// content; see `force_apply_staged`), but the user should learn it BEFORE +/// the confirm prompt, not from a post-hoc warning event. +/// +/// Best-effort and read-only: a detail-fetch failure or an unresolvable +/// installed path just skips the annotation — it never blocks the flow and +/// writes nothing (unlike `download_patch_records`, which stages blobs). +pub(super) async fn preverify_vendor_baselines( + api_client: &socket_patch_core::api::client::ApiClient, + org_slug: Option<&str>, + selected: &[PatchSearchResult], + crawled: &[socket_patch_core::crawlers::types::CrawledPackage], + lockfile_only: &HashSet, +) -> HashSet { + use socket_patch_core::manifest::schema::PatchFileInfo; + use socket_patch_core::patch::apply::{verify_file_patch, VerifyStatus}; + use socket_patch_core::utils::purl::purl_eq; + + let mut mismatched: HashSet = HashSet::new(); + for patch in selected { + // API purls come percent-encoded, crawler purls literal — purl_eq + // bridges the two spellings. + let base = strip_purl_qualifiers(&patch.purl); + // Lockfile-only packages have no installed bytes to compare — the + // vendor engine fetches them pristine (nothing to annotate). + if lockfile_only.contains(normalize_purl(base).as_ref()) { + continue; + } + let Some(pkg) = crawled.iter().find(|c| purl_eq(&c.purl, base)) else { + continue; + }; + let Ok(Some(detail)) = api_client.fetch_patch(org_slug, &patch.uuid).await else { + continue; + }; + for (file, info) in &detail.files { + let info = PatchFileInfo { + before_hash: info.before_hash.clone().unwrap_or_default(), + after_hash: info.after_hash.clone().unwrap_or_default(), + }; + if info.before_hash.is_empty() { + continue; // a new file has no baseline to compare + } + if verify_file_patch(&pkg.path, file, &info).await.status == VerifyStatus::HashMismatch + { + mismatched.insert(patch.uuid.clone()); + break; + } + } + } + mismatched +} + +/// Cross-reference an existing manifest against discovery results to find +/// PURLs whose newest available patch UUID differs from the locally-recorded +/// one. Used by both the discovery JSON path and the table-print path. +/// Pure / no I/O so it's unit-testable. +pub(super) fn detect_updates( + existing_manifest: Option<&PatchManifest>, + packages: &[BatchPackagePatches], +) -> Vec { + let Some(manifest) = existing_manifest else { + return Vec::new(); + }; + let mut updates = Vec::new(); + for pkg in packages { + let Some(existing) = manifest.patches.get(&pkg.purl) else { + continue; + }; + // Treat the first patch in the batch as the candidate the apply path + // would resolve to (mirrors `select_patches` ordering — newest-first + // for paid users, single-patch auto-select for free). + let Some(candidate) = pkg.patches.first() else { + continue; + }; + if candidate.uuid != existing.uuid { + updates.push(UpdateInfo { + purl: pkg.purl.clone(), + old_uuid: existing.uuid.clone(), + new_uuid: candidate.uuid.clone(), + }); + } + } + updates +} + +/// Collect the deduplicated CVE and GHSA identifiers across every patch of +/// a package, for the scan table's VULNERABILITIES column. CVEs are listed +/// before GHSAs and each group is sorted, so the rendered output is stable — +/// the per-patch ID lists and set-based dedup are otherwise nondeterministic +/// in order. Pure / no I/O so it's unit-testable. +pub(super) fn collect_vuln_ids(pkg: &BatchPackagePatches) -> Vec { + let mut cves: HashSet = HashSet::new(); + let mut ghsas: HashSet = HashSet::new(); + for patch in &pkg.patches { + for cve in &patch.cve_ids { + cves.insert(cve.clone()); + } + for ghsa in &patch.ghsa_ids { + ghsas.insert(ghsa.clone()); + } + } + let mut cves: Vec = cves.into_iter().collect(); + cves.sort(); + let mut ghsas: Vec = ghsas.into_iter().collect(); + ghsas.sort(); + cves.into_iter().chain(ghsas).collect() +} + +pub(super) fn severity_order(s: &str) -> u8 { + match s.to_lowercase().as_str() { + "critical" => 0, + "high" => 1, + // GHSA emits `moderate`; same tier as medium (see get.rs severity_rank). + "medium" | "moderate" => 2, + "low" => 3, + _ => 4, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use socket_patch_core::api::types::BatchPatchInfo; + + use crate::commands::scan::tests::manifest_with; + + // ---- severity_order ---------------------------------------------------- + + #[test] + fn severity_order_critical_is_zero() { + assert_eq!(severity_order("critical"), 0); + } + + #[test] + fn severity_order_is_case_insensitive() { + assert_eq!(severity_order("Critical"), 0); + assert_eq!(severity_order("CRITICAL"), 0); + assert_eq!(severity_order("High"), 1); + } + + #[test] + fn severity_order_known_levels() { + assert_eq!(severity_order("high"), 1); + assert_eq!(severity_order("medium"), 2); + assert_eq!(severity_order("low"), 3); + } + + #[test] + fn severity_order_moderate_is_medium_tier() { + // Regression: GHSA emits `moderate` for the medium tier, and scan + // passes raw API severities straight through. get.rs + // `severity_rank`, output.rs `format_severity`, and core's + // `get_severity_order` all map it to medium; ranking it 4 here + // (= unknown, below `low`) made the table's max-severity column + // show `low` for a package whose worst vuln is moderate. + assert_eq!(severity_order("moderate"), severity_order("medium")); + assert!(severity_order("moderate") < severity_order("low")); + assert_eq!(severity_order("Moderate"), severity_order("medium")); + } + + #[test] + fn severity_order_unknown_is_four() { + assert_eq!(severity_order("unknown"), 4); + assert_eq!(severity_order(""), 4); + assert_eq!(severity_order("informational"), 4); + } + + // ---- detect_updates ----------------------------------------------------- + + fn batch_with(purl: &str, uuids: &[&str]) -> BatchPackagePatches { + BatchPackagePatches { + purl: purl.to_string(), + patches: uuids + .iter() + .map(|u| BatchPatchInfo { + uuid: (*u).to_string(), + purl: purl.to_string(), + tier: "free".to_string(), + cve_ids: Vec::new(), + ghsa_ids: Vec::new(), + severity: None, + title: String::new(), + }) + .collect(), + } + } + + #[test] + fn detect_updates_returns_empty_when_no_manifest() { + let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-a"])]; + assert!(detect_updates(None, &pkgs).is_empty()); + } + + #[test] + fn detect_updates_returns_empty_for_empty_packages() { + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); + assert!(detect_updates(Some(&m), &[]).is_empty()); + } + + #[test] + fn detect_updates_returns_empty_when_no_overlap() { + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); + let pkgs = vec![batch_with("pkg:npm/bar@2.0", &["uuid-z"])]; + assert!(detect_updates(Some(&m), &pkgs).is_empty()); + } + + #[test] + fn detect_updates_skips_same_uuid() { + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); + let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-a"])]; + assert!(detect_updates(Some(&m), &pkgs).is_empty()); + } + + #[test] + fn detect_updates_flags_different_uuid() { + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); + let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-b"])]; + let updates = detect_updates(Some(&m), &pkgs); + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].purl, "pkg:npm/foo@1.0"); + assert_eq!(updates[0].old_uuid, "uuid-a"); + assert_eq!(updates[0].new_uuid, "uuid-b"); + } + + #[test] + fn detect_updates_reports_multiple_updates() { + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a"), ("pkg:npm/bar@2.0", "uuid-c")]); + let pkgs = vec![ + batch_with("pkg:npm/foo@1.0", &["uuid-b"]), + batch_with("pkg:npm/bar@2.0", &["uuid-d"]), + ]; + let updates = detect_updates(Some(&m), &pkgs); + assert_eq!(updates.len(), 2); + } + + #[test] + fn detect_updates_skips_packages_with_empty_patch_list() { + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); + // No candidate patches means we can't tell what the new UUID would + // be, so there's nothing to compare against. Correct behavior is to + // skip these silently. + let pkgs = vec![batch_with("pkg:npm/foo@1.0", &[])]; + assert!(detect_updates(Some(&m), &pkgs).is_empty()); + } + + #[test] + fn detect_updates_uses_first_patch_as_candidate() { + // `detect_updates` mirrors `select_patches` by picking the first + // patch in the batch as the candidate UUID. Locking this in so a + // future select_patches refactor doesn't silently drift the two. + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); + let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-b", "uuid-c"])]; + let updates = detect_updates(Some(&m), &pkgs); + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].new_uuid, "uuid-b"); + } + + #[test] + fn detect_updates_no_update_when_manifest_holds_candidate_despite_other_patches() { + // Regression: the human-readable table once flagged `[UPDATE]` (and + // bumped `updates_available`) whenever *any* batch patch differed from + // the manifest UUID. But the apply path resolves to the FIRST patch, + // so a manifest already holding that candidate is up to date even when + // the batch also lists older patches. The table and the JSON `updates` + // array must agree; both derive from this function, which compares the + // candidate (first) patch only. + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-newest")]); + let pkgs = vec![batch_with( + "pkg:npm/foo@1.0", + &["uuid-newest", "uuid-older", "uuid-oldest"], + )]; + assert!( + detect_updates(Some(&m), &pkgs).is_empty(), + "manifest already holds the candidate (first) patch — no update" + ); + } + + // ---- collect_vuln_ids -------------------------------------------------- + + /// Build a single-patch package whose patch carries the given CVE and + /// GHSA identifier lists. + fn batch_with_vulns(purl: &str, cves: &[&str], ghsas: &[&str]) -> BatchPackagePatches { + BatchPackagePatches { + purl: purl.to_string(), + patches: vec![BatchPatchInfo { + uuid: "uuid".to_string(), + purl: purl.to_string(), + tier: "free".to_string(), + cve_ids: cves.iter().map(|s| (*s).to_string()).collect(), + ghsa_ids: ghsas.iter().map(|s| (*s).to_string()).collect(), + severity: None, + title: String::new(), + }], + } + } + + #[test] + fn collect_vuln_ids_empty_when_no_vulns() { + let pkg = batch_with_vulns("pkg:npm/foo@1.0", &[], &[]); + assert!(collect_vuln_ids(&pkg).is_empty()); + } + + #[test] + fn collect_vuln_ids_lists_cves_before_ghsas_each_sorted() { + // Deliberately unsorted input; output must be CVEs (sorted) then + // GHSAs (sorted) so the rendered table column is deterministic. + let pkg = batch_with_vulns( + "pkg:npm/foo@1.0", + &["CVE-2024-2", "CVE-2024-1"], + &["GHSA-zzzz-zzzz-zzzz", "GHSA-aaaa-aaaa-aaaa"], + ); + assert_eq!( + collect_vuln_ids(&pkg), + vec![ + "CVE-2024-1".to_string(), + "CVE-2024-2".to_string(), + "GHSA-aaaa-aaaa-aaaa".to_string(), + "GHSA-zzzz-zzzz-zzzz".to_string(), + ], + ); + } + + #[test] + fn collect_vuln_ids_dedups_across_patches() { + // The same CVE appears on two patches of one package; it must be + // reported once. + let pkg = BatchPackagePatches { + purl: "pkg:npm/foo@1.0".to_string(), + patches: vec![ + BatchPatchInfo { + uuid: "u1".to_string(), + purl: "pkg:npm/foo@1.0".to_string(), + tier: "free".to_string(), + cve_ids: vec!["CVE-2024-1".to_string()], + ghsa_ids: vec![], + severity: None, + title: String::new(), + }, + BatchPatchInfo { + uuid: "u2".to_string(), + purl: "pkg:npm/foo@1.0".to_string(), + tier: "free".to_string(), + cve_ids: vec!["CVE-2024-1".to_string()], + ghsa_ids: vec!["GHSA-aaaa-aaaa-aaaa".to_string()], + severity: None, + title: String::new(), + }, + ], + }; + assert_eq!( + collect_vuln_ids(&pkg), + vec!["CVE-2024-1".to_string(), "GHSA-aaaa-aaaa-aaaa".to_string(),], + ); + } +} diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs new file mode 100644 index 00000000..b707e561 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -0,0 +1,586 @@ +//! The GC pass for `scan --prune`/`--sync`: manifest-entry pruning plus +//! orphan blob/diff/package-archive sweeps, in both mutating (apply) and +//! read-only (preview) forms. + +use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; +use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::utils::cleanup_blobs::{ + cleanup_unused_archives, cleanup_unused_blobs, CleanupResult, +}; +use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; +use std::collections::HashSet; +use std::path::Path; + +use crate::args::GlobalArgs; + +/// Aggregated outcome of a GC pass (or preview). Serialized into the +/// `scan --json` output's `gc` sub-object. See CLI_CONTRACT.md for the +/// stable schema. +#[derive(Debug, Default)] +pub(super) struct GcSummary { + /// PURLs removed from the manifest (apply mode) or eligible to be + /// removed (preview mode). + pub(super) pruned: Vec, + pub(super) blobs: CleanupResult, + pub(super) diffs: CleanupResult, + pub(super) packages: CleanupResult, + /// Vendored entries reverted (or revertable, preview mode) because + /// their patch is gone from the manifest or their dependency left the + /// lockfile graph — see `vendor::run_vendor_gc`. Sorted. + vendored_reverted: Vec, + /// Orphan `.socket/vendor//` dirs swept (or sweepable). + vendor_orphan_dirs: usize, + /// `true` when `--no-prune` was set; the sub-object only carries the + /// `skipped: true` field in that case. + skipped: bool, +} + +impl GcSummary { + pub(super) fn total_bytes(&self) -> u64 { + self.blobs.bytes_freed + self.diffs.bytes_freed + self.packages.bytes_freed + } + + /// Fold a vendored-state GC pass into this summary. + fn absorb_vendor_gc(&mut self, v: crate::commands::vendor::VendorGcSummary) { + self.vendored_reverted = v + .dropped_reverted + .into_iter() + .chain(v.unused_reverted) + .collect(); + self.vendored_reverted.sort(); + self.vendor_orphan_dirs = v.orphan_dirs; + } + + /// Serialize for a *mutating* GC pass (post-apply). + fn to_apply_json(&self) -> serde_json::Value { + if self.skipped { + return serde_json::json!({ "skipped": true }); + } + serde_json::json!({ + "prunedManifestEntries": self.pruned, + "removedBlobs": self.blobs.blobs_removed, + "removedDiffArchives": self.diffs.blobs_removed, + "removedPackageArchives": self.packages.blobs_removed, + "revertedVendoredEntries": self.vendored_reverted, + "removedVendorOrphanDirs": self.vendor_orphan_dirs, + "bytesFreed": self.total_bytes(), + }) + } + + /// Serialize for a *non-mutating* GC pass (read-only preview). + fn to_preview_json(&self) -> serde_json::Value { + if self.skipped { + return serde_json::json!({ "skipped": true }); + } + serde_json::json!({ + "prunableManifestEntries": self.pruned, + "orphanBlobs": self.blobs.blobs_removed, + "orphanDiffArchives": self.diffs.blobs_removed, + "orphanPackageArchives": self.packages.blobs_removed, + "revertableVendoredEntries": self.vendored_reverted, + "vendorOrphanDirs": self.vendor_orphan_dirs, + "bytesReclaimable": self.total_bytes(), + }) + } +} + +/// Compute GC actions without performing them. `dry_run = true` for the +/// preview path; `dry_run = false` for the apply path. The cleanup helpers +/// from `socket_patch_core::utils::cleanup_blobs` natively support dry-run, +/// so the same function works for both. +async fn run_gc( + manifest: &PatchManifest, + pruned: Vec, + socket_dir: &Path, + dry_run: bool, +) -> GcSummary { + let blobs = cleanup_unused_blobs(manifest, &socket_dir.join("blobs"), dry_run) + .await + .unwrap_or_default(); + let diffs = cleanup_unused_archives(manifest, &socket_dir.join("diffs"), dry_run) + .await + .unwrap_or_default(); + let packages = cleanup_unused_archives(manifest, &socket_dir.join("packages"), dry_run) + .await + .unwrap_or_default(); + GcSummary { + pruned, + blobs, + diffs, + packages, + ..Default::default() + } +} + +/// Apply-mode GC: re-read the manifest written by `download_and_apply_patches`, +/// prune manifest entries for PURLs not in `scanned_purls`, write the manifest +/// back, then sweep orphan blob/diff/package files. Callers must gate on the +/// `prune` flag — when GC isn't requested, simply don't call this function and +/// don't emit a `gc` sub-object. +pub(super) async fn run_apply_gc( + common: &crate::args::GlobalArgs, + manifest_path: &Path, + socket_dir: &Path, + scanned_purls: &HashSet, + vendored: &HashSet, +) -> GcSummary { + // Vendored-state GC FIRST: it reverts manifest-dropped and + // lockfile-unused vendored entries, dropping the latter's manifest + // entries — so the manifest prune + blob sweep below reclaims their + // blobs in this same pass (and the stale `vendored` exemption set is + // harmless: the entries it would exempt are already gone). + let vendor_gc = + crate::commands::vendor::run_vendor_gc(common, manifest_path, /*dry_run=*/ false).await; + + // Re-read the just-written manifest (the apply step may have added + // or updated entries we now want to consider for pruning). + let mut manifest = match read_manifest(manifest_path).await { + Ok(Some(m)) => m, + _ => { + let mut gc = GcSummary::default(); + gc.absorb_vendor_gc(vendor_gc); + return gc; + } + }; + let prunable = detect_prunable(&manifest, scanned_purls, vendored); + for purl in &prunable { + manifest.patches.remove(purl); + } + if !prunable.is_empty() { + // If pruning failed mid-write the manifest may be stale, but the + // file-level cleanup below still operates on the in-memory copy. + let _ = write_manifest(manifest_path, &manifest).await; + } + let mut gc = run_gc(&manifest, prunable, socket_dir, /*dry_run=*/ false).await; + gc.absorb_vendor_gc(vendor_gc); + gc +} + +/// Dry-run preview of the apply-mode GC pass. Same shape as +/// [`run_apply_gc`] but emits `prunable*`/`orphan*` field names and +/// performs no mutation. +async fn preview_apply_gc( + common: &crate::args::GlobalArgs, + manifest_path: &Path, + socket_dir: &Path, + scanned_purls: &HashSet, + vendored: &HashSet, +) -> GcSummary { + // Read-only preview of the vendored-state GC (lists, never reverts). + let vendor_gc = + crate::commands::vendor::run_vendor_gc(common, manifest_path, /*dry_run=*/ true).await; + + let mut manifest = match read_manifest(manifest_path).await { + Ok(Some(m)) => m, + _ => { + let mut gc = GcSummary::default(); + gc.absorb_vendor_gc(vendor_gc); + return gc; + } + }; + // Mirror the wet pass: an unused vendored entry's manifest keys are + // dropped before the blob sweep, so drop them from the in-memory copy + // too — otherwise the preview under-reports orphan blobs/bytes + // relative to what the real `--prune` run frees. + for purl in &vendor_gc.unused_reverted { + let base = strip_purl_qualifiers(purl).to_string(); + manifest + .patches + .retain(|k, _| k != purl && strip_purl_qualifiers(k) != base); + } + let prunable = detect_prunable(&manifest, scanned_purls, vendored); + // Mirror `run_apply_gc`: drop the prunable entries from the manifest + // *before* computing orphans (no write — this is the preview). The + // cleanup helpers derive the "referenced" blob/archive set from the + // manifest they're handed, so leaving the prunable entries in place + // would keep their blobs marked as used and the preview would + // under-report `orphan*`/`bytesReclaimable` relative to what the real + // `--prune`/`--sync` run actually frees. + for purl in &prunable { + manifest.patches.remove(purl); + } + let mut gc = run_gc(&manifest, prunable, socket_dir, /*dry_run=*/ true).await; + gc.absorb_vendor_gc(vendor_gc); + gc +} + +/// The `gc` sub-object for the JSON paths: a read-only preview under +/// `--dry-run`, the mutating pass otherwise, serialized with the matching +/// (`prunable*`/`orphan*` vs `pruned*`/`removed*`) field names. +pub(super) async fn gc_json( + common: &GlobalArgs, + manifest_path: &Path, + socket_dir: &Path, + scanned_purls: &HashSet, + vendored: &HashSet, + dry_run: bool, +) -> serde_json::Value { + if dry_run { + preview_apply_gc(common, manifest_path, socket_dir, scanned_purls, vendored) + .await + .to_preview_json() + } else { + run_apply_gc(common, manifest_path, socket_dir, scanned_purls, vendored) + .await + .to_apply_json() + } +} + +/// Human-readable one-liner for the vendored-state half of a GC pass; +/// prints nothing when that half did nothing. +pub(super) fn print_gc_vendored_line(gc: &GcSummary) { + if gc.vendored_reverted.is_empty() && gc.vendor_orphan_dirs == 0 { + return; + } + println!( + "GC: reverted {} vendored entr{}; swept {} orphan vendor dir{}.", + gc.vendored_reverted.len(), + if gc.vendored_reverted.len() == 1 { + "y" + } else { + "ies" + }, + gc.vendor_orphan_dirs, + if gc.vendor_orphan_dirs == 1 { "" } else { "s" }, + ); +} + +/// PURL strings present in the manifest but absent from `scanned_purls`. +/// These are candidates for pruning during `scan`'s GC pass — they +/// correspond to packages that were once patched but are no longer +/// installed (or no longer reachable to the crawler). Pure / no I/O so +/// it's unit-testable. +/// +/// Comparison is on the **base** PURL (qualifiers stripped) on both +/// sides: the pypi crawler reports base PURLs, but a manifest may hold +/// several qualified release variants (`?artifact_id=...`) of one +/// installed package. Matching on the base keeps every variant of an +/// installed package while still pruning all variants of one that is +/// gone — otherwise `scan --all-releases --sync` would prune the very +/// variants it just downloaded. +/// +/// `vendored` (the ledger's purl-key set, see `vendored_purl_keys`) is +/// always exempt: a vendored package is consumed from the committed +/// `.socket/vendor/` artifact, so the crawler not finding an installed +/// copy is its NORMAL state, not "no longer installed". Without this, a +/// wiped node_modules would prune the manifest entry — and the next +/// `vendor` run would then reconcile-revert the vendoring itself. +/// +/// Both sides are compared in percent-DECODED form (`normalize_purl`): +/// manifest keys come from the API encoded (`pkg:npm/%40scope/x@1`) while +/// crawler purls carry the literal `@scope` — comparing the raw strings +/// would make every encoded scoped entry look prunable and `--prune`/ +/// `--sync` would GC the very patch it just downloaded. +fn detect_prunable( + manifest: &PatchManifest, + scanned_purls: &HashSet, + vendored: &HashSet, +) -> Vec { + let scanned_bases: HashSet = scanned_purls + .iter() + .map(|p| normalize_purl(strip_purl_qualifiers(p)).into_owned()) + .collect(); + manifest + .patches + .keys() + .filter(|p| { + let base = normalize_purl(strip_purl_qualifiers(p)); + !scanned_bases.contains(base.as_ref()) + && !vendored.contains(p.as_str()) + && !vendored.contains(strip_purl_qualifiers(p)) + }) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::scan::tests::manifest_with; + + // ---- detect_prunable --------------------------------------------------- + + fn scanned(purls: &[&str]) -> HashSet { + purls.iter().map(|s| (*s).to_string()).collect() + } + + /// The "nothing vendored" set most prune tests run with. + fn no_vendored() -> HashSet { + HashSet::new() + } + + /// GlobalArgs rooted at the test project dir (the vendored-state GC + /// loads `.socket/vendor/state.json` from `cwd`; these fixtures have + /// none, so the vendor pass is a no-op). + fn gc_common(cwd: &Path) -> crate::args::GlobalArgs { + crate::args::GlobalArgs { + cwd: cwd.to_path_buf(), + ..Default::default() + } + } + + #[test] + fn detect_prunable_empty_manifest_empty_scanned() { + let m = PatchManifest::new(); + assert!(detect_prunable(&m, &scanned(&[]), &no_vendored()).is_empty()); + } + + #[test] + fn detect_prunable_empty_manifest_nonempty_scanned() { + let m = PatchManifest::new(); + // No manifest entries → nothing to prune even if the crawl found + // packages that don't appear in the manifest. + assert!(detect_prunable(&m, &scanned(&["pkg:npm/foo@1"]), &no_vendored()).is_empty()); + } + + #[test] + fn detect_prunable_all_entries_present_in_scan() { + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a"), ("pkg:npm/bar@2.0", "uuid-b")]); + let s = scanned(&["pkg:npm/foo@1.0", "pkg:npm/bar@2.0"]); + assert!(detect_prunable(&m, &s, &no_vendored()).is_empty()); + } + + #[test] + fn detect_prunable_returns_missing_entries() { + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a"), ("pkg:npm/bar@2.0", "uuid-b")]); + // foo is still installed, bar is gone. + let s = scanned(&["pkg:npm/foo@1.0"]); + let mut out = detect_prunable(&m, &s, &no_vendored()); + out.sort(); + assert_eq!(out, vec!["pkg:npm/bar@2.0".to_string()]); + } + + #[test] + fn detect_prunable_returns_everything_when_scan_is_empty() { + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a"), ("pkg:npm/bar@2.0", "uuid-b")]); + let mut out = detect_prunable(&m, &scanned(&[]), &no_vendored()); + out.sort(); + assert_eq!( + out, + vec!["pkg:npm/bar@2.0".to_string(), "pkg:npm/foo@1.0".to_string()], + ); + } + + #[test] + fn detect_prunable_keeps_pypi_variants_of_installed_base() { + // Manifest holds three qualified release variants; the crawler + // reports only the base PURL. None should be pruned — they all + // belong to the installed package. + let m = manifest_with(&[ + ("pkg:pypi/six@1.16.0?artifact_id=wheel-a", "uuid-a"), + ("pkg:pypi/six@1.16.0?artifact_id=wheel-b", "uuid-b"), + ("pkg:pypi/six@1.16.0?artifact_id=sdist", "uuid-c"), + ]); + let out = detect_prunable(&m, &scanned(&["pkg:pypi/six@1.16.0"]), &no_vendored()); + assert!( + out.is_empty(), + "variants of an installed base must not be pruned; got {out:?}" + ); + } + + #[test] + fn detect_prunable_removes_all_variants_of_uninstalled_base() { + // The package is no longer installed (empty crawl): every + // release variant is prunable. + let m = manifest_with(&[ + ("pkg:pypi/six@1.16.0?artifact_id=wheel-a", "uuid-a"), + ("pkg:pypi/six@1.16.0?artifact_id=sdist", "uuid-c"), + ]); + let out = detect_prunable(&m, &scanned(&[]), &no_vendored()); + assert_eq!(out.len(), 2, "all variants of a gone package should prune"); + } + + #[test] + fn detect_prunable_exempts_vendored_purls() { + // A vendored package is consumed from the committed artifact — + // the crawler not seeing an installed copy (wiped node_modules) + // is its normal state. Pruning it would orphan the manifest + // entry and let the next `vendor` run reconcile-revert the + // vendoring itself. + let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a"), ("pkg:npm/bar@2.0", "uuid-b")]); + let vendored: HashSet = ["pkg:npm/foo@1.0".to_string()].into_iter().collect(); + let out = detect_prunable(&m, &scanned(&[]), &vendored); + assert_eq!( + out, + vec!["pkg:npm/bar@2.0".to_string()], + "vendored foo exempt, non-vendored bar prunable" + ); + } + + #[test] + fn detect_prunable_encoded_manifest_key_not_pruned() { + // The API serves scoped purls percent-encoded and they land in the + // manifest verbatim; the crawler reports the literal `@scope` form. + // Comparing raw strings would make every encoded scoped entry look + // prunable — `scan --prune` would GC the patch it just downloaded. + let m = manifest_with(&[("pkg:npm/%40scope/x@1.0.0", "uuid-a")]); + let s = scanned(&["pkg:npm/@scope/x@1.0.0"]); + assert!( + detect_prunable(&m, &s, &no_vendored()).is_empty(), + "encoded manifest key must match the decoded scanned purl" + ); + // A genuinely-gone encoded entry still prunes. + let out = detect_prunable(&m, &scanned(&[]), &no_vendored()); + assert_eq!(out, vec!["pkg:npm/%40scope/x@1.0.0".to_string()]); + } + + #[test] + fn detect_prunable_exempts_qualified_variant_of_vendored_base() { + // The ledger key set carries qualifier-stripped bases (see + // `vendored_purl_keys`), so a qualified manifest variant of a + // vendored package is exempt via its base purl. + let m = manifest_with(&[("pkg:pypi/six@1.16.0?artifact_id=wheel-a", "uuid-a")]); + let vendored: HashSet = ["pkg:pypi/six@1.16.0".to_string()].into_iter().collect(); + let out = detect_prunable(&m, &scanned(&[]), &vendored); + assert!( + out.is_empty(), + "qualified variant of a vendored base must not prune; got {out:?}" + ); + } + + // ---- preview_apply_gc / run_apply_gc parity ---------------------------- + // The dry-run preview MUST report the same orphan blobs/archives the real + // (wet) prune would remove. Both delete the prunable manifest entries + // first, then sweep; the cleanup helpers derive the "still referenced" + // blob set from the manifest they're handed, so a preview that swept + // against the un-pruned manifest would keep the prunable entries' blobs + // marked "used" and under-report `orphan*`/`bytesReclaimable`. + + /// Write a manifest holding a single entry that references one afterHash + /// blob, plant that blob on disk, and return `(manifest_path, socket_dir, + /// blob_path)`. + fn seed_manifest_with_blob( + tmp: &std::path::Path, + purl: &str, + after_hash: &str, + ) -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) { + let socket_dir = tmp.join(".socket"); + let blobs_dir = socket_dir.join("blobs"); + std::fs::create_dir_all(&blobs_dir).unwrap(); + let blob_path = blobs_dir.join(after_hash); + // Non-trivial size so `bytesReclaimable`/`bytesFreed` is observably > 0. + std::fs::write(&blob_path, vec![0u8; 64]).unwrap(); + + let manifest_path = socket_dir.join("manifest.json"); + let manifest = serde_json::json!({ + "patches": { + purl: { + "uuid": "11111111-1111-4111-8111-111111111111", + "exportedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": "0".repeat(64), + "afterHash": after_hash, + } + }, + "vulnerabilities": {}, + "description": "seed", + "license": "MIT", + "tier": "free", + } + } + }); + std::fs::write( + &manifest_path, + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + (manifest_path, socket_dir, blob_path) + } + + #[tokio::test] + async fn preview_apply_gc_reports_blobs_of_prunable_entry() { + // The package is not installed (empty scan), so its entry is prunable + // and its only blob is reclaimable. A correct PREVIEW must count that + // blob even though it is still referenced by the not-yet-pruned entry. + let tmp = tempfile::tempdir().unwrap(); + let after_hash = "a".repeat(64); + let (manifest_path, socket_dir, blob_path) = + seed_manifest_with_blob(tmp.path(), "pkg:npm/gone@1.0.0", &after_hash); + + let scanned: HashSet = HashSet::new(); + let preview = preview_apply_gc( + &gc_common(tmp.path()), + &manifest_path, + &socket_dir, + &scanned, + &no_vendored(), + ) + .await; + + assert_eq!( + preview.pruned, + vec!["pkg:npm/gone@1.0.0".to_string()], + "preview must list the uninstalled entry as prunable" + ); + assert_eq!( + preview.blobs.blobs_removed, 1, + "preview must count the prunable entry's blob as an orphan \ + (regression: it was masked because the entry still referenced it)" + ); + assert!( + preview.total_bytes() > 0, + "bytesReclaimable must be > 0 when an orphan blob would be freed" + ); + // Preview is non-mutating: blob and manifest untouched. + assert!( + blob_path.exists(), + "dry-run preview must not delete the blob" + ); + let m = read_manifest(&manifest_path).await.unwrap().unwrap(); + assert!( + m.patches.contains_key("pkg:npm/gone@1.0.0"), + "dry-run preview must not prune the manifest entry" + ); + } + + #[tokio::test] + async fn preview_and_apply_gc_agree_on_orphan_counts() { + // The preview's reclaimable counts must equal what the wet run frees. + let after_hash = "b".repeat(64); + + let tmp_preview = tempfile::tempdir().unwrap(); + let (mp_p, sd_p, blob_p) = + seed_manifest_with_blob(tmp_preview.path(), "pkg:npm/gone@1.0.0", &after_hash); + let scanned: HashSet = HashSet::new(); + let preview = preview_apply_gc( + &gc_common(tmp_preview.path()), + &mp_p, + &sd_p, + &scanned, + &no_vendored(), + ) + .await; + assert!(blob_p.exists(), "preview must not mutate"); + + let tmp_wet = tempfile::tempdir().unwrap(); + let (mp_w, sd_w, blob_w) = + seed_manifest_with_blob(tmp_wet.path(), "pkg:npm/gone@1.0.0", &after_hash); + let wet = run_apply_gc( + &gc_common(tmp_wet.path()), + &mp_w, + &sd_w, + &scanned, + &no_vendored(), + ) + .await; + + assert_eq!( + preview.blobs.blobs_removed, wet.blobs.blobs_removed, + "preview and wet run must agree on the orphan-blob count" + ); + assert_eq!( + preview.total_bytes(), + wet.total_bytes(), + "preview and wet run must agree on reclaimable bytes" + ); + assert_eq!(preview.pruned, wet.pruned, "prunable set must match"); + // The wet run actually removed the blob and pruned the entry. + assert!(!blob_w.exists(), "wet run must delete the orphan blob"); + let m = read_manifest(&mp_w).await.unwrap().unwrap(); + assert!( + !m.patches.contains_key("pkg:npm/gone@1.0.0"), + "wet run must prune the entry" + ); + } +} diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs new file mode 100644 index 00000000..e3fff04e --- /dev/null +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -0,0 +1,539 @@ +//! The hosted-mode (`--mode hosted` / `--redirect`) flow: rewrite ONLY the +//! patched dependencies' lockfile / registry-config entries to point at +//! Socket's hosted vendored patches. Self-contained — reuses `run`'s +//! discovery, then returns without touching the apply/vendor branches. + +use socket_patch_core::api::types::BatchPackagePatches; + +use crate::commands::vex::generate_vex_from_manifest_path; + +use super::{discover_selected, ScanArgs}; + +/// Candidate lockfiles / registry configs the redirect rewriters may touch — +/// read from the project when present and handed to `rewrite_registry_redirect`. +const REDIRECT_CANDIDATE_FILES: &[&str] = &[ + "package-lock.json", + "npm-shrinkwrap.json", + "pnpm-lock.yaml", + "yarn.lock", + // A berry lock's cache-config gate reads `.yarnrc.yml`; bun's text lock is + // `bun.lock` (its binary `bun.lockb` is auto-migrated in `run_redirect`). + ".yarnrc.yml", + "bun.lock", + "requirements.txt", + "uv.lock", + "Cargo.toml", + "Cargo.lock", + ".cargo/config.toml", + "composer.lock", + "nuget.config", + "packages.lock.json", + "Gemfile", + "Gemfile.lock", + "pom.xml", + // Maven Trusted Checksums files the fail-closed maven rewriter merges into + // (read so an existing user config / checksum set is preserved, not + // clobbered). + ".mvn/maven.config", + ".mvn/checksums/checksums.sha256", + // Gradle build scripts are never edited — their presence only feeds the + // maven rewriter's paste-able `exclusiveContent` snippet warning. + "settings.gradle", + "settings.gradle.kts", + "build.gradle", + "build.gradle.kts", +]; + +/// `pkg:/@` → `(type, coordinate, version)`. The +/// coordinate keeps its full slash-bearing form (npm `@scope/name`, composer +/// `vendor/pkg`, golang module path) — the rewriters treat that as the `name` +/// (their `full_name()` is `name` when `namespace` is `None`). +fn parse_purl_simple(purl: &str) -> Option<(String, String, String)> { + let stripped = socket_patch_core::utils::purl::strip_purl_qualifiers(purl); + let rest = stripped.strip_prefix("pkg:")?; + let (typ, after) = rest.split_once('/')?; + let (coord, version) = after.rsplit_once('@')?; + let name = socket_patch_core::utils::purl::percent_decode_purl_component(coord).into_owned(); + Some((typ.to_string(), name, version.to_string())) +} + +/// `scan --redirect`: resolve hosted-patch references for the selected patches, +/// then rewrite ONLY those dependencies' lockfile/registry-config entries to +/// point at the hosted vendored patches (the byte-identical counterpart of the +/// GitHub-app registry mode). No artifact bytes land in the repo. +pub(super) async fn run_redirect( + args: &ScanArgs, + api_client: &socket_patch_core::api::client::ApiClient, + effective_org_slug: Option<&str>, + all_packages_with_patches: &[BatchPackagePatches], + can_access_paid_patches: bool, +) -> i32 { + use socket_patch_core::manifest::schema::PatchRecord; + use socket_patch_core::patch::redirect::{ + rewrite_registry_redirect, DepOverride, RedirectState, + }; + + // Same discovery/selection as `--apply`/`--vendor`. + let selected = match discover_selected( + api_client, + effective_org_slug, + all_packages_with_patches, + can_access_paid_patches, + ) + .await + { + Ok(s) => s, + Err(code) => return code, + }; + + let mut skipped: Vec = Vec::new(); + let mut overrides: Vec = Vec::new(); + // (purl, uuid, artifact_url, registry index_url, maven suffixed version) + // per granted reference — used AFTER the rewrite to decide which deps were + // actually redirected (their target URL / index / suffixed version landed + // in a file) before persisting records or attesting anything. The last + // element is Some only for fail-closed maven overrides. + type RedirectCandidate = (String, String, String, Option, Option); + let mut candidates: Vec = Vec::new(); + + if !selected.is_empty() { + let uuids: Vec = selected.iter().map(|s| s.uuid.clone()).collect(); + let references = match api_client.fetch_registry_references(&uuids).await { + Ok(r) => r, + Err(e) => { + eprintln!("failed to resolve patch references: {e}"); + return 1; + } + }; + for sel in &selected { + let Some(reference) = references.get(&sel.uuid) else { + skipped.push(serde_json::json!({ "purl": sel.purl, "uuid": sel.uuid, "reason": "not_found" })); + continue; + }; + if reference.status != "granted" && reference.status != "reused" { + skipped.push(serde_json::json!({ "purl": sel.purl, "uuid": sel.uuid, "reason": reference.status })); + continue; + } + let purl = reference.purl.as_deref().unwrap_or(&sel.purl); + let Some((ecosystem, name, version)) = parse_purl_simple(purl) else { + skipped.push( + serde_json::json!({ "purl": purl, "uuid": sel.uuid, "reason": "bad_purl" }), + ); + continue; + }; + let Some(url) = reference.url.clone() else { + skipped.push( + serde_json::json!({ "purl": purl, "uuid": sel.uuid, "reason": "no_url" }), + ); + continue; + }; + let mut integrity = reference + .artifacts + .iter() + .flatten() + .find(|a| a.kind == "tarball") + .map(|a| a.integrity.clone()) + .unwrap_or_default(); + // The yarn-berry cache zip carries the `yarnBerry10c0` checksum the + // berry rewriter pins (berry verifies the zip, not the tarball). + // Merge it in and carry the zip URL (None when not stored yet). + let berry_zip = reference + .artifacts + .iter() + .flatten() + .find(|a| a.kind == "yarn-berry-zip"); + if let Some(c) = berry_zip.and_then(|a| a.integrity.yarn_berry10c0.clone()) { + integrity.yarn_berry10c0 = Some(c); + } + candidates.push(( + purl.to_string(), + sel.uuid.clone(), + url.clone(), + reference + .registry_override + .as_ref() + .map(|o| o.index_url.clone()), + reference + .registry_override + .as_ref() + .and_then(|o| o.identifiers.maven_suffixed_version.clone()), + )); + overrides.push(DepOverride { + ecosystem, + name, + namespace: None, + version, + token: String::new(), + patch_uuid: sel.uuid.clone(), + artifact_url: url, + berry_zip_url: berry_zip.and_then(|a| a.url.clone()), + registry_override: reference.registry_override.clone(), + integrity, + }); + } + } + + // bun.lockb auto-migration: the redirect rewriter only edits the TEXT + // lockfile, so a project locked to a binary `bun.lockb` must be re-locked + // to `bun.lock` first. `bun install --save-text-lockfile --frozen-lockfile + // --lockfile-only` writes bun.lock, DELETES bun.lockb, needs no network, + // and fails closed on drift. Dry-run only warns; a failure degrades to the + // rewriter's own presence-only refusal (the .lockb stays a candidate file). + // Gated on an npm-ecosystem override: the migration exists solely so the + // bun rewriter has a text lock to edit — with nothing to redirect it would + // re-lock (and delete) the user's lockfile as a side effect of a no-op run. + let mut migration_warnings: Vec = Vec::new(); + let mut migration_edits: Vec = Vec::new(); + let has_lockb = args.common.cwd.join("bun.lockb").exists(); + let has_bun_lock = args.common.cwd.join("bun.lock").exists(); + let has_npm_override = overrides.iter().any(|o| o.ecosystem == "npm"); + if has_lockb && !has_bun_lock && has_npm_override { + if args.common.dry_run { + migration_warnings.push(serde_json::json!({ + "code": "redirect_bun_lockb_would_migrate", + "detail": "bun.lockb would be migrated to a text bun.lock \ + (`bun install --save-text-lockfile`) before redirecting; \ + re-run without --dry-run to apply", + })); + } else { + // `.output()` (not `.status()`): bun's install chatter must not + // interleave with the machine `--json` envelope on stdout. + let output = std::process::Command::new("bun") + .args([ + "install", + "--save-text-lockfile", + "--frozen-lockfile", + "--lockfile-only", + ]) + .current_dir(&args.common.cwd) + .output(); + let migrated = matches!(output, Ok(o) if o.status.success()) + && args.common.cwd.join("bun.lock").exists(); + if migrated { + // bun deleted bun.lockb itself. Record the removal so `--revert` + // knows the file was replaced (binary — git history is the + // restore path, so no `original` bytes are captured). + migration_edits.push(socket_patch_core::patch::redirect::FileEdit { + path: "bun.lockb".into(), + kind: "redirect_bun_lockb_migrated".into(), + action: "removed".into(), + key: None, + original: None, + new: None, + }); + } else { + migration_warnings.push(serde_json::json!({ + "code": "redirect_bun_lockb_unsupported", + "detail": "bun.lockb could not be migrated to a text bun.lock \ + (`bun install --save-text-lockfile` failed or is unavailable); \ + the redirect cannot pin a binary lockfile", + })); + } + } + } + + // Read the project's candidate files, run the rewriters. + let mut files: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for name in REDIRECT_CANDIDATE_FILES { + if let Ok(content) = std::fs::read_to_string(args.common.cwd.join(name)) { + files.insert((*name).to_string(), content); + } + } + + // Rush monorepos have no root package.json/lock pair: the single pnpm + // source-of-truth lock lives at common/config/rush/pnpm-lock.yaml, and + // (when subspaces are enabled) one lock per subspace under + // common/config/subspaces//. Add them under their repo-relative + // keys — the pnpm rewriter is basename-generalized, so nested keys are + // rewritten in place, and the write-back below is already path-generic. + let mut rush_warnings: Vec = Vec::new(); + let mut rush_lock_keys: Vec = Vec::new(); + if args.common.cwd.join("rush.json").is_file() { + let common_lock = "common/config/rush/pnpm-lock.yaml"; + if let Ok(content) = std::fs::read_to_string(args.common.cwd.join(common_lock)) { + files.insert(common_lock.to_string(), content); + rush_lock_keys.push(common_lock.to_string()); + } + let subspaces_dir = args.common.cwd.join("common/config/subspaces"); + if let Ok(read_dir) = std::fs::read_dir(&subspaces_dir) { + // read_dir order is unspecified — sort for deterministic output. + let mut subspace_dirs: Vec = read_dir + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .map(|e| e.path()) + .collect(); + subspace_dirs.sort(); + for dir in subspace_dirs { + let Some(name) = dir.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let key = format!("common/config/subspaces/{name}/pnpm-lock.yaml"); + if let Ok(content) = std::fs::read_to_string(dir.join("pnpm-lock.yaml")) { + files.insert(key.clone(), content); + rush_lock_keys.push(key); + } + } + } + } + + let rewrite = rewrite_registry_redirect(&files, &overrides); + let rewritten: Vec = rewrite.files.keys().cloned().collect(); + + // Editing a Rush lock outside `rush update` desyncs the + // pnpmShrinkwrapHash recorded in repo-state.json. When + // preventManualShrinkwrapChanges is enabled, `rush install` then + // refuses until `rush update` refreshes that hash — but the redirect + // survives `rush update` (pnpm preserves locked resolutions for + // unchanged specifiers). Warn only when the rewrite actually landed in a + // Rush lock and the repo-state file that carries the hash is present. + if rush_lock_keys + .iter() + .any(|key| rewrite.files.contains_key(key)) + && args + .common + .cwd + .join("common/config/rush/repo-state.json") + .is_file() + { + rush_warnings.push(serde_json::json!({ + "code": "redirect_rush_repo_state_stale", + "detail": + "pnpm-lock.yaml was edited outside `rush update`; if \ + preventManualShrinkwrapChanges is enabled, `rush install` fails until \ + `rush update` refreshes repo-state.json (the redirect survives `rush \ + update`)", + })); + } + + // A dep counts as REDIRECTED only if its hosted-artifact URL (or its + // per-dependency registry index URL) actually landed in the project's + // files — either written by this run or already present from an earlier + // one. A granted reference whose rewriter found nothing to edit (e.g. no + // lockfile) must NOT be recorded or attested: nothing pins the patch. + let final_texts: Vec<&String> = files + .iter() + .map(|(name, content)| rewrite.files.get(name).unwrap_or(content)) + .chain( + rewrite + .files + .iter() + .filter(|(name, _)| !files.contains_key(*name)) + .map(|(_, content)| content), + ) + .collect(); + let confirmed: Vec<(String, String)> = candidates + .iter() + .filter(|(_, _, artifact_url, index_url, suffixed_version)| { + let encoded = socket_patch_core::utils::uri::encode_uri_component(artifact_url); + final_texts.iter().any(|text| { + text.contains(artifact_url.as_str()) + // The berry rewriter writes the URL percent-encoded into the + // lock's `::__archiveUrl=` binding, so the raw form is absent. + || text.contains(encoded.as_str()) + || index_url.as_deref().is_some_and(|iu| text.contains(iu)) + // Fail-closed maven pins the globally-unique + // `-socket.` suffixed version (never the `.pom` URL), + // so match on that string. + || suffixed_version + .as_deref() + .is_some_and(|sv| text.contains(sv)) + }) + }) + .map(|(purl, uuid, _, _, _)| (purl.clone(), uuid.clone())) + .collect(); + + // Fetch the full patch view (file hashes + vulnerabilities) for each + // CONFIRMED redirect and persist it so a post-install `socket-patch vex` + // can attest the patch. A fetch failure does not undo the redirect, but + // it leaves the patch unattestable — surface it as a warning (JSON + + // stderr) so CI can detect the attestation gap and re-run. + let mut records: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + let mut record_warnings: Vec = Vec::new(); + if !args.common.dry_run { + for (purl, uuid) in &confirmed { + match api_client.fetch_patch(effective_org_slug, uuid).await { + Ok(Some(resp)) => { + let (rec_purl, record) = + crate::commands::get::record_from_patch_response(&resp); + records.insert(rec_purl, record); + } + Ok(None) | Err(_) => { + record_warnings.push(serde_json::json!({ + "code": "record_fetch_failed", + "detail": format!( + "{purl} redirected, but its patch record could not be fetched; \ + it will be missing from VEX until `scan --redirect` is re-run" + ), + })); + } + } + } + } + + if !args.common.dry_run { + for (rel, content) in &rewrite.files { + let path = args.common.cwd.join(rel); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::write(&path, content) { + eprintln!("failed to write {rel}: {e}"); + return 1; + } + } + // Ledger (mirrors the vendor state.json shape): recorded edits for a + // future revert + the patch records (file hashes + vulnerabilities) so + // a post-install `socket-patch vex` can attest the redirected patches. + // MERGE with any existing ledger rather than overwriting: an idempotent + // re-run produces no new edits (the lockfile already points at the + // hosted patch), and clobbering the file would lose the original + // pre-redirect values a future revert needs. New edits APPEND (revert + // walks them in reverse); records are keyed by PURL, newest wins. + if !rewrite.edits.is_empty() || !records.is_empty() || !migration_edits.is_empty() { + let vendor_dir = args.common.cwd.join(".socket").join("vendor"); + let _ = std::fs::create_dir_all(&vendor_dir); + let mut ledger = + socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd) + .await + .unwrap_or_else(RedirectState::new); + // Ledgers written before the mode-string rename carry + // `"mode": "redirect"`; normalize on rewrite so the on-disk + // ledger converges on the documented "hosted" name (the + // loader accepts either — mode is an opaque string to it). + ledger.mode = "hosted".to_string(); + // The bun.lockb→bun.lock migration removal precedes the rewrite + // edits so `--revert` unwinds it last (after restoring bun.lock). + ledger.edits.extend(migration_edits.iter().cloned()); + ledger.edits.extend(rewrite.edits.iter().cloned()); + ledger.records.extend(records.clone()); + // The ledger is the only revert path and the VEX record store — + // a swallowed write failure would leave the rewritten lockfiles + // unrevertable while reporting success. + if let Err(e) = std::fs::write( + vendor_dir.join("redirect-state.json"), + format!("{}\n", serde_json::to_string_pretty(&ledger).unwrap()), + ) { + eprintln!("failed to write .socket/vendor/redirect-state.json: {e}"); + return 1; + } + } + } + + // Emit an OpenVEX attestation when `--vex` was requested. The redirected + // bytes are fetched from the hosted patch server at install time, so the + // PURLs CONFIRMED REDIRECTED BY THIS RUN are attested from the ledger + // records WITHOUT hash verification (`assume_applied` — the integrity + // pins written into the lockfile are the evidence), while any OTHER + // manifest patches (previously applied / vendored — and any stale ledger + // records this run did not confirm) still verify normally. A post-install + // `socket-patch vex` hash-verifies the redirected patches against the + // installed tree (it reads the records back from the redirect ledger via + // augment_with_redirect). Requested-but-failed VEX (including "nothing to + // attest") flips the exit code, matching `scan --vex`. + let mut vex_statements: Option = None; + let mut vex_error: Option<(&'static str, String)> = None; + let mut vex_code = 0; + if args.vex.vex.is_some() && !args.common.dry_run { + let mut params = args.vex.to_build_params(); + params.assume_applied = confirmed.iter().map(|(purl, _)| purl.clone()).collect(); + let manifest_path = args.common.resolved_manifest_path(); + match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await { + Ok(summary) => vex_statements = Some(summary.statements), + Err(e) => { + vex_code = 1; + vex_error = Some((e.code, e.message)); + } + } + } + + if args.common.json { + let mut warnings: Vec = rewrite + .warnings + .iter() + .map(|w| { + serde_json::json!({ + "code": w.code, "detail": w.detail, + }) + }) + .collect(); + warnings.extend(record_warnings.iter().cloned()); + warnings.extend(migration_warnings.iter().cloned()); + warnings.extend(rush_warnings.iter().cloned()); + let mut result = serde_json::json!({ + "status": "success", + "redirect": { + // Final mode naming: `--redirect` IS hosted mode. Additive + // key so JSON consumers can dispatch on the mode without + // inferring it from which sub-object is present. + "mode": "hosted", + "redirected": confirmed.len(), + "rewrittenFiles": rewritten, + "skipped": skipped, + "warnings": warnings, + "dryRun": args.common.dry_run, + } + }); + if let Some(statements) = vex_statements { + result["vex"] = serde_json::json!({ + "path": args.vex.vex.as_ref().unwrap().display().to_string(), + "statements": statements, + "format": "openvex-0.2.0", + "verified": false, + }); + } else if let Some((code, message)) = &vex_error { + result["status"] = serde_json::json!("error"); + result["error"] = serde_json::json!({ "code": code, "message": message }); + } + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + } else { + if !args.common.silent { + let verb = if args.common.dry_run { + "would rewrite" + } else { + "rewrote" + }; + println!( + "Redirected {} package(s); {verb} {} file(s).", + confirmed.len(), + rewritten.len() + ); + for s in &skipped { + eprintln!(" skipped {} ({})", s["purl"], s["reason"]); + } + // Same warning set as the JSON envelope, same order: the + // rewriter's own warnings first (e.g. `no package-lock.json`), + // then the record/migration/rush extras. + for w in &rewrite.warnings { + eprintln!(" warning: {}", w.detail); + } + for w in &record_warnings { + eprintln!(" warning: {}", w["detail"]); + } + for w in &migration_warnings { + eprintln!(" warning: {}", w["detail"]); + } + for w in &rush_warnings { + eprintln!(" warning: {}", w["detail"]); + } + if let Some(statements) = vex_statements { + eprintln!( + "Wrote OpenVEX document with {} statement(s) to {} (redirected patches are \ + attested from the ledger, not hash-verified — their bytes are fetched at \ + install time; run `socket-patch vex` after installing to verify against \ + the installed tree).", + statements, + args.vex.vex.as_ref().unwrap().display(), + ); + } else if args.vex.vex.is_some() && args.common.dry_run { + eprintln!("Skipping VEX generation (--dry-run)."); + } + } + // Errors print even under --silent ("errors only", never + // "nothing"): exit 1 with no message would be undiagnosable. + if let Some((_, message)) = &vex_error { + eprintln!("Error: VEX generation failed: {message}"); + } + } + vex_code +} diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs new file mode 100644 index 00000000..42c567f3 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -0,0 +1,1568 @@ +//! The `scan` command: crawl installed (and lockfile-resolved) packages, +//! query the patch API for available patches, and optionally consume them +//! in one of three modes — hosted (`hosted::run_redirect`), vendored +//! (`vendor_flow`), or agent (in-place apply) — with an optional GC pass +//! (`gc`) and discovery helpers (`discovery`). This module keeps the CLI +//! surface (`ScanArgs`, `ScanMode`, `resolve_mode_flags`, `run`) and the +//! small helpers shared across the submodules. + +use clap::Args; +use socket_patch_core::api::client::{ + build_proxy_fallback_client, get_api_client_with_overrides, is_fallback_candidate, +}; +use socket_patch_core::api::types::{BatchPackagePatches, PatchSearchResult}; +use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; +use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; +use socket_patch_core::utils::telemetry::{track_patch_scan_failed, track_patch_scanned}; +use std::collections::HashSet; +use std::io::IsTerminal; +use std::path::Path; + +use crate::args::{apply_env_toggles, GlobalArgs}; +use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; +use crate::ecosystem_dispatch::crawl_all_ecosystems; +use crate::output::{color, confirm, format_severity}; + +use super::get::{ + download_and_apply_patches, select_patches, truncate_with_ellipsis, DownloadParams, +}; + +mod discovery; +mod gc; +mod hosted; +mod vendor_flow; + +use self::discovery::{ + collect_vuln_ids, detect_updates, lockfile_supplement, preverify_vendor_baselines, + severity_order, vendored_ledger_supplement, +}; +use self::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; +use self::hosted::run_redirect; +use self::vendor_flow::{ + boxed_vendor_interactive_path, boxed_vendor_json_path, fold_vendored_skips_into_apply, + partition_skipped_selected, +}; + +const DEFAULT_BATCH_SIZE: usize = 100; + +/// The three patch-application modes `scan` can drive, selectable via +/// `--mode` (the documented spelling). Each variant is equivalent to one +/// legacy boolean flag, which remains supported as an alias. +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScanMode { + /// Rewrite lockfiles so ONLY patched dependencies resolve to Socket's + /// hosted patch server (== `--redirect`): no artifact bytes land in the + /// repo, but installs must reach the patch server. + Hosted, + /// Commit patched artifacts to `.socket/vendor/` (== `--vendor`): + /// hermetic, offline-safe installs at the cost of repo size. + Vendored, + /// Record patches in `.socket/manifest.json` + blobs and re-apply them + /// in place, e.g. from CI (== `--apply`): smallest repo footprint, but + /// every install environment must run the agent. + Agent, +} + +impl ScanMode { + /// The CLI spelling of the variant (`--mode `), for error messages. + fn cli_name(self) -> &'static str { + match self { + ScanMode::Hosted => "hosted", + ScanMode::Vendored => "vendored", + ScanMode::Agent => "agent", + } + } +} + +/// Fold the legacy boolean spellings (`--redirect` / `--vendor` / +/// `--apply` / `--sync`) into `args.mode`, so `ScanMode` is the single +/// source of truth everything downstream reads (the booleans are input +/// spellings only, never consulted after this returns), and enforce the +/// cross-flag rules clap cannot express: +/// +/// * `--mode X` combined with a boolean belonging to a DIFFERENT mode is a +/// contradiction → `Err`. Clap's `conflicts_with` is value-independent — +/// it could not allow `--mode vendored --vendor` while rejecting +/// `--mode hosted --vendor` — so the check lives here. +/// * The same mode spelled both ways (`--mode vendored --vendor`) is +/// redundant but accepted: both spellings mean one thing. +/// * `--sync` implies `--apply`, so it counts as an agent-mode spelling; +/// `--prune` is an orthogonal GC knob and never conflicts. (`--sync`'s +/// prune half is orthogonal too, and stays a separate read in `run`.) +/// * `--detached` requires vendored mode in either spelling. The former +/// clap-level `requires = "vendor"` couldn't see `--mode vendored`, so +/// the requirement moved here too. +/// +/// Public (not `pub(crate)`) so the CLI-contract tests can exercise the +/// fold without driving a full `run()`. +pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { + if let Some(mode) = args.mode { + // First boolean that selects a mode OTHER than the requested one. + let mut conflicting: Option<&'static str> = None; + if args.redirect && mode != ScanMode::Hosted { + conflicting = Some("--redirect"); + } + if args.vendor && mode != ScanMode::Vendored { + conflicting = Some("--vendor"); + } + if args.apply && mode != ScanMode::Agent { + conflicting = Some("--apply"); + } + if args.sync && mode != ScanMode::Agent { + conflicting = Some("--sync"); + } + if let Some(flag) = conflicting { + // "cannot be used with" phrasing matches clap's conflict errors — + // the scan_vendor_e2e contract test accepts exactly that shape. + return Err(format!( + "--mode {} cannot be used with {flag}: the flags select different \ + modes (hosted == --redirect, vendored == --vendor, agent == --apply/--sync)", + mode.cli_name(), + )); + } + } else if args.redirect { + args.mode = Some(ScanMode::Hosted); + } else if args.vendor { + args.mode = Some(ScanMode::Vendored); + } else if args.apply || args.sync { + args.mode = Some(ScanMode::Agent); + } + if args.detached && args.mode != Some(ScanMode::Vendored) { + // "required" phrasing matches clap's requires errors — the + // scan_vendor_e2e contract test accepts exactly that shape. + return Err( + "--detached requires vendored mode: --mode vendored or --vendor is required" + .to_string(), + ); + } + Ok(()) +} + +#[derive(Args)] +pub struct ScanArgs { + #[command(flatten)] + pub common: GlobalArgs, + + /// Number of packages to query per API request. + #[arg(long = "batch-size", env = "SOCKET_BATCH_SIZE", default_value_t = DEFAULT_BATCH_SIZE)] + pub batch_size: usize, + + /// Deprecated spelling of `--mode agent` (kept for compatibility; + /// prefer `--mode`). Download and apply selected patches in JSON mode + /// (non-interactive). Without a mode, `scan --json` is read-only — it + /// lists available patches plus an `updates` array but does not mutate + /// the manifest. Designed for unattended workflows (cron jobs, bots + /// that open PRs); pair with `--yes` for clarity though `--json` + /// already implies non-interactive confirmation. No effect outside + /// `--json` mode (the non-JSON path always prompts the user). + #[arg(long, default_value_t = false)] + pub apply: bool, + + /// Garbage-collect after the scan: prune manifest entries for + /// packages no longer present in the crawl, then delete orphan + /// blob, diff, and package-archive files from `.socket/`. Off by + /// default to preserve manifest state across temporary uninstalls; + /// pair with `--apply` (or use `--sync`) for the auto-update + /// workflow. + #[arg(long, default_value_t = false)] + pub prune: bool, + + /// Convenience flag for the auto-update workflow: implies both + /// `--apply` and `--prune`. Designed so a cron job or CI workflow + /// can run `socket-patch scan --json --sync --yes` and end up in a + /// fully-reconciled state in one invocation. + #[arg(long, default_value_t = false)] + pub sync: bool, + + /// Deprecated spelling of `--mode vendored` (kept for compatibility; + /// prefer `--mode`). Vendor every patched dependency into the + /// committable `.socket/vendor/` tree instead of applying patches in + /// place: download the selected patches, record them in the manifest, + /// then build + wire the vendored artifacts (the whole manifest is + /// vendored, so a package vendored at an older patch uuid is + /// re-vendored automatically). Conflicts with `--apply`/`--sync` + /// (vendoring replaces the in-place apply); combine with `--prune` + /// to drop uninstalled entries before they fail vendoring. JSON mode + /// is non-interactive like `--apply`; the interactive path prompts + /// before downloading. + #[arg(long, default_value_t = false, conflicts_with_all = ["apply", "sync"])] + pub vendor: bool, + + /// With vendored mode (`--mode vendored` / `--vendor`): do not write + /// `.socket/manifest.json` entries — the vendor ledger + /// (`.socket/vendor/state.json`) carries an embedded copy of each + /// patch record instead. Detached patches are invisible to + /// apply/rollback/repair (nothing is in the manifest); they are + /// undone per-purl via `remove ` or wholesale via + /// `vendor --revert`, and are exempt from `vendor`'s manifest + /// reconcile. The vendored-mode requirement is enforced in + /// `resolve_mode_flags` (not clap `requires`) so `--mode vendored` + /// satisfies it too. + #[arg(long, default_value_t = false)] + pub detached: bool, + + /// Redirect every patched dependency to Socket's HOSTED vendored patches + /// by rewriting lockfiles/registry configs so ONLY the patched dependency + /// points at the patch-server (`--patch-server-url`), instead of applying + /// patches in place or ejecting local artifacts. This is the remote + /// counterpart of `--vendor`: no artifact bytes land in the repo — the + /// lockfile pins the hosted URL + integrity (npm/pypi/composer) or a + /// per-dependency registry override (cargo/nuget/gem/…). Conflicts with + /// `--apply`/`--sync`/`--vendor`. Hidden from help: the flag is + /// unreleased and `--mode hosted` is the documented spelling. + #[arg(long, default_value_t = false, hide = true, conflicts_with_all = ["apply", "sync", "vendor"])] + pub redirect: bool, + + /// How discovered patches are consumed — the documented selector for + /// the three modes (each is equivalent to one boolean flag, kept as an + /// alias): + /// + /// * `hosted` (== `--redirect`): rewrite lockfiles so only patched + /// dependencies resolve to Socket's hosted patch server — no + /// artifact bytes in the repo, but installs must reach the server. + /// * `vendored` (== `--vendor`): commit patched artifacts under + /// `.socket/vendor/` — hermetic, offline-safe installs at the cost + /// of repo size. + /// * `agent` (== `--apply`): record patches in `.socket/manifest.json` + /// plus blobs and re-apply in place — smallest repo footprint, but + /// every environment must run the agent. + /// + /// Combining `--mode` with a boolean flag from a DIFFERENT mode is + /// rejected (see `resolve_mode_flags`); the same mode spelled both + /// ways is accepted. + #[arg(long = "mode", value_enum)] + pub mode: Option, + + /// Download patches for every release/distribution variant of a + /// matched package, not just the one(s) matching the locally- + /// installed distribution. Affects ecosystems with per-release + /// variants — PyPI (wheel/sdist via `artifact_id`), RubyGems + /// (`platform`), and Maven (`classifier`). Off by default: narrow + /// scans store only the patch(es) for the installed dist, keeping + /// `.socket/` small; `--all-releases` makes the manifest portable + /// across environments (e.g. cross-platform CI caches). + #[arg( + long = "all-releases", + env = "SOCKET_ALL_RELEASES", + default_value_t = false, + value_parser = crate::args::parse_bool_flag, + )] + pub all_releases: bool, + + /// On a successful scan, also generate an OpenVEX 0.2.0 document. + /// `--vex ` is the trigger; the `--vex-*` knobs mirror the + /// standalone `vex` command. The document is built from the manifest + /// as it stands after the scan (including any `--apply`/`--sync` + /// writes) and verified against on-disk state. A requested-but-failed + /// VEX makes the command exit non-zero. + #[command(flatten)] + pub vex: VexEmbedArgs, +} + +/// Embedded-VEX side-effect for `scan`'s JSON terminal returns. When +/// `--vex` was requested and `base_code` is 0, generate the OpenVEX +/// document from the post-scan manifest and fold the outcome into +/// `result` — a `vex` object on success, or `status: "error"` + `error` +/// on failure (per the fail-the-command contract). Returns the final exit +/// code: `base_code` when not requested / skipped / on VEX success, `1` +/// when VEX generation failed. Caller prints `result` after this returns. +async fn embed_vex_into_json( + common: &GlobalArgs, + vex_args: &VexEmbedArgs, + manifest_path: &Path, + base_code: i32, + result: &mut serde_json::Value, +) -> i32 { + if vex_args.vex.is_none() || base_code != 0 { + return base_code; + } + let params = vex_args.to_build_params(); + match generate_vex_from_manifest_path(common, ¶ms, manifest_path).await { + Ok(summary) => { + result["vex"] = serde_json::json!({ + "path": vex_args.vex.as_ref().unwrap().display().to_string(), + "statements": summary.statements, + "format": "openvex-0.2.0", + }); + 0 + } + Err(e) => { + result["status"] = serde_json::json!("error"); + result["error"] = serde_json::json!({ + "code": e.code, + "message": e.message, + }); + 1 + } + } +} + +/// Embedded-VEX side-effect for `scan`'s human-readable terminal returns. +/// Prints a one-line note (or error) and returns the final exit code: +/// `base_code` when not requested / skipped / on VEX success, `1` on VEX +/// failure. No-op unless `--vex` was set and `base_code` is 0. +async fn embed_vex_human( + common: &GlobalArgs, + vex_args: &VexEmbedArgs, + manifest_path: &Path, + base_code: i32, +) -> i32 { + if vex_args.vex.is_none() || base_code != 0 { + return base_code; + } + let params = vex_args.to_build_params(); + match generate_vex_from_manifest_path(common, ¶ms, manifest_path).await { + Ok(summary) => { + if !common.silent { + println!( + "Wrote OpenVEX document with {} statement(s) to {}", + summary.statements, + vex_args.vex.as_ref().unwrap().display(), + ); + } + 0 + } + Err(e) => { + // Errors print even under --silent ("errors only", never + // "nothing"): exit 1 with no message would be undiagnosable. + eprintln!("Error: VEX generation failed: {}", e.message); + 1 + } + } +} + +/// The per-package discovery + selection step shared by the apply, vendor, +/// and redirect flows: search each patched package's full patch list, then +/// resolve the newest accessible patch per PURL. Per-package search errors +/// are skipped — but when EVERY query errors the step produced no +/// trustworthy patch data at all, and reporting the empty set would be +/// indistinguishable from a genuine "no patches" result (the same masking +/// the batch loop in `run` guards against), so that surfaces as `Err(1)` +/// with the failure on stderr. Passes `is_json = false` to +/// `select_patches`: scan-driven workflows have no "specify --id" option, +/// so non-TTY runs auto-select the newest patch rather than erroring with +/// `selection_required`. `Err` carries the exit code. +async fn discover_selected( + api_client: &socket_patch_core::api::client::ApiClient, + org_slug: Option<&str>, + packages: &[BatchPackagePatches], + can_access_paid_patches: bool, +) -> Result, i32> { + let mut all_search_results: Vec = Vec::new(); + let mut error_count = 0usize; + let mut last_error: Option = None; + for pkg in packages { + match api_client + .search_patches_by_package(org_slug, &pkg.purl) + .await + { + Ok(response) => all_search_results.extend(response.patches), + Err(e) => { + error_count += 1; + last_error = Some(e.to_string()); + } + } + } + if error_count > 0 && error_count == packages.len() { + let err = last_error.unwrap_or_else(|| "all patch-detail queries failed".to_string()); + eprintln!("Error: all {error_count} patch-detail queries failed: {err}"); + return Err(1); + } + if all_search_results.is_empty() { + return Ok(Vec::new()); + } + select_patches(&all_search_results, can_access_paid_patches, false) +} + +/// The `DownloadParams` every scan-driven download shares. Only the output +/// shape (`json`/`silent`) and `save_only` differ per flow; vendor mode +/// never persists blobs (the vendor step consumes the staged sources). +fn download_params(args: &ScanArgs, save_only: bool, json: bool, silent: bool) -> DownloadParams { + DownloadParams { + cwd: args.common.cwd.clone(), + manifest_path: args.common.resolved_manifest_path(), + org: args.common.org.clone(), + save_only, + global: args.common.global, + global_prefix: args.common.global_prefix.clone(), + json, + silent, + download_mode: args.common.download_mode.clone(), + api_overrides: args.common.api_client_overrides(), + all_releases: args.all_releases, + strict: args.common.strict, + persist_blobs: args.mode != Some(ScanMode::Vendored), + } +} + +pub async fn run(mut args: ScanArgs) -> i32 { + apply_env_toggles(&args.common); + + // Fold the legacy mode booleans into `args.mode` before anything reads + // it, so every branch below keeps a single source of truth (the enum; + // the booleans are never consulted past this point). Cross-mode + // combinations get a usage-style error (exit 2, matching clap's + // conflict exit code) — see `resolve_mode_flags` for why clap itself + // can't express them. + if let Err(message) = resolve_mode_flags(&mut args) { + eprintln!("error: {message}"); + return 2; + } + + // Strict airgap (CLI_CONTRACT.md `--offline`: never contact the + // network; operations that need remote data fail loudly). Scan's + // patch discovery IS remote data — proceeding would POST the crawled + // package inventory to the batch endpoint — so refuse up front, + // before the crawl and before the API client is built (org + // auto-resolve is itself a network call). No telemetry fires here: + // offline gates `is_telemetry_disabled` too. + if args.common.offline { + let err = "scan requires network access to query the patch API and cannot run with \ + --offline/SOCKET_OFFLINE (strict airgap)"; + if args.common.json { + // Mirror the all-batches-failed error envelope shape so JSON + // consumers see one consistent scan-error schema. + let result = serde_json::json!({ + "status": "error", + "error": err, + "scannedPackages": 0, + "packagesWithPatches": 0, + "totalPatches": 0, + "freePatches": 0, + "paidPatches": 0, + "canAccessPaidPatches": false, + "packages": [], + "updates": [], + }); + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + } else { + eprintln!("Error: {err}"); + } + return 1; + } + + // `--sync` is sugar for `--mode agent --prune`. Derive locals once and + // use them everywhere downstream so the flag interactions are + // expressed in one place. `--apply --prune --sync` is redundant + // but legal. + let apply = args.mode == Some(ScanMode::Agent); + let vendor = args.mode == Some(ScanMode::Vendored); + let hosted = args.mode == Some(ScanMode::Hosted); + let prune = args.prune || args.sync; + + // A zero batch size would panic the API-query loop below: both + // `all_purls.len().div_ceil(batch_size)` and `all_purls.chunks(batch_size)` + // abort the process on a divisor/chunk-size of 0. `--batch-size 0` + // (or `SOCKET_BATCH_SIZE=0`) is otherwise unvalidated, so clamp to a + // floor of 1 — degrade to one-package batches rather than crash. + let batch_size = args.batch_size.max(1); + + // Resolved up-front (rather than at the GC site) because the embedded + // `--vex` side-effect reads the manifest at several terminal returns, + // including the early "no packages" exit before the GC block. + let manifest_path = args.common.resolved_manifest_path(); + let socket_dir = manifest_path.parent().unwrap().to_path_buf(); + + let overrides = args.common.api_client_overrides(); + let (mut api_client, mut use_public_proxy) = + get_api_client_with_overrides(overrides.clone()).await; + let telemetry_token = api_client.api_token().cloned(); + let telemetry_org = api_client.org_slug().cloned(); + // Tracks whether scan was downgraded from the authenticated + // endpoint to the public proxy mid-run after a 401/403. Surfaces + // in the final `patch_scanned` telemetry event so we can measure + // how often stale-token fallbacks fire in the wild. + let mut fallback_to_proxy = false; + + // org slug is already stored in the client + let effective_org_slug: Option<&str> = None; + + let crawler_options = CrawlerOptions { + cwd: args.common.cwd.clone(), + global: args.common.global, + global_prefix: args.common.global_prefix.clone(), + }; + + let scan_target = if args.common.global || args.common.global_prefix.is_some() { + "global packages" + } else { + "packages" + }; + + // `--silent` is "errors only" (CLI_CONTRACT.md): progress, the crawl + // summary, the results table, and the per-patch listing are all + // suppressed below, mirroring `list`/`get`/`repair`/`remove`. Errors + // and the JSON envelope are unaffected. + let show_progress = !args.common.json && !args.common.silent && std::io::stderr().is_terminal(); + + if show_progress { + eprint!("Scanning {scan_target}..."); + } + + // Crawl packages + let (mut all_crawled, mut eco_counts) = crawl_all_ecosystems(&crawler_options).await; + + // Lockfile supplement: dependencies the project's lockfile resolves + // that have NO installed copy (fresh clone, partial install). They join + // discovery — counts, API lookup, table, the prune "scanned" set — and + // are flagged "not yet installed" everywhere a user could act on them. + let lockfile_only = lockfile_supplement(&args.common, &all_crawled).await; + if !lockfile_only.packages.is_empty() { + for pkg in &lockfile_only.packages { + if let Some(eco) = Ecosystem::from_purl(&pkg.purl) { + *eco_counts.entry(eco).or_insert(0) += 1; + } + } + all_crawled.extend(lockfile_only.packages.iter().cloned()); + } + let ledger_supplement = vendored_ledger_supplement(&args.common, &all_crawled).await; + for pkg in &ledger_supplement { + if let Some(eco) = Ecosystem::from_purl(&pkg.purl) { + *eco_counts.entry(eco).or_insert(0) += 1; + } + } + all_crawled.extend(ledger_supplement); + + // Every PURL the crawl found, captured BEFORE the `--ecosystems` + // display/query filter is applied. Prunable detection (manifest + // entries whose PURL is not installed) must reference the full + // installed set: `--ecosystems npm` narrows what we *query and + // show*, but packages of other ecosystems are still installed. If + // prune used the filtered set instead, `scan --ecosystems npm --prune` + // would treat every cargo/go/pypi/gem manifest entry as "uninstalled" + // and delete it (plus its blobs) — silent cross-ecosystem data loss. + // Lockfile-only purls are deliberately included: a dependency the + // lockfile still resolves must not be pruned just because node_modules + // is wiped or partially installed. + let scanned_purls: HashSet = all_crawled.iter().map(|p| p.purl.clone()).collect(); + + // Vendor-ledger purl keys, loaded once and shared by the prune + // exemption (a vendored package is consumed from the committed + // artifact, so "absent from the crawl" is its normal state, not + // grounds for pruning) and the vendored-skip in the apply path. + let vendored_purls = + socket_patch_core::patch::vendor::vendored_purl_keys(&args.common.cwd).await; + + // Filter by --ecosystems if provided + let filtered_crawled: Vec<_> = if let Some(ref allowed) = args.common.ecosystems { + all_crawled + .into_iter() + .filter(|pkg| { + if let Some(eco) = Ecosystem::from_purl(&pkg.purl) { + allowed.iter().any(|a| a == eco.cli_name()) + } else { + false + } + }) + .collect() + } else { + all_crawled + }; + + let all_purls: Vec = filtered_crawled.iter().map(|p| p.purl.clone()).collect(); + let package_count = all_purls.len(); + + if package_count == 0 { + if show_progress { + eprintln!(); + } + // Telemetry: empty-scan still counts as a successful scan. + track_patch_scanned( + 0, + 0, + 0, + false, + args.common + .ecosystems + .clone() + .unwrap_or_default() + .as_slice(), + false, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; + if args.common.json { + // When the crawler finds nothing, GC is intentionally skipped + // — pruning every manifest entry on the assumption that the + // user "uninstalled everything" is too destructive. Bots + // that need full cleanup can call `repair` explicitly. No + // `gc` field emitted because the user didn't request one. + let mut result = serde_json::json!({ + "status": "success", + "scannedPackages": 0, + "lockfileOnlyPackages": 0, + "packagesWithPatches": 0, + "totalPatches": 0, + "freePatches": 0, + "paidPatches": 0, + "canAccessPaidPatches": false, + "packages": [], + "updates": [], + }); + let code = + embed_vex_into_json(&args.common, &args.vex, &manifest_path, 0, &mut result).await; + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + return code; + } else if args.common.silent { + // Errors only: the empty-scan hint is informational. + } else if args.common.global || args.common.global_prefix.is_some() { + println!("No global packages found."); + } else { + #[allow(unused_mut)] + let mut install_cmds = String::from("npm/yarn/pnpm/pip"); + install_cmds.push_str("/cargo"); + install_cmds.push_str("/go"); + install_cmds.push_str("/mvn"); + install_cmds.push_str("/composer"); + println!("No packages found. Run {install_cmds} install first."); + } + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + } + + // Build ecosystem summary + let mut eco_parts = Vec::new(); + for eco in Ecosystem::all() { + let count = if args.common.ecosystems.is_some() { + // When filtering, count the filtered packages + filtered_crawled + .iter() + .filter(|p| Ecosystem::from_purl(&p.purl) == Some(*eco)) + .count() + } else { + eco_counts.get(eco).copied().unwrap_or(0) + }; + if count > 0 { + eco_parts.push(format!("{count} {}", eco.display_name())); + } + } + let eco_summary = if eco_parts.is_empty() { + String::new() + } else { + format!(" ({})", eco_parts.join(", ")) + }; + + if !args.common.json && !args.common.silent { + if show_progress { + eprintln!("\rFound {package_count} packages{eco_summary}"); + } else { + eprintln!("Found {package_count} packages{eco_summary}"); + } + if !lockfile_only.purls.is_empty() { + eprintln!( + "Note: {} package(s) from project lockfiles are not yet installed (lockfile-only).", + lockfile_only.purls.len(), + ); + } + } + + // Query API in batches + let mut all_packages_with_patches: Vec = Vec::new(); + let mut can_access_paid_patches = false; + let total_batches = all_purls.len().div_ceil(batch_size); + let mut batch_error_count = 0usize; + let mut last_batch_error: Option = None; + + if show_progress { + eprint!("Querying API for patches... (batch 1/{total_batches})"); + } + + for (batch_idx, chunk) in all_purls.chunks(batch_size).enumerate() { + if show_progress { + eprint!( + "\rQuerying API for patches... (batch {}/{})", + batch_idx + 1, + total_batches + ); + } + + let purls: Vec = chunk.to_vec(); + let mut result = api_client + .search_patches_batch(effective_org_slug, &purls) + .await; + + // Fallback: a 401/403 against the authenticated endpoint can + // mean a stale/revoked token. Retry against the public proxy + // (free patches only) once, then continue the rest of the + // loop with the downgraded client. Only triggers on the + // first authenticated batch; subsequent iterations are + // already on the proxy. + if !use_public_proxy { + if let Err(ref e) = result { + if is_fallback_candidate(e) { + eprintln!( + "Warning: authenticated API returned {e}; \ + falling back to public patch API proxy (free patches only)." + ); + api_client = build_proxy_fallback_client(&overrides); + use_public_proxy = true; + fallback_to_proxy = true; + result = api_client + .search_patches_batch(effective_org_slug, &purls) + .await; + } + } + } + + match result { + Ok(response) => { + if response.can_access_paid_patches { + can_access_paid_patches = true; + } + for pkg in response.packages { + if !pkg.patches.is_empty() { + all_packages_with_patches.push(pkg); + } + } + } + Err(e) => { + batch_error_count += 1; + last_batch_error = Some(e.to_string()); + if !args.common.json { + eprintln!("\nError querying batch {}: {e}", batch_idx + 1); + } + } + } + } + + // If every batch errored, surface this as a full scan failure rather + // than silently reporting zero patches (which historically looked + // identical to "no patches for these packages"). + if total_batches > 0 && batch_error_count == total_batches { + let err = last_batch_error.unwrap_or_else(|| "all batches failed".to_string()); + track_patch_scan_failed( + &err, + fallback_to_proxy, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; + + // A scan in which *every* batch failed produced no trustworthy + // patch data. Surfacing `status: "success"` / exit 0 here would be + // indistinguishable from a genuine "no patches" result and would + // mask a total API outage. Report the failure explicitly and bail + // before writing any manifest or attempting apply/prune. + if args.common.json { + let result = serde_json::json!({ + "status": "error", + "error": err, + "scannedPackages": package_count, + "packagesWithPatches": 0, + "totalPatches": 0, + "freePatches": 0, + "paidPatches": 0, + "canAccessPaidPatches": false, + "packages": [], + "updates": [], + }); + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + } else { + eprintln!("Error: all {total_batches} API batch queries failed: {err}"); + } + return 1; + } + + let total_patches_found: usize = all_packages_with_patches + .iter() + .map(|p| p.patches.len()) + .sum(); + + if !args.common.json && !args.common.silent { + if total_patches_found > 0 { + if show_progress { + eprintln!( + "\rFound {total_patches_found} patches for {} packages", + all_packages_with_patches.len() + ); + } else { + eprintln!( + "Found {total_patches_found} patches for {} packages", + all_packages_with_patches.len() + ); + } + } else if show_progress { + eprintln!("\rAPI query complete"); + } else { + eprintln!("API query complete"); + } + } + + // Calculate patch counts + let mut free_patches = 0usize; + let mut paid_patches = 0usize; + for pkg in &all_packages_with_patches { + for patch in &pkg.patches { + if patch.tier == "free" { + free_patches += 1; + } else { + paid_patches += 1; + } + } + } + let total_patches = free_patches + paid_patches; + + // Telemetry: record the scan outcome once we have the canonical + // per-tier counts. `fallback_to_proxy` is `true` iff the batch + // loop downgraded from the authenticated endpoint to the public + // proxy after a 401/403. + track_patch_scanned( + package_count, + free_patches, + paid_patches, + can_access_paid_patches, + args.common + .ecosystems + .clone() + .unwrap_or_default() + .as_slice(), + fallback_to_proxy, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; + + // Registry-redirect mode is a distinct, self-contained flow (rewrite + // lockfiles → hosted vendored patches). It reuses discovery above, then + // returns — it must NOT fall through to the apply/vendor branches. + if hosted { + return run_redirect( + &args, + &api_client, + effective_org_slug, + &all_packages_with_patches, + can_access_paid_patches, + ) + .await; + } + + // Read existing manifest once for update detection. Used by both the + // JSON-mode emission (always includes an `updates` array) and the + // non-JSON table-print path (counts `updates_available`). + // (`manifest_path`/`socket_dir` are resolved at the top of `run`.) + let existing_manifest = read_manifest(&manifest_path).await.ok().flatten(); + let updates = detect_updates(existing_manifest.as_ref(), &all_packages_with_patches); + + if args.common.json { + let mut result = serde_json::json!({ + "status": "success", + "scannedPackages": package_count, + "lockfileOnlyPackages": lockfile_only.purls.len(), + "packagesWithPatches": all_packages_with_patches.len(), + "totalPatches": total_patches, + "freePatches": free_patches, + "paidPatches": paid_patches, + "canAccessPaidPatches": can_access_paid_patches, + "packages": all_packages_with_patches, + "updates": updates.iter().map(|u| serde_json::json!({ + "purl": u.purl, + "oldUuid": u.old_uuid, + "newUuid": u.new_uuid, + })).collect::>(), + }); + // Flag lockfile-only packages so JSON consumers can tell "patch + // available but not installed" from the installed case. Additive + // field; absent means installed. Matching bridges the API's + // percent-encoded purl spelling to the supplement's literal form + // via `normalize_purl`, like the apply-path skip partitions. + if let Some(packages) = result["packages"].as_array_mut() { + for pkg in packages { + let is_lockfile_only = pkg["purl"].as_str().is_some_and(|p| { + lockfile_only + .purls + .contains(normalize_purl(strip_purl_qualifiers(p)).as_ref()) + }); + if is_lockfile_only { + pkg["notInstalled"] = serde_json::json!(true); + } + } + } + + // `apply` and `prune` are computed once at the top of run() + // (factoring in --sync, which implies both). They're independent + // here: a bot can `--apply` without `--prune`, or `--prune` + // without `--apply` (just GC-sweep), or both (full sync). + let dry = args.common.dry_run; + let mut apply_code = 0i32; + + // --- Apply path (if requested) ----------------------------------- + if apply { + let selected = match discover_selected( + &api_client, + effective_org_slug, + &all_packages_with_patches, + can_access_paid_patches, + ) + .await + { + Ok(s) => s, + Err(code) => return code, + }; + + // Vendor-owned purls are skipped BEFORE download (any uuid); + // a newer patch still surfaces in `updates[]` — the + // operator's signal to run `scan --vendor` (or `vendor`). + let (selected, vendored_records) = partition_skipped_selected( + selected, + |p| vendored_purls.contains(p) || vendored_purls.contains(strip_purl_qualifiers(p)), + "vendored", + ); + // Lockfile-only purls leave the apply selection here (calm + // skip records, never an error); the union rides the same + // bookkeeping as the vendored skips. + let (selected, vendored_records) = { + let (kept, not_installed) = partition_skipped_selected( + selected, + |p| { + lockfile_only + .purls + .contains(normalize_purl(strip_purl_qualifiers(p)).as_ref()) + }, + "package_not_installed", + ); + let mut all = vendored_records; + all.extend(not_installed); + all.sort_by(|a, b| a["purl"].as_str().cmp(&b["purl"].as_str())); + (kept, all) + }; + + if dry { + // Synthesize the per-patch outcome without touching disk. + // `decide_patch_action` consults the existing manifest, + // so it accurately reports what `--apply` *would* do. + let manifest_for_preview = + existing_manifest.clone().unwrap_or_else(PatchManifest::new); + let mut patches: Vec = selected + .iter() + .map(|p| { + match super::get::decide_patch_action( + &manifest_for_preview, + &p.purl, + &p.uuid, + ) { + super::get::PatchAction::Added => serde_json::json!({ + "purl": p.purl, "uuid": p.uuid, "action": "added", + }), + super::get::PatchAction::Updated { old_uuid } => serde_json::json!({ + "purl": p.purl, "uuid": p.uuid, + "action": "updated", "oldUuid": old_uuid, + }), + super::get::PatchAction::Skipped => serde_json::json!({ + "purl": p.purl, "uuid": p.uuid, "action": "skipped", + }), + } + }) + .collect(); + patches.extend(vendored_records.iter().cloned()); + let added = patches.iter().filter(|p| p["action"] == "added").count(); + let updated = patches.iter().filter(|p| p["action"] == "updated").count(); + let skipped = patches.iter().filter(|p| p["action"] == "skipped").count(); + result["apply"] = serde_json::json!({ + "found": selected.len() + vendored_records.len(), + "downloaded": 0, + "skipped": skipped, + "failed": 0, + "applied": 0, + "updated": updated, + "added": added, + "patches": patches, + "dryRun": true, + }); + } else if selected.is_empty() { + // No patches left to download (e.g. all paid for a free + // user, no packages had patches, or everything selected is + // vendor-owned). Emit a stable-shape `apply` carrying any + // vendored skips, then fall through to GC if requested. + result["apply"] = serde_json::json!({ + "found": vendored_records.len(), + "downloaded": 0, + "skipped": vendored_records.len(), + "failed": 0, "applied": 0, "updated": 0, + "patches": vendored_records, + }); + } else { + let params = download_params( + &args, /*save_only=*/ false, /*json=*/ true, /*silent=*/ true, + ); + let (code, apply_json) = download_and_apply_patches(&selected, ¶ms).await; + apply_code = code; + let mut apply_obj = apply_json; + fold_vendored_skips_into_apply(&mut apply_obj, &vendored_records); + result["apply"] = apply_obj; + if apply_code != 0 { + result["status"] = serde_json::json!("partial_failure"); + } + } + // --- Vendor path (if requested; conflicts with --apply/--sync) --- + } else if vendor { + // Extracted into its own boxed fn — and it must STAY extracted: + // this branch's temporaries (json! trees, DownloadParams, the + // engine dispatch) live in the enclosing poll frame in debug + // builds even when the branch is never taken, and that frame + // has to fit Windows' 1 MiB main-thread stack (regression- + // pinned by `scan_run_fits_windows_main_thread_stack`). + return boxed_vendor_json_path( + &args, + &api_client, + effective_org_slug, + &all_packages_with_patches, + can_access_paid_patches, + &mut result, + &manifest_path, + &socket_dir, + &scanned_purls, + &vendored_purls, + prune, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; + } + + // --- GC (post-apply, or standalone --prune GC-sweep) ------------- + if prune { + result["gc"] = gc_json( + &args.common, + &manifest_path, + &socket_dir, + &scanned_purls, + &vendored_purls, + dry, + ) + .await; + } + + let final_code = embed_vex_into_json( + &args.common, + &args.vex, + &manifest_path, + apply_code, + &mut result, + ) + .await; + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + return final_code; + } + + let use_color = std::io::stdout().is_terminal(); + + if all_packages_with_patches.is_empty() { + if !args.common.silent { + println!("\nNo patches available for installed packages."); + } + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + } + + // The whole table + summary section is presentational only (nothing + // computed inside is consumed downstream), so `--silent` skips it + // wholesale. + if !args.common.silent { + let mut updates_available = 0usize; + + // Canonical set of PURLs with a newer patch available, computed once via + // `detect_updates` (the same source the JSON `updates` array uses). The + // table path MUST agree with the JSON path, so reuse that result rather + // than re-deriving it: comparing against *any* batch patch (instead of the + // first/candidate one `select_patches` would resolve to) over-reports + // updates whenever the manifest already holds the newest patch but older + // patches also appear in the batch. + let update_purls: HashSet<&str> = updates.iter().map(|u| u.purl.as_str()).collect(); + + // Print table + println!("\n{}", "=".repeat(100)); + println!( + "{} {} {} VULNERABILITIES", + "PACKAGE".to_string() + &" ".repeat(33), + "PATCHES".to_string() + " ", + "SEVERITY".to_string() + &" ".repeat(8), + ); + println!("{}", "=".repeat(100)); + + for pkg in &all_packages_with_patches { + // Char-safe truncation: a byte slice (`&pkg.purl[..37]`) panics + // when the cut lands mid-codepoint. PURLs can carry non-ASCII + // names/qualifiers, so route through the shared helper. + let display_purl = truncate_with_ellipsis(&pkg.purl, 40); + + let pkg_free = pkg.patches.iter().filter(|p| p.tier == "free").count(); + let pkg_paid = pkg.patches.iter().filter(|p| p.tier == "paid").count(); + + let count_str = if pkg_paid > 0 { + if can_access_paid_patches { + format!("{}+{}", pkg_free, pkg_paid) + } else { + format!( + "{}+{}", + pkg_free, + color(&pkg_paid.to_string(), "33", use_color) + ) + } + } else { + format!("{}", pkg_free) + }; + + // Get highest severity + let severity = pkg + .patches + .iter() + .filter_map(|p| p.severity.as_deref()) + .min_by_key(|s| severity_order(s)) + .unwrap_or("unknown"); + + // Collect vuln IDs (deterministic: deduped, CVEs then GHSAs, + // each group sorted — see collect_vuln_ids). + let vuln_ids = collect_vuln_ids(pkg); + let vuln_str = if vuln_ids.len() > 2 { + format!("{} (+{})", vuln_ids[..2].join(", "), vuln_ids.len() - 2) + } else if vuln_ids.is_empty() { + "-".to_string() + } else { + vuln_ids.join(", ") + }; + + // Check for updates — consult the canonical `detect_updates` result + // (mirrored into `update_purls`) so the human table and JSON `updates` + // array never disagree. + let has_update = update_purls.contains(pkg.purl.as_str()); + if has_update { + updates_available += 1; + } + + let update_marker = if has_update { + color(" [UPDATE]", "33", use_color) + } else { + String::new() + }; + // Lockfile-only packages can be patched by `scan --vendor` + // (which fetches them pristine) but not applied in place. + // `normalize_purl` bridges the API's percent-encoded spelling + // to the supplement's literal form, like the JSON flag and the + // apply-path skip partitions. + let not_installed_marker = if lockfile_only + .purls + .contains(normalize_purl(strip_purl_qualifiers(&pkg.purl)).as_ref()) + { + color(" [NOT INSTALLED]", "33", use_color) + } else { + String::new() + }; + + println!( + "{:<40} {:>8} {:<16} {}{}{}", + display_purl, + count_str, + format_severity(severity, use_color), + vuln_str, + update_marker, + not_installed_marker, + ); + } + + println!("{}", "=".repeat(100)); + + // Summary + if can_access_paid_patches { + println!( + "\nSummary: {} package(s) with {} available patch(es)", + all_packages_with_patches.len(), + total_patches, + ); + } else { + println!( + "\nSummary: {} package(s) with {} free patch(es)", + all_packages_with_patches.len(), + free_patches, + ); + if paid_patches > 0 { + println!( + "{}", + color( + &format!( + " + {} additional patch(es) available with paid subscription", + paid_patches + ), + "33", + use_color, + ), + ); + println!( + "\nUpgrade to Socket's paid plan to access all patches: https://socket.dev/pricing" + ); + } + } + + if updates_available > 0 { + println!( + "\n{}", + color( + &format!("{updates_available} package(s) have newer patches available."), + "33", + use_color, + ), + ); + } + } + + // Count downloadable patches + let downloadable_count = if can_access_paid_patches { + all_packages_with_patches.len() + } else { + all_packages_with_patches + .iter() + .filter(|pkg| pkg.patches.iter().any(|p| p.tier == "free")) + .count() + }; + + if downloadable_count == 0 { + if !args.common.silent { + println!("\nNo downloadable patches (paid subscription required)."); + } + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + } + + // Fetch full PatchSearchResult for each package that has patches + if show_progress { + eprint!("\nFetching patch details..."); + } + + let mut all_search_results: Vec = Vec::new(); + for (i, pkg) in all_packages_with_patches.iter().enumerate() { + if show_progress { + eprint!( + "\rFetching patch details... ({}/{})", + i + 1, + all_packages_with_patches.len() + ); + } + match api_client + .search_patches_by_package(effective_org_slug, &pkg.purl) + .await + { + Ok(response) => { + all_search_results.extend(response.patches); + } + Err(e) => { + if !args.common.silent { + eprintln!("\n Warning: could not fetch details for {}: {e}", pkg.purl); + } + } + } + } + + if show_progress { + eprintln!(); + } + + if all_search_results.is_empty() { + eprintln!("Could not fetch patch details."); + return 1; + } + + // Smart selection + let selected: Vec = + match select_patches(&all_search_results, can_access_paid_patches, false) { + Ok(s) => s, + Err(code) => return code, + }; + + // Vendor-owned purls never download/apply here (mirrors the JSON + // path): the committed artifact is the patch, and a manifest moved + // past the vendored uuid would break VEX verification until a vendor + // run refreshes the artifact. In `--vendor` mode the partition is a + // no-op — re-vendoring a stale uuid is exactly what the flag is for. + let is_vendored = + |p: &str| vendored_purls.contains(p) || vendored_purls.contains(strip_purl_qualifiers(p)); + let (vendored_selected, selected): (Vec<_>, Vec<_>) = if vendor { + (Vec::new(), selected) + } else { + selected.into_iter().partition(|p| is_vendored(&p.purl)) + }; + if !args.common.silent { + for p in &vendored_selected { + println!( + " [skip] {} (vendored — run scan --vendor to update)", + normalize_purl(&p.purl) + ); + } + } + + // Lockfile-only purls leave the in-place apply selection (calm skip, + // mirrors the JSON path). In `--vendor` mode they stay: the vendor + // engine fetches lockfile-resolved packages pristine. + let (selected, not_installed_selected): (Vec<_>, Vec) = if vendor { + (selected, Vec::new()) + } else { + let (kept, skipped) = partition_skipped_selected( + selected, + |p| { + lockfile_only + .purls + .contains(normalize_purl(strip_purl_qualifiers(p)).as_ref()) + }, + "package_not_installed", + ); + let printed: Vec = skipped + .iter() + .filter_map(|r| r["purl"].as_str().map(str::to_string)) + .collect(); + (kept, printed) + }; + if !args.common.silent { + for purl in ¬_installed_selected { + println!( + " [skip] {} (not installed — run your package manager's install first, \ + or `scan --vendor` to vendor it from the lockfile)", + normalize_purl(purl) + ); + } + } + + if selected.is_empty() && !vendor { + if !args.common.silent { + println!("No patches selected."); + } + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + } + + // Vendor mode: pre-verify baselines so a content mismatch surfaces + // BEFORE the confirm prompt (vendoring still proceeds for these — + // the stage force-applies the verified patched content). + let mismatched_baselines: HashSet = if vendor && !args.common.silent { + preverify_vendor_baselines( + &api_client, + effective_org_slug, + &selected, + &filtered_crawled, + &lockfile_only.purls, + ) + .await + } else { + HashSet::new() + }; + + // Display detailed summary of selected patches before confirming + // (presentational only — skipped wholesale under --silent). + if !args.common.silent { + if vendor { + println!("\nPatches to vendor:\n"); + } else { + println!("\nPatches to apply:\n"); + } + for patch in &selected { + // Collect CVE/GHSA IDs and highest severity from vulnerabilities + let mut vuln_ids: Vec = Vec::new(); + let mut highest_severity: Option<&str> = None; + for (id, vuln) in &patch.vulnerabilities { + if vuln.cves.is_empty() { + vuln_ids.push(id.clone()); + } else { + for cve in &vuln.cves { + vuln_ids.push(cve.clone()); + } + } + let sev = vuln.severity.as_str(); + if highest_severity.is_none_or(|cur| severity_order(sev) < severity_order(cur)) { + highest_severity = Some(sev); + } + } + + let sev_display = highest_severity.unwrap_or("unknown"); + let sev_colored = format_severity(sev_display, use_color); + + // Char-safe: descriptions come straight from the API and routinely + // contain non-ASCII text; a `&desc[..69]` byte slice would panic. + let desc = truncate_with_ellipsis(&patch.description, 72); + + println!( + " {} [{}] {}", + // Human display only: show the decoded form of an + // API-encoded purl (`%40scope` → `@scope`). JSON output + // keeps the verbatim key. + normalize_purl(&patch.purl), + patch.tier.to_uppercase(), + sev_colored, + ); + if mismatched_baselines.contains(&patch.uuid) { + println!( + " (installed content differs from patch baseline — will vendor patched content)" + ); + } + if !vuln_ids.is_empty() { + println!(" Fixes: {}", vuln_ids.join(", ")); + } + // Show per-vulnerability summaries + for vuln in patch.vulnerabilities.values() { + if !vuln.summary.is_empty() { + // Char-safe: vulnerability summaries are API-sourced free + // text; a `&summary[..73]` byte slice would panic mid-codepoint. + let summary = truncate_with_ellipsis(&vuln.summary, 76); + let cve_label = if vuln.cves.is_empty() { + String::new() + } else { + format!("{}: ", vuln.cves.join(", ")) + }; + println!(" - {cve_label}{summary}"); + } + } + if !desc.is_empty() { + println!(" {desc}"); + } + println!(); + } + } + + // `--dry-run` is a non-mutating preview (see the global flag's doc and + // the JSON path's `dryRun` envelope). The interactive path must honor it + // too: stop here, having printed the table and the per-patch plan above, + // before the confirm prompt, the download/apply, and the prune GC — all + // of which mutate the manifest and `.socket/` on disk. + if args.common.dry_run { + if !args.common.silent { + let action = if vendor { + "download and vendor" + } else { + "download and apply" + }; + println!( + "\n[dry-run] Would {action} {} patch(es). No changes made.", + selected.len() + ); + } + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + } + + // Prompt to download + let verb = if vendor { "vendor" } else { "apply" }; + let prompt = format!("Download and {verb} {} patch(es)?", selected.len()); + if !confirm(&prompt, true, args.common.yes, args.common.json) { + if !args.common.silent { + println!("\nTo apply a patch, run:"); + println!(" socket-patch get "); + println!(" socket-patch get "); + } + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + } + + // Download, then apply in place — or vendor (`--vendor`, where the + // download only saves and the vendor step below does the rest). + let params = download_params( + &args, + /*save_only=*/ vendor, + /*json=*/ false, + args.common.silent, + ); + + let code = if vendor { + // Extracted + boxed for the same Windows-1-MiB-frame reason as the + // JSON path (see `run_vendor_json_path`). + boxed_vendor_interactive_path( + &args, + &selected, + ¶ms, + &manifest_path, + &socket_dir, + &scanned_purls, + &vendored_purls, + prune, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await + } else { + let (code, _) = download_and_apply_patches(&selected, ¶ms).await; + code + }; + + // Post-apply GC: only runs when the user opted in via `--prune` or + // `--sync`. Default `scan --yes` no longer touches the manifest + // beyond what `--apply` added — users wanting to clean up should + // run `socket-patch gc` (or `repair`) explicitly. (Vendor mode + // already ran its GC before the vendor step.) + if prune && !vendor { + let gc = run_apply_gc( + &args.common, + &manifest_path, + &socket_dir, + &scanned_purls, + &vendored_purls, + ) + .await; + let total = gc.blobs.blobs_removed + gc.diffs.blobs_removed + gc.packages.blobs_removed; + if !args.common.silent && (!gc.pruned.is_empty() || total > 0) { + println!( + "\nGC: pruned {} manifest entr{} and removed {} orphan file{} ({}).", + gc.pruned.len(), + if gc.pruned.len() == 1 { "y" } else { "ies" }, + total, + if total == 1 { "" } else { "s" }, + socket_patch_core::utils::cleanup_blobs::format_bytes(gc.total_bytes()), + ); + } + if !args.common.silent { + print_gc_vendored_line(&gc); + } + } + + embed_vex_human(&args.common, &args.vex, &manifest_path, code).await +} + +#[cfg(test)] +mod tests { + use super::*; + use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; + use std::collections::HashMap; + + pub(super) fn manifest_with(entries: &[(&str, &str)]) -> PatchManifest { + let mut m = PatchManifest::new(); + for (purl, uuid) in entries { + m.patches.insert( + (*purl).to_string(), + PatchRecord { + uuid: (*uuid).to_string(), + exported_at: String::new(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: "free".to_string(), + }, + ); + } + m + } + + // ---- truncate_with_ellipsis (scan's display columns) ------------------- + // scan.rs renders PURLs, descriptions, and vulnerability summaries — all + // API-sourced and potentially non-ASCII — into fixed-width columns. These + // pin scan's use of the char-safe helper; a raw `&s[..n]` byte slice + // would panic when the cut lands mid-codepoint. + + #[test] + fn truncate_multibyte_purl_does_not_panic() { + // 30 three-byte chars (90 bytes, 30 chars). The old purl path sliced + // `&purl[..37]` once `len() > 40`; byte 37 splits a codepoint here. + let purl = format!("pkg:npm/{}", "日".repeat(30)); + let out = truncate_with_ellipsis(&purl, 40); + assert!(out.chars().count() <= 40); + } + + #[test] + fn truncate_multibyte_description_truncates_on_char_boundary() { + // 100 two-byte chars; description column truncates at 72. + let desc = "é".repeat(100); + let out = truncate_with_ellipsis(&desc, 72); + assert_eq!(out.chars().count(), 72); + assert!(out.ends_with("...")); + } + + #[test] + fn truncate_multibyte_summary_truncates_on_char_boundary() { + // Summary column truncates at 76. + let summary = "—".repeat(100); // em dash, 3 bytes each + let out = truncate_with_ellipsis(&summary, 76); + assert_eq!(out.chars().count(), 76); + assert!(out.ends_with("...")); + } +} diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs new file mode 100644 index 00000000..86c7e1b7 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -0,0 +1,576 @@ +//! The vendored-mode (`--mode vendored` / `--vendor`) flow driven by +//! `scan`: the shared download + GC + vendor-engine step, its JSON and +//! interactive arms, the pre-download skip partitions, and the `boxed_*` +//! transient-frame constructors that keep the never-taken vendor branches +//! out of `run`'s poll frame (Windows 1 MiB main-thread stack). + +use socket_patch_core::api::types::{BatchPackagePatches, PatchSearchResult}; +use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; +use socket_patch_core::patch::apply_lock; +use socket_patch_core::patch::vendor::{load_state, lookup_entry}; +use socket_patch_core::utils::telemetry::track_patch_vendor_failed; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::time::Duration; + +use crate::args::GlobalArgs; +use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; +use crate::commands::get::{download_and_apply_patches, download_patch_records, DownloadParams}; +use crate::commands::vendor::{reconcile_dropped, track_outcomes_for_vendor, vendor_records}; +use crate::json_envelope::{Command as EnvelopeCommand, Envelope}; + +use super::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; +use super::{discover_selected, download_params, embed_vex_into_json, ScanArgs}; + +/// Dry-run preview for `scan --vendor`: classify each selected patch +/// against the vendor ledger without touching disk or the network beyond +/// discovery. Action values are part of the CLI contract: +/// `would_vendor` (no ledger entry), `already_vendored` (entry at this +/// uuid), `would_revendor` + `oldUuid` (entry at an older uuid). +async fn preview_vendor_json(cwd: &Path, selected: &[PatchSearchResult]) -> serde_json::Value { + let state = load_state(cwd).await.unwrap_or_default(); + let mut patches: Vec = selected + .iter() + .map(|p| match lookup_entry(&state.entries, &p.purl) { + Some(e) if e.uuid == p.uuid => serde_json::json!({ + "purl": p.purl, "uuid": p.uuid, "action": "already_vendored", + }), + Some(e) => serde_json::json!({ + "purl": p.purl, "uuid": p.uuid, + "action": "would_revendor", "oldUuid": e.uuid, + }), + None => serde_json::json!({ + "purl": p.purl, "uuid": p.uuid, "action": "would_vendor", + }), + }) + .collect(); + patches.sort_by(|a, b| a["purl"].as_str().cmp(&b["purl"].as_str())); + serde_json::json!({ "dryRun": true, "patches": patches }) +} + +/// The vendor step shared by `scan --vendor`'s JSON and interactive +/// paths: acquire the apply lock, stage patch sources, and drive +/// [`vendor_records`] — manifest mode (`detached_records: None`, records +/// come from re-reading the manifest, preceded by the same reconcile as +/// the `vendor` command) or detached mode (`Some(records)` from +/// [`download_patch_records`]; no manifest involvement at all). +/// +/// `Ok((has_errors, envelope))` on a run that reached the engine; +/// `Err((code, message))` for the lock/stage/manifest failures the +/// caller folds into its own output shape (scan's ad-hoc JSON can't use +/// `acquire_or_emit`, which prints an Envelope). +async fn run_scan_vendor_step( + common: &GlobalArgs, + manifest_path: &Path, + socket_dir: &Path, + detached_records: Option<&HashMap>, +) -> Result<(bool, Envelope), (&'static str, String)> { + // The download phase created `.socket/` already in every flow that + // reaches here, but `acquire` deliberately refuses to mkdir. + if let Err(e) = tokio::fs::create_dir_all(socket_dir).await { + return Err(("socket_dir_unwritable", e.to_string())); + } + let guard = apply_lock::acquire( + socket_dir, + Duration::from_secs(common.lock_timeout.unwrap_or(0)), + ) + .map_err(|e| match e { + apply_lock::LockError::Held => ( + "lock_held", + "another socket-patch process is operating in this directory".to_string(), + ), + apply_lock::LockError::Io { .. } => ("lock_io", e.to_string()), + })?; + + let mut env = Envelope::new(EnvelopeCommand::Vendor); + env.dry_run = common.dry_run; + let (manifest, detached, mut has_errors) = match detached_records { + Some(records) => { + // Staging probes blobs by the records' hashes; a synthetic + // manifest view is all it needs. + let synth = PatchManifest { + patches: records.clone(), + setup: None, + }; + (synth, true, false) + } + None => { + let manifest = match read_manifest(manifest_path).await { + Ok(Some(m)) => m, + Ok(None) => { + // No manifest ⇒ nothing downloaded and nothing + // pre-existing to vendor: a clean no-op. + drop(guard); + return Ok((false, env)); + } + Err(e) => return Err(("invalid_manifest", e.to_string())), + }; + // Same placement as the `vendor` command: dropped entries + // are reverted even when zero in-scope patches remain. + let has_errors = reconcile_dropped(&manifest, common, &mut env).await; + (manifest, false, has_errors) + } + }; + let staged = + match stage_vendor_sources_in_memory(common, &manifest, socket_dir, &common.cwd).await { + Ok(MemStageOutcome::Ready(s)) => s, + Ok(MemStageOutcome::Unavailable) => { + return Err(( + "no_local_source", + "patch artifacts unavailable (offline or download failure)".to_string(), + )) + } + Err(e) => return Err(("stage_failed", e)), + }; + let sources = staged.as_patch_sources(); + has_errors |= + boxed_vendor_records(common, &manifest.patches, &sources, detached, &mut env).await; + drop(guard); + if has_errors { + env.mark_partial_failure(); + } + Ok((has_errors, env)) +} + +/// The `scan --vendor` JSON path: discovery → (dry-run preview | download +/// → GC → vendor engine) → embedded VEX → print `result` → exit code. +/// +/// Extracted from `run` (and called through `Box::pin`) so its sizeable +/// temporaries get their own poll frame, entered only when `--vendor` is +/// actually requested — in debug builds the enclosing frame retains stack +/// slots for never-taken branches, and `run`'s frame must fit Windows' +/// 1 MiB main-thread stack. +#[allow(clippy::too_many_arguments)] +async fn run_vendor_json_path( + args: &ScanArgs, + api_client: &socket_patch_core::api::client::ApiClient, + effective_org_slug: Option<&str>, + all_packages_with_patches: &[BatchPackagePatches], + can_access_paid_patches: bool, + result: &mut serde_json::Value, + manifest_path: &Path, + socket_dir: &Path, + scanned_purls: &HashSet, + vendored_purls: &HashSet, + prune: bool, + telemetry_token: Option<&str>, + telemetry_org: Option<&str>, +) -> i32 { + // Same discovery as `--apply`. Vendored purls are NOT filtered here — + // re-vendoring a stale uuid is the point of the flag (same-uuid re-runs + // land on the backend's `already_vendored` skip). + let selected = match discover_selected( + api_client, + effective_org_slug, + all_packages_with_patches, + can_access_paid_patches, + ) + .await + { + Ok(s) => s, + Err(code) => return code, + }; + + if args.common.dry_run { + // No downloads, no backends: classify against the ledger + // and preview the GC, exactly like `--apply`'s dry run. + result["vendor"] = preview_vendor_json(&args.common.cwd, &selected).await; + if prune { + result["gc"] = gc_json( + &args.common, + manifest_path, + socket_dir, + scanned_purls, + vendored_purls, + true, + ) + .await; + } + let final_code = + embed_vex_into_json(&args.common, &args.vex, manifest_path, 0, result).await; + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + return final_code; + } + + // 1) Download phase. Manifest mode reuses the `--apply` + // download (with `save_only` — the nested apply::run never + // fires); detached mode fetches records without touching + // the manifest. Either way the vendor step still runs when + // zero patches were downloaded (re-vendor after a wipe). + let params = download_params( + args, /*save_only=*/ true, /*json=*/ true, /*silent=*/ true, + ); + let mut has_errors = false; + let detached_records: Option> = if args.detached { + let (code, mut dl_json, records) = boxed_download_patch_records(&selected, ¶ms).await; + has_errors |= code != 0; + if let Some(obj) = dl_json.as_object_mut() { + obj.remove("status"); + } + result["download"] = dl_json; + Some(records) + } else if selected.is_empty() { + result["download"] = serde_json::json!({ + "found": 0, "downloaded": 0, "skipped": 0, + "failed": 0, "patches": [], + }); + None + } else { + let (code, mut dl_json) = boxed_download_and_apply(&selected, ¶ms).await; + has_errors |= code != 0; + if let Some(obj) = dl_json.as_object_mut() { + obj.remove("status"); + // save_only: the nested apply never ran, so the + // `applied` count is structurally zero — drop it + // rather than report a misleading 0-applied. + obj.remove("applied"); + } + result["download"] = dl_json; + None + }; + + // 2) GC BEFORE the vendor step (when --prune): stale manifest + // entries would otherwise fail vendoring with + // package_not_installed; vendored entries are exempt from + // the prune itself. + if prune { + result["gc"] = gc_json( + &args.common, + manifest_path, + socket_dir, + scanned_purls, + vendored_purls, + false, + ) + .await; + } + + // 3) The vendor engine, under the same lock as apply/vendor. + let vendor_code = match boxed_scan_vendor_step( + &args.common, + manifest_path, + socket_dir, + detached_records.as_ref(), + ) + .await + { + Ok((vendor_errors, venv)) => { + has_errors |= vendor_errors; + track_outcomes_for_vendor( + vendor_errors, + &venv, + args.common.dry_run, + telemetry_token, + telemetry_org, + ) + .await; + result["vendor"] = + serde_json::to_value(&venv).unwrap_or_else(|_| serde_json::json!({})); + i32::from(has_errors) + } + Err((code, message)) => { + track_patch_vendor_failed( + &message, + args.common.dry_run, + telemetry_token, + telemetry_org, + ) + .await; + result["status"] = serde_json::json!("error"); + result["error"] = serde_json::json!({ + "code": code, + "message": message, + }); + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + return 1; + } + }; + if vendor_code != 0 { + result["status"] = serde_json::json!("partial_failure"); + } + + let final_code = + embed_vex_into_json(&args.common, &args.vex, manifest_path, vendor_code, result).await; + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + final_code +} + +/// The `scan --vendor` interactive arm: download (manifest or detached +/// mode) → pre-vendor GC → vendor engine, with human-readable output. +/// Extracted + boxed for the same Windows-1-MiB-poll-frame reason as +/// [`run_vendor_json_path`]. +#[allow(clippy::too_many_arguments)] +async fn run_vendor_interactive_path( + args: &ScanArgs, + selected: &[PatchSearchResult], + params: &DownloadParams, + manifest_path: &Path, + socket_dir: &Path, + scanned_purls: &HashSet, + vendored_purls: &HashSet, + prune: bool, + telemetry_token: Option<&str>, + telemetry_org: Option<&str>, +) -> i32 { + let mut has_errors = false; + let detached_records: Option> = if args.detached { + let (dl_code, _, records) = boxed_download_patch_records(selected, params).await; + has_errors |= dl_code != 0; + Some(records) + } else { + if !selected.is_empty() { + let (dl_code, _) = boxed_download_and_apply(selected, params).await; + has_errors |= dl_code != 0; + } + None + }; + // GC before the vendor step (see the JSON path): stale manifest + // entries would fail vendoring with package_not_installed. + if prune { + let gc = run_apply_gc( + &args.common, + manifest_path, + socket_dir, + scanned_purls, + vendored_purls, + ) + .await; + if !args.common.silent && !gc.pruned.is_empty() { + println!( + "GC: pruned {} manifest entr{}.", + gc.pruned.len(), + if gc.pruned.len() == 1 { "y" } else { "ies" }, + ); + } + if !args.common.silent { + print_gc_vendored_line(&gc); + } + } + match boxed_scan_vendor_step( + &args.common, + manifest_path, + socket_dir, + detached_records.as_ref(), + ) + .await + { + Ok((vendor_errors, venv)) => { + has_errors |= vendor_errors; + track_outcomes_for_vendor( + vendor_errors, + &venv, + args.common.dry_run, + telemetry_token, + telemetry_org, + ) + .await; + i32::from(has_errors) + } + Err((code, message)) => { + track_patch_vendor_failed( + &message, + args.common.dry_run, + telemetry_token, + telemetry_org, + ) + .await; + eprintln!("Error ({code}): {message}"); + 1 + } + } +} + +/// Partition purls matching `skip` out of the selected set and pre-render +/// their skip records (sorted by purl) with the contract `error_code`. +/// Two skip classes ride this, both removed BEFORE download: +/// +/// * `"vendored"` — the patch is consumed from the committed artifact, and +/// moving the manifest past the vendored uuid would break VEX +/// verification (`vendor_uuid_mismatch`) until a vendor run. +/// * `"package_not_installed"` — the package is not on disk to patch in +/// place, and downloading its patch into the manifest would create a +/// not-yet-appliable entry (and flip the apply path's exit code). +/// `scan --vendor` is the route that handles these (the vendor engine +/// auto-fetches lockfile-resolved packages); matching bridges API purl +/// encoding via `normalize_purl`. +/// +/// A plain fn (not inlined into `run`) so the json! temporaries don't ride +/// `run`'s async poll frame — see [`run_vendor_json_path`]'s Windows-stack +/// note. +pub(super) fn partition_skipped_selected( + selected: Vec, + skip: impl Fn(&str) -> bool, + error_code: &str, +) -> (Vec, Vec) { + let (skipped, kept): (Vec<_>, Vec<_>) = selected.into_iter().partition(|p| skip(&p.purl)); + let mut records: Vec = skipped + .iter() + .map(|p| { + serde_json::json!({ + "purl": p.purl, "uuid": p.uuid, + "action": "skipped", "errorCode": error_code, + }) + }) + .collect(); + records.sort_by(|a, b| a["purl"].as_str().cmp(&b["purl"].as_str())); + (kept, records) +} + +/// Fold the pre-download vendored skips into the apply report returned by +/// `download_and_apply_patches`: they were "found" by discovery and +/// skipped here, never downloaded. Also strips the inner `status` (scan +/// recomputes its own). Plain fn for the same poll-frame reason as +/// [`partition_skipped_selected`]. +pub(super) fn fold_vendored_skips_into_apply( + apply_obj: &mut serde_json::Value, + vendored_records: &[serde_json::Value], +) { + let Some(obj) = apply_obj.as_object_mut() else { + return; + }; + obj.remove("status"); + if vendored_records.is_empty() { + return; + } + let n = vendored_records.len() as u64; + for key in ["found", "skipped"] { + let bumped = obj.get(key).and_then(|v| v.as_u64()).unwrap_or(0) + n; + obj.insert(key.to_string(), serde_json::json!(bumped)); + } + if let Some(patches) = obj.get_mut("patches").and_then(|p| p.as_array_mut()) { + patches.extend(vendored_records.iter().cloned()); + } +} + +/// Construct the (large) vendor-JSON-path future on THIS transient frame +/// and hand `run` only the heap pointer. Writing +/// `Box::pin(run_vendor_json_path(..))` inline in `run` materializes the +/// future — which embeds the whole vendor engine — as a stack temporary in +/// `run`'s poll frame: debug builds allocate slots even for never-taken +/// branches, and that frame has to fit Windows' 1 MiB main-thread stack +/// (every plain `scan` was overflowing there). +#[allow(clippy::too_many_arguments)] +pub(super) fn boxed_vendor_json_path<'a>( + args: &'a ScanArgs, + api_client: &'a socket_patch_core::api::client::ApiClient, + effective_org_slug: Option<&'a str>, + all_packages_with_patches: &'a [BatchPackagePatches], + can_access_paid_patches: bool, + result: &'a mut serde_json::Value, + manifest_path: &'a Path, + socket_dir: &'a Path, + scanned_purls: &'a HashSet, + vendored_purls: &'a HashSet, + prune: bool, + telemetry_token: Option<&'a str>, + telemetry_org: Option<&'a str>, +) -> std::pin::Pin + 'a>> { + Box::pin(run_vendor_json_path( + args, + api_client, + effective_org_slug, + all_packages_with_patches, + can_access_paid_patches, + result, + manifest_path, + socket_dir, + scanned_purls, + vendored_purls, + prune, + telemetry_token, + telemetry_org, + )) +} + +/// The interactive twin of [`boxed_vendor_json_path`] — same transient- +/// frame indirection, same Windows-stack rationale. +#[allow(clippy::too_many_arguments)] +pub(super) fn boxed_vendor_interactive_path<'a>( + args: &'a ScanArgs, + selected: &'a [PatchSearchResult], + params: &'a DownloadParams, + manifest_path: &'a Path, + socket_dir: &'a Path, + scanned_purls: &'a HashSet, + vendored_purls: &'a HashSet, + prune: bool, + telemetry_token: Option<&'a str>, + telemetry_org: Option<&'a str>, +) -> std::pin::Pin + 'a>> { + Box::pin(run_vendor_interactive_path( + args, + selected, + params, + manifest_path, + socket_dir, + scanned_purls, + vendored_purls, + prune, + telemetry_token, + telemetry_org, + )) +} + +/// Transient-frame boxed constructor for [`run_scan_vendor_step`] — the +/// future embeds the entire vendor engine, and the vendor-path frames it +/// would otherwise ride must themselves fit Windows' 1 MiB main-thread +/// stack (same rationale as [`boxed_vendor_json_path`], one level down). +#[allow(clippy::type_complexity)] +fn boxed_scan_vendor_step<'a>( + common: &'a GlobalArgs, + manifest_path: &'a Path, + socket_dir: &'a Path, + detached_records: Option<&'a HashMap>, +) -> std::pin::Pin< + Box> + 'a>, +> { + Box::pin(run_scan_vendor_step( + common, + manifest_path, + socket_dir, + detached_records, + )) +} + +/// Transient-frame boxed constructors for the download-phase futures used +/// inside the vendor paths — `download_and_apply_patches`'s future embeds +/// the in-process `apply::run`, and these frames must fit Windows' 1 MiB +/// main-thread stack (same rationale as [`boxed_vendor_json_path`]). +fn boxed_download_and_apply<'a>( + selected: &'a [PatchSearchResult], + params: &'a DownloadParams, +) -> std::pin::Pin + 'a>> { + Box::pin(download_and_apply_patches(selected, params)) +} + +/// See [`boxed_download_and_apply`]. +#[allow(clippy::type_complexity)] +fn boxed_download_patch_records<'a>( + selected: &'a [PatchSearchResult], + params: &'a DownloadParams, +) -> std::pin::Pin< + Box< + dyn std::future::Future)> + + 'a, + >, +> { + Box::pin(download_patch_records(selected, params)) +} + +/// Transient-frame boxed constructor for the vendor engine itself +/// ([`vendor_records`]) — the deepest, largest future on the scan-vendor +/// chain. See [`boxed_vendor_json_path`] for the Windows-stack rationale. +fn boxed_vendor_records<'a>( + common: &'a GlobalArgs, + records: &'a HashMap, + sources: &'a socket_patch_core::patch::apply::PatchSources<'a>, + detached: bool, + env: &'a mut Envelope, +) -> std::pin::Pin + 'a>> { + // `scan --vendor` builds locally (no vendoring-service config); the + // `vendor` command is the service-download entry point. + Box::pin(vendor_records( + common, records, sources, detached, false, env, None, + )) +} diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index c11899f1..7322b970 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -1,8 +1,6 @@ use clap::Args; -#[cfg(feature = "composer")] use socket_patch_core::composer_setup::{self, ComposerSetupStatus}; use socket_patch_core::crawlers::python_crawler::is_python_project; -use socket_patch_core::crawlers::CrawlerOptions; use socket_patch_core::gem_setup::{self, GemSetupStatus}; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{PatchManifest, SetupConfig}; @@ -14,17 +12,20 @@ use socket_patch_core::package_json::update::{ remove_package_json, update_package_json, RemoveResult, RemoveStatus, UpdateResult, UpdateStatus, }; -use socket_patch_core::pth_hook::{ - add_hook_dependency, deps_contain_hook, detect_python_pm, pyproject_contains_hook, - remove_hook_dependency, ManifestKind, PthEditResult, PthStatus, PythonPackageManager, +use socket_patch_core::pth_hook::detect::{ + deps_contain_hook, detect_python_pm, PythonPackageManager, +}; +use socket_patch_core::pth_hook::edit::{ + add_hook_dependency, pyproject_contains_hook, remove_hook_dependency, ManifestKind, + PthEditResult, PthStatus, }; use socket_patch_core::utils::telemetry::track_patch_setup; -use socket_patch_core::vex::applied_patches; +use socket_patch_core::vex::applied_patches_with_vendor; use std::io::{self, Write}; use std::path::{Path, PathBuf}; -use crate::args::GlobalArgs; -use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls}; +use crate::args::{apply_env_toggles, GlobalArgs}; +use crate::ecosystem_dispatch::find_manifest_package_paths; use crate::output::stdin_is_tty; /// Stringify the detected npm-family manager for telemetry. @@ -72,7 +73,7 @@ pub struct SetupArgs { long = "check", conflicts_with = "remove", default_value_t = false, - value_parser = clap::builder::BoolishValueParser::new(), + value_parser = crate::args::parse_bool_flag, )] pub check: bool, @@ -82,7 +83,7 @@ pub struct SetupArgs { #[arg( long = "remove", default_value_t = false, - value_parser = clap::builder::BoolishValueParser::new(), + value_parser = crate::args::parse_bool_flag, )] pub remove: bool, @@ -98,6 +99,7 @@ pub struct SetupArgs { } pub async fn run(args: SetupArgs) -> i32 { + apply_env_toggles(&args.common); if args.check { run_check(&args).await } else if args.remove { @@ -135,17 +137,17 @@ async fn discover(args: &SetupArgs, excludes: &[String]) -> Vec i32 { +/// Emit the shared `no_files` result and exit code. `counts` carries the +/// per-command zero-valued summary fields (`setup` → updated/already/errors, +/// `check` → configured/needs/errors, `remove` → removed/notConfigured/errors) +/// so the `no_files` envelope keeps the documented shape (CLI_CONTRACT "Setup +/// command contract") instead of dropping them. +fn report_no_files(args: &SetupArgs, counts: &[(&str, i64)]) -> i32 { if args.common.json { // `serde_json::Map` preserves insertion order (the crate enables // `preserve_order`), so status → counts → files comes out in that order. let mut map = serde_json::Map::new(); - map.insert("status".to_string(), serde_json::json!(status)); + map.insert("status".to_string(), serde_json::json!("no_files")); for (key, value) in counts { map.insert((*key).to_string(), serde_json::json!(value)); } @@ -167,6 +169,29 @@ fn pathdiff(path: &str, base: &Path) -> String { .unwrap_or_else(|_| path.to_string()) } +/// The setup/remove mutation gate (shared verbatim by both flows): default-no +/// prompt on a TTY, auto-proceed with a stderr note when stdin is not +/// interactive. Returns whether to go ahead. (Deliberately NOT +/// `output::confirm`, whose semantics differ: stderr prompt, `default_yes` +/// honored on non-TTY and empty input.) +fn confirm_proceed(prompt: &str) -> bool { + if !stdin_is_tty() { + eprintln!("Non-interactive mode detected, proceeding automatically."); + return true; + } + print!("{prompt}"); + io::stdout().flush().unwrap(); + let mut answer = String::new(); + if io::stdin().read_line(&mut answer).is_err() { + // Terminals can deliver non-UTF-8 bytes (e.g. a Latin-1 paste); + // `read_line` reports those as InvalidData. Treat any read + // failure like an unrecognized answer (abort), not a panic. + return false; + } + let answer = answer.trim().to_lowercase(); + answer == "y" || answer == "yes" +} + /// Whether an ecosystem is in scope for this run, honoring the global /// `--ecosystems` filter (`CLI_CONTRACT.md` → "Setup command contract", /// property 2). With no filter (or an empty one) every ecosystem is in scope. @@ -214,10 +239,12 @@ fn is_member_excluded(manifest_path: &Path, cwd: &Path, excludes: &[String]) -> excludes.iter().any(|e| normalize_rel_path(e) == rel) } -/// The persisted `setup.exclude` list from `.socket/manifest.json` (empty if no -/// manifest / no setup state). Read-only. -async fn read_setup_excludes(common: &GlobalArgs) -> Vec { - match read_manifest(&common.resolved_manifest_path()).await { +/// The exclude set in effect for this run: the persisted `setup.exclude` list +/// from `.socket/manifest.json` (empty if no manifest / no setup state) union +/// the `--exclude` flag values (all normalized). This is what a clone inherits +/// — a clone with no flag still reads the persisted set. Read-only. +async fn effective_excludes(common: &GlobalArgs, flag: &[String]) -> Vec { + let mut set: Vec = match read_manifest(&common.resolved_manifest_path()).await { Ok(Some(m)) => m .setup .map(|s| s.exclude) @@ -226,14 +253,7 @@ async fn read_setup_excludes(common: &GlobalArgs) -> Vec { .map(|e| normalize_rel_path(e)) .collect(), _ => Vec::new(), - } -} - -/// The exclude set in effect for this run: the persisted `setup.exclude` union -/// the `--exclude` flag values (all normalized). This is what a clone inherits -/// — a clone with no flag still reads the persisted set. -async fn effective_excludes(common: &GlobalArgs, flag: &[String]) -> Vec { - let mut set = read_setup_excludes(common).await; + }; for e in flag { let n = normalize_rel_path(e); if !n.is_empty() && !set.contains(&n) { @@ -329,9 +349,8 @@ pub(crate) async fn configured_ecosystems( } } - #[cfg(feature = "composer")] - if let Some(project) = composer_setup::discover_composer_project(&common.cwd).await { - if let Ok(content) = tokio::fs::read_to_string(&project.composer_json).await { + if let Some(composer_json) = composer_setup::discover_composer_project(&common.cwd).await { + if let Ok(content) = tokio::fs::read_to_string(&composer_json).await { if composer_setup::is_hook_present(&content) { set.insert(Ecosystem::Composer); } @@ -345,7 +364,6 @@ pub(crate) async fn configured_ecosystems( const ECO_NPM: &[&str] = &["npm"]; const ECO_PYPI: &[&str] = &["pypi", "python"]; const ECO_GEM: &[&str] = &["gem", "ruby"]; -#[cfg(feature = "composer")] const ECO_COMPOSER: &[&str] = &["composer", "php"]; // ───────────────────────────────────────────────────────────────────────── @@ -454,8 +472,13 @@ async fn finalize_python(plan: &PythonPlan, edits: &[PthEditResult], cwd: &Path) return warnings; } // Lockfile refresh (broad auto-edit): only when the manager uses a lockfile - // that exists. Best-effort — never fatal. - if let Some((program, args)) = plan.pm.lock_command() { + // that exists. Best-effort — never fatal. The spellings are tried in + // order: pin-preserving first (`poetry lock --no-update`, + // `pdm lock --update-reuse`), bare `lock` as the fallback for versions + // that dropped the flag (Poetry 2.x, where bare `lock` is already + // pin-preserving). A successful fallback is not a failure — only warn + // when every spelling failed. + if let Some((program, spellings)) = plan.pm.lock_commands() { let lockfile = match plan.pm { PythonPackageManager::Uv => Some("uv.lock"), PythonPackageManager::Poetry => Some("poetry.lock"), @@ -467,49 +490,50 @@ async fn finalize_python(plan: &PythonPlan, edits: &[PthEditResult], cwd: &Path) None => false, }; if lock_present { - match tokio::process::Command::new(program) - .args(args) - .current_dir(cwd) - .output() - .await - { - Ok(o) if o.status.success() => {} - Ok(o) => warnings.push(format!( - "`{program} {}` failed ({}); update the lockfile manually", - args.join(" "), - o.status - )), - Err(e) => warnings.push(format!( - "could not run `{program} {}`: {e}; update the lockfile manually", - args.join(" ") - )), + let mut failure: Option = None; + for args in spellings { + match tokio::process::Command::new(program) + .args(*args) + .current_dir(cwd) + .output() + .await + { + Ok(o) if o.status.success() => { + failure = None; + break; + } + Ok(o) => { + failure = Some(format!( + "`{program} {}` failed ({}); update the lockfile manually", + args.join(" "), + o.status + )); + } + Err(e) => { + // The program itself didn't spawn (not installed / + // not on PATH): retrying another spelling of the + // same program is pointless. + failure = Some(format!( + "could not run `{program} {}`: {e}; update the lockfile manually", + args.join(" ") + )); + break; + } + } + } + if let Some(w) = failure { + warnings.push(w); } } } warnings } -fn pth_status_str(s: &PthStatus) -> &'static str { - match s { - PthStatus::Updated => "updated", - PthStatus::AlreadyConfigured => "already_configured", - PthStatus::Error => "error", - } -} - -fn update_status_str(s: &UpdateStatus) -> &'static str { - match s { - UpdateStatus::Updated => "updated", - UpdateStatus::AlreadyConfigured => "already_configured", - UpdateStatus::Error => "error", - } -} - // ───────────────────────────────────────────────────────────────────────── // Shared per-ecosystem setup outcome // ───────────────────────────────────────────────────────────────────────── -/// Feature-agnostic summary of one ecosystem branch's contribution to a +/// Summary of one ecosystem branch's contribution to a /// setup/remove run. Each `build_*_outcome` returns one of these and the shared /// reporting code merges + renders them without naming ecosystem-specific types. #[derive(Default)] @@ -532,8 +556,7 @@ struct SetupOutcome { /// Build the gem branch's contribution to a setup/remove run: add (or remove) /// the managed `plugin "socket-patch"` block in the Gemfile + the generated -/// `.socket/bundler-plugin/` plugin files. Gem is an unconditional ecosystem, -/// so this is never feature-gated. +/// `.socket/bundler-plugin/` plugin files. async fn build_gem_outcome(common: &GlobalArgs, remove: bool, dry_run: bool) -> SetupOutcome { if !eco_in_scope(common, ECO_GEM) { return SetupOutcome::default(); @@ -604,15 +627,12 @@ fn gem_status_str(s: &GemSetupStatus, for_remove: bool) -> &'static str { /// Build the composer branch's contribution to a setup/remove run: add (or /// remove) the `socket-patch apply` command in `composer.json`'s -/// `post-install-cmd` / `post-update-cmd` script events. Feature-gated behind -/// `composer` (a no-op `Default` when off) — composer apply itself only exists -/// with the feature, so wiring a hook without it would be incoherent. -#[cfg(feature = "composer")] +/// `post-install-cmd` / `post-update-cmd` script events. async fn build_composer_outcome(common: &GlobalArgs, remove: bool, dry_run: bool) -> SetupOutcome { if !eco_in_scope(common, ECO_COMPOSER) { return SetupOutcome::default(); } - let project = match composer_setup::discover_composer_project(&common.cwd).await { + let composer_json = match composer_setup::discover_composer_project(&common.cwd).await { Some(p) => p, None => return SetupOutcome::default(), }; @@ -623,9 +643,9 @@ async fn build_composer_outcome(common: &GlobalArgs, remove: bool, dry_run: bool }; let r = if remove { - composer_setup::remove_hook(&project, dry_run).await + composer_setup::remove_hook(&composer_json, dry_run).await } else { - composer_setup::add_hook(&project, dry_run).await + composer_setup::add_hook(&composer_json, dry_run).await }; let mut added_paths: Vec = Vec::new(); @@ -660,16 +680,6 @@ async fn build_composer_outcome(common: &GlobalArgs, remove: bool, dry_run: bool out } -#[cfg(not(feature = "composer"))] -async fn build_composer_outcome( - _common: &GlobalArgs, - _remove: bool, - _dry_run: bool, -) -> SetupOutcome { - SetupOutcome::default() -} - -#[cfg(feature = "composer")] fn composer_status_str(s: &ComposerSetupStatus, for_remove: bool) -> &'static str { match (s, for_remove) { (ComposerSetupStatus::Updated, false) => "updated", @@ -684,7 +694,6 @@ fn composer_status_str(s: &ComposerSetupStatus, for_remove: bool) -> &'static st /// `run_check` entries list. Returns whether a composer project was found. /// Checks the SETUP wiring only — patch consistency is the shared /// `append_patch_consistency_entries` pass. -#[cfg(feature = "composer")] async fn append_composer_check_entries( common: &GlobalArgs, entries: &mut Vec<(&'static str, String, CheckState, Option)>, @@ -692,11 +701,11 @@ async fn append_composer_check_entries( if !eco_in_scope(common, ECO_COMPOSER) { return false; } - let project = match composer_setup::discover_composer_project(&common.cwd).await { + let composer_json = match composer_setup::discover_composer_project(&common.cwd).await { Some(p) => p, None => return false, }; - let (state, err) = match tokio::fs::read_to_string(&project.composer_json).await { + let (state, err) = match tokio::fs::read_to_string(&composer_json).await { Ok(content) => { if composer_setup::is_hook_present(&content) { (CheckState::Configured, None) @@ -706,23 +715,10 @@ async fn append_composer_check_entries( } Err(e) => (CheckState::Error, Some(e.to_string())), }; - entries.push(( - "composer", - project.composer_json.display().to_string(), - state, - err, - )); + entries.push(("composer", composer_json.display().to_string(), state, err)); true } -#[cfg(not(feature = "composer"))] -async fn append_composer_check_entries( - _common: &GlobalArgs, - _entries: &mut Vec<(&'static str, String, CheckState, Option)>, -) -> bool { - false -} - /// Materialise gem patches right after wiring the plugin (the "automatic" step) /// so the first `bundle install` finds them already applied. Best-effort and /// offline; a non-zero exit becomes a warning — the plugin heals on the next @@ -804,8 +800,11 @@ async fn append_gem_check_entries( /// wired but patches drifted/un-applied is not in a correctly-patched state. /// /// Reuses the same machinery `vex` uses — the qualified-aware rollback resolver -/// (so release-variant PURLs resolve) honoring `--ecosystems`, then -/// [`applied_patches`]. An *uninstalled* package (`package_not_found`, also the +/// (so release-variant PURLs resolve) honoring `--ecosystems`, the committed +/// vendor ledger ([`crate::commands::vex::load_vendor_context`]: a vendored +/// patch is judged by its `.socket/vendor/` artifact — the bytes the next +/// install consumes — never the expectedly-unpatched installed tree), then +/// [`applied_patches_with_vendor`]. An *uninstalled* package (`package_not_found`, also the /// bucket for out-of-scope PURLs absent from the map) cannot be patched yet, and /// a degenerate zero-file record (`no_files`) has nothing to hash — neither is /// drift, so both are skipped. A missing/empty/unreadable manifest contributes @@ -821,20 +820,13 @@ async fn append_patch_consistency_entries( }; let purls: Vec = manifest.patches.keys().cloned().collect(); - let partitioned = partition_purls(&purls, common.ecosystems.as_deref()); - let crawler_options = CrawlerOptions { - cwd: common.cwd.clone(), - global: common.global, - global_prefix: common.global_prefix.clone(), - batch_size: 0, // unused for find_packages_for_rollback - }; // `--json` reserves stdout for the check report: silence the dispatch's // human chrome ("Using at: ...") like apply/rollback do. let package_paths = - find_packages_for_rollback(&partitioned, &crawler_options, common.silent || common.json) - .await; + find_manifest_package_paths(&purls, common, common.silent || common.json).await; - let outcome = applied_patches(&manifest, &package_paths).await; + let vendor = crate::commands::vex::load_vendor_context(common, &manifest).await; + let outcome = applied_patches_with_vendor(&manifest, &package_paths, vendor.as_ref()).await; for failed in &outcome.failed { match failed.reason.as_str() { // Not installed (or out of scope) / nothing to hash → not drift. @@ -897,7 +889,13 @@ async fn run_check(args: &SetupArgs) -> i32 { for loc in &npm_files { let (state, err) = match tokio::fs::read_to_string(&loc.path).await { Ok(content) => { - if serde_json::from_str::(&content).is_err() { + // npm and Node strip a leading UTF-8 BOM when reading + // package.json (and `setup` itself tolerates one via + // `is_setup_configured_str`); parse the same bytes they would, + // or a BOM'd configured file fails `--check` as "Invalid + // package.json" while `setup` calls it already_configured. + let json = content.strip_prefix('\u{feff}').unwrap_or(&content); + if serde_json::from_str::(json).is_err() { (CheckState::Error, Some("Invalid package.json".to_string())) } else if is_setup_configured_str(&content).needs_update { (CheckState::NeedsConfiguration, None) @@ -943,7 +941,6 @@ async fn run_check(args: &SetupArgs) -> i32 { if entries.is_empty() { return report_no_files( args, - "no_files", &[("configured", 0), ("needsConfiguration", 0), ("errors", 0)], ); } @@ -1013,6 +1010,20 @@ async fn run_check(args: &SetupArgs) -> i32 { "{needs} manifest(s) need configuration, {errs} error(s). Run `socket-patch setup` to fix." ); } + } else { + // `--silent` is "errors only": the status report is muted, but + // read/parse failures must still reach stderr. A plain + // needs-configuration state is not an error — the exit code alone + // carries it. + for (_, path, state, err) in &entries { + if *state == CheckState::Error { + eprintln!( + "Error: {}: {}", + pathdiff(path, &args.common.cwd), + err.as_deref().unwrap_or("unknown error") + ); + } + } } if all_ok { @@ -1058,11 +1069,7 @@ async fn run_remove(args: &SetupArgs) -> i32 { && !gem_preview.present && !composer_preview.present { - return report_no_files( - args, - "no_files", - &[("removed", 0), ("notConfigured", 0), ("errors", 0)], - ); + return report_no_files(args, &[("removed", 0), ("notConfigured", 0), ("errors", 0)]); } let gem_present = gem_preview.present; let extra_preview = merge_outcomes(gem_preview, composer_preview); @@ -1121,6 +1128,10 @@ async fn run_remove(args: &SetupArgs) -> i32 { println!("No socket-patch install hooks found to remove."); } } + eprint_errors_when_silent( + common, + &remove_error_messages(&npm_preview, &py_preview, &extra_preview), + ); return if preview_errs > 0 { 1 } else { 0 }; } @@ -1132,24 +1143,17 @@ async fn run_remove(args: &SetupArgs) -> i32 { println!("\nSummary:"); println!(" {n_remove} item(s) would have socket-patch removed"); } + eprint_errors_when_silent( + common, + &remove_error_messages(&npm_preview, &py_preview, &extra_preview), + ); return if preview_errs > 0 { 1 } else { 0 }; } // Confirm before mutating. - if !common.yes && !common.json { - if !stdin_is_tty() { - eprintln!("Non-interactive mode detected, proceeding automatically."); - } else { - print!("Remove these install hooks? (y/N): "); - io::stdout().flush().unwrap(); - let mut answer = String::new(); - io::stdin().read_line(&mut answer).unwrap(); - let answer = answer.trim().to_lowercase(); - if answer != "y" && answer != "yes" { - println!("Aborted"); - return 0; - } - } + if !common.yes && !common.json && !confirm_proceed("Remove these install hooks? (y/N): ") { + println!("Aborted"); + return 0; } if !quiet { @@ -1223,6 +1227,11 @@ async fn run_remove(args: &SetupArgs) -> i32 { } } + eprint_errors_when_silent( + common, + &remove_error_messages(&npm_results, &py_results, &extra_results), + ); + if errs > 0 { 1 } else { @@ -1243,6 +1252,64 @@ fn outcome_error_messages(o: &SetupOutcome) -> Vec { .collect() } +/// `--silent` is "errors only" (CLI_CONTRACT.md): the previews, summaries, +/// and status report that normally carry per-item failures are muted, so +/// before an error exit the failures themselves must still reach stderr — +/// mirroring `remove`/`scan`, whose error paths keep their stderr output. +/// JSON mode is exempt: its envelope already carries the errors. +fn eprint_errors_when_silent(common: &GlobalArgs, errs: &[String]) { + if !common.silent || common.json { + return; + } + for e in errs { + eprintln!("Error: {e}"); + } +} + +/// Per-item error messages across the three remove result families (npm + +/// Python + gem/composer) — the preview "Errors:" section and the +/// silent-mode stderr reporting share this. +fn remove_error_messages( + npm: &[RemoveResult], + py: &[PthEditResult], + extra: &SetupOutcome, +) -> Vec { + let mut errs: Vec = npm + .iter() + .filter(|r| r.status == RemoveStatus::Error) + .filter_map(|r| r.error.clone()) + .chain( + py.iter() + .filter(|r| r.status == PthStatus::Error) + .filter_map(|r| r.error.clone()), + ) + .collect(); + errs.extend(outcome_error_messages(extra)); + errs +} + +/// Per-item error messages across the three setup result families (npm + +/// Python + gem/composer) — the preview "Errors:" section and the +/// silent-mode stderr reporting share this. +fn setup_error_messages( + npm: &[UpdateResult], + py: &[PthEditResult], + extra: &SetupOutcome, +) -> Vec { + let mut errs: Vec = npm + .iter() + .filter(|r| r.status == UpdateStatus::Error) + .filter_map(|r| r.error.clone()) + .chain( + py.iter() + .filter(|r| r.status == PthStatus::Error) + .filter_map(|r| r.error.clone()), + ) + .collect(); + errs.extend(outcome_error_messages(extra)); + errs +} + fn print_remove_preview( npm: &[RemoveResult], py: &[PthEditResult], @@ -1289,17 +1356,7 @@ fn print_remove_preview( // Surface failures so the "(see errors above)" line `run_remove` prints when // nothing could be removed actually points at something. - let mut errs: Vec = npm - .iter() - .filter(|r| r.status == RemoveStatus::Error) - .filter_map(|r| r.error.clone()) - .chain( - py.iter() - .filter(|r| r.status == PthStatus::Error) - .filter_map(|r| r.error.clone()), - ) - .collect(); - errs.extend(outcome_error_messages(extra)); + let errs = remove_error_messages(npm, py, extra); if !errs.is_empty() { println!("Errors:"); for e in &errs { @@ -1418,22 +1475,10 @@ async fn run_setup(args: &SetupArgs) -> i32 { && !gem_preview.present && !composer_preview.present { - if common.json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "status": "no_files", - "updated": 0, - "alreadyConfigured": 0, - "errors": 0, - "files": [], - })) - .unwrap() - ); - } else if !common.silent { - println!("No package.json, Python, Bundler, or Composer project found"); - } - return 0; + return report_no_files( + args, + &[("updated", 0), ("alreadyConfigured", 0), ("errors", 0)], + ); } let gem_present = gem_preview.present; @@ -1511,6 +1556,10 @@ async fn run_setup(args: &SetupArgs) -> i32 { println!("All install hooks are already configured with socket-patch!"); } } + eprint_errors_when_silent( + common, + &setup_error_messages(&npm_preview, &py_preview, &extra_preview), + ); return if preview_errors > 0 { 1 } else { 0 }; } @@ -1529,23 +1578,16 @@ async fn run_setup(args: &SetupArgs) -> i32 { println!("\nSummary (dry run):"); println!(" {n_changes} item(s) would be updated"); } + eprint_errors_when_silent( + common, + &setup_error_messages(&npm_preview, &py_preview, &extra_preview), + ); return if preview_errors > 0 { 1 } else { 0 }; } - if !common.yes && !common.json { - if !stdin_is_tty() { - eprintln!("Non-interactive mode detected, proceeding automatically."); - } else { - print!("Proceed with these changes? (y/N): "); - io::stdout().flush().unwrap(); - let mut answer = String::new(); - io::stdin().read_line(&mut answer).unwrap(); - let answer = answer.trim().to_lowercase(); - if answer != "y" && answer != "yes" { - println!("Aborted"); - return 0; - } - } + if !common.yes && !common.json && !confirm_proceed("Proceed with these changes? (y/N): ") { + println!("Aborted"); + return 0; } if !quiet { @@ -1634,6 +1676,11 @@ async fn run_setup(args: &SetupArgs) -> i32 { } } + eprint_errors_when_silent( + common, + &setup_error_messages(&npm_results, &py_results, &extra_results), + ); + if errors > 0 { 1 } else { @@ -1691,17 +1738,7 @@ fn print_setup_preview( ); } - let mut errs: Vec = npm - .iter() - .filter(|r| r.status == UpdateStatus::Error) - .filter_map(|r| r.error.clone()) - .chain( - py.iter() - .filter(|r| r.status == PthStatus::Error) - .filter_map(|r| r.error.clone()), - ) - .collect(); - errs.extend(outcome_error_messages(extra)); + let errs = setup_error_messages(npm, py, extra); if !errs.is_empty() { println!("\nErrors:"); for e in &errs { @@ -1747,7 +1784,11 @@ fn print_setup_envelope( serde_json::json!({ "kind": "package_json", "path": r.path, - "status": update_status_str(&r.status), + "status": match r.status { + UpdateStatus::Updated => "updated", + UpdateStatus::AlreadyConfigured => "already_configured", + UpdateStatus::Error => "error", + }, "error": r.error, }) }) @@ -1756,7 +1797,11 @@ fn print_setup_envelope( serde_json::json!({ "kind": "pth", "path": r.path, - "status": pth_status_str(&r.status), + "status": match r.status { + PthStatus::Updated => "updated", + PthStatus::AlreadyConfigured => "already_configured", + PthStatus::Error => "error", + }, "error": r.error, }) })); diff --git a/crates/socket-patch-cli/src/commands/unlock.rs b/crates/socket-patch-cli/src/commands/unlock.rs index 77d49265..6208066a 100644 --- a/crates/socket-patch-cli/src/commands/unlock.rs +++ b/crates/socket-patch-cli/src/commands/unlock.rs @@ -14,7 +14,10 @@ //! operators who want a true clean slate. Refused when the lock is //! held — that's the `--break-lock` flag's job on the mutating //! subcommands, and routing the two through different verbs makes -//! the dangerous override explicit. +//! the dangerous override explicit. Under the global `--dry-run` +//! flag the release is previewed, not performed: the leftover file +//! stays on disk and the JSON body carries `dryRun` + `wouldRelease` +//! instead of flipping `released`. use std::path::Path; use std::time::Duration; @@ -24,7 +27,8 @@ use socket_patch_core::patch::apply_lock::{acquire, LockError}; use socket_patch_core::utils::telemetry::{track_patch_unlock_failed, track_patch_unlocked}; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; -use crate::json_envelope::{Command, Envelope, EnvelopeError}; +use crate::commands::lock_cli::error_envelope; +use crate::json_envelope::Command; #[derive(Args)] pub struct UnlockArgs { @@ -40,8 +44,10 @@ pub struct UnlockArgs { /// strings `true`/`false` from the env binding, so /// `SOCKET_UNLOCK_RELEASE=1` (or an exported-but-empty /// `SOCKET_UNLOCK_RELEASE=`) aborted every `unlock` invocation. - /// This flag is also outside `GLOBAL_ARG_ENV_VARS`, so `main`'s - /// empty-var scrub never rescues it. + /// (`main`'s empty-var scrub also removes a blank + /// `SOCKET_UNLOCK_RELEASE` via `LOCAL_ARG_ENV_VARS`, but the + /// parser itself must not depend on it — library callers of + /// `Cli::parse` never run the scrub.) #[arg( long = "release", env = "SOCKET_UNLOCK_RELEASE", @@ -62,8 +68,8 @@ pub async fn run(args: UnlockArgs) -> i32 { let manifest_path = args.common.resolved_manifest_path(); let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); let lock_file = socket_dir.join("apply.lock"); - let api_token = args.common.api_token.clone(); - let org_slug = args.common.org.clone(); + let api_token = args.common.api_token.as_deref(); + let org_slug = args.common.org.as_deref(); // No `.socket/` at all → treat as "free" (no one could be // holding a lock that doesn't exist). Useful for fresh repos @@ -73,13 +79,15 @@ pub async fn run(args: UnlockArgs) -> i32 { // remove, so `released` is false regardless of whether the // user passed --release. Telemetry and the emitted envelope // must agree on this. - track_patch_unlocked(false, false, api_token.as_deref(), org_slug.as_deref()).await; + track_patch_unlocked(false, false, api_token, org_slug).await; return emit_free( args.common.json, args.common.silent, &lock_file, false, + false, args.release, + args.common.dry_run, ); } @@ -98,7 +106,7 @@ pub async fn run(args: UnlockArgs) -> i32 { // delete races nothing. drop(guard); - if args.release { + let removed = if args.release && !args.common.dry_run { match std::fs::remove_file(&lock_file) { // `remove_file` here almost always returns `Ok` // (the probe's `acquire` ensured the file exists), @@ -108,86 +116,63 @@ pub async fn run(args: UnlockArgs) -> i32 { // operator asked for a clean slate), but only claim // we "released" something when a lock file was there // before we probed. - Ok(()) => { - track_patch_unlocked( - false, - lock_existed, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; - emit_free( - args.common.json, - args.common.silent, - &lock_file, - lock_existed, - true, - ) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - // The file was never created (e.g. socket - // dir existed but no run has acquired the - // lock yet). Treat as success. - track_patch_unlocked( - false, - false, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; - emit_free( - args.common.json, - args.common.silent, - &lock_file, - false, - true, - ) - } + Ok(()) => lock_existed, + // NotFound: the file was never created (e.g. socket + // dir existed but no run has acquired the lock yet). + // Treat as success. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, Err(e) => { let msg = format!( "failed to remove lock file at {}: {}", lock_file.display(), e ); - track_patch_unlock_failed(&msg, api_token.as_deref(), org_slug.as_deref()) - .await; - emit_error(args.common.json, args.common.silent, "lock_io", &msg); - 1 + track_patch_unlock_failed(&msg, api_token, org_slug).await; + emit_error( + args.common.json, + args.common.silent, + args.common.dry_run, + "lock_io", + &msg, + ); + return 1; } } } else { - track_patch_unlocked(false, false, api_token.as_deref(), org_slug.as_deref()).await; - emit_free( - args.common.json, - args.common.silent, - &lock_file, - false, - false, - ) - } + // `--dry-run` previews the release without performing it — + // the pre-existing leftover (the thing a real `--release` + // would delete) stays on disk. Deleting a file the probe + // itself just created is NOT the previewed mutation though: + // leaving it would make the dry run the only mode that + // *adds* state, so restore the tree to what we found. + if args.common.dry_run && !lock_existed { + let _ = std::fs::remove_file(&lock_file); + } + false + }; + let would_remove = args.release && args.common.dry_run && lock_existed; + track_patch_unlocked(false, removed, api_token, org_slug).await; + emit_free( + args.common.json, + args.common.silent, + &lock_file, + removed, + would_remove, + args.release, + args.common.dry_run, + ) } Err(LockError::Held) => { - track_patch_unlock_failed( - "lock held by another process", - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + track_patch_unlock_failed("lock held by another process", api_token, org_slug).await; + let msg = format!( + "another socket-patch process is operating in {}", + socket_dir.display() + ); if args.common.json { - let mut env = Envelope::new(Command::Unlock); - env.mark_error(EnvelopeError::new( - "lock_held", - format!( - "another socket-patch process is operating in {}", - socket_dir.display() - ), - )); + let env = error_envelope(Command::Unlock, args.common.dry_run, "lock_held", &msg); println!("{}", env.to_pretty_json()); } else if !args.common.silent { - eprintln!( - "Lock is held: another socket-patch process is operating in {}.", - socket_dir.display() - ); + eprintln!("Lock is held: {msg}."); if args.release { eprintln!( " Refusing to release a held lock. Re-run the failing mutating command with --break-lock if you're sure no holder exists." @@ -202,8 +187,14 @@ pub async fn run(args: UnlockArgs) -> i32 { } Err(LockError::Io { path, source }) => { let msg = format!("failed to open lock file at {}: {}", path.display(), source); - track_patch_unlock_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; - emit_error(args.common.json, args.common.silent, "lock_io", &msg); + track_patch_unlock_failed(&msg, api_token, org_slug).await; + emit_error( + args.common.json, + args.common.silent, + args.common.dry_run, + "lock_io", + &msg, + ); 1 } } @@ -211,11 +202,21 @@ pub async fn run(args: UnlockArgs) -> i32 { /// Print the "free" success envelope and return exit code 0. /// `removed` is true when `--release` actually deleted the file -/// (vs. the no-op case where the file didn't exist). +/// (vs. the no-op case where the file didn't exist). `would_remove` +/// is the `--dry-run` preview of `removed`: a pre-existing leftover +/// that a real `--release` would have deleted. /// `silent` suppresses the human-readable lines (the JSON envelope is /// machine output and always prints) — same `--silent` contract as the /// sibling subcommands. -fn emit_free(json: bool, silent: bool, lock_file: &Path, removed: bool, release: bool) -> i32 { +fn emit_free( + json: bool, + silent: bool, + lock_file: &Path, + removed: bool, + would_remove: bool, + release: bool, + dry_run: bool, +) -> i32 { if json { // Build the success body by hand rather than re-using the // shared `Envelope` shape — the `events`/`summary` fields @@ -223,17 +224,31 @@ fn emit_free(json: bool, silent: bool, lock_file: &Path, removed: bool, release: // `{status, lockFile, ...}` is friendlier to jq pipelines. // We still tag `command: "unlock"` so generic consumers // can route on subcommand identity. - let body = serde_json::json!({ + let mut body = serde_json::json!({ "command": "unlock", "status": "free", "lockFile": lock_file.display().to_string(), "released": removed, }); + // `released` stays truthful on a dry run (nothing was deleted), + // so the preview rides separate additive fields — same shape as + // `setup`'s dry-run-only `dryRun`/`wouldUpdate` pair. + if dry_run { + body["dryRun"] = serde_json::json!(true); + if release { + body["wouldRelease"] = serde_json::json!(would_remove); + } + } println!("{}", serde_json::to_string_pretty(&body).unwrap()); } else if silent { // Suppress the informational lines; the exit code carries the verdict. } else if release && removed { println!("Lock is free. Removed {}.", lock_file.display()); + } else if would_remove { + println!( + "Lock is free. Would remove {} (dry run).", + lock_file.display() + ); } else if release { println!("Lock is free (no lock file to remove)."); } else { @@ -242,10 +257,9 @@ fn emit_free(json: bool, silent: bool, lock_file: &Path, removed: bool, release: 0 } -fn emit_error(json: bool, silent: bool, code: &str, message: &str) { +fn emit_error(json: bool, silent: bool, dry_run: bool, code: &str, message: &str) { if json { - let mut env = Envelope::new(Command::Unlock); - env.mark_error(EnvelopeError::new(code, message)); + let env = error_envelope(Command::Unlock, dry_run, code, message); println!("{}", env.to_pretty_json()); } else if !silent { eprintln!("Error: {message}."); @@ -386,6 +400,48 @@ mod tests { ); } + /// `--release --dry-run` previews the release without performing + /// it: the pre-existing leftover file must survive. Regression + /// guard: `run` never read `common.dry_run`, so the global + /// `--dry-run` ("Preview, no mutations") flag was silently ignored + /// and the file was deleted anyway. + #[tokio::test] + async fn run_release_dry_run_keeps_leftover_lock_file() { + let dir = tempfile::tempdir().unwrap(); + let socket_dir = dir.path().join(".socket"); + std::fs::create_dir_all(&socket_dir).unwrap(); + std::fs::write(socket_dir.join("apply.lock"), b"crashed-run-leftover").unwrap(); + + let mut args = args_in(dir.path(), true); + args.common.dry_run = true; + let code = run(args).await; + assert_eq!(code, 0); + assert!( + socket_dir.join("apply.lock").is_file(), + "--dry-run must not delete the leftover lock file" + ); + } + + /// `--release --dry-run` against a clean `.socket/` must not leave + /// the probe-created file behind — a dry run may not *add* state + /// either. Companion to `run_release_cleans_up_probe_created_file`. + #[tokio::test] + async fn run_release_dry_run_leaves_no_probe_created_file() { + let dir = tempfile::tempdir().unwrap(); + let socket_dir = dir.path().join(".socket"); + std::fs::create_dir_all(&socket_dir).unwrap(); + assert!(!socket_dir.join("apply.lock").exists()); + + let mut args = args_in(dir.path(), true); + args.common.dry_run = true; + let code = run(args).await; + assert_eq!(code, 0); + assert!( + !socket_dir.join("apply.lock").exists(), + "--release --dry-run must not leave a probe-created lock file behind" + ); + } + /// `--release` against a HELD lock refuses (exit 1), file stays. #[tokio::test] async fn run_refuses_release_when_held() { diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index d45a2b94..b2a284bf 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -19,13 +19,14 @@ use clap::Args; use socket_patch_core::api::client::get_api_client_with_overrides; use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; -use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{verify_file_patch, PatchSources}; use socket_patch_core::patch::copy_tree::remove_tree; use socket_patch_core::patch::vendor::{ - self, ecosystem_dir_for_purl, load_state, save_state, RevertOutcome, VendorEntry, - VendorOutcome, VendorServiceConfig, VendorSource, VendorWarning, + self, ecosystem_dir_for_purl, load_state, lock_inventory, lookup_entry, registry_fetch, + save_state, RevertOutcome, VendorEntry, VendorOutcome, VendorServiceConfig, VendorSource, + VendorState, VendorWarning, }; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; use socket_patch_core::utils::telemetry::{track_patch_vendor_failed, track_patch_vendored}; @@ -82,7 +83,7 @@ pub struct VendorArgs { /// Refusal codes that are expected skips, not command failures: the user's /// request is still fully satisfied when these are the only non-successes. -pub(crate) fn refusal_is_benign(code: &str) -> bool { +fn refusal_is_benign(code: &str) -> bool { matches!(code, "vendor_unsupported_ecosystem" | "already_vendored") } @@ -108,60 +109,35 @@ pub(crate) async fn dispatch_vendor_one( let eco = ecosystem_dir_for_purl(purl)?; // Prebuilt service downloads now cover every vendorable ecosystem: npm, - // pypi, cargo, golang, composer, and gem. Gem's `.gem` archive doesn't - // carry the eval-able stub gemspec a bundler path source wants, so the - // converter generates it and serves it as a `gem-stub-gemspec` second - // artifact alongside the `.gem` (the gem backend downloads + verifies both). + // pypi, cargo, golang, composer, gem, nuget, and maven. Gem's `.gem` + // archive doesn't carry the eval-able stub gemspec a bundler path source + // wants, so the converter generates it and serves it as a + // `gem-stub-gemspec` second artifact alongside the `.gem` (the gem backend + // downloads + verifies both). // Under fail-closed `service` mode, refuse any not-covered ecosystem with a // clear message rather than silently building (which would violate the // contract). Under `auto`/`build` they fall through to the local build. - const SERVICE_ECOSYSTEMS: &[&str] = - &["npm", "pypi", "cargo", "golang", "composer", "gem", "nuget"]; + const SERVICE_ECOSYSTEMS: &[&str] = &[ + "npm", "pypi", "cargo", "golang", "composer", "gem", "nuget", "maven", + ]; if let Some(cfg) = service { if cfg.source.requires_service() && !SERVICE_ECOSYSTEMS.contains(&eco) { return Some(VendorOutcome::Refused { code: "vendor_service_unsupported_ecosystem", detail: format!( "--vendor-source=service is not supported for `{eco}` \ - (prebuilt downloads cover npm, pypi, cargo, golang, composer, and gem); \ + (prebuilt downloads cover npm, pypi, cargo, golang, composer, \ + gem, nuget, and maven); \ use --vendor-source=auto or --vendor-source=build" ), }); } } - Some(match eco { - "npm" => { - // The flavor router probes the project's lockfile (package-lock / - // yarn / pnpm / bun) and dispatches or refuses per flavor. - socket_patch_core::patch::vendor::npm_flavor::vendor_npm_any( - purl, - pkg_path, - project_root, - record, - sources, - vendored_at, - dry_run, - force, - service, - ) - .await - } - "pypi" => { - socket_patch_core::patch::vendor::pypi::vendor_pypi( - purl, - pkg_path, - project_root, - record, - sources, - vendored_at, - dry_run, - force, - service, - ) - .await - } - "gem" => { - socket_patch_core::patch::vendor::gem::vendor_gem( + // Every backend takes the identical 9-argument tuple; the macro keeps + // the per-arm #[cfg] while collapsing the eight-way repetition. + macro_rules! vend { + ($backend:path) => { + $backend( purl, pkg_path, project_root, @@ -173,82 +149,19 @@ pub(crate) async fn dispatch_vendor_one( service, ) .await - } - #[cfg(feature = "cargo")] - "cargo" => { - socket_patch_core::patch::vendor::cargo::vendor_cargo_crate( - purl, - pkg_path, - project_root, - record, - sources, - vendored_at, - dry_run, - force, - service, - ) - .await - } - #[cfg(feature = "golang")] - "golang" => { - socket_patch_core::patch::vendor::golang::vendor_go_module( - purl, - pkg_path, - project_root, - record, - sources, - vendored_at, - dry_run, - force, - service, - ) - .await - } - #[cfg(feature = "composer")] - "composer" => { - socket_patch_core::patch::vendor::composer_lock::vendor_composer( - purl, - pkg_path, - project_root, - record, - sources, - vendored_at, - dry_run, - force, - service, - ) - .await - } - #[cfg(feature = "nuget")] - "nuget" => { - socket_patch_core::patch::vendor::nuget_feed::vendor_nuget( - purl, - pkg_path, - project_root, - record, - sources, - vendored_at, - dry_run, - force, - service, - ) - .await - } - #[cfg(feature = "maven")] - "maven" => { - socket_patch_core::patch::vendor::maven_repo::vendor_maven( - purl, - pkg_path, - project_root, - record, - sources, - vendored_at, - dry_run, - force, - service, - ) - .await - } + }; + } + Some(match eco { + // The flavor router probes the project's lockfile (package-lock / + // yarn / pnpm / bun) and dispatches or refuses per flavor. + "npm" => vend!(vendor::npm_flavor::vendor_npm_any), + "pypi" => vend!(vendor::pypi::vendor_pypi), + "gem" => vend!(vendor::gem::vendor_gem), + "cargo" => vend!(vendor::cargo::vendor_cargo_crate), + "golang" => vend!(vendor::golang::vendor_go_module), + "composer" => vend!(vendor::composer_lock::vendor_composer), + "nuget" => vend!(vendor::nuget_feed::vendor_nuget), + "maven" => vend!(vendor::maven_repo::vendor_maven), _ => return None, }) } @@ -260,53 +173,14 @@ pub(crate) async fn dispatch_revert_one( dry_run: bool, ) -> RevertOutcome { match entry.ecosystem.as_str() { - "npm" => { - socket_patch_core::patch::vendor::npm_flavor::revert_npm_any( - entry, - project_root, - dry_run, - ) - .await - } - "pypi" => { - socket_patch_core::patch::vendor::pypi::revert_pypi(entry, project_root, dry_run).await - } - "gem" => { - socket_patch_core::patch::vendor::gem::revert_gem(entry, project_root, dry_run).await - } - #[cfg(feature = "cargo")] - "cargo" => { - socket_patch_core::patch::vendor::cargo::revert_cargo_vendor( - entry, - project_root, - dry_run, - ) - .await - } - #[cfg(feature = "golang")] - "golang" => { - socket_patch_core::patch::vendor::golang::revert_go_vendor(entry, project_root, dry_run) - .await - } - #[cfg(feature = "composer")] - "composer" => { - socket_patch_core::patch::vendor::composer_lock::revert_composer( - entry, - project_root, - dry_run, - ) - .await - } - #[cfg(feature = "nuget")] - "nuget" => { - socket_patch_core::patch::vendor::nuget_feed::revert_nuget(entry, project_root, dry_run) - .await - } - #[cfg(feature = "maven")] - "maven" => { - socket_patch_core::patch::vendor::maven_repo::revert_maven(entry, project_root, dry_run) - .await - } + "npm" => vendor::npm_flavor::revert_npm_any(entry, project_root, dry_run).await, + "pypi" => vendor::pypi::revert_pypi(entry, project_root, dry_run).await, + "gem" => vendor::gem::revert_gem(entry, project_root, dry_run).await, + "cargo" => vendor::cargo::revert_cargo_vendor(entry, project_root, dry_run).await, + "golang" => vendor::golang::revert_go_vendor(entry, project_root, dry_run).await, + "composer" => vendor::composer_lock::revert_composer(entry, project_root, dry_run).await, + "nuget" => vendor::nuget_feed::revert_nuget(entry, project_root, dry_run).await, + "maven" => vendor::maven_repo::revert_maven(entry, project_root, dry_run).await, other => RevertOutcome::failed(format!( "this build has no vendor backend for ecosystem `{other}`" )), @@ -317,12 +191,9 @@ pub(crate) async fn dispatch_revert_one( /// dependency graph? `None` = cannot determine — callers must keep the /// entry (fail-safe): non-npm ecosystems have no in-use probe yet, and a /// missing/unreadable lockfile proves nothing. -pub(crate) async fn dispatch_in_use_one(entry: &VendorEntry, project_root: &Path) -> Option { +async fn dispatch_in_use_one(entry: &VendorEntry, project_root: &Path) -> Option { match entry.ecosystem.as_str() { - "npm" => { - socket_patch_core::patch::vendor::npm_flavor::vendored_entry_in_use(entry, project_root) - .await - } + "npm" => vendor::npm_flavor::vendored_entry_in_use(entry, project_root).await, _ => None, } } @@ -333,11 +204,11 @@ pub(crate) async fn dispatch_in_use_one(entry: &VendorEntry, project_root: &Path /// owned by a recorded entry, so removal is safe; removed unless /// `dry_run`. Unparseable dirs are never returned (and never deleted). /// Returns the orphans so callers can emit events / counts. -pub(crate) async fn sweep_orphan_vendor_dirs( +async fn sweep_orphan_vendor_dirs( cwd: &Path, - state: &socket_patch_core::patch::vendor::VendorState, + state: &VendorState, dry_run: bool, -) -> Vec { +) -> Vec { let recorded_units: HashSet<(&str, &str)> = state .entries .values() @@ -447,29 +318,46 @@ pub async fn run(args: VendorArgs) -> i32 { env.record(lock_broken_event(&socket_dir)); } - let exit = if args.revert { + let mut exit = if args.revert { run_revert(&args, &mut env).await } else { run_vendor(&args, &manifest_path, &mut env, &vendor_service).await }; // Embedded VEX: same contract as `apply --vex` — only on success, and a - // requested-but-failed VEX flips the exit code. - let mut exit = exit; + // requested-but-failed VEX flips the exit code. A dry run vendors + // nothing, so there is no vendored state to attest: generating here + // would verify the deliberately untouched tree, spuriously fail the + // whole command with `no_applicable_patches`, and write an attestation + // file during --dry-run. Skip instead. if exit == 0 && !args.revert { if let Some(vex_path) = args.vex.vex.as_ref() { - let params = args.vex.to_build_params(); - match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await { - Ok(summary) => { - env.vex = Some(VexSummary { - path: vex_path.display().to_string(), - statements: summary.statements, - format: "openvex-0.2.0".to_string(), - }); + if args.common.dry_run { + if !args.common.json && !args.common.silent { + println!("Skipping VEX generation (--dry-run: nothing was vendored)."); } - Err(e) => { - env.mark_error(EnvelopeError::new(e.code, e.message.clone())); - exit = 1; + } else { + let params = args.vex.to_build_params(); + match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await + { + Ok(summary) => { + env.vex = Some(VexSummary { + path: vex_path.display().to_string(), + statements: summary.statements, + format: "openvex-0.2.0".to_string(), + }); + } + Err(e) => { + env.mark_error(EnvelopeError::new(e.code, e.message.clone())); + // The envelope only prints under --json; in human mode + // this error is the sole explanation for the flipped + // exit code, so it prints even under --silent ("errors + // only", never "nothing"). + if !args.common.json { + eprintln!("Error: VEX generation failed: {}", e.message); + } + exit = 1; + } } } } @@ -480,28 +368,35 @@ pub async fn run(args: VendorArgs) -> i32 { } if !args.revert { - if exit == 0 { - track_patch_vendored( - env.summary.applied, - args.common.dry_run, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; - } else { - track_patch_vendor_failed( - "vendor completed with failures", - args.common.dry_run, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; - } + track_outcomes_for_vendor( + exit != 0, + &env, + args.common.dry_run, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; } exit } +/// Telemetry for a vendor run's success/failure split, shared by +/// [`run`] and the scan-driven vendor step (`scan --vendor`). +pub(crate) async fn track_outcomes_for_vendor( + has_errors: bool, + env: &Envelope, + dry_run: bool, + token: Option<&str>, + org: Option<&str>, +) { + if has_errors { + track_patch_vendor_failed("vendor completed with failures", dry_run, token, org).await; + } else { + track_patch_vendored(env.summary.applied, dry_run, token, org).await; + } +} + async fn run_vendor( args: &VendorArgs, manifest_path: &Path, @@ -566,15 +461,6 @@ async fn run_vendor( } } -/// The vendoring engine, decoupled from the manifest file. `records` is the -/// purl → [`PatchRecord`] view to vendor: `manifest.patches` for the -/// manifest-driven `vendor` command (and `scan --vendor`), or the -/// freshly-fetched record map for `scan --vendor --detached`. Entries written -/// in `detached` mode carry [`VendorEntry::detached`] plus an embedded copy -/// of their record, so revert/verify/VEX work without a manifest entry. -/// -/// Does NOT lock, read the manifest, or print the envelope — callers own all -/// three. Returns whether any non-benign failure occurred. /// Persist one backend-returned ledger entry: detached flagging, wiring /// `original` carry-forward from the entry being replaced, per-package save /// (crash-consistent with what is already wired), and the stale-uuid-dir @@ -583,9 +469,9 @@ async fn run_vendor( pub(crate) async fn persist_vendor_entry( common: &GlobalArgs, env: &mut Envelope, - state: &mut socket_patch_core::patch::vendor::VendorState, + state: &mut VendorState, candidate: &str, - mut entry: socket_patch_core::patch::vendor::VendorEntry, + mut entry: VendorEntry, detached: bool, record: &PatchRecord, ) -> bool { @@ -611,9 +497,7 @@ pub(crate) async fn persist_vendor_entry( // (`vendor_lock_entry_drifted`) instead of // restoring the registry fragment. for rec in &mut entry.wiring { - if rec.action == socket_patch_core::patch::vendor::state::WiringAction::Rewritten - && rec.original.is_none() - { + if rec.action == vendor::state::WiringAction::Rewritten && rec.original.is_none() { if let Some(prev_rec) = prev .wiring .iter() @@ -668,7 +552,7 @@ pub(crate) async fn persist_vendor_entry( /// for vendored packages, so only `--revert`'s restore data still knows the /// registry resolution). Always integrity-verified fail-closed. pub(crate) enum PristineFetch { - Fetched(socket_patch_core::patch::vendor::registry_fetch::FetchedPackage), + Fetched(registry_fetch::FetchedPackage), /// Neither the lockfile nor the ledger can name a verifiable source. NoSource, Unverifiable(String), @@ -677,13 +561,11 @@ pub(crate) enum PristineFetch { pub(crate) async fn fetch_pristine_package( project_root: &Path, - inventory: &[socket_patch_core::patch::vendor::lock_inventory::LockfileEntry], - client: &socket_patch_core::patch::vendor::registry_fetch::RegistryClient, + inventory: &[lock_inventory::LockfileEntry], + client: ®istry_fetch::RegistryClient, purl: &str, - ledger_entry: Option<&socket_patch_core::patch::vendor::VendorEntry>, + ledger_entry: Option<&VendorEntry>, ) -> PristineFetch { - use socket_patch_core::patch::vendor::{lock_inventory, registry_fetch}; - let entry = match lock_inventory::lookup(inventory, purl) { Some(e) => e.clone(), None => { @@ -709,6 +591,15 @@ pub(crate) async fn fetch_pristine_package( } } +/// The vendoring engine, decoupled from the manifest file. `records` is the +/// purl → [`PatchRecord`] view to vendor: `manifest.patches` for the +/// manifest-driven `vendor` command (and `scan --vendor`), or the +/// freshly-fetched record map for `scan --vendor --detached`. Entries written +/// in `detached` mode carry [`VendorEntry::detached`] plus an embedded copy +/// of their record, so revert/verify/VEX work without a manifest entry. +/// +/// Does NOT lock, read the manifest, or print the envelope — callers own all +/// three. Returns whether any non-benign failure occurred. pub(crate) async fn vendor_records( common: &GlobalArgs, records: &HashMap, @@ -723,15 +614,11 @@ pub(crate) async fn vendor_records( let mut has_errors = false; let manifest_purls: Vec = records.keys().cloned().collect(); let partitioned = partition_purls(&manifest_purls, common.ecosystems.as_deref()); - let target_manifest_purls: HashSet = partitioned - .values() - .flat_map(|p| p.iter().cloned()) - .collect(); - // Purls with no vendor backend (jsr, or ecosystems compiled out of this - // binary) are expected skips, not failures. - let (vendorable, unsupported): (Vec, Vec) = target_manifest_purls - .iter() + // Purls with no vendor backend (jsr) are expected skips, not failures. + let (vendorable, unsupported): (Vec, Vec) = partitioned + .values() + .flatten() .cloned() .partition(|p| vendor::is_vendorable(p)); for purl in &unsupported { @@ -767,7 +654,6 @@ pub(crate) async fn vendor_records( cwd: common.cwd.clone(), global: common.global, global_prefix: common.global_prefix.clone(), - batch_size: 100, }; let mut all_packages = find_packages_for_purls( &vendorable_partition, @@ -784,13 +670,11 @@ pub(crate) async fn vendor_records( // project tree is never touched, and the lock wiring works without an // installed copy (it keys off lock entries). The holders keep the // tempdirs alive until the dispatch loop below has staged from them. - let mut fetched_holders: Vec = - Vec::new(); + let mut fetched_holders: Vec = Vec::new(); // Fetch failures must keep their distinct Failed event; this set // suppresses the later duplicate `package_not_installed` skip. let mut fetch_failed: HashSet = HashSet::new(); { - use socket_patch_core::patch::vendor::{lock_inventory, registry_fetch}; let missing: Vec = vendorable .iter() .filter(|p| !all_packages.contains_key(*p)) @@ -807,10 +691,7 @@ pub(crate) async fn vendor_records( // against the ledger — offline-safe, no registry traffic. let ledger = load_state(&common.cwd).await.unwrap_or_default(); for purl in &missing { - let ledger_entry = ledger - .entries - .get(purl) - .or_else(|| ledger.entries.values().find(|e| &e.base_purl == purl)); + let ledger_entry = lookup_entry(&ledger.entries, purl); if let Some(entry) = ledger_entry .filter(|e| e.ecosystem == "npm" && e.artifact.path.ends_with(".tgz")) { @@ -1118,14 +999,10 @@ pub(crate) async fn vendor_records( // Offline runs name the packages the lockfile COULD have fetched — // the inventory is a local file read, allowed offline. let lock_resolvable: HashSet = if common.offline { - let entries = - socket_patch_core::patch::vendor::lock_inventory::inventory_project(&common.cwd) - .await; + let entries = lock_inventory::inventory_project(&common.cwd).await; unmatched .iter() - .filter(|p| { - socket_patch_core::patch::vendor::lock_inventory::lookup(&entries, p).is_some() - }) + .filter(|p| lock_inventory::lookup(&entries, p).is_some()) .cloned() .collect() } else { @@ -1168,35 +1045,44 @@ pub(crate) async fn vendor_records( has_errors } -/// Revert vendored entries whose patches were dropped from the manifest. -/// Shared with `scan --vendor` (which runs the same engine in-process). -pub(crate) async fn reconcile_dropped( +/// Ledger entries whose patch is gone from the manifest — the stale test +/// shared by [`reconcile_dropped`] and [`run_vendor_gc`]. Respects this +/// run's --ecosystems scope: a `vendor --ecosystems npm` invocation must +/// not silently revert a cargo/go entry (restoring its lockfile and +/// deleting its artifact) as a cross-ecosystem side effect. Detached +/// entries (`scan --vendor --detached`) are never manifest-tracked, so +/// "absent from the manifest" is their normal state, not a drop — only +/// `vendor --revert` or `remove` may undo them. +fn manifest_dropped_purls( + state: &VendorState, manifest: &PatchManifest, common: &GlobalArgs, - env: &mut Envelope, -) -> bool { - let mut state = match load_state(&common.cwd).await { - Ok(s) => s, - Err(_) => return false, // unreadable state is reported by the main path - }; - // Respect this run's --ecosystems scope: a `vendor --ecosystems npm` - // invocation must not silently revert a cargo/go entry (restoring its - // lockfile and deleting its artifact) as a cross-ecosystem side effect. - let stale: Vec = state +) -> Vec { + state .entries .iter() .filter(|(purl, entry)| { - // Detached entries (`scan --vendor --detached`) are never - // manifest-tracked, so "absent from the manifest" is their - // normal state, not a drop — only `vendor --revert` or - // `remove` may undo them. !entry.detached && ecosystem_in_scope(common, &entry.ecosystem) && !manifest.patches.contains_key(*purl) && !manifest.patches.contains_key(&entry.base_purl) }) .map(|(purl, _)| purl.clone()) - .collect(); + .collect() +} + +/// Revert vendored entries whose patches were dropped from the manifest. +/// Shared with `scan --vendor` (which runs the same engine in-process). +pub(crate) async fn reconcile_dropped( + manifest: &PatchManifest, + common: &GlobalArgs, + env: &mut Envelope, +) -> bool { + let mut state = match load_state(&common.cwd).await { + Ok(s) => s, + Err(_) => return false, // unreadable state is reported by the main path + }; + let stale = manifest_dropped_purls(&state, manifest, common); let mut had_error = false; for purl in stale { let entry = state.entries.get(&purl).cloned().expect("listed above"); @@ -1242,11 +1128,8 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { }; let mut has_errors = false; - let recorded: Vec = { - let mut keys: Vec = state.entries.keys().cloned().collect(); - keys.sort(); - keys - }; + let mut recorded: Vec = state.entries.keys().cloned().collect(); + recorded.sort(); for purl in &recorded { let entry = state.entries.get(purl).cloned().expect("key listed above"); @@ -1391,24 +1274,16 @@ pub(crate) async fn run_vendor_gc( } }; - // (a) manifest-dropped entries. - let mut manifest = socket_patch_core::manifest::operations::read_manifest(manifest_path) - .await - .ok() - .flatten(); + // (a) manifest-dropped entries. Everything (a) touches is excluded from + // (b): in a dry run the ledger keeps the entry, and after a wet revert + // failure it does too — either way (b) would list/fail the same purl a + // second time, which the wet success path (entry removed before (b)'s + // candidate scan) never does. + let mut handled_by_a: HashSet = HashSet::new(); + let mut manifest = read_manifest(manifest_path).await.ok().flatten(); if let Some(m) = &manifest { - let stale: Vec = state - .entries - .iter() - .filter(|(purl, entry)| { - !entry.detached - && ecosystem_in_scope(common, &entry.ecosystem) - && !m.patches.contains_key(*purl) - && !m.patches.contains_key(&entry.base_purl) - }) - .map(|(purl, _)| purl.clone()) - .collect(); - for purl in stale { + for purl in manifest_dropped_purls(&state, m, common) { + handled_by_a.insert(purl.clone()); if dry_run { out.dropped_reverted.push(purl); continue; @@ -1431,7 +1306,11 @@ pub(crate) async fn run_vendor_gc( let candidates: Vec = state .entries .iter() - .filter(|(_, entry)| !entry.detached && ecosystem_in_scope(common, &entry.ecosystem)) + .filter(|(purl, entry)| { + !entry.detached + && ecosystem_in_scope(common, &entry.ecosystem) + && !handled_by_a.contains(*purl) + }) .map(|(purl, _)| purl.clone()) .collect(); for purl in candidates { @@ -1471,8 +1350,7 @@ pub(crate) async fn run_vendor_gc( let _ = save_state(&common.cwd, &state).await; if manifest_dirty { if let Some(m) = &manifest { - let _ = - socket_patch_core::manifest::operations::write_manifest(manifest_path, m).await; + let _ = write_manifest(manifest_path, m).await; } } } @@ -1484,12 +1362,71 @@ pub(crate) async fn run_vendor_gc( out } +#[cfg(test)] +mod dispatch_tests { + use super::*; + use socket_patch_core::patch::vendor::VendorSource; + + /// Fail-closed `--vendor-source=service` must not refuse maven at the + /// dispatch gate: the maven backend has a full service path (prebuilt + /// jar download + registry pom), and its own errors advise exactly + /// that flag. Regression: PR #117 shipped the backend and added nuget + /// to `SERVICE_ECOSYSTEMS` but left maven off the list, so the gate + /// dead-ended the flag the backend recommends. + #[tokio::test] + async fn service_mode_gate_admits_maven() { + let tmp = tempfile::tempdir().unwrap(); + let record = PatchRecord { + uuid: "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f".to_string(), + exported_at: String::new(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + }; + let sources = PatchSources { + blobs_path: tmp.path(), + packages_path: None, + diffs_path: None, + mem_blobs: None, + }; + let service = VendorServiceConfig { + source: VendorSource::Service, + client: None, + use_public_proxy: false, + vendor_url: None, + patch_server_url: None, + offline: false, + }; + let outcome = dispatch_vendor_one( + "pkg:maven/org.apache.logging.log4j/log4j-core@2.17.0", + tmp.path(), + tmp.path(), + &record, + &sources, + "2026-01-01T00:00:00Z", + false, + false, + Some(&service), + ) + .await; + // The backend itself may refuse (nothing is installed in the + // fixture) — the gate just must not be what stops it. + match outcome { + Some(VendorOutcome::Refused { code, .. }) => assert_ne!( + code, "vendor_service_unsupported_ecosystem", + "maven has a service backend; the dispatch gate must admit it" + ), + _ => {} + } + } +} + #[cfg(test)] mod gc_tests { use super::*; - use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::patch::vendor::state::VendorArtifact; - use socket_patch_core::patch::vendor::VendorState; use std::path::PathBuf; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; @@ -1699,6 +1636,36 @@ mod gc_tests { .contains_key(PURL)); } + /// An entry that is BOTH manifest-dropped and lockfile-unused must be + /// listed exactly once. The wet pass removes it from the ledger in (a) + /// before (b) runs; the dry-run preview leaves the ledger untouched, so + /// without excluding (a)-handled purls from (b) the same purl lands in + /// both lists and `scan --prune`'s `revertableVendoredEntries` preview + /// duplicates it (breaking preview/wet parity). + #[tokio::test] + async fn vendor_gc_dry_run_lists_dropped_and_unused_entry_once() { + let (tmp, common, manifest_path) = gc_fixture(false).await; + // Patch gone from the manifest AND dependency gone from the lock. + write_manifest(&manifest_path, &PatchManifest::new()) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("package-lock.json"), "{\"packages\":{}}") + .await + .unwrap(); + + let dry = run_vendor_gc(&common, &manifest_path, true).await; + assert_eq!(dry.dropped_reverted, vec![PURL.to_string()], "{dry:?}"); + assert!( + dry.unused_reverted.is_empty(), + "an (a)-handled entry must not also be previewed as (b)-unused: {dry:?}" + ); + + // Wet parity: the same single listing. + let wet = run_vendor_gc(&common, &manifest_path, false).await; + assert_eq!(wet.dropped_reverted, vec![PURL.to_string()], "{wet:?}"); + assert!(wet.unused_reverted.is_empty(), "{wet:?}"); + } + /// (c) uuid dirs with no owning ledger entry are swept (wet) / counted /// (dry). #[tokio::test] diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index af772fe4..969a75e8 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -17,17 +17,17 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use clap::Args; -use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; +use socket_patch_core::crawlers::Ecosystem; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::utils::telemetry::{track_vex_failed, track_vex_generated}; use socket_patch_core::vex::{ - build_document_with_provenance, detect_product, BuildOptions, Document, FailedPatch, - VendorContext, VerifyOutcome, + build_document, detect_product, BuildOptions, Document, FailedPatch, VendorContext, + VerifyOutcome, }; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; -use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls}; +use crate::ecosystem_dispatch::find_manifest_package_paths; use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent}; #[derive(Args)] @@ -177,7 +177,6 @@ pub(crate) struct VexBuildParams { pub(crate) struct VexWriteSummary { pub statements: usize, pub failed: Vec, - pub wrote_to_file: bool, /// The built document — returned so the standalone `vex` command can /// emit its per-subcomponent envelope without rebuilding. pub doc: Document, @@ -200,59 +199,18 @@ pub async fn run(args: VexArgs) -> i32 { // on the same stdout stream. Bail out with a clear error before // doing any work. if args.common.json && args.output.is_none() { - emit_envelope_error_and_track( - &args, + let e = fail( + &args.common, "json_requires_output", "--json requires --output (the VEX document is itself JSON; \ - route it to a file so the envelope can use stdout)", + route it to a file so the envelope can use stdout)" + .to_string(), ) .await; + emit_envelope_error(&args, e.code, &e.message, &[]); return 2; } - let manifest_path = args.common.resolved_manifest_path(); - - // `None` ⇒ no manifest file. That is no longer terminal by itself: - // a `scan --vendor --detached` project carries its patch records in - // the vendor ledger instead, and those must still be attestable. - let manifest_file = match read_manifest(&manifest_path).await { - Ok(m) => m, - Err(e) => { - emit_envelope_error_and_track(&args, "manifest_unreadable", &e.to_string()).await; - return 2; - } - }; - let had_manifest_file = manifest_file.is_some(); - let manifest = augment_with_detached( - &args.common, - manifest_file.unwrap_or_else(PatchManifest::new), - ) - .await; - // `scan --redirect` patches live only in the redirect ledger (no manifest - // record); fold them in before the emptiness check so they're attestable. - let (manifest, redirected) = augment_with_redirect(&args.common, manifest).await; - - if manifest.patches.is_empty() { - if !had_manifest_file { - // No manifest AND nothing detached — the original contract. - emit_envelope_error_and_track( - &args, - "manifest_not_found", - &format!("Manifest not found at {}", manifest_path.display()), - ) - .await; - return 2; - } - emit_envelope_error_and_track( - &args, - "no_patches", - "Manifest is empty — nothing to attest. Run `socket-patch get` \ - or `socket-patch scan --sync` first.", - ) - .await; - return 1; - } - let params = VexBuildParams { output: args.output.clone(), product: args.product.clone(), @@ -262,16 +220,17 @@ pub async fn run(args: VexArgs) -> i32 { assume_applied: Vec::new(), }; - match generate_vex(&args.common, ¶ms, &manifest, &redirected).await { + let manifest_path = args.common.resolved_manifest_path(); + match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await { Ok(summary) => { if args.common.json { emit_envelope_success(&summary.doc, &summary.failed); - } else if summary.wrote_to_file { + } else if let Some(path) = &args.output { if !args.common.silent { - let path = args.output.as_ref().unwrap().display(); println!( - "Wrote OpenVEX document with {} statement(s) to {path}", - summary.statements + "Wrote OpenVEX document with {} statement(s) to {}", + summary.statements, + path.display() ); } } else if !args.common.silent { @@ -279,16 +238,29 @@ pub async fn run(args: VexArgs) -> i32 { } 0 } - // `no_applicable_patches` is a soft "nothing to attest" (exit 1) - // and lists the omitted patches; every other error is a hard - // failure (exit 2). `generate_vex` already fired telemetry, so - // these emit-only sinks must not re-track. + // `no_applicable_patches` and `no_patches` are soft "nothing to + // attest" cases (exit 1); every other error is a hard failure + // (exit 2). `generate_vex_from_manifest_path` already fired + // telemetry, so these emit-only sinks must not re-track. Err(e) if e.code == "no_applicable_patches" => { - emit_envelope_error_with_failures(&args, e.code, &e.message, &e.failed); + emit_envelope_error(&args, e.code, &e.message, &e.failed); + 1 + } + // Standalone-only remediation hint: after an embedded `apply --vex` + // / `scan --vex` run the advice would be circular, so the shared + // path keeps the bare message and it is appended here. + Err(e) if e.code == "no_patches" => { + emit_envelope_error( + &args, + e.code, + "Manifest is empty — nothing to attest. Run `socket-patch get` \ + or `socket-patch scan --sync` first.", + &[], + ); 1 } Err(e) => { - emit_envelope_error(&args, e.code, &e.message); + emit_envelope_error(&args, e.code, &e.message, &[]); 2 } } @@ -296,28 +268,20 @@ pub async fn run(args: VexArgs) -> i32 { /// Map a `setup.manual` entry to an `Ecosystem`. Accepts the canonical /// `cli_name` plus the friendly aliases `setup --exclude`/`--ecosystems` accept -/// (`go`/`golang`, `python`/`pypi`, `ruby`/`gem`, `php`/`composer`). Names for -/// ecosystems not compiled into this build (or unrecognized) yield `None` and -/// are ignored. +/// (`go`/`golang`, `python`/`pypi`, `ruby`/`gem`, `php`/`composer`). +/// Unrecognized names yield `None` and are ignored. fn ecosystem_from_manual_name(name: &str) -> Option { match name.to_ascii_lowercase().as_str() { "npm" | "yarn" | "pnpm" | "bun" => Some(Ecosystem::Npm), "pypi" | "python" => Some(Ecosystem::Pypi), "gem" | "ruby" => Some(Ecosystem::Gem), - #[cfg(feature = "cargo")] "cargo" | "rust" => Some(Ecosystem::Cargo), - #[cfg(feature = "golang")] "golang" | "go" => Some(Ecosystem::Golang), - #[cfg(feature = "composer")] "composer" | "php" => Some(Ecosystem::Composer), // The apply-only ecosystems are the primary use of `manual` (hand-applied - // patches with no auto-install hook); they must map too, feature-gated to - // match the compiled-in `Ecosystem` variants `from_purl` can return. - #[cfg(feature = "maven")] + // patches with no auto-install hook); they must map too. "maven" | "java" => Some(Ecosystem::Maven), - #[cfg(feature = "nuget")] "nuget" | "dotnet" => Some(Ecosystem::Nuget), - #[cfg(feature = "deno")] "deno" | "jsr" => Some(Ecosystem::Deno), _ => None, } @@ -330,7 +294,7 @@ fn ecosystem_from_manual_name(name: &str) -> Option { /// telemetry. Returns a [`VexWriteSummary`] on success or a structured /// [`VexGenError`] (with a stable code) on failure. All `track_vex_*` /// telemetry is fired here so every caller reports consistently. -pub(crate) async fn generate_vex( +async fn generate_vex( common: &GlobalArgs, params: &VexBuildParams, manifest: &PatchManifest, @@ -344,8 +308,25 @@ pub(crate) async fn generate_vex( // Partition manifest into applied / failed. let mut outcome = if params.no_verify { + // Trust-the-manifest mode still needs the vendored classification: + // the property-7 exemption and the "(vendored)" phrasing key off + // `outcome.vendored`, and both are about how the patch persists, + // not whether this run hashed it. The committed ledger is as + // trustworthy as the manifest beside it, and reading it hashes + // nothing. An unreadable ledger degrades to "nothing vendored". + let entries = socket_patch_core::patch::vendor::load_state(&common.cwd) + .await + .map(|state| state.entries) + .unwrap_or_default(); + let vendored = manifest + .patches + .keys() + .filter(|purl| socket_patch_core::patch::vendor::lookup_entry(&entries, purl).is_some()) + .cloned() + .collect(); VerifyOutcome { applied: manifest.patches.keys().cloned().collect(), + vendored, ..Default::default() } } else { @@ -354,7 +335,8 @@ pub(crate) async fn generate_vex( // the dispatch's human chrome ("Using at: ...") in both, // mirroring apply/rollback's `silent || json` gating. let quiet = common.silent || common.json || params.output.is_none(); - let package_paths = resolve_package_paths(common, manifest, quiet).await; + let purls: Vec = manifest.patches.keys().cloned().collect(); + let package_paths = find_manifest_package_paths(&purls, common, quiet).await; let vendor = load_vendor_context(common, manifest).await; socket_patch_core::vex::applied_patches_with_vendor( manifest, @@ -440,7 +422,7 @@ pub(crate) async fn generate_vex( tooling: Some(format!("socket-patch {}", env!("CARGO_PKG_VERSION"))), }; - let doc = match build_document_with_provenance( + let doc = match build_document( manifest, &outcome.applied, &outcome.vendored, @@ -464,16 +446,13 @@ pub(crate) async fn generate_vex( }; // Serialize. - let serialized = if params.compact { - match serde_json::to_string(&doc) { - Ok(s) => s, - Err(e) => return Err(fail(common, "serialize_failed", e.to_string()).await), - } + let serialized = match if params.compact { + serde_json::to_string(&doc) } else { - match serde_json::to_string_pretty(&doc) { - Ok(s) => s, - Err(e) => return Err(fail(common, "serialize_failed", e.to_string()).await), - } + serde_json::to_string_pretty(&doc) + } { + Ok(s) => s, + Err(e) => return Err(fail(common, "serialize_failed", e.to_string()).await), }; // Write. @@ -502,7 +481,6 @@ pub(crate) async fn generate_vex( Ok(VexWriteSummary { statements: doc.statements.len(), failed: outcome.failed, - wrote_to_file, doc, }) } @@ -624,7 +602,9 @@ async fn resolve_product_id(common: &GlobalArgs, product: Option<&str>) -> Resul /// Build the [`VendorContext`] for verification: the committed /// `.socket/vendor/state.json` ledger plus synthesized entries for the -/// legacy `.socket/go-patches/` redirect backend. +/// legacy `.socket/go-patches/` redirect backend. Shared by `vex` and +/// `setup --check`'s patch-consistency pass — both must judge a vendored +/// patch by the committed artifact, never the installed tree. /// /// The go-patches synthesis fixes a latent bug: an apply-redirected Go /// patch leaves the module cache pristine (the `replace` directive routes @@ -638,7 +618,7 @@ async fn resolve_product_id(common: &GlobalArgs, product: Option<&str>) -> Resul /// installed tree, fail verification there, and are omitted — fail-closed, /// never falsely attested. Returns `None` when there is nothing vendored /// and no redirect to synthesize (the common case). -async fn load_vendor_context( +pub(crate) async fn load_vendor_context( common: &GlobalArgs, manifest: &PatchManifest, ) -> Option { @@ -647,25 +627,15 @@ async fn load_vendor_context( Err(e) => { if !common.silent { eprintln!( - "Warning: unreadable vendor state ({e}); vendored patches will be \ - omitted from VEX" + "Warning: unreadable vendor state ({e}); vendored patches cannot be \ + verified from the committed artifact" ); } HashMap::new() } }; - let go_patches = { - #[cfg(feature = "golang")] - { - synthesize_go_patches(common, manifest, &entries).await - } - #[cfg(not(feature = "golang"))] - { - let _ = manifest; - HashMap::new() - } - }; + let go_patches = synthesize_go_patches(common, manifest, &entries).await; if entries.is_empty() && go_patches.is_empty() { return None; @@ -681,7 +651,6 @@ async fn load_vendor_context( /// every socket-owned (`.socket/go-patches/`) `replace` in `go.mod` whose /// module+version maps to a manifest golang PURL with no explicit vendor /// entry, record the absolute redirect copy dir for dir-hash verification. -#[cfg(feature = "golang")] async fn synthesize_go_patches( common: &GlobalArgs, manifest: &PatchManifest, @@ -690,6 +659,7 @@ async fn synthesize_go_patches( use socket_patch_core::patch::go_mod_edit::{ read_replace_entries, ReplaceOwner, GO_PATCHES_DIR, }; + use socket_patch_core::patch::go_redirect::{are_safe_redirect_coords, copy_dir_for}; use socket_patch_core::utils::purl::build_golang_purl; let mut go_patches = HashMap::new(); @@ -706,107 +676,29 @@ async fn synthesize_go_patches( } // Explicit vendor entries take precedence over the synthesis // (vendor may have taken over an apply redirect). - if entries.contains_key(&purl) || entries.values().any(|e| e.base_purl == purl) { + if socket_patch_core::patch::vendor::lookup_entry(entries, &purl).is_some() { continue; } // SECURITY: module/version come from a committed (tamper-able) - // go.mod and are about to key a path we hash. Validate with the - // same per-segment rules `go_redirect::are_safe_redirect_coords` - // applies (it is crate-private to core) before building the - // copy-dir path. - if !are_safe_go_redirect_coords(&entry.module, version) { + // go.mod and are about to key a path we hash. Apply the same + // fail-closed coordinate guard `go_redirect` itself uses before + // building the copy-dir path. + if !are_safe_redirect_coords(&entry.module, version) { continue; } go_patches.insert( purl, - common - .cwd - .join(GO_PATCHES_DIR) - .join(format!("{}@{version}", entry.module)), + copy_dir_for(&common.cwd, GO_PATCHES_DIR, &entry.module, version), ); } go_patches } -/// Local mirror of core's `go_redirect::are_safe_redirect_coords` (which is -/// `pub(crate)` there): a module path is `/`-separated segments, each -/// non-empty and not `.`/`..`, no leading `/`, no backslash/NUL; a version -/// is a single such segment. Fail-closed before any disk access. -#[cfg(feature = "golang")] -fn are_safe_go_redirect_coords(module: &str, version: &str) -> bool { - fn safe_segment(seg: &str) -> bool { - !seg.is_empty() && seg != "." && seg != ".." - } - let module_ok = !module.is_empty() - && !module.starts_with('/') - && !module.contains('\\') - && !module.contains('\0') - && module.split('/').all(safe_segment); - let version_ok = safe_segment(version) - && !version.contains('/') - && !version.contains('\\') - && !version.contains('\0'); - module_ok && version_ok -} - -/// Walk the ecosystem dispatch to build the PURL -> on-disk-path map -/// used by `vex::verify::applied_patches`. -async fn resolve_package_paths( - common: &GlobalArgs, - manifest: &PatchManifest, - quiet: bool, -) -> HashMap { - let purls: Vec = manifest.patches.keys().cloned().collect(); - let partitioned = partition_purls(&purls, common.ecosystems.as_deref()); - let crawler_options = CrawlerOptions { - cwd: common.cwd.clone(), - global: common.global, - global_prefix: common.global_prefix.clone(), - batch_size: 0, // unused for find_packages_for_rollback - }; - // Use the rollback (qualified-aware) resolver, NOT - // `find_packages_for_purls`. Release-variant ecosystems - // (PyPI / RubyGems / Maven) key the manifest by *qualified* PURLs - // (`?artifact_id=`, `?platform=`, `?classifier=&ext=`), but the - // crawler only knows the *base* PURL. `find_packages_for_purls` - // would key the result map by the base PURL, so the qualified - // lookups in `vex::applied_patches` would all miss and every - // PyPI/Gem/Maven patch would be silently dropped from the VEX doc - // as `package_not_found`. The rollback variant fans each base path - // back out to every qualified manifest PURL — the same mapping the - // manifest was written with (`get` uses the same resolver). - find_packages_for_rollback(&partitioned, &crawler_options, quiet).await -} - -fn emit_envelope_error(args: &VexArgs, code: &str, message: &str) { - if args.common.json { - let mut env = Envelope::new(Command::Vex); - env.mark_error(EnvelopeError::new(code, message.to_string())); - println!("{}", env.to_pretty_json()); - } else { - eprintln!("Error: {message}"); - } -} - -/// Async error sink that mirrors `emit_envelope_error` and also fires -/// the `vex_failed` telemetry event. Centralizes both side effects so -/// each `return` site in `run` only needs one call. -async fn emit_envelope_error_and_track(args: &VexArgs, code: &str, message: &str) { - track_vex_failed( - code, - args.common.api_token.as_deref(), - args.common.org.as_deref(), - ) - .await; - emit_envelope_error(args, code, message); -} - -fn emit_envelope_error_with_failures( - args: &VexArgs, - code: &str, - message: &str, - failures: &[FailedPatch], -) { +/// Emit a `vex` error to the active output channel: an error envelope on +/// stdout in `--json` mode, a stderr message otherwise. `failures` lists +/// patches omitted by verification (populated for `no_applicable_patches`, +/// empty everywhere else). +fn emit_envelope_error(args: &VexArgs, code: &str, message: &str, failures: &[FailedPatch]) { if args.common.json { let mut env = Envelope::new(Command::Vex); for f in failures { @@ -861,30 +753,24 @@ mod tests { use super::*; use clap::Parser; - // Property 7: every ecosystem the build can classify a PURL for must also be + // Property 7: every ecosystem a PURL can classify to must also be // declarable `manual`. Apply-only maven/nuget/deno are the *primary* use of // `manual`; they were missing originally, silently dropping their patches. #[test] - fn ecosystem_from_manual_name_maps_compiled_in_ecosystems() { + fn ecosystem_from_manual_name_maps_every_ecosystem() { assert_eq!(ecosystem_from_manual_name("npm"), Some(Ecosystem::Npm)); assert_eq!(ecosystem_from_manual_name("PyPI"), Some(Ecosystem::Pypi)); // case-insensitive assert_eq!(ecosystem_from_manual_name("python"), Some(Ecosystem::Pypi)); assert_eq!(ecosystem_from_manual_name("ruby"), Some(Ecosystem::Gem)); assert_eq!(ecosystem_from_manual_name("nonsense"), None); - #[cfg(feature = "cargo")] assert_eq!(ecosystem_from_manual_name("cargo"), Some(Ecosystem::Cargo)); - #[cfg(feature = "golang")] assert_eq!(ecosystem_from_manual_name("go"), Some(Ecosystem::Golang)); - #[cfg(feature = "composer")] assert_eq!( ecosystem_from_manual_name("composer"), Some(Ecosystem::Composer) ); - #[cfg(feature = "maven")] assert_eq!(ecosystem_from_manual_name("maven"), Some(Ecosystem::Maven)); - #[cfg(feature = "nuget")] assert_eq!(ecosystem_from_manual_name("nuget"), Some(Ecosystem::Nuget)); - #[cfg(feature = "deno")] assert_eq!(ecosystem_from_manual_name("deno"), Some(Ecosystem::Deno)); } @@ -910,36 +796,37 @@ mod tests { } } - /// The local mirror of core's `are_safe_redirect_coords` must enforce - /// the same accept/reject set (cases lifted from core's pinned tests) — - /// a divergence would let a tampered go.mod `replace` key an - /// out-of-tree path into the go-patches verification map. - #[cfg(feature = "golang")] + /// The go-patches synthesis guards its copy-dir keys with core's + /// `are_safe_redirect_coords`; pin the accept/reject set from the CLI + /// side — a regression here would let a tampered go.mod `replace` key + /// an out-of-tree path into the go-patches verification map. #[test] fn go_redirect_coord_guard_matches_core_rules() { - assert!(are_safe_go_redirect_coords("github.com/foo/bar", "v1.4.2")); - assert!(are_safe_go_redirect_coords("gopkg.in/inf.v0", "v0.9.1")); - assert!(are_safe_go_redirect_coords( + use socket_patch_core::patch::go_redirect::are_safe_redirect_coords; + + assert!(are_safe_redirect_coords("github.com/foo/bar", "v1.4.2")); + assert!(are_safe_redirect_coords("gopkg.in/inf.v0", "v0.9.1")); + assert!(are_safe_redirect_coords( "github.com/foo/bar/v2", "v2.0.0-20210101000000-abcdef123456" )); - assert!(!are_safe_go_redirect_coords("../../../etc", "v1.0.0")); - assert!(!are_safe_go_redirect_coords( + assert!(!are_safe_redirect_coords("../../../etc", "v1.0.0")); + assert!(!are_safe_redirect_coords( "github.com/../../../etc", "v1.0.0" )); - assert!(!are_safe_go_redirect_coords("/abs/path", "v1.0.0")); - assert!(!are_safe_go_redirect_coords("github.com//bar", "v1.0.0")); - assert!(!are_safe_go_redirect_coords("foo/./bar", "v1.0.0")); - assert!(!are_safe_go_redirect_coords("foo\\bar", "v1.0.0")); - assert!(!are_safe_go_redirect_coords("", "v1.0.0")); - assert!(!are_safe_go_redirect_coords( + assert!(!are_safe_redirect_coords("/abs/path", "v1.0.0")); + assert!(!are_safe_redirect_coords("github.com//bar", "v1.0.0")); + assert!(!are_safe_redirect_coords("foo/./bar", "v1.0.0")); + assert!(!are_safe_redirect_coords("foo\\bar", "v1.0.0")); + assert!(!are_safe_redirect_coords("", "v1.0.0")); + assert!(!are_safe_redirect_coords( "github.com/foo/bar", "../../../evil" )); - assert!(!are_safe_go_redirect_coords("github.com/foo/bar", "v1/0/0")); - assert!(!are_safe_go_redirect_coords("github.com/foo/bar", "..")); - assert!(!are_safe_go_redirect_coords("github.com/foo/bar", "")); + assert!(!are_safe_redirect_coords("github.com/foo/bar", "v1/0/0")); + assert!(!are_safe_redirect_coords("github.com/foo/bar", "..")); + assert!(!are_safe_redirect_coords("github.com/foo/bar", "")); } #[derive(Parser)] diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 11f55a97..0ec9e392 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -1,38 +1,30 @@ use socket_patch_core::crawlers::{ - CrawledPackage, CrawlerOptions, Ecosystem, NpmCrawler, PythonCrawler, + CrawledPackage, CrawlerOptions, Ecosystem, NpmCrawler, PythonCrawler, RubyCrawler, }; use socket_patch_core::utils::purl::strip_purl_qualifiers; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -#[cfg(feature = "cargo")] +use crate::args::GlobalArgs; + use socket_patch_core::crawlers::CargoCrawler; -#[cfg(feature = "composer")] use socket_patch_core::crawlers::ComposerCrawler; -#[cfg(feature = "deno")] use socket_patch_core::crawlers::DenoCrawler; -#[cfg(feature = "golang")] use socket_patch_core::crawlers::GoCrawler; -#[cfg(feature = "maven")] use socket_patch_core::crawlers::MavenCrawler; -#[cfg(feature = "nuget")] use socket_patch_core::crawlers::NuGetCrawler; -use socket_patch_core::crawlers::RubyCrawler; /// Runtime opt-in gate for experimental Maven support. /// -/// Even when the binary is compiled with `--features maven`, the -/// crawler does NOT run unless `SOCKET_EXPERIMENTAL_MAVEN=1` (or +/// The Maven crawler does NOT run unless `SOCKET_EXPERIMENTAL_MAVEN=1` (or /// `=true`). Applying a Maven patch corrupts the jar sidecar /// checksums (`.jar.sha1`, `.jar.md5`) that the local /// Maven repository keeps next to each artifact, and there is no /// recovery — the user has to re-download the jar. -#[cfg(feature = "maven")] fn maven_runtime_enabled() -> bool { env_truthy("SOCKET_EXPERIMENTAL_MAVEN") } -#[cfg(feature = "maven")] fn warn_maven_disabled(skipped: usize) { eprintln!( "Warning: {} Maven patch(es) skipped — Maven support is experimental.", @@ -49,12 +41,10 @@ fn warn_maven_disabled(skipped: usize) { /// fixup cannot honestly rewrite this without the original `.nupkg` /// (which we don't have post-extraction). Refuse to dispatch unless /// the operator has explicitly opted in to the experimental tier. -#[cfg(feature = "nuget")] fn nuget_runtime_enabled() -> bool { env_truthy("SOCKET_EXPERIMENTAL_NUGET") } -#[cfg(feature = "nuget")] fn warn_nuget_disabled(skipped: usize) { eprintln!( "Warning: {} NuGet patch(es) skipped — NuGet support is experimental.", @@ -65,7 +55,6 @@ fn warn_nuget_disabled(skipped: usize) { eprintln!(" Set SOCKET_EXPERIMENTAL_NUGET=1 to enable at your own risk."); } -#[cfg(any(feature = "maven", feature = "nuget"))] fn env_truthy(name: &str) -> bool { std::env::var(name) .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) @@ -164,7 +153,7 @@ type MergeFn = fn(&mut HashMap, &[String], HashMap, _purls: &[String], - packages: HashMap, + packages: HashMap, ) { for (purl, pkg) in packages { out.entry(purl).or_insert(pkg.path); @@ -180,7 +169,7 @@ fn merge_first_wins( fn merge_qualified( out: &mut HashMap, purls: &[String], - packages: HashMap, + packages: HashMap, ) { for (base_purl, pkg) in packages { for qualified in purls { @@ -251,7 +240,6 @@ async fn dispatch_find( on_match = variant_merge, ); - #[cfg(feature = "cargo")] scan_ecosystem!( out = out, partitioned = partitioned, @@ -283,7 +271,6 @@ async fn dispatch_find( on_match = variant_merge, ); - #[cfg(feature = "golang")] scan_ecosystem!( out = out, partitioned = partitioned, @@ -298,7 +285,6 @@ async fn dispatch_find( on_match = merge_first_wins, ); - #[cfg(feature = "maven")] if let Some(maven_purls) = partitioned.get(&Ecosystem::Maven) { if !maven_purls.is_empty() && !maven_runtime_enabled() { if !silent { @@ -325,7 +311,6 @@ async fn dispatch_find( } } - #[cfg(feature = "composer")] scan_ecosystem!( out = out, partitioned = partitioned, @@ -340,7 +325,6 @@ async fn dispatch_find( on_match = merge_first_wins, ); - #[cfg(feature = "nuget")] if let Some(nuget_purls) = partitioned.get(&Ecosystem::Nuget) { if !nuget_purls.is_empty() && !nuget_runtime_enabled() { if !silent { @@ -363,7 +347,6 @@ async fn dispatch_find( } } - #[cfg(feature = "deno")] scan_ecosystem!( out = out, partitioned = partitioned, @@ -404,6 +387,32 @@ pub async fn find_packages_for_rollback( dispatch_find(partitioned, options, silent, merge_qualified).await } +/// Resolve manifest PURLs to their installed on-disk paths (partition, +/// build crawler options from the global args, dispatch). Uses the +/// rollback (qualified-aware) resolver, NOT `find_packages_for_purls`: +/// release-variant ecosystems (PyPI / RubyGems / Maven) key the manifest +/// by *qualified* PURLs (`?artifact_id=`, `?platform=`, +/// `?classifier=&ext=`), but the crawler only knows the *base* PURL. +/// `find_packages_for_purls` would key the result map by the base PURL, +/// so qualified manifest lookups would all miss and every PyPI/Gem/Maven +/// patch would silently resolve as `package_not_found`. The rollback +/// variant fans each base path back out to every qualified manifest PURL +/// — the same mapping the manifest was written with (`get` uses the same +/// resolver). +pub async fn find_manifest_package_paths( + purls: &[String], + common: &GlobalArgs, + quiet: bool, +) -> HashMap { + let partitioned = partition_purls(purls, common.ecosystems.as_deref()); + let crawler_options = CrawlerOptions { + cwd: common.cwd.clone(), + global: common.global, + global_prefix: common.global_prefix.clone(), + }; + find_packages_for_rollback(&partitioned, &crawler_options, quiet).await +} + /// Crawl all enabled ecosystems and return all packages plus per-ecosystem counts. pub async fn crawl_all_ecosystems( options: &CrawlerOptions, @@ -421,25 +430,19 @@ pub async fn crawl_all_ecosystems( crawl!(Ecosystem::Npm, NpmCrawler); crawl!(Ecosystem::Pypi, PythonCrawler); - #[cfg(feature = "cargo")] crawl!(Ecosystem::Cargo, CargoCrawler); crawl!(Ecosystem::Gem, RubyCrawler); - #[cfg(feature = "golang")] crawl!(Ecosystem::Golang, GoCrawler); - #[cfg(feature = "maven")] if maven_runtime_enabled() { // Same runtime gate as `find_packages_for_purls` — `scan` // walks the Maven repo only when the operator has explicitly // opted into experimental support. crawl!(Ecosystem::Maven, MavenCrawler); } - #[cfg(feature = "composer")] crawl!(Ecosystem::Composer, ComposerCrawler); - #[cfg(feature = "nuget")] if nuget_runtime_enabled() { crawl!(Ecosystem::Nuget, NuGetCrawler); } - #[cfg(feature = "deno")] crawl!(Ecosystem::Deno, DenoCrawler); (all_packages, counts) @@ -690,22 +693,15 @@ mod tests { fn release_variant_predicate_matches_dispatch_expectations() { assert!(Ecosystem::Pypi.supports_release_variants()); assert!(Ecosystem::Gem.supports_release_variants()); - #[cfg(feature = "maven")] assert!(Ecosystem::Maven.supports_release_variants()); assert!(!Ecosystem::Npm.supports_release_variants()); - #[cfg(feature = "cargo")] assert!(!Ecosystem::Cargo.supports_release_variants()); - #[cfg(feature = "golang")] assert!(!Ecosystem::Golang.supports_release_variants()); - #[cfg(feature = "composer")] assert!(!Ecosystem::Composer.supports_release_variants()); - #[cfg(feature = "nuget")] assert!(!Ecosystem::Nuget.supports_release_variants()); - #[cfg(feature = "deno")] assert!(!Ecosystem::Deno.supports_release_variants()); } - #[cfg(any(feature = "maven", feature = "nuget"))] #[test] fn env_truthy_accepts_one_and_true_case_insensitive() { let key = "SOCKET_TEST_ENV_TRUTHY"; @@ -721,7 +717,6 @@ mod tests { assert!(!env_truthy(key)); } - #[cfg(any(feature = "maven", feature = "nuget"))] #[test] fn env_truthy_rejects_empty_and_padded_values() { // The experimental gates must NOT open on an empty assignment @@ -756,15 +751,7 @@ mod tests { "pkg:cargo/baz@3.0".to_string(), ]; let map = partition_purls(&purls, None); - // `pkg:cargo/...` is only recognized when the `cargo` feature is - // compiled in; otherwise `Ecosystem::from_purl` drops it. Keep the - // expected length in step with the active feature set so this test - // is correct in both configurations. - #[cfg(feature = "cargo")] - let expected_len = 3; - #[cfg(not(feature = "cargo"))] - let expected_len = 2; - assert_eq!(map.len(), expected_len); + assert_eq!(map.len(), 3); assert_eq!( map.get(&Ecosystem::Npm), Some(&vec!["pkg:npm/foo@1.0".to_string()]) @@ -773,7 +760,6 @@ mod tests { map.get(&Ecosystem::Pypi), Some(&vec!["pkg:pypi/bar@2.0".to_string()]) ); - #[cfg(feature = "cargo")] assert_eq!( map.get(&Ecosystem::Cargo), Some(&vec!["pkg:cargo/baz@3.0".to_string()]) @@ -909,7 +895,6 @@ mod tests { cwd, global: false, global_prefix: None, - batch_size: 100, } } @@ -985,7 +970,6 @@ mod tests { // one gets no entry. That distinction lets us test the gate without a // real Maven repo / NuGet cache fixture. - #[cfg(feature = "maven")] #[tokio::test] #[serial_test::serial(experimental_gate_env)] async fn crawl_all_gates_maven_on_runtime_flag() { @@ -1008,7 +992,6 @@ mod tests { std::env::remove_var("SOCKET_EXPERIMENTAL_MAVEN"); } - #[cfg(feature = "nuget")] #[tokio::test] #[serial_test::serial(experimental_gate_env)] async fn crawl_all_gates_nuget_on_runtime_flag() { diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index 13eb3427..6490710f 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -26,10 +26,7 @@ use serde::Serialize; -pub use socket_patch_core::patch::sidecars::{ - SidecarAdvisory, SidecarAdvisoryCode, SidecarFile, SidecarFileAction, SidecarRecord, - SidecarSeverity, -}; +pub use socket_patch_core::patch::sidecars::{SidecarFile, SidecarFileAction, SidecarRecord}; /// Top-level JSON envelope emitted by every `--json` invocation. #[derive(Debug, Clone, Serialize)] @@ -133,13 +130,6 @@ impl Envelope { self.events.push(event); } - /// Append a sidecar fixup record. Called once per `ApplyResult` - /// whose `sidecar` field is `Some`. Order matches the order - /// `apply` processed packages, which is best-effort. - pub fn record_sidecar(&mut self, sidecar: SidecarRecord) { - self.sidecars.push(sidecar); - } - /// Mark the run as a partial failure. Idempotent. pub fn mark_partial_failure(&mut self) { if !matches!(self.status, Status::Error) { @@ -210,15 +200,8 @@ impl PatchEvent { /// Use the `with_*` builders to attach optional fields. pub fn new(action: PatchAction, purl: impl Into) -> Self { Self { - action, purl: Some(purl.into()), - uuid: None, - old_uuid: None, - files: Vec::new(), - reason: None, - error_code: None, - error: None, - details: None, + ..Self::artifact(action) } } @@ -722,7 +705,7 @@ mod tests { let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); assert!(!v.as_object().unwrap().contains_key("sidecars")); - env.record_sidecar(SidecarRecord { + env.sidecars.push(SidecarRecord { purl: "pkg:cargo/foo@1.0.0".into(), ecosystem: "cargo".into(), files: vec![SidecarFile { diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index 8c0181e8..fb354f0d 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -7,7 +7,7 @@ pub mod args; pub mod commands; -pub mod ecosystem_dispatch; +pub(crate) mod ecosystem_dispatch; pub mod json_envelope; pub mod output; @@ -83,7 +83,7 @@ pub enum Commands { /// /// Used by [`parse_with_uuid_fallback`] to detect the convenience form /// `socket-patch ` and rewrite it to `socket-patch get `. -pub fn looks_like_uuid(s: &str) -> bool { +fn looks_like_uuid(s: &str) -> bool { let parts: Vec<&str> = s.split('-').collect(); if parts.len() != 5 { return false; diff --git a/crates/socket-patch-cli/src/main.rs b/crates/socket-patch-cli/src/main.rs index 995a7267..e824e981 100644 --- a/crates/socket-patch-cli/src/main.rs +++ b/crates/socket-patch-cli/src/main.rs @@ -1,17 +1,41 @@ use socket_patch_cli::{commands, parse_with_uuid_fallback, Commands}; use socket_patch_core::utils::env_compat::promote_legacy_env_vars; +/// Restore the default SIGPIPE disposition. The Rust runtime starts every +/// process with SIGPIPE ignored, so once a pipeline consumer exits +/// (`socket-patch scan | head -1`) the next `println!` gets `EPIPE` and +/// *panics* — exit 101 and a "failed printing to stdout: Broken pipe" +/// crash report instead of the quiet SIGPIPE death every other Unix CLI +/// has in that position. Network sockets are unaffected: std and socket2 +/// write with `MSG_NOSIGNAL` / `SO_NOSIGPIPE`. +#[cfg(unix)] +fn restore_default_sigpipe() { + // SAFETY: SIG_DFL is a valid disposition for SIGPIPE, and this runs + // first thing in `main`, before any other threads exist. + unsafe { + libc::signal(libc::SIGPIPE, libc::SIG_DFL); + } +} + +#[cfg(not(unix))] +fn restore_default_sigpipe() {} + #[tokio::main] async fn main() { + // Must precede any output: the deprecation warnings and clap help both + // write to possibly-already-closed pipes. + restore_default_sigpipe(); + // Migrate legacy SOCKET_PATCH_* env vars into the new SOCKET_* names // before clap parses, so downstream code only needs to know the new // names. A one-shot deprecation warning fires per legacy name set. promote_legacy_env_vars(); - // Then drop exported-but-empty SOCKET_* globals (`SOCKET_CWD=` means - // "unset", not "crash the parse"). Must run after the promotion so a - // blanked legacy name is scrubbed too. - socket_patch_cli::args::scrub_empty_global_env_vars(); + // Then drop exported-but-empty SOCKET_* flag vars — global and + // subcommand-local (`SOCKET_CWD=` means "unset", not "crash the + // parse"). Must run after the promotion so a blanked legacy name is + // scrubbed too. + socket_patch_cli::args::scrub_empty_env_vars(); // The parser surface is `String`-typed, but argv is raw bytes on Unix — // `std::env::args()` would *panic* on a non-Unicode argument. Collect diff --git a/crates/socket-patch-cli/src/output.rs b/crates/socket-patch-cli/src/output.rs index d00a94ca..b53a63d1 100644 --- a/crates/socket-patch-cli/src/output.rs +++ b/crates/socket-patch-cli/src/output.rs @@ -1,17 +1,7 @@ use std::io::{self, IsTerminal, Write}; -/// Check if stdout is a terminal (for ANSI color output). -pub fn stdout_is_tty() -> bool { - std::io::stdout().is_terminal() -} - -/// Check if stderr is a terminal (for progress output). -pub fn stderr_is_tty() -> bool { - std::io::stderr().is_terminal() -} - /// Check if stdin is a terminal (for interactive prompts). -pub fn stdin_is_tty() -> bool { +pub(crate) fn stdin_is_tty() -> bool { std::io::stdin().is_terminal() } @@ -43,7 +33,9 @@ pub fn color(text: &str, code: &str, use_color: bool) -> String { pub enum SelectError { /// User cancelled the selection. Cancelled, - /// JSON mode requires explicit selection (e.g. via --id). + /// JSON mode requires explicit selection (re-running with the chosen + /// UUID as the identifier — `--id` is a boolean type-tag, not a + /// value-taking selector). JsonModeNeedsExplicit, } @@ -53,7 +45,7 @@ pub enum SelectError { /// - Non-TTY stdin: return `default_yes` with a stderr warning. /// - Interactive: print prompt to stderr, read line; empty = `default_yes`; /// unreadable input (e.g. non-UTF-8 bytes) = no. -pub fn confirm(prompt: &str, default_yes: bool, skip_prompt: bool, is_json: bool) -> bool { +pub(crate) fn confirm(prompt: &str, default_yes: bool, skip_prompt: bool, is_json: bool) -> bool { if skip_prompt || is_json { return default_yes; } @@ -97,16 +89,13 @@ pub fn select_one(prompt: &str, options: &[String], is_json: bool) -> Result Ok(idx), - None => Err(SelectError::Cancelled), - } + .map_err(|_| SelectError::Cancelled)? + .ok_or(SelectError::Cancelled) } #[cfg(test)] @@ -336,7 +325,7 @@ mod tests { #[test] fn select_one_json_mode_ignores_options_contents() { // Even with a single option, JSON mode must defer to an explicit - // `--id` rather than silently picking it. + // UUID re-run rather than silently picking it. let opts = vec!["only".to_string()]; assert!(matches!( select_one("pick", &opts, true), diff --git a/crates/socket-patch-cli/tests/apply_network.rs b/crates/socket-patch-cli/tests/apply_network.rs index 5c4eadb8..0bada1a3 100644 --- a/crates/socket-patch-cli/tests/apply_network.rs +++ b/crates/socket-patch-cli/tests/apply_network.rs @@ -794,3 +794,84 @@ async fn apply_mismatch_redownloads_full_blob_and_applies() { let content = std::fs::read(tmp.path().join("node_modules/mismatch/index.js")).unwrap(); assert_eq!(content, after); } + +// --------------------------------------------------------------------------- +// --offline is a strict airgap: ZERO network requests, including the +// telemetry-labeling client's org-slug auto-resolution at command start. +// --------------------------------------------------------------------------- + +/// `run()` builds an API client up front purely to label telemetry with the +/// token/org. When `SOCKET_API_TOKEN` is set but no org is configured, that +/// client construction auto-resolves the org slug via `GET +/// /v0/organizations` — a live network round-trip that must not happen under +/// `--offline` (the same strict-airgap contract that already disables +/// telemetry and blob fetching). The apply itself is fully satisfiable +/// offline here (blob staged locally), so the ONLY traffic the mock could +/// ever see is contract-violating. +#[tokio::test] +async fn offline_apply_with_token_makes_zero_network_requests() { + let before = b"offline airgap before\n"; + let after = b"offline airgap after\n"; + let before_hash = git_sha256(before); + let after_hash = git_sha256(after); + + // No mocks mounted: every request is a violation, and all are recorded. + let mock = MockServer::start().await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "airgap-test", "1.0.0", "index.js", before); + let socket = tmp.path().join(".socket"); + write_manifest_with_patch( + &socket, + "pkg:npm/airgap-test@1.0.0", + "33333333-3333-4333-8333-333333333333", + &before_hash, + &after_hash, + ); + let blobs = socket.join("blobs"); + std::fs::create_dir_all(&blobs).expect("create blobs"); + std::fs::write(blobs.join(&after_hash), after).expect("stage blob"); + + let mut cmd = Command::new(binary()); + cmd.args(["apply", "--json", "--offline"]) + .current_dir(tmp.path()); + // Scrub ambient SOCKET_* (an ambient SOCKET_ORG_SLUG would skip the + // auto-resolution and make this test vacuously green), then seed only + // what the scenario needs: token set, org absent, both URLs pinned to + // the mock so any traffic — API or proxy path — is captured. + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + cmd.env("SOCKET_API_URL", mock.uri()) + .env("SOCKET_PROXY_URL", mock.uri()) + .env("SOCKET_API_TOKEN", "fake-token-for-test"); + let out = cmd.output().expect("run socket-patch"); + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + assert_eq!( + code, 0, + "offline apply with a locally staged blob must succeed; \ + stdout={stdout}\nstderr={stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("json envelope"); + assert_eq!( + v["summary"]["applied"], 1, + "the staged patch must actually apply offline; stdout={stdout}" + ); + + let requests = mock.received_requests().await.unwrap_or_default(); + let hits: Vec = requests + .iter() + .map(|r| format!("{} {}", r.method, r.url.path())) + .collect(); + assert!( + hits.is_empty(), + "--offline must make ZERO network requests (strict airgap), but the \ + mock server saw: {hits:?}" + ); +} diff --git a/crates/socket-patch-cli/tests/cli_apply_silent.rs b/crates/socket-patch-cli/tests/cli_apply_silent.rs new file mode 100644 index 00000000..0a92d820 --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_apply_silent.rs @@ -0,0 +1,171 @@ +//! `apply --silent` / `apply --check` error-output contract tests. +//! +//! CLI_CONTRACT.md defines `--silent` as "Errors only" — never "nothing": +//! an exit-1 run with zero output is undiagnosable. It also requires every +//! `--json` invocation to emit exactly one envelope. Regression guards for +//! the apply error paths that gated their ONLY error print on `!silent` +//! (or skipped the JSON envelope entirely): +//! +//! 1. `apply --silent` with an unreadable manifest (the +//! `apply_patches_inner` error path) exited 1 with zero output. +//! 2. `apply --check --silent` with an unreadable manifest (fail-closed +//! drift) exited 1 with zero output. +//! 3. `apply --check --json` with an unreadable manifest exited 1 with NO +//! JSON envelope at all. +//! 4. `apply --check --silent` with real redirect drift exited 1 with zero +//! output (the OUT OF SYNC report was muted). +//! +//! Same bug class previously fixed in `scan` (`embed_vex_human`), `setup` +//! (all three modes), and apply's own yarn-PnP refusal. +//! +//! Stderr assertions ignore the "No SOCKET_API_TOKEN set" client warning: +//! it's printed unconditionally by `get_api_client_with_overrides` in core +//! for every command and is out of scope for `apply`'s `--silent` gating. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use socket_patch_cli::args::GLOBAL_ARG_ENV_VARS; + +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +/// Run `socket-patch apply` in `cwd` with a scrubbed SOCKET_* environment +/// so ambient developer/CI configuration (tokens, silent toggles) can't +/// change the branch under test. +fn run_apply(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.arg("apply").args(args).current_dir(cwd); + for var in GLOBAL_ARG_ENV_VARS { + cmd.env_remove(var); + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + let out = cmd.output().expect("run socket-patch apply"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) +} + +/// Non-error stderr lines: drop the unconditional core API-token warning +/// (both its lead line and its "Got: ... Continuing anyway" continuation) +/// and blank lines, keep everything else. +fn stderr_chatter(stderr: &str) -> Vec { + stderr + .lines() + .filter(|l| { + !l.contains("SOCKET_API_TOKEN") + && !l.contains("Continuing anyway") + && !l.trim().is_empty() + }) + .map(|l| l.to_string()) + .collect() +} + +fn write_corrupt_manifest(root: &Path) { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write(socket.join("manifest.json"), "{ not json").unwrap(); +} + +/// Valid manifest with one golang patch entry and NO committed copy under +/// `.socket/go-patches/` — `apply --check` must report `MissingCopy` drift. +fn write_drifted_go_manifest(root: &Path) { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write( + socket.join("manifest.json"), + r#"{ "patches": { + "pkg:golang/example.com/mod@v1.0.0": { + "uuid": "go-drift-uuid-0000", + "exportedAt": "2024-01-01T00:00:00Z", + "files": { "file.go": { + "beforeHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "afterHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }}, + "vulnerabilities": {}, "description": "x", + "license": "MIT", "tier": "free" + } + }}"#, + ) + .unwrap(); +} + +/// `apply --silent` with an unreadable manifest must still print the error +/// ("errors only", never "nothing" — exit 1 with no output is +/// undiagnosable in the npm postinstall hook that runs `apply` silently). +#[test] +fn apply_silent_unreadable_manifest_keeps_error_output() { + let tmp = tempfile::tempdir().unwrap(); + write_corrupt_manifest(tmp.path()); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--silent", "--offline"]); + assert_eq!(code, 1, "unreadable manifest must fail: {stderr}"); + assert!( + stdout.trim().is_empty(), + "silent human mode writes errors to stderr, not stdout: {stdout}" + ); + let chatter = stderr_chatter(&stderr); + assert!( + chatter.iter().any(|l| l.contains("Error")), + "--silent must keep the error output (errors only, never nothing); \ + stderr was: {stderr:?}" + ); +} + +/// `apply --check --silent` on an unreadable manifest (fail-closed drift) +/// must still print why it failed. +#[test] +fn apply_check_silent_unreadable_manifest_keeps_error_output() { + let tmp = tempfile::tempdir().unwrap(); + write_corrupt_manifest(tmp.path()); + + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--check", "--silent"]); + assert_eq!(code, 1, "unreadable manifest must fail closed: {stderr}"); + let chatter = stderr_chatter(&stderr); + assert!( + chatter + .iter() + .any(|l| l.contains("could not read the manifest")), + "--check --silent must keep the fail-closed error output; \ + stderr was: {stderr:?}" + ); +} + +/// `apply --check --json` on an unreadable manifest must emit the unified +/// envelope (CLI_CONTRACT.md: every `--json` invocation emits a single +/// JSON object) — not exit 1 with empty stdout. +#[test] +fn apply_check_json_unreadable_manifest_emits_error_envelope() { + let tmp = tempfile::tempdir().unwrap(); + write_corrupt_manifest(tmp.path()); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--check", "--json"]); + assert_eq!(code, 1, "unreadable manifest must fail closed: {stderr}"); + let env: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("--json must emit an envelope ({e}); stdout was: {stdout:?}")); + assert_eq!(env["command"], "apply"); + assert_eq!(env["status"], "error"); + assert_eq!(env["error"]["code"], "manifest_unreadable"); +} + +/// `apply --check --silent` with real redirect drift must still print the +/// OUT OF SYNC report — drift IS the error the exit code signals. +#[test] +fn apply_check_silent_drift_keeps_error_output() { + let tmp = tempfile::tempdir().unwrap(); + write_drifted_go_manifest(tmp.path()); + + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--check", "--silent"]); + assert_eq!( + code, 1, + "missing go-patches copy must report drift: {stderr}" + ); + let chatter = stderr_chatter(&stderr); + assert!( + chatter.iter().any(|l| l.contains("OUT OF SYNC")), + "--check --silent must keep the drift error output; stderr was: {stderr:?}" + ); +} diff --git a/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs b/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs index eda92c8e..b76a5e73 100644 --- a/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs @@ -106,13 +106,13 @@ fn apply_dry_run_empty_manifest_emits_dry_run_envelope() { .unwrap_or_else(|e| panic!("invalid JSON: {e}\n{stdout}")); assert_eq!(v["command"], "apply"); assert_eq!(v["dryRun"], true); - // INTENDED CONTRACT (see this test's doc-comment: "reports dryRun:true - // and success"). These two asserts are currently RED and intentionally - // left so: `apply` against an empty/non-matching manifest exits 1 with - // status="partialFailure" instead of a clean no-op success. That is a - // known, separately-tracked production bug (it breaks the npm - // postinstall hook, which runs `apply` on every install) — NOT a test - // defect. Do not relax these to match the buggy output; fix the bug. + // Pinned contract: `apply` against an empty manifest is a clean no-op + // success — exit 0, status "success" — never a partialFailure/exit-1. + // This is load-bearing for the install hooks (npm postinstall, the + // Python .pth hook, the Bundler plugin), which run `apply` on every + // install; a non-zero exit there would break user installs. The + // non-dry-run flavor is pinned by + // `in_process_edge_cases::apply_empty_manifest_is_noop`. assert_eq!( out.status.code(), Some(0), @@ -276,6 +276,106 @@ fn apply_dry_run_with_real_patch_verifies_without_mutating() { ); } +const VENDORED_PURL: &str = "pkg:npm/vendored-pkg@1.0.0"; + +/// Extend [`make_applicable_npm_patch`] with a SECOND manifest entry that +/// is vendor-owned: recorded in `.socket/vendor/state.json`, with no +/// installed tree (the committed artifact is the source of truth). It +/// reuses the applicable patch's file hashes so the staged blob set stays +/// complete for `--offline`. +fn add_vendored_manifest_entry(root: &Path) { + let socket = root.join(".socket"); + let manifest_path = socket.join("manifest.json"); + let mut manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap(); + let template = manifest["patches"][DRYRUN_PURL].clone(); + manifest["patches"][VENDORED_PURL] = template; + std::fs::write( + &manifest_path, + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + + let vendor = socket.join("vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + let state = format!( + r#"{{ + "version": 1, + "entries": {{ + "{VENDORED_PURL}": {{ + "ecosystem": "npm", + "basePurl": "{VENDORED_PURL}", + "uuid": "33333333-3333-4333-8333-333333333333", + "artifact": {{ + "path": ".socket/vendor/npm/33333333-3333-4333-8333-333333333333/vendored-pkg-1.0.0.tgz" + }}, + "wiring": [] + }} + }} +}}"# + ); + std::fs::write(vendor.join("state.json"), state).unwrap(); +} + +/// Regression: the human dry-run summary counted vendor-owned manifest +/// entries as "can be patched". The same run's JSON envelope classifies +/// them `skipped`/`vendored` (apply must never re-patch what +/// `socket-patch vendor` owns), so the human count must exclude them too: +/// one applicable patch + one vendored patch is "1 package(s) can be +/// patched", not 2. +#[test] +fn apply_dry_run_human_count_excludes_vendored() { + let tmp = tempfile::tempdir().expect("tempdir"); + make_applicable_npm_patch(tmp.path()); + add_vendored_manifest_entry(tmp.path()); + + // Prove the fixture is non-vacuous first: in JSON mode the vendored + // entry must classify as skipped/vendored (if the vendor ledger were + // unreadable it would fail open and this test would assert nothing). + let out = Command::new(binary()) + .args(["apply", "--json", "--dry-run", "--offline"]) + .current_dir(tmp.path()) + .env_remove("SOCKET_API_TOKEN") + .output() + .expect("run apply --json --dry-run"); + let stdout = String::from_utf8_lossy(&out.stdout); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "invalid JSON: {e}\nstdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ) + }); + assert_eq!(out.status.code(), Some(0), "dry-run should exit 0: {v}"); + let events = v["events"].as_array().expect("events array"); + let vendored_ev = events + .iter() + .find(|e| e["purl"] == VENDORED_PURL) + .unwrap_or_else(|| panic!("expected an event for {VENDORED_PURL}: {v}")); + assert_eq!( + vendored_ev["action"], "skipped", + "vendored entry must be skipped: {v}" + ); + assert_eq!( + vendored_ev["errorCode"], "vendored", + "vendored entry must carry the vendored reason: {v}" + ); + + // The human summary must agree with that classification: only the + // genuinely applicable package counts as patchable. + let out = Command::new(binary()) + .args(["apply", "--dry-run", "--offline"]) + .current_dir(tmp.path()) + .env_remove("SOCKET_API_TOKEN") + .output() + .expect("run apply --dry-run"); + assert_eq!(out.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("1 package(s) can be patched"), + "human dry-run count must exclude the vendored entry; stdout:\n{stdout}" + ); +} + /// `repair --dry-run --offline --json`: dry-run with no patches /// should succeed with `dryRun:true`. #[test] diff --git a/crates/socket-patch-cli/tests/cli_global_args.rs b/crates/socket-patch-cli/tests/cli_global_args.rs index 527a0fc3..067f6a92 100644 --- a/crates/socket-patch-cli/tests/cli_global_args.rs +++ b/crates/socket-patch-cli/tests/cli_global_args.rs @@ -86,6 +86,7 @@ fn global_flag_cases() -> Vec<(&'static str, Option<&'static str>, fn(&GlobalArg assert_eq!(c.patch_server_url.as_deref(), Some("http://localhost:4026")) }), ("--offline", None, |c| assert!(c.offline)), + ("--strict", None, |c| assert!(c.strict)), ("--global", None, |c| assert!(c.global)), ("--global-prefix", Some("/opt/global"), |c| { assert_eq!(c.global_prefix, Some(PathBuf::from("/opt/global"))) @@ -217,17 +218,47 @@ fn global_flag_cases_cover_every_global_field() { patch_server_url: _, } = common; - // 23 fields ↔ 23 long-flag cases. Bump both this count and add a case when + // 24 fields ↔ 24 long-flag cases. Bump both this count and add a case when // the destructure above forces you to add a field. assert_eq!( global_flag_cases().len(), - 23, + 24, "every GlobalArgs field needs a long-flag case in global_flag_cases()", ); restore_global_env(saved); } +/// Tripwire for the tripwire above: the manual field count can be bumped +/// without actually adding a case for the new flag (exactly how `--strict` +/// shipped unguarded). Derive the long-flag set from clap itself and demand a +/// case for each — this cannot drift. +#[test] +fn global_flag_cases_cover_every_global_long_flag() { + use clap::CommandFactory; + + #[derive(Parser, Debug)] + struct Probe { + #[command(flatten)] + common: GlobalArgs, + } + + let tested: std::collections::HashSet = global_flag_cases() + .iter() + .map(|(flag, _, _)| flag.trim_start_matches("--").to_string()) + .collect(); + let missing: Vec = Probe::command() + .get_arguments() + .filter(|a| a.get_id() != "help") + .filter_map(|a| a.get_long().map(str::to_string)) + .filter(|long| !tested.contains(long)) + .collect(); + assert!( + missing.is_empty(), + "GlobalArgs long flags with no case in global_flag_cases(): {missing:?}", + ); +} + /// Tripwire: every subcommand clap knows about must appear in the /// `SUBCOMMANDS_*` lists, so the global-flag matrix above genuinely covers /// *every* command. If someone adds a subcommand (and forgets to flatten @@ -389,12 +420,13 @@ fn env_vars_populate_global_args() { ("SOCKET_API_TOKEN", "env-token"), ("SOCKET_ORG_SLUG", "env-org"), ("SOCKET_PROXY_URL", "https://env-proxy.example.com"), - // npm + gem are unconditional ecosystems, so this env-binding - // assertion holds regardless of which optional features are - // compiled in (maven is not in the default build). ("SOCKET_ECOSYSTEMS", "npm,gem"), ("SOCKET_DOWNLOAD_MODE", "package"), + ("SOCKET_VENDOR_SOURCE", "service"), + ("SOCKET_VENDOR_URL", "https://env-vendor.example.com"), + ("SOCKET_PATCH_SERVER_URL", "http://localhost:4026"), ("SOCKET_OFFLINE", "true"), + ("SOCKET_STRICT", "true"), ("SOCKET_GLOBAL", "true"), ("SOCKET_GLOBAL_PREFIX", "/env/global"), ("SOCKET_JSON", "true"), @@ -432,7 +464,17 @@ fn env_vars_populate_global_args() { Some(&["npm".to_string(), "gem".to_string()][..]) ); assert_eq!(args.common.download_mode, "package"); + assert_eq!(args.common.vendor_source, "service"); + assert_eq!( + args.common.vendor_url.as_deref(), + Some("https://env-vendor.example.com") + ); + assert_eq!( + args.common.patch_server_url.as_deref(), + Some("http://localhost:4026") + ); assert!(args.common.offline); + assert!(args.common.strict); assert!(args.common.global); assert_eq!( args.common.global_prefix, @@ -477,6 +519,7 @@ fn bool_env_vars_accept_one_and_yes() { // (env var name, value to set) let cases: &[(&str, &str)] = &[ ("SOCKET_OFFLINE", "1"), + ("SOCKET_STRICT", "on"), ("SOCKET_GLOBAL", "yes"), ("SOCKET_JSON", "on"), ("SOCKET_VERBOSE", "1"), @@ -499,6 +542,7 @@ fn bool_env_vars_accept_one_and_yes() { let cli = Cli::try_parse_from(["socket-patch", "list"]).expect("parse"); if let socket_patch_cli::Commands::List(args) = cli.command { assert!(args.common.offline, "SOCKET_OFFLINE=1 must parse as true"); + assert!(args.common.strict, "SOCKET_STRICT=on must parse as true"); assert!(args.common.global, "SOCKET_GLOBAL=yes must parse as true"); assert!(args.common.json, "SOCKET_JSON=on must parse as true"); assert!(args.common.verbose, "SOCKET_VERBOSE=1 must parse as true"); @@ -598,8 +642,9 @@ fn bool_env_vars_reject_zero_and_falsey() { #[serial_test::serial] fn empty_bool_env_var_resolves_to_false_not_crash() { // (env var, accessor) for every boolean global. - let bool_vars: [(&str, fn(&GlobalArgs) -> bool); 10] = [ + let bool_vars: [(&str, fn(&GlobalArgs) -> bool); 11] = [ ("SOCKET_OFFLINE", |c| c.offline), + ("SOCKET_STRICT", |c| c.strict), ("SOCKET_GLOBAL", |c| c.global), ("SOCKET_JSON", |c| c.json), ("SOCKET_VERBOSE", |c| c.verbose), @@ -629,30 +674,13 @@ fn empty_bool_env_var_resolves_to_false_not_crash() { restore_global_env(saved); } -/// Names of every `SOCKET_*` env var that `GlobalArgs` binds, so tests that -/// need a clean slate can save/clear/restore them in one place. -const GLOBAL_ENV_VARS: &[&str] = &[ - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_API_TOKEN", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_OFFLINE", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", -]; +/// Every `SOCKET_*` env var that `GlobalArgs` binds, so tests that need a +/// clean slate can save/clear/restore them in one place. This is the +/// production list itself — a private copy already went stale once +/// (`SOCKET_STRICT` + the vendor knobs were missing, so ambient values +/// survived the "clean slate" and could taint every parse in this file); +/// `save_and_clear_covers_every_bound_global_env_var` above pins the fix. +use socket_patch_cli::args::GLOBAL_ARG_ENV_VARS as GLOBAL_ENV_VARS; /// An exported-but-**empty** non-bool env var must mean "unset", not crash. /// @@ -663,7 +691,7 @@ const GLOBAL_ENV_VARS: &[&str] = &[ /// "cannot parse integer from empty string"), and empty /// `SOCKET_DOWNLOAD_MODE=` / `SOCKET_MANIFEST_PATH=` leaked `""` past the /// documented defaults. The binary now scrubs empty `GlobalArgs` env vars -/// before clap parses (`args::scrub_empty_global_env_vars` in `main`), +/// before clap parses (`args::scrub_empty_env_vars` in `main`), /// restoring the documented CLI > env > default precedence for blank vars. /// This spawns the real binary because the scrub is `main` wiring. #[test] @@ -684,6 +712,11 @@ fn empty_nonbool_env_vars_do_not_crash_the_binary() { "SOCKET_LOCK_TIMEOUT", "SOCKET_ECOSYSTEMS", "SOCKET_DOWNLOAD_MODE", + // Crash-class without the scrub: the vendor-source validator rejects + // `""` outright; the two URL knobs would leak `Some("")` downstream. + "SOCKET_VENDOR_SOURCE", + "SOCKET_VENDOR_URL", + "SOCKET_PATCH_SERVER_URL", ] { cmd.env(var, ""); } @@ -715,6 +748,48 @@ fn empty_nonbool_env_vars_do_not_crash_the_binary() { assert_eq!(out.status.code(), Some(1), "manifest_not_found exits 1"); } +/// `save_and_clear_global_env` must clear **every** env var `GlobalArgs` +/// binds — the production list, not a private copy that can go stale. A var +/// that survives the clear taints every parse in this file: an ambient +/// `SOCKET_STRICT=garbage` in a developer's shell would abort all of them +/// with a `ValueValidation` error that looks nothing like the real cause. +#[test] +#[serial_test::serial] +fn save_and_clear_covers_every_bound_global_env_var() { + use socket_patch_cli::args::GLOBAL_ARG_ENV_VARS; + + // Snapshot the full bound set so this test is hermetic even on failure. + let originals: Vec<(&str, Option)> = GLOBAL_ARG_ENV_VARS + .iter() + .map(|&k| (k, std::env::var(k).ok())) + .collect(); + for &(k, _) in &originals { + std::env::set_var(k, "ambient-poison"); + } + + // Discard the snapshot it returns — it captured the poison values. + let _ = save_and_clear_global_env(); + + let leaked: Vec<&str> = GLOBAL_ARG_ENV_VARS + .iter() + .copied() + .filter(|k| std::env::var(k).is_ok()) + .collect(); + + // Restore before asserting so a failure can't poison later serial tests. + for (k, orig) in originals { + match orig { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + + assert!( + leaked.is_empty(), + "save_and_clear_global_env left GlobalArgs-bound env vars set: {leaked:?}", + ); +} + fn save_and_clear_global_env() -> Vec<(&'static str, Option)> { let saved: Vec<(&'static str, Option)> = GLOBAL_ENV_VARS .iter() @@ -806,9 +881,13 @@ fn production_defaults_populate_when_unset() { assert_eq!(c.api_url, "https://api.socket.dev"); assert_eq!(c.proxy_url, "https://patches-api.socket.dev"); assert_eq!(c.download_mode, "diff"); + assert_eq!(c.vendor_source, "auto"); + assert!(c.vendor_url.is_none()); + assert!(c.patch_server_url.is_none()); assert!(c.api_token.is_none()); assert!(c.org.is_none()); assert!(c.ecosystems.is_none()); + assert!(!c.strict); assert!(!c.offline && !c.global && !c.json && !c.verbose && !c.silent); assert!(!c.dry_run && !c.yes && !c.break_lock && !c.debug && !c.no_telemetry); assert!(c.lock_timeout.is_none()); diff --git a/crates/socket-patch-cli/tests/cli_parse_get.rs b/crates/socket-patch-cli/tests/cli_parse_get.rs index 420ad899..c229608e 100644 --- a/crates/socket-patch-cli/tests/cli_parse_get.rs +++ b/crates/socket-patch-cli/tests/cli_parse_get.rs @@ -39,7 +39,11 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_PROXY_URL", "SOCKET_ECOSYSTEMS", "SOCKET_DOWNLOAD_MODE", + "SOCKET_VENDOR_SOURCE", + "SOCKET_VENDOR_URL", + "SOCKET_PATCH_SERVER_URL", "SOCKET_OFFLINE", + "SOCKET_STRICT", "SOCKET_GLOBAL", "SOCKET_GLOBAL_PREFIX", "SOCKET_JSON", @@ -128,7 +132,11 @@ struct Snap { proxy_url: String, ecosystems: Option>, download_mode: String, + vendor_source: String, + vendor_url: Option, + patch_server_url: Option, offline: bool, + strict: bool, global: bool, global_prefix: Option, json: bool, @@ -160,7 +168,11 @@ fn snapshot(a: &GetArgs) -> Snap { proxy_url: a.common.proxy_url.clone(), ecosystems: a.common.ecosystems.clone(), download_mode: a.common.download_mode.clone(), + vendor_source: a.common.vendor_source.clone(), + vendor_url: a.common.vendor_url.clone(), + patch_server_url: a.common.patch_server_url.clone(), offline: a.common.offline, + strict: a.common.strict, global: a.common.global, global_prefix: a.common.global_prefix.clone(), json: a.common.json, @@ -200,7 +212,11 @@ fn expected_defaults(identifier: &str) -> Snap { proxy_url: "https://patches-api.socket.dev".to_string(), ecosystems: None, download_mode: "diff".to_string(), + vendor_source: "auto".to_string(), + vendor_url: None, + patch_server_url: None, offline: false, + strict: false, global: false, global_prefix: None, json: false, @@ -514,3 +530,45 @@ fn unknown_flag_errors() { }; assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } + +// --- Hermeticity of the scrub itself ------------------------------------------- + +#[test] +#[serial_test::serial] +fn scrub_covers_every_global_env_var_clap_consults() { + // [`SOCKET_ENV_VARS`] claims to list "every SOCKET_* env var that clap + // consults while parsing `get`". `GlobalArgs` is flattened in whole, so + // the production `GLOBAL_ARG_ENV_VARS` list is the oracle — a flag added + // to `GlobalArgs` with an env binding is consulted here the moment it + // lands, and if the scrub list lags behind, an ambient value either + // aborts every parse in this file (validated flags: bools, ints, + // `--ecosystems`, `--vendor-source`) or silently leaks into the parsed + // args (string flags), voiding the hermeticity the module doc promises. + // `garbage` is rejected by every validating parser and visibly non-default + // for every string/path/option flag, so a missing scrub entry fails + // loudly either way. + for &var in socket_patch_cli::args::GLOBAL_ARG_ENV_VARS { + let prev = std::env::var(var).ok(); + std::env::set_var(var, "garbage"); + let parsed = { + let _scrub = EnvScrub::new(); + Cli::try_parse_from(["socket-patch", "get", "some-id"]) + }; + match prev { + Some(v) => std::env::set_var(var, v), + None => std::env::remove_var(var), + } + let a = match parsed { + Ok(cli) => match cli.command { + Commands::Get(a) => a, + _ => panic!("expected Get"), + }, + Err(e) => panic!("ambient {var}=garbage aborted the scrubbed parse: {e}"), + }; + assert_eq!( + snapshot(&a), + expected_defaults("some-id"), + "ambient {var}=garbage leaked into the scrubbed parse", + ); + } +} diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs index 4b4099c5..98dfa0b1 100644 --- a/crates/socket-patch-cli/tests/cli_parse_list.rs +++ b/crates/socket-patch-cli/tests/cli_parse_list.rs @@ -240,7 +240,7 @@ async fn populated_manifest_returns_0_json() { #[tokio::test] async fn absolute_manifest_path_wins_over_cwd() { // Manifest lives in tmp_manifest_dir, cwd points elsewhere. - // resolve_manifest_path() must prefer the absolute path. + // resolved_manifest_path() must prefer the absolute path. let tmp_manifest_dir = tempfile::tempdir().unwrap(); let tmp_cwd = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-cli/tests/cli_parse_repair.rs b/crates/socket-patch-cli/tests/cli_parse_repair.rs index ac87853c..f03bda68 100644 --- a/crates/socket-patch-cli/tests/cli_parse_repair.rs +++ b/crates/socket-patch-cli/tests/cli_parse_repair.rs @@ -46,7 +46,11 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_PROXY_URL", "SOCKET_ECOSYSTEMS", "SOCKET_DOWNLOAD_MODE", + "SOCKET_VENDOR_SOURCE", + "SOCKET_VENDOR_URL", + "SOCKET_PATCH_SERVER_URL", "SOCKET_OFFLINE", + "SOCKET_STRICT", "SOCKET_GLOBAL", "SOCKET_GLOBAL_PREFIX", "SOCKET_JSON", @@ -138,7 +142,11 @@ struct Snap { proxy_url: String, ecosystems: Option>, download_mode: String, + vendor_source: String, + vendor_url: Option, + patch_server_url: Option, offline: bool, + strict: bool, global: bool, global_prefix: Option, json: bool, @@ -163,7 +171,11 @@ fn snapshot(a: &RepairArgs) -> Snap { proxy_url: a.common.proxy_url.clone(), ecosystems: a.common.ecosystems.clone(), download_mode: a.common.download_mode.clone(), + vendor_source: a.common.vendor_source.clone(), + vendor_url: a.common.vendor_url.clone(), + patch_server_url: a.common.patch_server_url.clone(), offline: a.common.offline, + strict: a.common.strict, global: a.common.global, global_prefix: a.common.global_prefix.clone(), json: a.common.json, @@ -195,7 +207,11 @@ fn expected_defaults() -> Snap { proxy_url: "https://patches-api.socket.dev".to_string(), ecosystems: None, download_mode: "diff".to_string(), + vendor_source: "auto".to_string(), + vendor_url: None, + patch_server_url: None, offline: false, + strict: false, global: false, global_prefix: None, json: false, @@ -385,8 +401,10 @@ fn repair_gc_alias_accepts_flags() { /// fall back to the default (false)", not abort every `repair` invocation /// with a ValueValidation error. The flattened `GlobalArgs` bool flags /// already have this semantic via `parse_bool_flag`; `repair`'s own -/// `--download-only` env binding must match (it is also outside -/// `GLOBAL_ARG_ENV_VARS`, so `main`'s empty-var scrub never rescues it). +/// `--download-only` env binding must match. (`main`'s empty-var scrub also +/// removes a blank `SOCKET_DOWNLOAD_ONLY` via `LOCAL_ARG_ENV_VARS`, but the +/// parser itself must not depend on it — library callers of `Cli::parse` +/// never run the scrub, as this test's direct `try_parse_from` shows.) #[test] #[serial_test::serial] fn empty_download_only_env_var_parses_as_false_not_crash() { @@ -420,6 +438,48 @@ fn truthy_download_only_env_var_sets_flag() { } } +// --- Hermeticity of the scrub itself ------------------------------------------- + +#[test] +#[serial_test::serial] +fn scrub_covers_every_global_env_var_clap_consults() { + // [`SOCKET_ENV_VARS`] claims to list "every SOCKET_* env var that clap + // consults while parsing `repair`". `GlobalArgs` is flattened in whole, + // so the production `GLOBAL_ARG_ENV_VARS` list is the oracle — a flag + // added to `GlobalArgs` with an env binding is consulted here the moment + // it lands, and if the scrub list lags behind, an ambient value either + // aborts every parse in this file (validated flags: bools, ints, + // `--ecosystems`, `--vendor-source`) or silently leaks into the parsed + // args (string flags), voiding the hermeticity the module doc promises. + // `garbage` is rejected by every validating parser and visibly + // non-default for every string/path/option flag, so a missing scrub + // entry fails loudly either way. Mirrors `cli_parse_get.rs`. + for &var in socket_patch_cli::args::GLOBAL_ARG_ENV_VARS { + let prev = std::env::var(var).ok(); + std::env::set_var(var, "garbage"); + let parsed = { + let _scrub = EnvScrub::new(); + Cli::try_parse_from(["socket-patch", "repair"]) + }; + match prev { + Some(v) => std::env::set_var(var, v), + None => std::env::remove_var(var), + } + let a = match parsed { + Ok(cli) => match cli.command { + Commands::Repair(a) => a, + _ => panic!("expected Repair"), + }, + Err(e) => panic!("ambient {var}=garbage aborted the scrubbed parse: {e}"), + }; + assert_eq!( + snapshot(&a), + expected_defaults(), + "ambient {var}=garbage leaked into the scrubbed parse", + ); + } +} + #[test] #[serial_test::serial] fn repair_unknown_flag_is_unknown_argument_error() { diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index e9ec112c..6edfd6bb 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -45,9 +45,13 @@ const SCAN_ENV_VARS: &[&str] = &[ "SOCKET_MANIFEST_PATH", "SOCKET_OFFLINE", "SOCKET_ORG_SLUG", + "SOCKET_PATCH_SERVER_URL", "SOCKET_PROXY_URL", "SOCKET_SILENT", + "SOCKET_STRICT", "SOCKET_TELEMETRY_DISABLED", + "SOCKET_VENDOR_SOURCE", + "SOCKET_VENDOR_URL", "SOCKET_VERBOSE", "SOCKET_VEX", "SOCKET_VEX_COMPACT", @@ -303,9 +307,6 @@ fn batch_size_negative_fails() { #[test] #[serial_test::serial] fn ecosystems_csv_multi() { - // Use only the unconditional ecosystems (npm/pypi/gem are always - // compiled in) so this CSV-splitting assertion is independent of which - // optional ecosystem features the test crate was built with. let args = parse_scan(&["--ecosystems", "npm,pypi,gem"]); assert_eq!( args.common.ecosystems, @@ -320,11 +321,8 @@ fn ecosystems_csv_multi() { #[test] #[serial_test::serial] fn ecosystems_unsupported_name_rejected() { - // The `--ecosystems` value-parser rejects names this build does not - // support — both typos and ecosystems whose feature is not compiled - // in. `definitely-not-an-ecosystem` is never a valid name in any - // feature configuration, so this assertion holds regardless of the - // build's feature set. + // The `--ecosystems` value-parser rejects names that are not + // supported ecosystems, so typos fail loudly. let err = match try_parse_scan(&["--ecosystems", "definitely-not-an-ecosystem"]) { Ok(_) => panic!("unsupported ecosystem name should fail to parse"), Err(e) => e, @@ -339,28 +337,6 @@ fn ecosystems_unsupported_name_rejected() { ); } -/// maven is not in the default feature set, so a default build must reject -/// `--ecosystems maven` (the whole point of marking it unsupported). When -/// the `maven` feature *is* compiled in, the name is legitimately accepted, -/// so this assertion is itself feature-gated to match. -#[cfg(not(feature = "maven"))] -#[test] -#[serial_test::serial] -fn ecosystems_maven_rejected_without_feature() { - let err = match try_parse_scan(&["--ecosystems", "maven"]) { - Ok(_) => panic!("`maven` must be rejected when the maven feature is off"), - Err(e) => e, - }; - assert!( - matches!( - err.kind(), - clap::error::ErrorKind::ValueValidation | clap::error::ErrorKind::InvalidValue - ), - "expected ValueValidation or InvalidValue, got {:?}", - err.kind() - ); -} - #[test] #[serial_test::serial] fn ecosystems_csv_single() { @@ -561,15 +537,16 @@ fn scan_json_empty_cwd_emits_updates_key() { // --- `--mode` selector (documented spelling of the mode booleans) ---------- // // `--mode ` is the RELEASED spelling of the three -// mode flags. The clap parser only fills `args.mode`; `resolve_mode_flags` -// (run at the top of `scan::run`, exercised directly here) folds it into the -// legacy `--redirect`/`--vendor`/`--apply` booleans and enforces the -// cross-mode rules clap can't express (a value-dependent conflict). These -// tests lock both the fold and the legacy aliases. +// mode flags. `resolve_mode_flags` (run at the top of `scan::run`, +// exercised directly here) makes `args.mode` the single source of truth: +// the legacy `--redirect`/`--vendor`/`--apply`/`--sync` booleans fold INTO +// the enum (they are input spellings, never read downstream), and the +// cross-mode rules clap can't express (a value-dependent conflict) are +// enforced. These tests lock both the fold and the legacy aliases. /// Parse `extra` (must parse cleanly at the clap level), then run the mode -/// fold — mirroring exactly what `scan::run` does before it reads any mode -/// boolean. +/// fold — mirroring exactly what `scan::run` does before it reads the +/// resolved mode. fn parse_and_resolve(extra: &[&str]) -> Result { let mut args = parse_scan(extra); resolve_mode_flags(&mut args)?; @@ -578,35 +555,55 @@ fn parse_and_resolve(extra: &[&str]) -> Result { #[test] #[serial_test::serial] -fn mode_hosted_folds_to_redirect() { - // The parser records the enum verbatim... +fn mode_hosted_is_the_source_of_truth() { + // The parser records the enum verbatim and the fold leaves it as the + // single source of truth (the booleans are inputs, not outputs). + let folded = parse_and_resolve(&["--mode", "hosted"]).expect("fold ok"); + assert_eq!(folded.mode, Some(ScanMode::Hosted)); + // ...and the legacy boolean spelling folds INTO the enum. + let folded = parse_and_resolve(&["--redirect"]).expect("fold ok"); assert_eq!( - parse_scan(&["--mode", "hosted"]).mode, - Some(ScanMode::Hosted) + folded.mode, + Some(ScanMode::Hosted), + "--redirect == --mode hosted" ); - // ...and the fold turns it into the redirect boolean, exclusively. - let folded = parse_and_resolve(&["--mode", "hosted"]).expect("fold ok"); - assert!(folded.redirect, "--mode hosted sets --redirect"); - assert!(!folded.vendor); - assert!(!folded.apply); } #[test] #[serial_test::serial] -fn mode_vendored_folds_to_vendor() { +fn mode_vendored_is_the_source_of_truth() { let folded = parse_and_resolve(&["--mode", "vendored"]).expect("fold ok"); - assert!(folded.vendor, "--mode vendored sets --vendor"); - assert!(!folded.redirect); - assert!(!folded.apply); + assert_eq!(folded.mode, Some(ScanMode::Vendored)); + let folded = parse_and_resolve(&["--vendor"]).expect("fold ok"); + assert_eq!( + folded.mode, + Some(ScanMode::Vendored), + "--vendor == --mode vendored" + ); } #[test] #[serial_test::serial] -fn mode_agent_folds_to_apply() { +fn mode_agent_is_the_source_of_truth() { let folded = parse_and_resolve(&["--mode", "agent"]).expect("fold ok"); - assert!(folded.apply, "--mode agent sets --apply"); - assert!(!folded.redirect); - assert!(!folded.vendor); + assert_eq!(folded.mode, Some(ScanMode::Agent)); + let folded = parse_and_resolve(&["--apply"]).expect("fold ok"); + assert_eq!( + folded.mode, + Some(ScanMode::Agent), + "--apply == --mode agent" + ); + // --sync counts as an agent-mode spelling (its prune half is orthogonal). + let folded = parse_and_resolve(&["--sync"]).expect("fold ok"); + assert_eq!( + folded.mode, + Some(ScanMode::Agent), + "--sync == --mode agent --prune" + ); + assert!(folded.sync, "the prune half of --sync stays readable"); + // No mode selected at all: scan stays read-only. + let folded = parse_and_resolve(&[]).expect("fold ok"); + assert_eq!(folded.mode, None, "modeless scan is read-only"); } #[test] @@ -645,15 +642,15 @@ fn mode_hosted_with_vendor_boolean_errors() { fn mode_agent_with_apply_boolean_is_allowed() { // Same mode spelled both ways is redundant but legal. let folded = parse_and_resolve(&["--mode", "agent", "--apply"]).expect("same-mode ok"); - assert!(folded.apply); + assert_eq!(folded.mode, Some(ScanMode::Agent)); } #[test] #[serial_test::serial] fn mode_agent_with_sync_boolean_is_allowed() { - // --sync implies --apply, so it counts as an agent-mode spelling. + // --sync implies agent mode, so it counts as an agent-mode spelling. let folded = parse_and_resolve(&["--mode", "agent", "--sync"]).expect("same-mode ok"); - assert!(folded.apply); + assert_eq!(folded.mode, Some(ScanMode::Agent)); assert!(folded.sync); } @@ -662,7 +659,7 @@ fn mode_agent_with_sync_boolean_is_allowed() { fn mode_vendored_with_detached_ok() { // --detached is legal under vendored mode selected via --mode. let folded = parse_and_resolve(&["--mode", "vendored", "--detached"]).expect("fold ok"); - assert!(folded.vendor); + assert_eq!(folded.mode, Some(ScanMode::Vendored)); assert!(folded.detached); } @@ -682,13 +679,88 @@ fn detached_without_vendored_mode_errors() { #[test] #[serial_test::serial] fn legacy_mode_spellings_still_parse() { - // The boolean aliases keep working with no `--mode` given; the fold is - // then a no-op and leaves the booleans exactly as parsed. + // The boolean aliases keep working with no `--mode` given; the fold + // derives the mode enum from them (the inverse of the historical + // direction — `args.mode` is now the single source of truth). assert!(parse_scan(&["--redirect"]).redirect); assert!(parse_scan(&["--vendor"]).vendor); assert!(parse_scan(&["--apply"]).apply); let folded = parse_and_resolve(&["--vendor", "--detached"]).expect("legacy fold ok"); - assert!(folded.vendor); assert!(folded.detached); - assert_eq!(folded.mode, None, "no --mode ⇒ selector stays None"); + assert_eq!( + folded.mode, + Some(ScanMode::Vendored), + "legacy --vendor folds into the mode selector" + ); +} + +// --- scrub-list completeness guard ----------------------------------------- + +/// Full-surface snapshot of a parsed `ScanArgs`, for env-leak detection. +/// The flattened `GlobalArgs` participates via its `Debug` impl so every +/// field — including ones added after this file was written — is covered +/// without being named here; the scan-local and vex-embed fields (no +/// `Debug` derive) are formatted individually. +fn snap(a: &ScanArgs) -> String { + format!( + "{:?} batch_size={} apply={} prune={} sync={} vendor={} detached={} \ + redirect={} mode={:?} all_releases={} vex={:?} vex_product={:?} \ + vex_no_verify={} vex_doc_id={:?} vex_compact={}", + a.common, + a.batch_size, + a.apply, + a.prune, + a.sync, + a.vendor, + a.detached, + a.redirect, + a.mode, + a.all_releases, + a.vex.vex, + a.vex.vex_product, + a.vex.vex_no_verify, + a.vex.vex_doc_id, + a.vex.vex_compact, + ) +} + +#[test] +#[serial_test::serial] +fn scrub_covers_every_scan_env_var_clap_consults() { + // `SCAN_ENV_VARS` claims to cover every `SOCKET_*` var clap consults + // while parsing `scan`. The production oracles are + // `GLOBAL_ARG_ENV_VARS` (the flattened `GlobalArgs`, consulted by every + // subcommand the moment a binding lands) and `LOCAL_ARG_ENV_VARS` + // (subcommand-local bindings — scan's own plus other subcommands', + // which the scan parse never reads, so probing them is harmless). For + // each var: plant `garbage`, parse under the scrub, and require the + // exact clean-parse result. A var missing from the scrub fails loudly + // either way — validated flags (bools, ints, `--ecosystems`, + // `--vendor-source`) abort the parse; free-form strings/paths leak a + // visibly non-default value into the snapshot. + let baseline = snap(&parse_scan(&[])); + for &var in socket_patch_cli::args::GLOBAL_ARG_ENV_VARS + .iter() + .chain(socket_patch_cli::args::LOCAL_ARG_ENV_VARS) + { + let prev = std::env::var(var).ok(); + std::env::set_var(var, "garbage"); + let parsed = with_clean_env(|| Cli::try_parse_from(["socket-patch", "scan"])); + match prev { + Some(v) => std::env::set_var(var, v), + None => std::env::remove_var(var), + } + let args = match parsed { + Ok(cli) => match cli.command { + Commands::Scan(a) => a, + _ => panic!("expected Scan"), + }, + Err(e) => panic!("ambient {var}=garbage aborted the scrubbed parse: {e}"), + }; + assert_eq!( + snap(&args), + baseline, + "ambient {var}=garbage leaked into the scrubbed parse", + ); + } } diff --git a/crates/socket-patch-cli/tests/cli_parse_unlock.rs b/crates/socket-patch-cli/tests/cli_parse_unlock.rs index ebfb0330..3cbc6cb3 100644 --- a/crates/socket-patch-cli/tests/cli_parse_unlock.rs +++ b/crates/socket-patch-cli/tests/cli_parse_unlock.rs @@ -5,10 +5,12 @@ //! parse_bool_flag`, so clap's default bool parser accepted only the //! literal strings `true`/`false` from the env — `SOCKET_UNLOCK_RELEASE=1` //! (or an exported-but-empty `SOCKET_UNLOCK_RELEASE=`) aborted every -//! `unlock` invocation with a ValueValidation error. The flag is also -//! outside `GLOBAL_ARG_ENV_VARS`, so `main`'s empty-var scrub never -//! rescues it. Same bug class previously fixed on `repair -//! --download-only` and `rollback --one-off`. +//! `unlock` invocation with a ValueValidation error. (`main`'s +//! empty-var scrub also removes a blank `SOCKET_UNLOCK_RELEASE` via +//! `LOCAL_ARG_ENV_VARS`, but the parser itself must not depend on it — +//! library callers of `Cli::parse` never run the scrub, as these +//! tests' direct `try_parse_from` shows.) Same bug class previously +//! fixed on `repair --download-only` and `rollback --one-off`. //! //! ## Hermeticity //! @@ -32,7 +34,11 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_PROXY_URL", "SOCKET_ECOSYSTEMS", "SOCKET_DOWNLOAD_MODE", + "SOCKET_VENDOR_SOURCE", + "SOCKET_VENDOR_URL", + "SOCKET_PATCH_SERVER_URL", "SOCKET_OFFLINE", + "SOCKET_STRICT", "SOCKET_GLOBAL", "SOCKET_GLOBAL_PREFIX", "SOCKET_JSON", @@ -86,6 +92,29 @@ fn release_of(cli: Cli) -> bool { } } +/// Drift guard for [`SOCKET_ENV_VARS`]: parsing `unlock` consults every +/// flattened `GlobalArgs` env binding, so the scrub list must cover all +/// of `GLOBAL_ARG_ENV_VARS` (plus the subcommand-local +/// `SOCKET_UNLOCK_RELEASE`). Regression: the hand-rolled list omitted +/// `SOCKET_VENDOR_SOURCE` / `SOCKET_VENDOR_URL` / +/// `SOCKET_PATCH_SERVER_URL` / `SOCKET_STRICT`, so an ambient +/// `SOCKET_VENDOR_SOURCE=bogus` in the developer's shell or CI aborted +/// every parse in this file — spuriously failing all four tests. +#[test] +fn env_scrub_covers_every_global_args_env_var() { + for var in socket_patch_cli::args::GLOBAL_ARG_ENV_VARS { + assert!( + SOCKET_ENV_VARS.contains(var), + "SOCKET_ENV_VARS is missing the GlobalArgs binding {var}; \ + an ambient {var} would leak into every parse in this file" + ); + } + assert!( + SOCKET_ENV_VARS.contains(&"SOCKET_UNLOCK_RELEASE"), + "SOCKET_ENV_VARS must scrub unlock's own env binding" + ); +} + #[test] #[serial_test::serial] fn unlock_release_defaults_to_false() { diff --git a/crates/socket-patch-cli/tests/cli_parse_vendor.rs b/crates/socket-patch-cli/tests/cli_parse_vendor.rs index 97b458d2..31db6683 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vendor.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vendor.rs @@ -43,7 +43,11 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_PROXY_URL", "SOCKET_ECOSYSTEMS", "SOCKET_DOWNLOAD_MODE", + "SOCKET_VENDOR_SOURCE", + "SOCKET_VENDOR_URL", + "SOCKET_PATCH_SERVER_URL", "SOCKET_OFFLINE", + "SOCKET_STRICT", "SOCKET_GLOBAL", "SOCKET_GLOBAL_PREFIX", "SOCKET_JSON", @@ -594,6 +598,25 @@ fn bare_uuid_fallback_still_routes_to_get_not_vendor() { } } +// --- Harness invariants -------------------------------------------------------- + +/// Drift guard: [`EnvScrub`] must cover every env var `GlobalArgs` binds — +/// the production `GLOBAL_ARG_ENV_VARS` list is the source of truth. A +/// `GlobalArgs` flag whose env var is missing from [`SOCKET_ENV_VARS`] +/// escapes the scrub, so an ambient value in the developer's shell or CI +/// (e.g. `SOCKET_STRICT=garbage`) aborts every parse in this file — +/// exactly the wrong-reason failure mode the hermeticity contract at the +/// top of this file promises away. +#[test] +fn env_scrub_covers_every_global_arg_env_var() { + for var in socket_patch_cli::args::GLOBAL_ARG_ENV_VARS { + assert!( + SOCKET_ENV_VARS.contains(var), + "{var} is bound by GlobalArgs but missing from SOCKET_ENV_VARS — EnvScrub won't scrub it", + ); + } +} + // --- Error paths ------------------------------------------------------------- #[test] diff --git a/crates/socket-patch-cli/tests/cli_parse_vex.rs b/crates/socket-patch-cli/tests/cli_parse_vex.rs index ca757f3e..ec6e1f1b 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vex.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vex.rs @@ -11,9 +11,10 @@ //! and `scan`, the ambient env var broke those commands too (including //! `apply` running from a postinstall hook). The fix wires //! `value_parser = parse_bool_flag`, matching the `GlobalArgs` bool flags -//! and `repair --download-only` / `unlock --release`. These vars are also -//! outside `GLOBAL_ARG_ENV_VARS`, so `main`'s empty-var scrub never rescues -//! them. +//! and `repair --download-only` / `unlock --release`. `main`'s empty-var +//! scrub also removes exported-but-empty values (the vars are in +//! `LOCAL_ARG_ENV_VARS`), but these library-level parses bypass `main`, so +//! the value parser must accept the empty string itself. //! //! ## Hermeticity //! @@ -21,7 +22,10 @@ //! [`EnvScrub`]) and each test is `#[serial_test::serial]` because the //! process environment is global. This mirrors `cli_parse_repair.rs`. +use std::path::PathBuf; + use clap::Parser; +use socket_patch_cli::commands::vex::VexArgs; use socket_patch_cli::{Cli, Commands}; /// Every `SOCKET_*` env var clap consults while parsing `vex`, `apply`, or @@ -38,7 +42,11 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_PROXY_URL", "SOCKET_ECOSYSTEMS", "SOCKET_DOWNLOAD_MODE", + "SOCKET_VENDOR_SOURCE", + "SOCKET_VENDOR_URL", + "SOCKET_PATCH_SERVER_URL", "SOCKET_OFFLINE", + "SOCKET_STRICT", "SOCKET_GLOBAL", "SOCKET_GLOBAL_PREFIX", "SOCKET_JSON", @@ -176,6 +184,158 @@ fn empty_vex_compact_env_parses_as_false_on_scan() { } } +/// Owned, comparable snapshot of *every* parsed field in `VexArgs` — its own +/// five flags plus every field of the flattened `GlobalArgs`. `VexArgs` / +/// `GlobalArgs` are production types that don't derive `PartialEq`, so this +/// mirror exists purely so a single `assert_eq!` can police the entire +/// parsed surface at once. Mirrors `cli_parse_repair.rs`. +#[derive(Debug, Clone, PartialEq)] +struct Snap { + cwd: PathBuf, + manifest_path: String, + api_url: String, + api_token: Option, + org: Option, + proxy_url: String, + ecosystems: Option>, + download_mode: String, + vendor_source: String, + vendor_url: Option, + patch_server_url: Option, + offline: bool, + strict: bool, + global: bool, + global_prefix: Option, + json: bool, + verbose: bool, + silent: bool, + dry_run: bool, + yes: bool, + lock_timeout: Option, + break_lock: bool, + debug: bool, + no_telemetry: bool, + output: Option, + product: Option, + no_verify: bool, + doc_id: Option, + compact: bool, +} + +fn snapshot(a: &VexArgs) -> Snap { + Snap { + cwd: a.common.cwd.clone(), + manifest_path: a.common.manifest_path.clone(), + api_url: a.common.api_url.clone(), + api_token: a.common.api_token.clone(), + org: a.common.org.clone(), + proxy_url: a.common.proxy_url.clone(), + ecosystems: a.common.ecosystems.clone(), + download_mode: a.common.download_mode.clone(), + vendor_source: a.common.vendor_source.clone(), + vendor_url: a.common.vendor_url.clone(), + patch_server_url: a.common.patch_server_url.clone(), + offline: a.common.offline, + strict: a.common.strict, + global: a.common.global, + global_prefix: a.common.global_prefix.clone(), + json: a.common.json, + verbose: a.common.verbose, + silent: a.common.silent, + dry_run: a.common.dry_run, + yes: a.common.yes, + lock_timeout: a.common.lock_timeout, + break_lock: a.common.break_lock, + debug: a.common.debug, + no_telemetry: a.common.no_telemetry, + output: a.output.clone(), + product: a.product.clone(), + no_verify: a.no_verify, + doc_id: a.doc_id.clone(), + compact: a.compact, + } +} + +/// Independent oracle: the snapshot a correct parse of bare `vex` (no flags, +/// no env) must produce. The values are transcribed BY HAND from the +/// `default_value`/`default_value_t` declarations on `VexArgs`/`GlobalArgs` +/// and the `DEFAULT_*` constants in `socket-patch-core` — NOT read back from +/// a live parse — so this can actually disagree with the implementation if a +/// default regresses. +fn expected_defaults() -> Snap { + Snap { + cwd: PathBuf::from("."), + manifest_path: ".socket/manifest.json".to_string(), + api_url: "https://api.socket.dev".to_string(), + api_token: None, + org: None, + proxy_url: "https://patches-api.socket.dev".to_string(), + ecosystems: None, + download_mode: "diff".to_string(), + vendor_source: "auto".to_string(), + vendor_url: None, + patch_server_url: None, + offline: false, + strict: false, + global: false, + global_prefix: None, + json: false, + verbose: false, + silent: false, + dry_run: false, + yes: false, + lock_timeout: None, + break_lock: false, + debug: false, + no_telemetry: false, + output: None, + product: None, + no_verify: false, + doc_id: None, + compact: false, + } +} + +/// [`SOCKET_ENV_VARS`] claims to list "every `SOCKET_*` env var clap consults +/// while parsing `vex`, `apply`, or `scan`". `GlobalArgs` is flattened in +/// whole, so the production `GLOBAL_ARG_ENV_VARS` list is the oracle — a flag +/// added to `GlobalArgs` with an env binding is consulted here the moment it +/// lands, and if the scrub list lags behind, an ambient value either aborts +/// every parse in this file (validated flags: bools, ints, `--ecosystems`, +/// `--vendor-source`) or silently leaks into the parsed args (string flags), +/// voiding the hermeticity the module doc promises. `garbage` is rejected by +/// every validating parser and visibly non-default for every +/// string/path/option flag, so a missing scrub entry fails loudly either way. +/// Mirrors `cli_parse_repair.rs` / `cli_parse_get.rs`. +#[test] +#[serial_test::serial] +fn scrub_covers_every_global_env_var_clap_consults() { + for &var in socket_patch_cli::args::GLOBAL_ARG_ENV_VARS { + let prev = std::env::var(var).ok(); + std::env::set_var(var, "garbage"); + let parsed = { + let _scrub = EnvScrub::new(); + Cli::try_parse_from(["socket-patch", "vex"]) + }; + match prev { + Some(v) => std::env::set_var(var, v), + None => std::env::remove_var(var), + } + let a = match parsed { + Ok(cli) => match cli.command { + Commands::Vex(a) => a, + _ => panic!("expected Vex"), + }, + Err(e) => panic!("ambient {var}=garbage aborted the scrubbed parse: {e}"), + }; + assert_eq!( + snapshot(&a), + expected_defaults(), + "ambient {var}=garbage leaked into the scrubbed parse", + ); + } +} + /// The explicit CLI flags keep working through the env fix (the custom /// value parser must not change flag-only usage). #[test] diff --git a/crates/socket-patch-cli/tests/cli_remove_silent.rs b/crates/socket-patch-cli/tests/cli_remove_silent.rs index a3f68235..9c5b08df 100644 --- a/crates/socket-patch-cli/tests/cli_remove_silent.rs +++ b/crates/socket-patch-cli/tests/cli_remove_silent.rs @@ -176,6 +176,354 @@ fn remove_silent_suppresses_break_lock_warning() { ); } +/// Write a vendor ledger with one npm entry (empty wiring, so the revert +/// is a pure offline artifact-dir delete) plus the artifact dir it names. +/// `manifest_purl` is the ledger key AND base purl; `detached` selects the +/// `remove` code path under test. +fn write_vendor_state(root: &Path, purl: &str, uuid: &str, detached: bool) { + write_vendor_state_wired(root, purl, uuid, detached, "[]"); +} + +/// Like [`write_vendor_state`] but with an explicit wiring array, so tests +/// can plant a wiring record that makes the (still offline, still +/// successful) revert emit a backend warning. +fn write_vendor_state_wired(root: &Path, purl: &str, uuid: &str, detached: bool, wiring: &str) { + let vendor = root.join(".socket/vendor"); + let artifact_dir = vendor.join("npm").join(uuid); + std::fs::create_dir_all(&artifact_dir).expect("create artifact dir"); + std::fs::write(artifact_dir.join("package.tgz"), b"tgz").expect("write artifact"); + let detached_field = if detached { r#""detached": true,"# } else { "" }; + let state = format!( + r#"{{ + "version": 1, + "entries": {{ + "{purl}": {{ + "ecosystem": "npm", + "basePurl": "{purl}", + "uuid": "{uuid}", + "artifact": {{ "path": ".socket/vendor/npm/{uuid}/package.tgz" }}, + {detached_field} + "wiring": {wiring} + }} + }} +}}"# + ); + std::fs::write(vendor.join("state.json"), state).expect("write vendor state"); +} + +/// A wiring record naming a file the npm revert backend does not edit: +/// the revert still succeeds (artifact deleted, ledger entry dropped) but +/// emits a `vendor_lock_entry_drifted` warning — fully offline. +const DRIFTED_WIRING: &str = r#"[{ "file": "weird.txt", "kind": "npm_lock_entry", "action": "added", "key": "node_modules/x" }]"#; + +/// `--silent` must also gate the vendor-revert chatter on the manifest +/// path: removing a vendored patch printed "Reverted vendoring for ..." +/// (stdout) even under `--silent`, because the vendor block gated its +/// human output on `!json` alone — the same bug class the rest of this +/// file guards, reintroduced with the vendor overhaul. `vendor --revert` +/// itself gates the identical message on `!silent && !json`. +#[test] +fn remove_silent_suppresses_vendored_revert_output() { + let purl = "pkg:npm/__remove_silent_test__@1.0.0"; + let uuid = "33333333-3333-4333-8333-333333333333"; + + let tmp = tempfile::tempdir().expect("tempdir"); + make_socket_dir(tmp.path()); + write_vendor_state(tmp.path(), purl, uuid, false); + + let (code, stdout, stderr) = run_remove(tmp.path(), &[purl, "--silent", "--yes"]); + assert_eq!( + code, 0, + "remove must succeed; stdout={stdout:?} stderr={stderr:?}" + ); + assert!( + stdout.trim().is_empty(), + "--silent must suppress the vendor-revert stdout chatter; got {stdout:?}" + ); + let stderr_rest: Vec<&str> = stderr + .lines() + .filter(|l| !l.contains("SOCKET_API_TOKEN") && !l.trim().is_empty()) + .collect(); + assert!( + stderr_rest.is_empty(), + "--silent must produce no stderr chatter on success; got {stderr_rest:?}" + ); + + // The revert must still have happened — silent suppresses output, + // not the mutation. An emptied ledger is deleted outright. + assert!( + !tmp.path().join(".socket/vendor/state.json").exists(), + "vendor ledger entry must be reverted (empty ledger deleted)" + ); + + // Control run: without --silent the revert message must print — + // otherwise the assertions above pass vacuously. + let tmp2 = tempfile::tempdir().expect("tempdir"); + make_socket_dir(tmp2.path()); + write_vendor_state(tmp2.path(), purl, uuid, false); + let (loud_code, loud_stdout, _loud_stderr) = run_remove(tmp2.path(), &[purl, "--yes"]); + assert_eq!(loud_code, 0); + assert!( + loud_stdout.contains("Reverted vendoring for"), + "non-silent run must print the vendor-revert message; got {loud_stdout:?}" + ); +} + +/// The `--skip-rollback` "vendor wiring left in place" note is chatter, +/// not an error, so `--silent` must suppress it too. +#[test] +fn remove_silent_suppresses_vendored_skip_rollback_note() { + let purl = "pkg:npm/__remove_silent_test__@1.0.0"; + let uuid = "33333333-3333-4333-8333-333333333333"; + + let tmp = tempfile::tempdir().expect("tempdir"); + make_socket_dir(tmp.path()); + write_vendor_state(tmp.path(), purl, uuid, false); + + let (code, stdout, stderr) = + run_remove(tmp.path(), &[purl, "--silent", "--yes", "--skip-rollback"]); + assert_eq!( + code, 0, + "remove must succeed; stdout={stdout:?} stderr={stderr:?}" + ); + let stderr_rest: Vec<&str> = stderr + .lines() + .filter(|l| !l.contains("SOCKET_API_TOKEN") && !l.trim().is_empty()) + .collect(); + assert!( + stderr_rest.is_empty(), + "--silent must suppress the vendored --skip-rollback note; got {stderr_rest:?}" + ); + assert!( + stdout.trim().is_empty(), + "--silent must produce no stdout; got {stdout:?}" + ); + + // Control run: without --silent the note must print. + let tmp2 = tempfile::tempdir().expect("tempdir"); + make_socket_dir(tmp2.path()); + write_vendor_state(tmp2.path(), purl, uuid, false); + let (loud_code, _loud_stdout, loud_stderr) = + run_remove(tmp2.path(), &[purl, "--yes", "--skip-rollback"]); + assert_eq!(loud_code, 0); + assert!( + loud_stderr.contains("is vendored; --skip-rollback leaves"), + "non-silent --skip-rollback must print the vendored note; got {loud_stderr:?}" + ); +} + +/// The detached-only remove path (`scan --vendor --detached` entries with +/// no manifest record) printed its pre-removal listing (stderr) and +/// "Reverted vendoring for ..." (stdout) even under `--silent`: the whole +/// function gated on `!json` alone. +#[test] +fn remove_silent_suppresses_detached_revert_output() { + let purl = "pkg:npm/__remove_silent_detached__@1.0.0"; + let uuid = "44444444-4444-4444-8444-444444444444"; + + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).expect("create .socket"); + // Empty manifest: the identifier matches only the detached ledger entry. + std::fs::write(socket.join("manifest.json"), r#"{ "patches": {} }"#).expect("write manifest"); + write_vendor_state(tmp.path(), purl, uuid, true); + + let (code, stdout, stderr) = run_remove(tmp.path(), &[purl, "--silent", "--yes"]); + assert_eq!( + code, 0, + "detached remove must succeed; stdout={stdout:?} stderr={stderr:?}" + ); + assert!( + stdout.trim().is_empty(), + "--silent must suppress the detached revert stdout chatter; got {stdout:?}" + ); + let stderr_rest: Vec<&str> = stderr + .lines() + .filter(|l| !l.contains("SOCKET_API_TOKEN") && !l.trim().is_empty()) + .collect(); + assert!( + stderr_rest.is_empty(), + "--silent must suppress the detached pre-removal listing; got {stderr_rest:?}" + ); + + // The removal must still have happened. + assert!( + !tmp.path().join(".socket/vendor/state.json").exists(), + "detached ledger entry must be reverted (empty ledger deleted)" + ); + + // Control run: without --silent both messages must print. + let tmp2 = tempfile::tempdir().expect("tempdir"); + let socket2 = tmp2.path().join(".socket"); + std::fs::create_dir_all(&socket2).expect("create .socket"); + std::fs::write(socket2.join("manifest.json"), r#"{ "patches": {} }"#).expect("write manifest"); + write_vendor_state(tmp2.path(), purl, uuid, true); + let (loud_code, loud_stdout, loud_stderr) = run_remove(tmp2.path(), &[purl, "--yes"]); + assert_eq!(loud_code, 0); + assert!( + loud_stderr.contains("detached vendored patch(es) will be reverted"), + "non-silent detached run must print the listing; got {loud_stderr:?}" + ); + assert!( + loud_stdout.contains("Reverted vendoring for"), + "non-silent detached run must print the revert message; got {loud_stdout:?}" + ); +} + +/// Backend revert warnings are chatter, not errors: `vendor --revert` +/// gates the identical "Warning (code): detail" stderr line on +/// `!silent && !json` (`record_warning`), but remove's vendor block +/// printed it under `--silent` (gated on `!json` alone). +#[test] +fn remove_silent_suppresses_vendor_revert_warnings() { + let purl = "pkg:npm/__remove_silent_test__@1.0.0"; + let uuid = "33333333-3333-4333-8333-333333333333"; + + let tmp = tempfile::tempdir().expect("tempdir"); + make_socket_dir(tmp.path()); + write_vendor_state_wired(tmp.path(), purl, uuid, false, DRIFTED_WIRING); + + let (code, _stdout, stderr) = run_remove(tmp.path(), &[purl, "--silent", "--yes"]); + assert_eq!(code, 0, "remove must succeed; stderr={stderr:?}"); + assert!( + !stderr.contains("Warning ("), + "--silent must suppress backend revert warnings; got {stderr:?}" + ); + + // Control run: without --silent the warning must print. + let tmp2 = tempfile::tempdir().expect("tempdir"); + make_socket_dir(tmp2.path()); + write_vendor_state_wired(tmp2.path(), purl, uuid, false, DRIFTED_WIRING); + let (loud_code, _loud_stdout, loud_stderr) = run_remove(tmp2.path(), &[purl, "--yes"]); + assert_eq!(loud_code, 0); + assert!( + loud_stderr.contains("Warning (vendor_lock_entry_drifted)"), + "non-silent run must print the backend warning; got {loud_stderr:?}" + ); +} + +/// The `--dry-run` "Would revert vendoring for ..." preview (stdout) is +/// chatter too: the manifest-path vendor block gated it on `!json` alone. +/// Dry-run skips the confirm prompt, so no `--yes` is needed. +#[test] +fn remove_silent_suppresses_dry_run_revert_preview() { + let purl = "pkg:npm/__remove_silent_test__@1.0.0"; + let uuid = "33333333-3333-4333-8333-333333333333"; + + let tmp = tempfile::tempdir().expect("tempdir"); + make_socket_dir(tmp.path()); + write_vendor_state(tmp.path(), purl, uuid, false); + + let (code, stdout, stderr) = run_remove(tmp.path(), &[purl, "--silent", "--dry-run"]); + assert_eq!( + code, 0, + "dry-run remove must succeed; stdout={stdout:?} stderr={stderr:?}" + ); + assert!( + stdout.trim().is_empty(), + "--silent must suppress the dry-run revert preview; got {stdout:?}" + ); + // Dry-run mutates nothing: the ledger must survive. + assert!( + tmp.path().join(".socket/vendor/state.json").exists(), + "dry-run must not touch the vendor ledger" + ); + + // Control run: without --silent the preview must print. + let tmp2 = tempfile::tempdir().expect("tempdir"); + make_socket_dir(tmp2.path()); + write_vendor_state(tmp2.path(), purl, uuid, false); + let (loud_code, loud_stdout, _loud_stderr) = run_remove(tmp2.path(), &[purl, "--dry-run"]); + assert_eq!(loud_code, 0); + assert!( + loud_stdout.contains("Would revert vendoring for"), + "non-silent dry-run must print the revert preview; got {loud_stdout:?}" + ); +} + +/// Detached-path twin of the dry-run preview: the listing (stderr) and +/// "Would revert vendoring for ..." (stdout) both printed under `--silent`. +#[test] +fn remove_silent_suppresses_detached_dry_run_preview() { + let purl = "pkg:npm/__remove_silent_detached__@1.0.0"; + let uuid = "44444444-4444-4444-8444-444444444444"; + + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).expect("create .socket"); + std::fs::write(socket.join("manifest.json"), r#"{ "patches": {} }"#).expect("write manifest"); + write_vendor_state(tmp.path(), purl, uuid, true); + + let (code, stdout, stderr) = run_remove(tmp.path(), &[purl, "--silent", "--dry-run"]); + assert_eq!( + code, 0, + "detached dry-run must succeed; stdout={stdout:?} stderr={stderr:?}" + ); + assert!( + stdout.trim().is_empty(), + "--silent must suppress the detached dry-run preview; got {stdout:?}" + ); + let stderr_rest: Vec<&str> = stderr + .lines() + .filter(|l| !l.contains("SOCKET_API_TOKEN") && !l.trim().is_empty()) + .collect(); + assert!( + stderr_rest.is_empty(), + "--silent must suppress the detached listing on dry-run; got {stderr_rest:?}" + ); + assert!( + tmp.path().join(".socket/vendor/state.json").exists(), + "dry-run must not touch the vendor ledger" + ); + + // Control run: without --silent the preview must print. + let tmp2 = tempfile::tempdir().expect("tempdir"); + let socket2 = tmp2.path().join(".socket"); + std::fs::create_dir_all(&socket2).expect("create .socket"); + std::fs::write(socket2.join("manifest.json"), r#"{ "patches": {} }"#).expect("write manifest"); + write_vendor_state(tmp2.path(), purl, uuid, true); + let (loud_code, loud_stdout, _loud_stderr) = run_remove(tmp2.path(), &[purl, "--dry-run"]); + assert_eq!(loud_code, 0); + assert!( + loud_stdout.contains("Would revert vendoring for"), + "non-silent detached dry-run must print the preview; got {loud_stdout:?}" + ); +} + +/// Detached-path twin of the backend-warning gate: warnings printed under +/// `--silent` because the whole function gated on `!json` alone. +#[test] +fn remove_silent_suppresses_detached_revert_warnings() { + let purl = "pkg:npm/__remove_silent_detached__@1.0.0"; + let uuid = "44444444-4444-4444-8444-444444444444"; + + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).expect("create .socket"); + std::fs::write(socket.join("manifest.json"), r#"{ "patches": {} }"#).expect("write manifest"); + write_vendor_state_wired(tmp.path(), purl, uuid, true, DRIFTED_WIRING); + + let (code, _stdout, stderr) = run_remove(tmp.path(), &[purl, "--silent", "--yes"]); + assert_eq!(code, 0, "detached remove must succeed; stderr={stderr:?}"); + assert!( + !stderr.contains("Warning ("), + "--silent must suppress detached revert warnings; got {stderr:?}" + ); + + // Control run: without --silent the warning must print. + let tmp2 = tempfile::tempdir().expect("tempdir"); + let socket2 = tmp2.path().join(".socket"); + std::fs::create_dir_all(&socket2).expect("create .socket"); + std::fs::write(socket2.join("manifest.json"), r#"{ "patches": {} }"#).expect("write manifest"); + write_vendor_state_wired(tmp2.path(), purl, uuid, true, DRIFTED_WIRING); + let (loud_code, _loud_stdout, loud_stderr) = run_remove(tmp2.path(), &[purl, "--yes"]); + assert_eq!(loud_code, 0); + assert!( + loud_stderr.contains("Warning (vendor_lock_entry_drifted)"), + "non-silent detached run must print the backend warning; got {loud_stderr:?}" + ); +} + /// Errors must still print under `--silent` ("errors only", not "nothing"): /// an unknown identifier keeps its stderr message and exit 1. #[test] diff --git a/crates/socket-patch-cli/tests/cli_rollback_silent.rs b/crates/socket-patch-cli/tests/cli_rollback_silent.rs new file mode 100644 index 00000000..149f6b01 --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_rollback_silent.rs @@ -0,0 +1,269 @@ +//! `rollback --silent` error-output contract tests. +//! +//! CLI_CONTRACT.md defines `--silent` as "Errors only" — never "nothing": +//! an exit-1 run with zero output is undiagnosable. Regression guards for +//! the rollback error paths that gated their ONLY error print on `!silent`: +//! +//! 1. `rollback --silent` with no manifest exited 1 with zero output. +//! 2. `rollback --silent ` (the `rollback_patches_inner` error +//! path) exited 1 with zero output. +//! 3. `rollback --silent --offline` with a missing before-blob (the offline +//! bail) exited 1 with zero output. +//! 4. `rollback --silent` whose blob download fails (unreachable server) +//! exited 1 with zero output. +//! 5. `rollback --silent` with a per-package failure (installed file +//! modified after patching — hash mismatch) exited 1 with zero output. +//! +//! Same bug class previously fixed in `scan` (`embed_vex_human`), `setup` +//! (all three modes), `apply` (`--silent`/`--check` mutes), and `remove`. +//! +//! Stderr assertions ignore the "No SOCKET_API_TOKEN set" client warning: +//! it's printed unconditionally by `get_api_client_with_overrides` in core +//! for every command and is out of scope for `rollback`'s `--silent` gating. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use sha2::{Digest, Sha256}; + +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +/// Run `socket-patch rollback` in `cwd` with the entire `SOCKET_*` ambient +/// environment scrubbed (prefix scrub — ambient tokens, silent toggles, or +/// manifest redirects must not change the branch under test) and telemetry +/// disabled. +fn run_rollback(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.arg("rollback").args(args).current_dir(cwd); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + let out = cmd.output().expect("run socket-patch rollback"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) +} + +/// Non-error stderr lines: drop the unconditional core API-token warning +/// (both its lead line and its "Got: ... Continuing anyway" continuation) +/// and blank lines, keep everything else. +fn stderr_chatter(stderr: &str) -> Vec { + stderr + .lines() + .filter(|l| { + !l.contains("SOCKET_API_TOKEN") + && !l.contains("Continuing anyway") + && !l.trim().is_empty() + }) + .map(|l| l.to_string()) + .collect() +} + +/// Git-SHA256: SHA256("blob \0" ++ content). +fn git_sha256(content: &[u8]) -> String { + let header = format!("blob {}\0", content.len()); + let mut hasher = Sha256::new(); + hasher.update(header.as_bytes()); + hasher.update(content); + hex::encode(hasher.finalize()) +} + +/// Manifest with one npm patch whose before-blob is NOT staged. +fn write_missing_blob_manifest(root: &Path) { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write( + socket.join("manifest.json"), + r#"{ "patches": { + "pkg:npm/__rb_silent__@1.0.0": { + "uuid": "44444444-4444-4444-8444-444444444444", + "exportedAt": "2024-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": "0000000000000000000000000000000000000000000000000000000000000000", + "afterHash": "1111111111111111111111111111111111111111111111111111111111111111" + }}, + "vulnerabilities": {}, "description": "x", + "license": "MIT", "tier": "free" + } + }}"#, + ) + .unwrap(); +} + +/// `rollback --silent` with no manifest must still print the error. +#[test] +fn rollback_silent_no_manifest_keeps_error_output() { + let tmp = tempfile::tempdir().unwrap(); + + let (code, stdout, stderr) = run_rollback(tmp.path(), &["--silent", "--offline"]); + assert_eq!(code, 1, "no manifest must fail; stderr={stderr}"); + assert!( + stdout.trim().is_empty(), + "silent human mode writes errors to stderr, not stdout: {stdout}" + ); + let chatter = stderr_chatter(&stderr); + assert!( + chatter.iter().any(|l| l.contains("Manifest not found")), + "--silent must keep the manifest-not-found error (errors only, \ + never nothing); stderr was: {stderr:?}" + ); +} + +/// `rollback --silent ` (the inner error path) must +/// still print why it failed. +#[test] +fn rollback_silent_unknown_identifier_keeps_error_output() { + let tmp = tempfile::tempdir().unwrap(); + write_missing_blob_manifest(tmp.path()); + + let (code, stdout, stderr) = run_rollback( + tmp.path(), + &["--silent", "--offline", "pkg:npm/does-not-exist@9.9.9"], + ); + assert_eq!(code, 1, "unknown identifier must fail; stderr={stderr}"); + assert!( + stdout.trim().is_empty(), + "silent human mode writes errors to stderr, not stdout: {stdout}" + ); + let chatter = stderr_chatter(&stderr); + assert!( + chatter + .iter() + .any(|l| l.contains("No patch found matching identifier")), + "--silent must keep the unknown-identifier error; stderr was: {stderr:?}" + ); +} + +/// `rollback --silent --offline` with a missing before-blob (the offline +/// bail) must still print the error. This path returns a contentless +/// partial_failure — the eprintln IS the only diagnostic. +#[test] +fn rollback_silent_offline_missing_blob_keeps_error_output() { + let tmp = tempfile::tempdir().unwrap(); + write_missing_blob_manifest(tmp.path()); + + let (code, stdout, stderr) = run_rollback(tmp.path(), &["--silent", "--offline"]); + assert_eq!(code, 1, "offline missing blob must fail; stderr={stderr}"); + assert!( + stdout.trim().is_empty(), + "silent human mode writes errors to stderr, not stdout: {stdout}" + ); + let chatter = stderr_chatter(&stderr); + assert!( + chatter + .iter() + .any(|l| l.contains("missing") && l.contains("--offline")), + "--silent must keep the offline missing-blob error; stderr was: {stderr:?}" + ); +} + +/// `rollback --silent` whose blob download fails (both API and proxy pinned +/// to an unroutable localhost port — nothing leaves the machine, and the +/// connection-refused failure is instant) must still print the error. +#[test] +fn rollback_silent_undownloadable_blob_keeps_error_output() { + let tmp = tempfile::tempdir().unwrap(); + write_missing_blob_manifest(tmp.path()); + + let (code, stdout, stderr) = run_rollback( + tmp.path(), + &[ + "--silent", + "--api-url", + "http://127.0.0.1:1/", + "--proxy-url", + "http://127.0.0.1:1/", + ], + ); + assert_eq!(code, 1, "failed blob download must fail; stderr={stderr}"); + assert!( + stdout.trim().is_empty(), + "silent human mode writes errors to stderr, not stdout: {stdout}" + ); + let chatter = stderr_chatter(&stderr); + assert!( + chatter + .iter() + .any(|l| l.contains("could not be downloaded")), + "--silent must keep the undownloadable-blob error; stderr was: {stderr:?}" + ); +} + +/// `rollback --silent` with a per-package failure (installed file modified +/// after patching, so neither beforeHash nor afterHash matches) must still +/// print the per-package failure line. +#[test] +fn rollback_silent_per_package_failure_keeps_error_output() { + let before = b"original-content\n"; + let after = b"patched-content\n"; + let before_hash = git_sha256(before); + let after_hash = git_sha256(after); + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("package.json"), + r#"{ "name": "rb-silent", "version": "0.0.0" }"#, + ) + .unwrap(); + let pkg_dir = tmp.path().join("node_modules/mismatch-target"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + std::fs::write( + pkg_dir.join("package.json"), + r#"{ "name": "mismatch-target", "version": "1.0.0" }"#, + ) + .unwrap(); + // Locally modified: matches NEITHER hash — rollback must fail this + // package (HashMismatch, "modified after patching"). + std::fs::write(pkg_dir.join("index.js"), b"user-edited-content\n").unwrap(); + + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write( + socket.join("manifest.json"), + format!( + r#"{{ "patches": {{ + "pkg:npm/mismatch-target@1.0.0": {{ + "uuid": "55555555-5555-4555-8555-555555555555", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{ "package/index.js": {{ + "beforeHash": "{before_hash}", + "afterHash": "{after_hash}" + }}}}, + "vulnerabilities": {{}}, "description": "x", + "license": "MIT", "tier": "free" + }} + }}}}"# + ), + ) + .unwrap(); + let blobs = socket.join("blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + std::fs::write(blobs.join(&before_hash), before).unwrap(); + + let (code, stdout, stderr) = run_rollback(tmp.path(), &["--silent", "--offline"]); + assert_eq!(code, 1, "per-package failure must fail; stderr={stderr}"); + assert!( + stdout.trim().is_empty(), + "silent human mode writes errors to stderr, not stdout: {stdout}" + ); + let chatter = stderr_chatter(&stderr); + assert!( + chatter + .iter() + .any(|l| l.contains("Failed to rollback") && l.contains("mismatch-target")), + "--silent must keep the per-package failure line; stderr was: {stderr:?}" + ); + // The mismatched file must be left untouched (fail-safe). + assert_eq!( + std::fs::read(pkg_dir.join("index.js")).unwrap(), + b"user-edited-content\n", + "a hash-mismatched file must never be overwritten" + ); +} diff --git a/crates/socket-patch-cli/tests/cli_scan_silent.rs b/crates/socket-patch-cli/tests/cli_scan_silent.rs index bd38ccbf..afc42193 100644 --- a/crates/socket-patch-cli/tests/cli_scan_silent.rs +++ b/crates/socket-patch-cli/tests/cli_scan_silent.rs @@ -301,6 +301,283 @@ async fn scan_silent_apply_flow_produces_no_output_but_still_applies() { ); } +/// A v3 package-lock with a single registry-resolved dependency, so the +/// `--vendor` flow can rewire it to the vendored artifact (the npm vendor +/// backend keys off lock entries). +fn write_npm_lock(root: &Path) { + let lock = serde_json::json!({ + "name": "scan-silent-test", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scan-silent-test", + "version": "0.0.0", + "dependencies": { "silent-target": "^1.0.0" } + }, + "node_modules/silent-target": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/silent-target/-/silent-target-1.0.0.tgz", + "integrity": "sha512-orig==", + "license": "MIT" + } + } + }); + let mut bytes = serde_json::to_vec_pretty(&lock).unwrap(); + bytes.push(b'\n'); + std::fs::write(root.join("package-lock.json"), bytes).unwrap(); +} + +/// Seed `.socket/manifest.json` with an entry for a package that is NOT +/// installed, so a `--prune` pass has something to prune. +fn seed_manifest_with_gone_entry(root: &Path) { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + let manifest = serde_json::json!({ + "patches": { + "pkg:npm/gone@1.0.0": { + "uuid": "99999999-9999-4999-8999-999999999999", + "exportedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": "0".repeat(64), + "afterHash": "a".repeat(64), + } + }, + "vulnerabilities": {}, + "description": "seed", + "license": "MIT", + "tier": "free", + } + } + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); +} + +/// The vendored-mode GC line must honor `--silent` like the apply-mode one +/// does: `scan --vendor --prune --silent --yes` prints nothing when it +/// succeeds. Regression guard: `run_vendor_interactive_path` printed +/// "GC: pruned N manifest entries." (and the vendored-revert GC line) +/// unconditionally. +#[tokio::test] +async fn scan_vendor_silent_gc_prints_nothing() { + let purl = "pkg:npm/silent-target@1.0.0"; + let before = b"before\n"; + + let mock = MockServer::start().await; + mount_one_patch_api(&mock, purl, before).await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root(tmp.path()); + write_npm_lock(tmp.path()); + write_npm_package(tmp.path(), "silent-target", "1.0.0", before); + seed_manifest_with_gone_entry(tmp.path()); + + let (code, stdout, stderr) = run_scan( + tmp.path(), + &[ + "--vendor", + "--prune", + "--silent", + "--yes", + "--api-url", + &mock.uri(), + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + ], + ); + assert_eq!( + code, 0, + "scan --vendor --prune must succeed; stdout={stdout:?} stderr={stderr:?}" + ); + assert!( + stdout.trim().is_empty(), + "--silent must produce no stdout (regression: the vendor path's \ + GC line printed unconditionally); got {stdout:?}" + ); + let chatter = stderr_chatter(&stderr); + assert!( + chatter.is_empty(), + "--silent must produce no stderr chatter on success; got {chatter:?}" + ); + + // Silent suppresses output, not the work: the prune and the vendoring + // both still happened. + let manifest = + std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).expect("read manifest"); + let v: serde_json::Value = serde_json::from_str(&manifest).expect("parse manifest"); + assert!( + v["patches"]["pkg:npm/gone@1.0.0"].is_null(), + "the uninstalled entry must still be pruned under --silent: {v}" + ); + assert_eq!(v["patches"][purl]["uuid"], UUID, "manifest={v}"); + assert!( + tmp.path() + .join(format!(".socket/vendor/npm/{UUID}/silent-target-1.0.0.tgz")) + .is_file(), + "the package must still be vendored under --silent" + ); + + // Control run: the same scenario WITHOUT --silent must print the GC + // line — otherwise the assertions above pass vacuously. + let tmp2 = tempfile::tempdir().expect("tempdir"); + write_root(tmp2.path()); + write_npm_lock(tmp2.path()); + write_npm_package(tmp2.path(), "silent-target", "1.0.0", before); + seed_manifest_with_gone_entry(tmp2.path()); + let (loud_code, loud_stdout, loud_stderr) = run_scan( + tmp2.path(), + &[ + "--vendor", + "--prune", + "--yes", + "--api-url", + &mock.uri(), + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + ], + ); + assert_eq!( + loud_code, 0, + "control run must succeed; stderr={loud_stderr:?}" + ); + assert!( + loud_stdout.contains("GC: pruned 1 manifest entry."), + "non-silent vendor scan must print the GC line; got {loud_stdout:?}" + ); +} + +/// The embedded-VEX failure path must keep its error under `--silent` +/// ("errors only", not "nothing"): a requested-but-failed `--vex` exits +/// 1, and the failure message must still reach stderr. Regression +/// guard: `embed_vex_human` gated its error print on `!silent`, so +/// `scan --silent --vex out.json` failed with exit 1 and no output at +/// all. Fully offline: the empty crawl short-circuits before the API, +/// and the missing manifest makes VEX generation fail deterministically +/// (`manifest_not_found`). +#[test] +fn scan_silent_vex_failure_keeps_error_output() { + let tmp = tempfile::tempdir().expect("tempdir"); + write_root(tmp.path()); + let vex_path = tmp.path().join("out.vex.json"); + let vex_arg = vex_path.to_str().unwrap().to_string(); + + let (code, stdout, stderr) = run_scan(tmp.path(), &["--silent", "--vex", &vex_arg]); + assert_eq!( + code, 1, + "requested-but-failed VEX must exit 1; stdout={stdout:?} stderr={stderr:?}" + ); + assert!( + stdout.trim().is_empty(), + "--silent must produce no stdout; got {stdout:?}" + ); + assert!( + stderr.contains("VEX generation failed"), + "--silent must NOT suppress the VEX failure message; got {stderr:?}" + ); + + // Control run: the same failure WITHOUT --silent must print the same + // error — otherwise the assertion above could pass against a message + // that never prints for anyone. + let (loud_code, _, loud_stderr) = run_scan(tmp.path(), &["--vex", &vex_arg]); + assert_eq!(loud_code, 1); + assert!( + loud_stderr.contains("VEX generation failed"), + "non-silent VEX failure must print the error; got {loud_stderr:?}" + ); +} + +/// The redirect flow's embedded-VEX failure path must keep its error +/// under `--silent` too ("errors only", not "nothing"): `scan --redirect +/// --vex out.json --silent` with nothing to attest (the reference is +/// forbidden, no manifest exists) exits 1, and the failure message must +/// still reach stderr. Regression guard: `run_redirect` printed its +/// `vex_error` inside the `!silent` human branch, so the run failed with +/// exit 1 and no output at all — the same bug `embed_vex_human` already +/// fixed on the non-redirect path (see +/// `scan_silent_vex_failure_keeps_error_output` above). +#[tokio::test] +async fn scan_redirect_silent_vex_failure_keeps_error_output() { + let purl = "pkg:npm/silent-target@1.0.0"; + let before = b"before\n"; + + let mock = MockServer::start().await; + mount_one_patch_api(&mock, purl, before).await; + // Reference endpoint: the patch exists but this org may not download + // it, so nothing is redirected and the requested VEX has no subject. + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { UUID: { "status": "forbidden", "url": null, "purl": purl, "artifacts": [], "registryOverride": null } } + }))) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root(tmp.path()); + write_npm_package(tmp.path(), "silent-target", "1.0.0", before); + let vex_path = tmp.path().join("out.vex.json"); + let vex_arg = vex_path.to_str().unwrap().to_string(); + + let args = |silent: bool| { + let mut v = vec!["--redirect", "--yes"]; + if silent { + v.push("--silent"); + } + v.extend_from_slice(&["--vex", &vex_arg, "--vex-product", "pkg:npm/consumer@0.0.0"]); + v + }; + + let base = [ + "--api-url".to_string(), + mock.uri(), + "--api-token".to_string(), + "fake-token".to_string(), + "--org".to_string(), + ORG_SLUG.to_string(), + ]; + let full: Vec<&str> = args(true) + .into_iter() + .chain(base.iter().map(String::as_str)) + .collect(); + let (code, stdout, stderr) = run_scan(tmp.path(), &full); + assert_eq!( + code, 1, + "requested-but-failed VEX must exit 1; stdout={stdout:?} stderr={stderr:?}" + ); + assert!( + stdout.trim().is_empty(), + "--silent must produce no stdout; got {stdout:?}" + ); + assert!( + stderr.contains("VEX generation failed"), + "--silent must NOT suppress the redirect VEX failure message; got {stderr:?}" + ); + + // Control run: the same failure WITHOUT --silent must print the same + // error — otherwise the assertion above could pass against a message + // that never prints for anyone. + let full_loud: Vec<&str> = args(false) + .into_iter() + .chain(base.iter().map(String::as_str)) + .collect(); + let (loud_code, _, loud_stderr) = run_scan(tmp.path(), &full_loud); + assert_eq!(loud_code, 1); + assert!( + loud_stderr.contains("VEX generation failed"), + "non-silent redirect VEX failure must print the error; got {loud_stderr:?}" + ); +} + /// Errors must still print under `--silent` ("errors only", not /// "nothing"): when every API batch fails, the failure message keeps /// its stderr output and exit 1 — but the informational "Found N diff --git a/crates/socket-patch-cli/tests/cli_setup_silent.rs b/crates/socket-patch-cli/tests/cli_setup_silent.rs index 0e159d61..92b915d7 100644 --- a/crates/socket-patch-cli/tests/cli_setup_silent.rs +++ b/crates/socket-patch-cli/tests/cli_setup_silent.rs @@ -210,6 +210,67 @@ fn setup_remove_silent_prints_nothing_but_removes() { ); } +/// Errors must still print under `--silent` ("errors only", not "nothing"), +/// mirroring the remove/scan silent suites: an invalid package.json keeps +/// its error message on stderr and exit 1 in all three modes, while the +/// informational stdout stays suppressed. +#[test] +fn setup_silent_keeps_error_output() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("package.json"), "{ not json").unwrap(); + + for mode in [&[][..], &["--check"][..], &["--remove"][..]] { + let mut args: Vec<&str> = mode.to_vec(); + args.extend(["--silent", "--yes"]); + let (code, stdout, stderr) = run_setup(tmp.path(), &args); + assert_eq!( + code, 1, + "invalid package.json must exit 1 for {mode:?}; stdout={stdout:?} stderr={stderr:?}" + ); + assert!( + stdout.trim().is_empty(), + "--silent must still suppress informational stdout for {mode:?}; got {stdout:?}" + ); + assert!( + stderr.contains("Invalid package.json"), + "--silent must NOT suppress error output for {mode:?}; got {stderr:?}" + ); + } +} + +/// Same contract for the apply-phase (post-preview) failures: a package.json +/// that previews fine but cannot be rewritten (read-only directory) must +/// surface its write error on stderr under `--silent`, not just exit 1. +#[cfg(unix)] +#[test] +fn setup_silent_keeps_apply_phase_error_output() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root(tmp.path()); + std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o555)).unwrap(); + + let (code, stdout, stderr) = run_setup(tmp.path(), &["--silent", "--yes"]); + + // Restore so the tempdir can clean up regardless of the assertions. + std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + + assert_eq!( + code, 1, + "unwritable package.json must exit 1; stdout={stdout:?} stderr={stderr:?}" + ); + assert!( + stdout.trim().is_empty(), + "--silent must still suppress informational stdout; got {stdout:?}" + ); + assert!( + stderr_chatter(&stderr) + .iter() + .any(|l| l.starts_with("Error:")), + "--silent must NOT suppress the apply-phase write error; got {stderr:?}" + ); +} + /// The `no_files` path (no project found at all) is informational, not an /// error: under `--silent` it must print nothing and exit 0. Covers both /// the plain-setup inline branch and the shared `report_no_files` helper diff --git a/crates/socket-patch-cli/tests/cli_sigpipe.rs b/crates/socket-patch-cli/tests/cli_sigpipe.rs new file mode 100644 index 00000000..f72173b9 --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_sigpipe.rs @@ -0,0 +1,64 @@ +//! Regression test: a closed stdout pipe must not crash the binary. +//! +//! The Rust runtime starts every process with SIGPIPE ignored, so a write to +//! a pipe whose reader has exited surfaces as an `EPIPE` error — and +//! `println!` turns that error into a panic. `socket-patch | head -1` +//! therefore died with `thread 'main' panicked ... failed printing to +//! stdout: Broken pipe` and exit code 101 ("please report this bug" +//! territory) the moment `head` closed its end. Every other Unix CLI in that +//! pipeline position (`grep`, `cat`, `git log`) dies quietly of SIGPIPE; +//! `main.rs` must restore the default disposition so socket-patch does too. +//! +//! This test runs the compiled binary as a subprocess because the bug lives +//! in `main.rs` itself (process-wide signal state), upstream of everything +//! the in-process tests can reach. + +#![cfg(unix)] + +use std::os::unix::process::ExitStatusExt; +use std::process::{Command, Stdio}; + +const BINARY: &str = env!("CARGO_BIN_EXE_socket-patch"); +/// `libc::SIGPIPE`, inlined so the test crate needs no libc dependency. +const SIGPIPE: i32 = 13; + +/// `unlock` in an empty directory is the cheapest command that writes to +/// stdout: offline, lock-free, no manifest needed — prints "Lock is free." +/// and exits 0 when stdout is healthy. +#[test] +fn closed_stdout_pipe_is_not_a_panic() { + let dir = tempfile::tempdir().expect("tempdir"); + + // Build a pipe and close the read end BEFORE the child spawns, so the + // child's first stdout write hits EPIPE deterministically (piping to a + // real `head -1` would race its exit against our writes). + let (reader, writer) = std::io::pipe().expect("pipe"); + drop(reader); + + let mut cmd = Command::new(BINARY); + cmd.arg("unlock") + .current_dir(dir.path()) + .stdout(Stdio::from(writer)) + .stderr(Stdio::piped()); + // Scrub the global env-var surface so ambient SOCKET_* vars can never + // perturb the invocation (the assertion is about stdout plumbing). + for var in socket_patch_cli::args::GLOBAL_ARG_ENV_VARS { + cmd.env_remove(var); + } + let out = cmd.output().expect("spawn socket-patch"); + let stderr = String::from_utf8_lossy(&out.stderr); + + assert!( + !stderr.contains("panicked"), + "a closed stdout pipe must not crash with a Rust panic; stderr was:\n{stderr}" + ); + // Dying of SIGPIPE (the Unix pipeline convention) and a clean exit 0 + // (a writer that swallows EPIPE) are both acceptable; the panic + // runtime's exit 101 is not. + assert!( + out.status.signal() == Some(SIGPIPE) || out.status.code() == Some(0), + "expected death-by-SIGPIPE or exit 0, got {:?} (code {:?}); stderr was:\n{stderr}", + out.status, + out.status.code() + ); +} diff --git a/crates/socket-patch-cli/tests/common/mod.rs b/crates/socket-patch-cli/tests/common/mod.rs index 6bc804a3..795e32c6 100644 --- a/crates/socket-patch-cli/tests/common/mod.rs +++ b/crates/socket-patch-cli/tests/common/mod.rs @@ -45,9 +45,9 @@ pub fn has_command(cmd: &str) -> bool { } /// Run the CLI binary with `args`, working dir `cwd`. Returns -/// `(exit_code, stdout, stderr)`. Strips `SOCKET_API_TOKEN` from the -/// environment so apply paths default to the public proxy and tests -/// don't accidentally exercise authed endpoints. +/// `(exit_code, stdout, stderr)`. Scrubs the ambient `SOCKET_*` +/// environment (see `run_with_env`) so apply paths default to the +/// public proxy and only the flags each test passes are in effect. pub fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { run_with_env(cwd, args, &[]) } @@ -58,9 +58,46 @@ pub fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { /// touching the parent process's environment — keeps tests parallel-safe. pub fn run_with_env(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { let mut cmd = Command::new(binary()); - cmd.args(args) - .current_dir(cwd) + cmd.args(args).current_dir(cwd); + // The binary binds a wide `SOCKET_*` env surface (SOCKET_CWD, + // SOCKET_DRY_RUN, SOCKET_STRICT, SOCKET_GLOBAL, SOCKET_MANIFEST_PATH, + // ...). An ambient value silently changes what these tests exercise — + // SOCKET_DRY_RUN=true turns every real apply into a no-op, + // SOCKET_GLOBAL_PREFIX flips commands into global mode (aiming + // mutations at the host's *real* global caches), and the output-mode + // trio (SOCKET_JSON / SOCKET_SILENT / SOCKET_VERBOSE) silently flips + // which printer a test's assertions run against. The highest-risk + // vars are seeded with hostile values and then scrubbed — `env_remove` + // clears the seed too, so the child never sees it, but if a scrub line + // is ever dropped the seed (rather than a developer's ambient shell, + // which this suite can't rely on) turns the tests red immediately. + cmd.env("SOCKET_GLOBAL", "true") + .env("SOCKET_GLOBAL_PREFIX", "/nonexistent") + .env("SOCKET_DRY_RUN", "true") + .env("SOCKET_MANIFEST_PATH", "/nonexistent/manifest.json") + .env("SOCKET_JSON", "true") + .env("SOCKET_SILENT", "true") + .env("SOCKET_VERBOSE", "true") + .env_remove("SOCKET_GLOBAL") + .env_remove("SOCKET_GLOBAL_PREFIX") + .env_remove("SOCKET_DRY_RUN") + .env_remove("SOCKET_MANIFEST_PATH") + .env_remove("SOCKET_JSON") + .env_remove("SOCKET_SILENT") + .env_remove("SOCKET_VERBOSE") .env_remove("SOCKET_API_TOKEN"); + // Prefix-scrub whatever else the ambient shell carries; removing + // SOCKET_API_TOKEN also forces the public proxy (free-tier). + // Telemetry opt-outs are deliberately kept so an opted-out dev + // stays opted out. + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + cmd.env_remove(&key); + } + } + // Caller-supplied env lands last so explicit injections (runtime + // gates, discovery roots) survive the scrub. for (k, v) in env { cmd.env(k, v); } diff --git a/crates/socket-patch-cli/tests/docker_e2e_deno.rs b/crates/socket-patch-cli/tests/docker_e2e_deno.rs index e8a7a3a0..6693541c 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_deno.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_deno.rs @@ -25,9 +25,9 @@ //! healthy; it does not produce the scanned layout. //! //! Run command: -//! `cargo test -p socket-patch-cli --features docker-e2e,deno --test docker_e2e_deno` +//! `cargo test -p socket-patch-cli --features docker-e2e --test docker_e2e_deno` -#![cfg(all(feature = "docker-e2e", feature = "deno"))] +#![cfg(feature = "docker-e2e")] use std::process::Command; @@ -162,6 +162,29 @@ fn api_url_for_container(server: &MockServer) -> String { format!("http://host.docker.internal:{}", server.address().port()) } +/// Minimal wiremock for the JSR scan variant: the batch endpoint answers +/// "no patches" for whatever purls scan submits. Nothing else is +/// consulted — the variant only verifies discovery counts — but pinning +/// scan to this server keeps the run hermetic: without it the CLI falls +/// back to the live public patch proxy, so the test would leak synthetic +/// `pkg:jsr` purls to production and fail outright wherever that proxy +/// is unreachable (an all-batches-failed scan exits 1). +async fn make_empty_batch_mock_server() -> MockServer { + let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock to 0.0.0.0:0"); + let server = MockServer::builder().listener(listener).start().await; + + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + + server +} + /// Driver script for the `deno install` + node_modules variant. Deno /// 2.0 reads `package.json`, resolves dependencies through the npm /// registry, and populates `node_modules/` — at which point the @@ -292,7 +315,9 @@ exit 0 /// produce scannable trees. This test stages exactly that layout via /// `mkdir` so the docker run proves the CLI ↔ DenoCrawler integration /// end-to-end, even before real-world Deno output matches. -fn deno_jsr_script() -> String { +fn deno_jsr_script(api_url: &str) -> String { + // Plain `.replace` instead of `format!` so the script's many literal + // braces (JSON heredocs, bash expansions) don't need `{{` escaping. r#"#!/usr/bin/env bash set -uo pipefail @@ -360,9 +385,12 @@ EOF # socket-patch scan --global --ecosystems deno --global-prefix . # global-prefix bypasses default ~/.cache/deno discovery and points -# explicitly at our synthetic JSR root. +# explicitly at our synthetic JSR root. The API args pin the patch +# lookup to the host wiremock — omitting them would send the batch +# query to the live public proxy instead. SCAN_OUT=$(socket-patch scan --json --global \ --global-prefix "$JSR" \ + --api-url '{api_url}' --api-token fake --org {ORG} \ --ecosystems deno 2>/tmp/scan.err) SCAN_RC=$? echo "scan exit=$SCAN_RC" >&2 @@ -413,7 +441,31 @@ echo "scanned jsr packages count matches oracle: $SCANNED" >&2 echo "===SCAN VERIFIED===" >&2 echo "===E2E PASS===" exit 0 -"#.to_string() +"# + .replace("{api_url}", api_url) + .replace("{ORG}", ORG) +} + +/// Hermeticity guard for the JSR variant, runnable without the docker +/// image. Every network-touching `socket-patch` invocation in the +/// generated script must be pinned to the test's wiremock: without +/// `--api-url`, `scan` falls back to the LIVE public patch proxy, so +/// the run leaks the synthetic `pkg:jsr` purls to production and fails +/// outright (an all-batches-failed scan exits 1) on any machine where +/// that proxy is unreachable. +#[test] +fn deno_jsr_script_pins_scan_to_the_mock_api() { + let script = deno_jsr_script("http://host.docker.internal:12345"); + assert!( + script.contains("--api-url 'http://host.docker.internal:12345'"), + "JSR-variant scan must target the test's wiremock, not the live \ + public proxy:\n{script}" + ); + assert!( + script.contains(&format!("--org {ORG}")), + "JSR-variant scan must send the batch query to the mocked org \ + endpoint:\n{script}" + ); } /// Returns `true` when the test must skip because the docker image is @@ -504,10 +556,12 @@ async fn deno_install_node_modules_full_apply_chain() { #[tokio::test] async fn deno_jsr_synthetic_layout_scan_verifies_discovery() { + let server = make_empty_batch_mock_server().await; + let api_url = api_url_for_container(&server); if skip_if_no_image() { return; } - let out = run_container(&deno_jsr_script()); + let out = run_container(&deno_jsr_script(&api_url)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( diff --git a/crates/socket-patch-cli/tests/docker_e2e_maven.rs b/crates/socket-patch-cli/tests/docker_e2e_maven.rs index 7d10fe9f..d88a15dd 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_maven.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_maven.rs @@ -9,7 +9,7 @@ //! containing the marker. The .pom is just metadata — apply replaces //! it byte-for-byte and the grep verifies on disk. -#![cfg(all(feature = "docker-e2e", feature = "maven"))] +#![cfg(feature = "docker-e2e")] use std::process::Command; diff --git a/crates/socket-patch-cli/tests/docker_e2e_nuget.rs b/crates/socket-patch-cli/tests/docker_e2e_nuget.rs index 18ce38ac..9e5381ed 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_nuget.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_nuget.rs @@ -13,7 +13,7 @@ //! Both tests overwrite the package's `LICENSE.md` file with synthetic //! bytes containing the marker. -#![cfg(all(feature = "docker-e2e", feature = "nuget"))] +#![cfg(feature = "docker-e2e")] use std::process::Command; diff --git a/crates/socket-patch-cli/tests/docker_e2e_pypi.rs b/crates/socket-patch-cli/tests/docker_e2e_pypi.rs index 91706a88..1a48ecba 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_pypi.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_pypi.rs @@ -608,7 +608,7 @@ exit 0 /// `uv tool install` puts a tool at `~/.local/share/uv/tools//` /// with its own venv. The script installs `httpie` (a small CLI tool /// available on PyPI), then drives a patch against one of its modules. -fn uv_tool_script(_api_url: &str, patched_marker: &str) -> String { +fn uv_tool_script(api_url: &str, patched_marker: &str) -> String { // httpie has a top-level package called `httpie`. We patch // `httpie/__init__.py`. The PURL in the manifest is fixed up by // the wiremock fixture; here we just need to discover it. @@ -632,7 +632,9 @@ parse_scanned() {{ # Measuring the DELTA introduced by `uv tool install` isolates the # uv-tools contribution and can only be satisfied if that layout was # actually walked. -BASELINE_OUT=$(socket-patch scan --json --global --ecosystems pypi 2>/tmp/baseline.err) +BASELINE_OUT=$(socket-patch scan --json --global \ + --api-url '{api_url}' --api-token fake --org {ORG} \ + --ecosystems pypi 2>/tmp/baseline.err) BASELINE_RC=$? cat /tmp/baseline.err >&2 || true if [ "$BASELINE_RC" -ne 0 ]; then @@ -667,7 +669,9 @@ echo "Installed httpie at: $INIT_PY" >&2 # output reports a `scannedPackages` count but doesn't enumerate by name # (only patched packages are listed), so we compare the count against the # baseline. -SCAN_OUT=$(socket-patch scan --json --global --ecosystems pypi 2>/tmp/scan.err) +SCAN_OUT=$(socket-patch scan --json --global \ + --api-url '{api_url}' --api-token fake --org {ORG} \ + --ecosystems pypi 2>/tmp/scan.err) SCAN_RC=$? echo "scan exit=$SCAN_RC" >&2 cat /tmp/scan.err >&2 || true @@ -727,6 +731,32 @@ exit 0 ) } +/// Hermeticity guard for the uv-tool variant, runnable without the +/// docker image. BOTH scans in the generated script (baseline + +/// post-install) must be pinned to the test's wiremock: without +/// `--api-url`, `scan --global` falls back to the LIVE public patch +/// proxy, leaking the container's real installed purls to production +/// on every run and failing outright (an all-batches-failed scan +/// exits 1) on any machine where that proxy is unreachable. +#[test] +fn uv_tool_script_pins_scans_to_the_mock_api() { + let script = uv_tool_script("http://host.docker.internal:12345", "marker"); + assert_eq!( + script + .matches("--api-url 'http://host.docker.internal:12345'") + .count(), + 2, + "both uv-tool scans (baseline + post-install) must target the \ + test's wiremock, not the live public proxy:\n{script}" + ); + assert_eq!( + script.matches(&format!("--org {ORG}")).count(), + 2, + "both uv-tool scans must send their batch queries to the mocked \ + org endpoint:\n{script}" + ); +} + /// Returns `true` when the test should skip (docker missing, image /// missing). Prints a skip notice to stderr — the test still reports as /// `ok` because Rust integration tests have no native "skipped" outcome. diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_maven.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_maven.rs index 540a0a5c..be872a93 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_vendor_maven.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_maven.rs @@ -50,7 +50,7 @@ //! `vendor --revert` restores `pom.xml` byte-identical and removes //! `.socket/vendor` → a re-vendor succeeds again. -#![cfg(all(feature = "docker-e2e", feature = "maven"))] +#![cfg(feature = "docker-e2e")] #[path = "docker_vendor_common/mod.rs"] mod docker_vendor_common; @@ -176,8 +176,10 @@ VPOM="$LEAF/commons-text-1.10.0.pom" # fabricated minimal stand-in. grep -q 'commons-lang3' "$VPOM" || { cat "$VPOM" >&2; fail "vendored pom dropped the commons-lang3 transitive"; } # The patched marker really is inside the rebuilt jar. -rm -rf /tmp/vjx && mkdir -p /tmp/vjx && ( cd /tmp/vjx && jar xf "$OLDPWD/$VJAR" META-INF/NOTICE.txt 2>/dev/null || jar xf "$PWD/$VJAR" ) +rm -rf /tmp/vjx && mkdir -p /tmp/vjx && ( cd /tmp/vjx && jar xf "$OLDPWD/$VJAR" META-INF/NOTICE.txt 2>/dev/null || jar xf "$OLDPWD/$VJAR" ) grep -q 'SOCKET-PATCH-VENDOR-E2E-MARKER' /tmp/vjx/META-INF/NOTICE.txt || fail "rebuilt jar's NOTICE.txt is not patched" +[ "$(sha256sum /tmp/vjx/META-INF/NOTICE.txt | cut -d' ' -f1)" = "$(cat /workspace/snap/patched.sha)" ] \ + || fail "rebuilt jar's NOTICE.txt is not byte-identical to the staged patched bytes" # The sidecar matches the jar bytes (what checksumPolicy=fail validates). [ "$(sha1sum "$VJAR" | cut -d' ' -f1)" = "$(cat "$VJAR.sha1" | tr -d '[:space:]')" ] || fail "jar .sha1 sidecar does not match the jar bytes" echo "===ARTIFACT VERIFIED===" @@ -259,6 +261,8 @@ cmp -s target/dep/commons-text-1.10.0.jar "$VJAR" \ # And it really carries the patched marker. rm -rf /tmp/cjx && mkdir -p /tmp/cjx && ( cd /tmp/cjx && jar xf "/workspace/fresh/target/dep/commons-text-1.10.0.jar" META-INF/NOTICE.txt 2>/dev/null || jar xf "/workspace/fresh/target/dep/commons-text-1.10.0.jar" ) grep -q 'SOCKET-PATCH-VENDOR-E2E-MARKER' /tmp/cjx/META-INF/NOTICE.txt || fail "consumed commons-text jar is not patched" +[ "$(sha256sum /tmp/cjx/META-INF/NOTICE.txt | cut -d' ' -f1)" = "$(cat /workspace/snap/patched.sha)" ] \ + || fail "consumed NOTICE.txt is not byte-identical to the staged patched bytes" echo "===FRESH INSTALL VERIFIED===" # TAMPER PROBE: mutate the vendored jar (leaving its .sha1 stale), purge the diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_nuget.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_nuget.rs index 5a96efd3..9bcf98f4 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_vendor_nuget.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_nuget.rs @@ -30,7 +30,7 @@ //! `packages.lock.json` byte-identical, DELETES the created `nuget.config`, //! and removes `.socket/vendor` → a re-vendor succeeds again. -#![cfg(all(feature = "docker-e2e", feature = "nuget"))] +#![cfg(feature = "docker-e2e")] #[path = "docker_vendor_common/mod.rs"] mod docker_vendor_common; diff --git a/crates/socket-patch-cli/tests/docker_vendor_common/mod.rs b/crates/socket-patch-cli/tests/docker_vendor_common/mod.rs index 8fe24405..6e5637da 100644 --- a/crates/socket-patch-cli/tests/docker_vendor_common/mod.rs +++ b/crates/socket-patch-cli/tests/docker_vendor_common/mod.rs @@ -136,6 +136,9 @@ fail() { echo "FAIL: $*" >&2; exit 1; } git_blob_sha() { # git blob sha256: sha256("blob \0" + bytes) local f="$1" + # Fail up front: the hashing pipeline's exit status comes from `cut`, so a + # missing file would otherwise "succeed" with the hash of the bare header. + [ -f "$f" ] && [ -r "$f" ] || return 1 local len len=$(wc -c < "$f" | tr -d '[:space:]') { printf 'blob %s\0' "$len"; cat "$f"; } | sha256sum | cut -d' ' -f1 diff --git a/crates/socket-patch-cli/tests/docker_vendor_common_selftest.rs b/crates/socket-patch-cli/tests/docker_vendor_common_selftest.rs new file mode 100644 index 00000000..729f7783 --- /dev/null +++ b/crates/socket-patch-cli/tests/docker_vendor_common_selftest.rs @@ -0,0 +1,166 @@ +//! Host-side self-tests for the shared docker vendor harness +//! (`docker_vendor_common/mod.rs`): the bash snippets are exercised with +//! plain local bash — no docker image or `docker-e2e` feature required — so +//! the guards inside `stage_patch` stay honest. +//! +//! Why this matters: `vendor` warn-and-overwrites beforeHash mismatches +//! (`commands/vendor.rs` — `vendor_content_mismatch_overwritten`), so if +//! `stage_patch` silently records a garbage hash for a typo'd fixture path, +//! the capstone suites still green while exercising the wrong code path. +//! The `|| fail "hashing ..."` guards are the only thing standing in the +//! way, and they only work if `git_blob_sha` actually reports failure. + +use std::path::Path; +use std::process::{Command, Output, Stdio}; + +use sha2::{Digest, Sha256}; + +#[path = "docker_vendor_common/mod.rs"] +mod docker_vendor_common; + +use docker_vendor_common::{bash_prelude, stage_patch_fn}; + +/// The docker images always have coreutils `sha256sum`; macOS dev hosts may +/// only have perl `shasum`, so shim it for these local runs. +const SHA256SUM_SHIM: &str = + "command -v sha256sum >/dev/null 2>&1 || sha256sum() { shasum -a 256 \"$@\"; }\n"; + +/// A *functional* bash, not merely a spawnable one: on windows-latest +/// `Command::new("bash")` resolves to the System32 WSL stub, which spawns +/// fine with no distro installed but cannot run a script (it prints its +/// error as UTF-16 on stdout and exits non-zero) — `.status().is_ok()` +/// passed on it and the suite ran against a bash that executes nothing. +/// Probe an actual `-c` run and require the sentinel to come back. +fn has_bash() -> bool { + Command::new("bash") + .args(["-c", "echo bash-probe-ok"]) + .stderr(Stdio::null()) + .output() + .map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains("bash-probe-ok")) + .unwrap_or(false) +} + +/// Run `body` under the same prelude + stage_patch definitions the docker +/// stage scripts get, with `dir` as the project root. +fn run_stage_script(dir: &Path, body: &str) -> Output { + let script = format!( + "{}{}{}{}", + bash_prelude(), + SHA256SUM_SHIM, + stage_patch_fn(), + body + ); + Command::new("bash") + .args(["-c", &script]) + .current_dir(dir) + .output() + .expect("failed to run bash") +} + +/// Git-blob SHA-256 (`sha256("blob \0" ++ bytes)`) — the hash format +/// socket-patch records in manifests. +fn git_sha256(content: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(format!("blob {}\0", content.len()).as_bytes()); + hasher.update(content); + hex::encode(hasher.finalize()) +} + +/// A missing before-file must abort staging at the hashing guard — not +/// silently record the hash of the bare `blob \0` header (a plausible +/// 64-hex value) in the manifest and return success. +#[test] +fn stage_patch_missing_before_file_fails_at_hashing() { + if !has_bash() { + eprintln!("skipping: bash not on PATH"); + return; + } + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("after.txt"), b"patched\n").unwrap(); + let out = run_stage_script( + dir.path(), + "stage_patch 'pkg:npm/x@1.0.0' uuid-1 index.js ./missing-before.txt ./after.txt\n", + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success(), + "stage_patch must fail when the before-file is missing\nstderr=\n{stderr}" + ); + assert!( + stderr.contains("FAIL: hashing ./missing-before.txt"), + "stage_patch must fail at the hashing guard\nstderr=\n{stderr}" + ); + assert!( + !dir.path().join(".socket/manifest.json").exists(), + "no manifest may be written after a hashing failure" + ); +} + +/// Same guard for the after-file: it must trip at hashing, not limp on to +/// the `cp` with a garbage blob name. +#[test] +fn stage_patch_missing_after_file_fails_at_hashing() { + if !has_bash() { + eprintln!("skipping: bash not on PATH"); + return; + } + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("before.txt"), b"original\n").unwrap(); + let out = run_stage_script( + dir.path(), + "stage_patch 'pkg:npm/x@1.0.0' uuid-1 index.js ./before.txt ./missing-after.txt\n", + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success(), + "stage_patch must fail when the after-file is missing\nstderr=\n{stderr}" + ); + assert!( + stderr.contains("FAIL: hashing ./missing-after.txt"), + "stage_patch must fail at the hashing guard\nstderr=\n{stderr}" + ); +} + +/// Happy path: the manifest must carry the exact git-blob SHA-256s the +/// socket-patch binary computes (NUL byte in the `blob \0` header +/// included), the after-blob must be staged under its hash, and the +/// optional ghsa/cve pair must land in `vulnerabilities`. +#[test] +fn stage_patch_records_git_blob_sha256_and_stages_blob() { + if !has_bash() { + eprintln!("skipping: bash not on PATH"); + return; + } + let before: &[u8] = b"original content\n"; + let after: &[u8] = b"patched content\n"; + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("before.txt"), before).unwrap(); + std::fs::write(dir.path().join("after.txt"), after).unwrap(); + let out = run_stage_script( + dir.path(), + "stage_patch 'pkg:npm/x@1.0.0' uuid-1 package/index.js ./before.txt ./after.txt \ + GHSA-xxxx-yyyy-zzzz CVE-2024-99999\n", + ); + assert!( + out.status.success(), + "stage_patch failed\nstdout=\n{}\nstderr=\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(dir.path().join(".socket/manifest.json")).unwrap(), + ) + .expect("stage_patch wrote invalid JSON"); + let patch = &manifest["patches"]["pkg:npm/x@1.0.0"]; + let files = &patch["files"]["package/index.js"]; + assert_eq!(files["beforeHash"], git_sha256(before).as_str()); + assert_eq!(files["afterHash"], git_sha256(after).as_str()); + assert_eq!( + patch["vulnerabilities"]["GHSA-xxxx-yyyy-zzzz"]["cves"], + serde_json::json!(["CVE-2024-99999"]) + ); + + let blob = dir.path().join(".socket/blobs").join(git_sha256(after)); + assert_eq!(std::fs::read(&blob).unwrap(), after, "staged blob bytes"); +} diff --git a/crates/socket-patch-cli/tests/e2e_cargo.rs b/crates/socket-patch-cli/tests/e2e_cargo.rs index d5a497d5..ae574ce4 100644 --- a/crates/socket-patch-cli/tests/e2e_cargo.rs +++ b/crates/socket-patch-cli/tests/e2e_cargo.rs @@ -1,18 +1,25 @@ -#![cfg(feature = "cargo")] //! End-to-end tests for the Cargo/Rust crate patching lifecycle. //! //! These tests exercise crawling against a temporary directory with a fake //! Cargo registry layout. They do **not** require network access or a real -//! Cargo installation. +//! Cargo installation: the scan's patch lookup is pinned to an in-test +//! [`wiremock`] public-proxy stand-in via `--proxy-url`. That pinning is +//! load-bearing, not cosmetic — since the all-batches-failed fix, an +//! unreachable API is a hard scan failure (exit 1, `status: "error"`), so an +//! unpinned scan would phone home to the live proxy on every test run and go +//! red whenever the network (or an ambient `SOCKET_*` variable) misbehaved. //! //! # Running //! ```sh -//! cargo test -p socket-patch-cli --features cargo --test e2e_cargo +//! cargo test -p socket-patch-cli --test e2e_cargo //! ``` -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Output}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -21,13 +28,51 @@ fn binary() -> PathBuf { env!("CARGO_BIN_EXE_socket-patch").into() } -fn run(args: &[&str], cwd: &std::path::Path) -> Output { - Command::new(binary()) - .args(args) - .current_dir(cwd) - .env("CARGO_HOME", cwd.join(".cargo")) - .output() - .expect("Failed to run socket-patch binary") +/// Start a mock Socket public proxy answering the scan's `POST /patch/batch` +/// with an empty (no-patch) result, so no scan in this file ever leaves +/// localhost. +async fn start_proxy() -> MockServer { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/patch/batch")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + server +} + +/// Run the binary as a blocking subprocess (off the async runtime so the +/// in-test proxy can service its requests concurrently), pinned to `proxy_url`. +/// +/// `SOCKET_API_TOKEN` is stripped so the binary deterministically takes the +/// public-proxy path (an ambient token would flip it onto the authenticated +/// API, bypassing `--proxy-url`), and every other variable that could +/// redirect the API elsewhere or disable it is scrubbed so an ambient value +/// can't quietly change what the scan reports. +async fn run(args: &[&str], cwd: &Path, proxy_url: &str) -> Output { + let mut args: Vec = args.iter().map(|s| s.to_string()).collect(); + args.extend(["--proxy-url".to_string(), proxy_url.to_string()]); + let cwd = cwd.to_path_buf(); + tokio::task::spawn_blocking(move || { + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + Command::new(binary()) + .args(&arg_refs) + .current_dir(&cwd) + .env("CARGO_HOME", cwd.join(".cargo")) + .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_API_URL") + .env_remove("SOCKET_OFFLINE") + .env_remove("SOCKET_PROXY_URL") + .env_remove("SOCKET_PATCH_PROXY_URL") + .env_remove("SOCKET_BATCH_SIZE") + .output() + .expect("Failed to run socket-patch binary") + }) + .await + .expect("socket-patch subprocess task panicked") } /// Run `socket-patch scan --json ...`, assert the process succeeded, and @@ -35,10 +80,16 @@ fn run(args: &[&str], cwd: &std::path::Path) -> Output { /// /// Parsing (rather than substring matching) means a malformed or missing /// envelope fails the test loudly instead of slipping past a `.contains()` -/// check. Doing this offline is safe: the package *count* is derived from the -/// local crawl and is emitted regardless of whether the API query succeeds. -fn scan_json(cwd: &std::path::Path) -> serde_json::Value { - let output = run(&["scan", "--json", "--cwd", cwd.to_str().unwrap()], cwd); +/// check. The package *count* is derived from the local crawl; the patch +/// lookup is served by the in-test proxy, so the exit-0 / status=success +/// assertions hold without live network access. +async fn scan_json(cwd: &Path, proxy_url: &str) -> serde_json::Value { + let output = run( + &["scan", "--json", "--cwd", cwd.to_str().unwrap()], + cwd, + proxy_url, + ) + .await; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -58,14 +109,31 @@ fn scan_json(cwd: &std::path::Path) -> serde_json::Value { value } +/// Regression guard for the hermeticity fix: every scan in a test must have +/// routed its patch lookup through the in-test proxy. Fewer recorded requests +/// than scans means at least one binary invocation talked to the live API (or +/// skipped the lookup outright) despite the pinning — exactly the bug this +/// file used to have. +async fn assert_proxy_served_scans(server: &MockServer, scans: usize) { + let requests = server.received_requests().await.unwrap_or_default(); + assert!( + requests.len() >= scans, + "expected all {scans} scan invocations to hit the in-test proxy; \ + recorded only {} request(s)", + requests.len() + ); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- /// Verify that `socket-patch scan` discovers crates in a registry-cache layout /// (`$CARGO_HOME/registry/src/index.crates.io-*/-/`). -#[test] -fn scan_discovers_fake_registry_crates() { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn scan_discovers_fake_registry_crates() { + let server = start_proxy().await; + let proxy_url = server.uri(); let dir = tempfile::tempdir().unwrap(); // The crawler only falls back to scanning the global `$CARGO_HOME` @@ -108,7 +176,7 @@ fn scan_discovers_fake_registry_crates() { .unwrap(); // --- JSON path: assert the exact discovered count, not just "non-zero". - let json = scan_json(dir.path()); + let json = scan_json(dir.path(), &proxy_url).await; assert_eq!( json["scannedPackages"], 2, "scan must discover exactly the two registry crates (serde + tokio); got:\n{json:#}" @@ -119,7 +187,12 @@ fn scan_discovers_fake_registry_crates() { // what found them. This also guards against the old loophole where the // failure message "No packages found" satisfied a `contains("packages")` // check. - let output = run(&["scan", "--cwd", dir.path().to_str().unwrap()], dir.path()); + let output = run( + &["scan", "--cwd", dir.path().to_str().unwrap()], + dir.path(), + &proxy_url, + ) + .await; let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let combined = format!("{stdout}{stderr}"); @@ -138,12 +211,16 @@ fn scan_discovers_fake_registry_crates() { !combined.contains("No packages found"), "scan reported no packages despite a populated registry:\n{combined}" ); + + assert_proxy_served_scans(&server, 2).await; } /// Verify that `socket-patch scan` discovers crates in a vendor layout /// (`/vendor//`). -#[test] -fn scan_discovers_vendor_crates() { +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn scan_discovers_vendor_crates() { + let server = start_proxy().await; + let proxy_url = server.uri(); let dir = tempfile::tempdir().unwrap(); // A bare `vendor/` dir is not cargo-specific; the crawler only treats it as @@ -163,7 +240,7 @@ fn scan_discovers_vendor_crates() { .unwrap(); // --- JSON path: exactly one vendored crate must be discovered. - let json = scan_json(dir.path()); + let json = scan_json(dir.path(), &proxy_url).await; assert_eq!( json["scannedPackages"], 1, "scan must discover exactly the one vendored crate (serde); got:\n{json:#}" @@ -171,7 +248,12 @@ fn scan_discovers_vendor_crates() { // --- Human path: the discovery must be attributed to the cargo ecosystem, // and must NOT report "No packages found" (the old loophole). - let output = run(&["scan", "--cwd", dir.path().to_str().unwrap()], dir.path()); + let output = run( + &["scan", "--cwd", dir.path().to_str().unwrap()], + dir.path(), + &proxy_url, + ) + .await; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let combined = format!("{stdout}{stderr}"); @@ -186,4 +268,6 @@ fn scan_discovers_vendor_crates() { !combined.contains("No packages found"), "scan reported no packages despite a populated vendor dir:\n{combined}" ); + + assert_proxy_served_scans(&server, 2).await; } diff --git a/crates/socket-patch-cli/tests/e2e_composer.rs b/crates/socket-patch-cli/tests/e2e_composer.rs index c8da0975..178e97ba 100644 --- a/crates/socket-patch-cli/tests/e2e_composer.rs +++ b/crates/socket-patch-cli/tests/e2e_composer.rs @@ -1,4 +1,3 @@ -#![cfg(feature = "composer")] //! End-to-end tests for the Composer/PHP package patching lifecycle. //! //! These tests exercise crawling against a temporary directory with a fake @@ -7,7 +6,7 @@ //! //! # Running //! ```sh -//! cargo test -p socket-patch-cli --features composer --test e2e_composer +//! cargo test -p socket-patch-cli --test e2e_composer //! ``` use std::path::PathBuf; diff --git a/crates/socket-patch-cli/tests/e2e_embedded_vex.rs b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs index 610d94e5..077832f5 100644 --- a/crates/socket-patch-cli/tests/e2e_embedded_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs @@ -1,5 +1,5 @@ //! End-to-end tests for embedded OpenVEX generation via `--vex` on the -//! `apply` and `scan` subcommands. +//! `apply`, `scan`, and `vendor` subcommands. //! //! These exercise the *integration* added on top of the core `vex` //! pipeline (which `e2e_vex.rs` already covers): that a successful @@ -323,6 +323,61 @@ fn apply_json_envelope_carries_vex_summary() { ); } +/// `--dry-run` applies nothing, so embedded VEX generation must be +/// skipped entirely. Before the fix, VEX ran anyway and verified the +/// deliberately-unapplied tree: every patch classified `not_applied`, +/// `build_document` produced nothing, and the whole command exited 1 +/// with `no_applicable_patches` even though the dry-run verification +/// itself succeeded. A dry run must exit 0, report no `vex` summary, +/// and never write an attestation file. +#[test] +fn apply_dry_run_skips_embedded_vex() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + seed_offline_apply(cwd); + let vex_path = cwd.join("apply.vex.json"); + + let out = cli() + .args([ + "apply", + "--cwd", + cwd.to_str().unwrap(), + "--offline", + "--dry-run", + "--json", + "--vex", + vex_path.to_str().unwrap(), + "--vex-product", + "pkg:npm/my-app@1.0.0", + ]) + .output() + .expect("invoke apply"); + assert!( + out.status.success(), + "a clean dry run must exit 0 even with --vex requested. stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + + let env: Value = serde_json::from_slice(&out.stdout).expect("apply envelope JSON"); + assert_eq!(env["command"], "apply"); + assert_eq!(env["dryRun"], true); + assert_eq!(env["status"], "success"); + assert!( + env["vex"].is_null(), + "no vex summary may be reported on a dry run, got {:?}", + env["vex"] + ); + assert!( + !vex_path.exists(), + "a dry run must not write a VEX document" + ); + + // And the dry run must not have touched the package. + let on_disk = std::fs::read(cwd.join("node_modules/vuln-pkg/index.js")).unwrap(); + assert_eq!(on_disk, b"before contents\n"); +} + #[test] fn apply_vex_failure_flips_exit_code() { // Apply succeeds, but no product PURL can be detected (no root @@ -360,6 +415,72 @@ fn apply_vex_failure_flips_exit_code() { assert_eq!(&on_disk, b"after contents\n"); } +/// `--silent` means "errors only", never "nothing" (CLI_CONTRACT): a +/// requested-but-failed VEX still exits 1, and the failure message must +/// reach stderr. Regression guard: the human-readable VEX status block +/// gated ALL of its arms — the error arm included — on `!silent`, so +/// `apply --silent --vex out.json` on a VEX failure exited 1 with zero +/// diagnostic output. Same fixture as `apply_vex_failure_flips_exit_code`: +/// apply succeeds offline, then product detection fails (no root +/// package.json / git remote, no `--vex-product`). +#[test] +fn apply_silent_vex_failure_keeps_error_output() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + seed_offline_apply(cwd); + let vex_path = cwd.join("apply.vex.json"); + + let out = cli() + .args([ + "apply", + "--cwd", + cwd.to_str().unwrap(), + "--offline", + "--silent", + "--vex", + vex_path.to_str().unwrap(), + ]) + .output() + .expect("invoke apply"); + assert!( + !out.status.success(), + "a requested-but-failed VEX must flip the exit code even under --silent" + ); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stdout.trim().is_empty(), + "--silent must produce no stdout; got {stdout:?}" + ); + assert!( + stderr.contains("VEX generation failed"), + "--silent must NOT suppress the VEX failure message; got {stderr:?}" + ); + + // Control run: the same failure WITHOUT --silent must print the same + // error (re-running on the patched tree is still a successful apply — + // "already patched" — so VEX still runs and still fails) — otherwise + // the assertion above could pass against a message that never prints + // for anyone. + let loud = cli() + .args([ + "apply", + "--cwd", + cwd.to_str().unwrap(), + "--offline", + "--vex", + vex_path.to_str().unwrap(), + ]) + .output() + .expect("invoke apply"); + assert!(!loud.status.success()); + let loud_stderr = String::from_utf8_lossy(&loud.stderr); + assert!( + loud_stderr.contains("VEX generation failed"), + "non-silent VEX failure must print the error; got {loud_stderr:?}" + ); +} + // ────────────────────────────────────────────────────────────────────── // scan --vex (read-only; zero installed packages → no network) // ────────────────────────────────────────────────────────────────────── @@ -485,3 +606,82 @@ fn scan_json_vex_verify_failure_is_error() { assert_eq!(result["error"]["code"], "no_applicable_patches"); assert!(!vex_path.exists()); } + +// ────────────────────────────────────────────────────────────────────── +// vendor --vex (same fail-the-command contract as apply --vex) +// ────────────────────────────────────────────────────────────────────── + +/// `vendor --vex` shares apply's fail-the-command contract, but its +/// human-readable mode dropped the VEX outcome entirely: the `Err` arm +/// only marked the JSON envelope — which prints solely under `--json` — +/// so a requested-but-failed VEX flipped the exit from 0 to 1 without a +/// word of diagnosis, with or without `--silent`. Offline fixture: an +/// empty-patches manifest makes vendor itself succeed ("no vendorable +/// patches in scope") and VEX generation then fail deterministically +/// (`no_patches`) before touching the network. +#[test] +fn vendor_human_vex_failure_prints_error() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_manifest(cwd, &PatchManifest::new()); + let vex_path = cwd.join("vendor.vex.json"); + + // Control: without --vex this exact fixture exits 0 — the failure + // asserted below is introduced by the VEX side-effect alone. + let base = cli() + .args(["vendor", "--cwd", cwd.to_str().unwrap(), "--offline"]) + .output() + .expect("invoke vendor"); + assert!( + base.status.success(), + "vendor without --vex must exit 0 on an empty manifest. stderr:\n{}", + String::from_utf8_lossy(&base.stderr) + ); + + let out = cli() + .args([ + "vendor", + "--cwd", + cwd.to_str().unwrap(), + "--offline", + "--vex", + vex_path.to_str().unwrap(), + "--vex-product", + "pkg:npm/my-app@1.0.0", + ]) + .output() + .expect("invoke vendor"); + assert!( + !out.status.success(), + "a requested-but-failed VEX must flip vendor's exit code" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("VEX generation failed"), + "human mode must print the VEX failure, got {stderr:?}" + ); + assert!(!vex_path.exists(), "no VEX file on failure"); + + // And under --silent the error must survive ("errors only", never + // "nothing"). + let silent = cli() + .args([ + "vendor", + "--cwd", + cwd.to_str().unwrap(), + "--offline", + "--silent", + "--vex", + vex_path.to_str().unwrap(), + "--vex-product", + "pkg:npm/my-app@1.0.0", + ]) + .output() + .expect("invoke vendor"); + assert!(!silent.status.success()); + let silent_stderr = String::from_utf8_lossy(&silent.stderr); + assert!( + silent_stderr.contains("VEX generation failed"), + "--silent must NOT suppress the VEX failure message; got {silent_stderr:?}" + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_gem.rs b/crates/socket-patch-cli/tests/e2e_gem.rs index 0e2f995d..366dc964 100644 --- a/crates/socket-patch-cli/tests/e2e_gem.rs +++ b/crates/socket-patch-cli/tests/e2e_gem.rs @@ -17,7 +17,6 @@ //! cargo test -p socket-patch-cli --test e2e_gem -- --ignored //! ``` -use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -135,21 +134,6 @@ fn read_patch_files(manifest_path: &Path) -> serde_json::Value { patch["files"].clone() } -/// Record hashes of all files in the gem dir that will be patched. -fn record_original_hashes(gem_dir: &Path, files: &serde_json::Value) -> HashMap { - let mut hashes = HashMap::new(); - for (rel_path, _) in files.as_object().expect("files object") { - let full_path = gem_dir.join(rel_path); - let hash = if full_path.exists() { - git_sha256_file(&full_path) - } else { - String::new() - }; - hashes.insert(rel_path.clone(), hash); - } - hashes -} - /// Verify all patched files match their afterHash from the manifest. fn assert_after_hashes(gem_dir: &Path, files: &serde_json::Value) { for (rel_path, info) in files.as_object().expect("files object") { @@ -190,21 +174,49 @@ fn assert_before_hashes(gem_dir: &Path, files: &serde_json::Value) { } } -/// Verify files match the originally recorded hashes. -fn assert_original_hashes(gem_dir: &Path, original_hashes: &HashMap) { - for (rel_path, orig_hash) in original_hashes { - if orig_hash.is_empty() { - continue; - } - let full_path = gem_dir.join(rel_path); - if full_path.exists() { - assert_eq!( - git_sha256_file(&full_path), - *orig_hash, - "{rel_path} should match original hash" - ); - } - } +/// The "files are not patched" oracle used by `test_gem_dry_run` / +/// `test_gem_save_only` (after `get --no-apply` / `get --save-only`) must +/// FAIL when the gem is actually in the applied state — otherwise a `get` +/// that wrongly applies sails through the whole test. Hermetic stand-in for +/// that masked regression: a gem dir whose files carry afterHash content +/// plus a patch-created file, checked with the exact oracle those tests run. +#[test] +fn not_patched_oracle_catches_applied_state() { + let dir = tempfile::tempdir().unwrap(); + let gem_dir = dir.path().to_path_buf(); + + let files = serde_json::json!({ + "lib/modified.rb": { + "beforeHash": git_sha256(b"original content\n"), + "afterHash": git_sha256(b"patched content\n"), + }, + "lib/created.rb": { + "beforeHash": "", + "afterHash": git_sha256(b"new file\n"), + }, + }); + + // Applied state: modified file has afterHash content, created file exists. + std::fs::create_dir_all(gem_dir.join("lib")).unwrap(); + std::fs::write(gem_dir.join("lib/modified.rb"), b"patched content\n").unwrap(); + std::fs::write(gem_dir.join("lib/created.rb"), b"new file\n").unwrap(); + + let oracle = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // The exact not-patched oracle the lifecycle tests run. It must be + // anchored to the manifest's beforeHash, not a snapshot taken after + // the command under test (which is vacuously self-consistent). + assert_before_hashes(&gem_dir, &files); + })); + assert!( + oracle.is_err(), + "the not-patched oracle passed on a fully applied gem — it cannot \ + catch a `get --no-apply`/`--save-only` that wrongly applies" + ); + + // And it must PASS on the pristine state (no false failures). + std::fs::write(gem_dir.join("lib/modified.rb"), b"original content\n").unwrap(); + std::fs::remove_file(gem_dir.join("lib/created.rb")).unwrap(); + assert_before_hashes(&gem_dir, &files); } // --------------------------------------------------------------------------- @@ -533,14 +545,16 @@ fn test_gem_dry_run() { // Read manifest to get file list and expected hashes. let manifest_path = cwd.join(".socket/manifest.json"); let files = read_patch_files(&manifest_path); - let original_hashes = record_original_hashes(&gem_dir, &files); - // Files should still be original (not patched). - assert_original_hashes(&gem_dir, &original_hashes); + // Files should still be original (not patched) — checked against the + // manifest's beforeHash, an oracle independent of the current disk + // state (a snapshot taken after `get --no-apply` would pass even if + // the flag regressed and applied). + assert_before_hashes(&gem_dir, &files); // Dry-run should succeed but leave files untouched. assert_run_ok(cwd, &["apply", "--dry-run"], "apply --dry-run"); - assert_original_hashes(&gem_dir, &original_hashes); + assert_before_hashes(&gem_dir, &files); // Real apply should work. assert_run_ok(cwd, &["apply"], "apply"); @@ -570,10 +584,10 @@ fn test_gem_save_only() { // Read manifest to get file list and expected hashes. let manifest_path = cwd.join(".socket/manifest.json"); let files = read_patch_files(&manifest_path); - let original_hashes = record_original_hashes(&gem_dir, &files); - // Files should still be original (not patched). - assert_original_hashes(&gem_dir, &original_hashes); + // Files should still be original (not patched) — checked against the + // manifest's beforeHash, independent of the current disk state. + assert_before_hashes(&gem_dir, &files); let manifest: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap(); diff --git a/crates/socket-patch-cli/tests/e2e_golang.rs b/crates/socket-patch-cli/tests/e2e_golang.rs index 83b41e15..497e9772 100644 --- a/crates/socket-patch-cli/tests/e2e_golang.rs +++ b/crates/socket-patch-cli/tests/e2e_golang.rs @@ -1,4 +1,3 @@ -#![cfg(feature = "golang")] //! End-to-end tests for the Go module patching lifecycle. //! //! These tests exercise crawling against a temporary directory with a fake @@ -13,7 +12,7 @@ //! //! # Running //! ```sh -//! cargo test -p socket-patch-cli --features golang --test e2e_golang +//! cargo test -p socket-patch-cli --test e2e_golang //! ``` use std::collections::BTreeSet; @@ -73,6 +72,27 @@ async fn run(args: &[&str], cwd: &Path, gomodcache: &Path, api_url: &str) -> Out .env("SOCKET_API_URL", &api_url) .env("SOCKET_API_TOKEN", "sktsec_dummy_e2e_golang_token_api") .env("SOCKET_ORG_SLUG", ORG) + // Ambient-pollution regression guard: seed a hostile value for + // each scan-affecting variable, then scrub it below. `env_remove` + // clears explicitly-set values too, so the child never sees the + // seeds — but if a scrub line is ever dropped, the seed (rather + // than a developer's ambient shell, which this suite can't rely + // on) turns the tests red immediately. Each seed was verified to + // break the suite when inherited: `SOCKET_ECOSYSTEMS` filters the + // go crawler out entirely, `SOCKET_GLOBAL` / `SOCKET_GLOBAL_PREFIX` + // redirect the crawl away from the pinned `GOMODCACHE`, and + // `SOCKET_JSON` / `SOCKET_SILENT` replace or suppress the human + // output the tests assert on. + .env("SOCKET_ECOSYSTEMS", "npm") + .env("SOCKET_GLOBAL", "true") + .env("SOCKET_GLOBAL_PREFIX", "/nonexistent") + .env("SOCKET_JSON", "true") + .env("SOCKET_SILENT", "true") + .env_remove("SOCKET_ECOSYSTEMS") + .env_remove("SOCKET_GLOBAL") + .env_remove("SOCKET_GLOBAL_PREFIX") + .env_remove("SOCKET_JSON") + .env_remove("SOCKET_SILENT") .env_remove("GOPATH") .env_remove("SOCKET_OFFLINE") .env_remove("SOCKET_PROXY_URL") diff --git a/crates/socket-patch-cli/tests/e2e_golang_build.rs b/crates/socket-patch-cli/tests/e2e_golang_build.rs index ab9ed600..f08a5cb3 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_build.rs @@ -1,4 +1,4 @@ -#![cfg(all(unix, feature = "golang"))] +#![cfg(unix)] //! Full go-toolchain capstone for the Go `replace`-redirect: proves the patched //! bytes are actually LINKED by `go build`, and that the read-only //! `apply --check` redirect auditor detects drift in the committed copy. @@ -19,7 +19,7 @@ use std::process::Command; #[path = "common/mod.rs"] mod common; -use common::{git_sha256, has_command, run_with_env}; +use common::{binary, git_sha256, has_command}; const UMOD: &str = "example.com/upstream"; const UVER: &str = "v1.0.0"; @@ -28,15 +28,41 @@ const PRISTINE_LIB: &str = "package upstream\n\nfunc Greeting() string { return const PATCHED_LIB: &str = "package upstream\n\nfunc Greeting() string { return \"PATCHED\" }\n"; /// Env for every `go` invocation: hermetic file-proxy + temp cache, sums off. +/// `GOTOOLCHAIN=local` keeps the installed toolchain from trying to download +/// a different one — an ambient `GOTOOLCHAIN` pin would otherwise send every +/// `go` command chasing a toolchain the file proxy can't serve. fn go_env<'a>(modcache: &'a str, proxy_url: &'a str) -> Vec<(&'a str, &'a str)> { vec![ ("GOMODCACHE", modcache), ("GOPROXY", proxy_url), ("GOSUMDB", "off"), ("GOFLAGS", "-mod=mod"), + ("GOTOOLCHAIN", "local"), ] } +/// Run socket-patch with ambient `SOCKET_*` scrubbed + the fixture GOMODCACHE +/// (the go crawler resolves installed modules through it). Every global flag +/// is env-backed (`SOCKET_DRY_RUN`, `SOCKET_GLOBAL`, `SOCKET_MANIFEST_PATH`, +/// …), so an unscrubbed ambient value would silently reconfigure `apply` / +/// `--check` out from under the assertions. +fn run_socket(cwd: &Path, args: &[&str], modcache: &Path) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + cmd.env("GOMODCACHE", modcache); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + fn go(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> std::process::Output { let mut cmd = Command::new("go"); cmd.args(args).current_dir(dir); @@ -156,6 +182,14 @@ fn go_build_links_patch_via_replace_redirect() { eprintln!("skipping e2e_golang_build: `go`/`zip` not installed"); return; } + // RED guards for the hermeticity pins: bake the hostile ambient values in + // so this suite fails deterministically if either leak returns. + // `GOTOOLCHAIN` must lose to go_env's `local` pin (or every `go` command + // chases a nonexistent toolchain through the file proxy); `SOCKET_DRY_RUN` + // must be scrubbed by `run_socket` (or every apply is a no-op that still + // exits 0 and the patched-symbol assert sees PRISTINE). + std::env::set_var("GOTOOLCHAIN", "go1.99.99"); + std::env::set_var("SOCKET_DRY_RUN", "true"); let tmp = tempfile::tempdir().unwrap(); let (consumer, modcache, proxy_url) = stage(tmp.path()); let cs = consumer.to_str().unwrap(); @@ -174,10 +208,10 @@ fn go_build_links_patch_via_replace_redirect() { // Patch + apply (socket-patch reads only the cache; no `go`). This writes the // project-local copy under `.socket/go-patches/` and the `go.mod` `replace`. write_patch(&consumer); - let (code, so, se) = run_with_env( + let (code, so, se) = run_socket( &consumer, &["apply", "--offline", "--ecosystems", "golang", "--cwd", cs], - &[("GOMODCACHE", mc)], + &modcache, ); assert_eq!(code, 0, "apply failed.\n{so}\n{se}"); @@ -196,10 +230,10 @@ fn go_build_links_patch_via_replace_redirect() { // `apply --check` (read-only redirect auditor) reports the committed // redirect as in sync. - let (code, _so, _se) = run_with_env( + let (code, _so, _se) = run_socket( &consumer, &["apply", "--check", "--ecosystems", "golang", "--cwd", cs], - &[("GOMODCACHE", mc)], + &modcache, ); assert_eq!(code, 0, "apply --check should be in sync after apply"); @@ -217,10 +251,10 @@ fn go_build_links_patch_via_replace_redirect() { "package upstream\n\nfunc Greeting() string { return \"DRIFT\" }\n", ) .unwrap(); - let (code, _so, _se) = run_with_env( + let (code, _so, _se) = run_socket( &consumer, &["apply", "--check", "--ecosystems", "golang", "--cwd", cs], - &[("GOMODCACHE", mc)], + &modcache, ); assert_ne!( code, 0, @@ -228,10 +262,10 @@ fn go_build_links_patch_via_replace_redirect() { ); // A fresh `apply` re-materialises the copy and `go build` links PATCHED again. - let (code, _so, _se) = run_with_env( + let (code, _so, _se) = run_socket( &consumer, &["apply", "--offline", "--ecosystems", "golang", "--cwd", cs], - &[("GOMODCACHE", mc)], + &modcache, ); assert_eq!(code, 0, "re-apply should heal the drifted copy"); let healed = go(&consumer, &["run", "."], &goenv); diff --git a/crates/socket-patch-cli/tests/e2e_golang_redirect.rs b/crates/socket-patch-cli/tests/e2e_golang_redirect.rs index c59d21f2..d861f3e2 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_redirect.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_redirect.rs @@ -1,4 +1,4 @@ -#![cfg(all(unix, feature = "golang"))] +#![cfg(unix)] //! End-to-end for the Go `replace`-redirect backend, driven through the CLI //! binary. No `go` toolchain needed: `apply`/`--check` only read a pristine //! extracted module-cache dir and write project-local copies + a `go.mod` @@ -15,9 +15,7 @@ use std::path::Path; #[path = "common/mod.rs"] mod common; -use common::{ - git_sha256, git_sha256_file, run_with_env, write_blob, write_minimal_manifest, PatchEntry, -}; +use common::{binary, git_sha256, git_sha256_file, write_blob, write_minimal_manifest, PatchEntry}; const MODULE: &str = "github.com/foo/bar"; const VERSION: &str = "v1.4.2"; @@ -30,7 +28,7 @@ const REPLACE_LINE: &str = "replace github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2"; /// Stage a fake extracted module-cache dir + a consumer go.mod + the synthetic -/// patch manifest/blob. Returns (root, gomodcache, cache_src_file). +/// patch manifest/blob. Returns (gomodcache, cache_dir). fn stage(root: &Path) -> (std::path::PathBuf, std::path::PathBuf) { // Fake GOMODCACHE with the pristine extracted module. let gomodcache = root.join("modcache"); @@ -67,9 +65,47 @@ fn stage(root: &Path) -> (std::path::PathBuf, std::path::PathBuf) { (gomodcache, cache_dir) } +/// Run the CLI binary with the environment pinned hard (mirrors the +/// seed-then-scrub runner in `e2e_golang.rs`). Each variable below was +/// verified to break this suite when inherited from the ambient shell, so it +/// is seeded with a hostile value and then scrubbed — `env_remove` clears the +/// seed too, so the child never sees it, but if a scrub line is ever dropped +/// the seed (rather than a developer's ambient shell, which this suite can't +/// rely on) turns the tests red immediately. `SOCKET_GLOBAL` / +/// `SOCKET_GLOBAL_PREFIX` take the redirect backend out of local scope +/// (apply patches the fake module cache IN PLACE and `--check` exits 0 +/// having checked nothing), `SOCKET_DRY_RUN` makes every apply a no-op, and +/// `SOCKET_MANIFEST_PATH` points apply/check at a manifest that isn't there. +/// `--offline`, `--ecosystems`, and `--cwd` are passed as flags, which +/// outrank env, so their env twins can't bite; `GOMODCACHE` pins the crawl +/// root (so `GOPATH` is never consulted). +fn run_cli(root: &Path, gomodcache: &Path, args: &[&str]) -> (i32, String, String) { + let out = std::process::Command::new(binary()) + .args(args) + .current_dir(root) + .env("GOMODCACHE", gomodcache) + .env("SOCKET_GLOBAL", "true") + .env("SOCKET_GLOBAL_PREFIX", "/nonexistent") + .env("SOCKET_DRY_RUN", "true") + .env("SOCKET_MANIFEST_PATH", "/nonexistent/manifest.json") + .env_remove("SOCKET_GLOBAL") + .env_remove("SOCKET_GLOBAL_PREFIX") + .env_remove("SOCKET_DRY_RUN") + .env_remove("SOCKET_MANIFEST_PATH") + .env_remove("SOCKET_API_TOKEN") + .output() + .expect("failed to execute socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) +} + fn apply(root: &Path, gomodcache: &Path) -> (i32, String, String) { - run_with_env( + run_cli( root, + gomodcache, &[ "apply", "--offline", @@ -78,13 +114,13 @@ fn apply(root: &Path, gomodcache: &Path) -> (i32, String, String) { "--cwd", root.to_str().unwrap(), ], - &[("GOMODCACHE", gomodcache.to_str().unwrap())], ) } fn check(root: &Path, gomodcache: &Path) -> i32 { - run_with_env( + run_cli( root, + gomodcache, &[ "apply", "--check", @@ -94,7 +130,6 @@ fn check(root: &Path, gomodcache: &Path) -> i32 { "--cwd", root.to_str().unwrap(), ], - &[("GOMODCACHE", gomodcache.to_str().unwrap())], ) .0 } diff --git a/crates/socket-patch-cli/tests/e2e_maven.rs b/crates/socket-patch-cli/tests/e2e_maven.rs index ba081721..4e0cb76e 100644 --- a/crates/socket-patch-cli/tests/e2e_maven.rs +++ b/crates/socket-patch-cli/tests/e2e_maven.rs @@ -1,18 +1,25 @@ -#![cfg(feature = "maven")] //! End-to-end tests for the Maven/Java package patching lifecycle. //! //! These tests exercise crawling against a temporary directory with a fake //! Maven local repository layout. They do **not** require network access or a -//! real Maven/Java installation. +//! real Maven/Java installation: the scan's patch lookup is pinned to an +//! in-test [`wiremock`] public-proxy stand-in via `--proxy-url`. That pinning +//! is load-bearing, not cosmetic — since the all-batches-failed fix, an +//! unreachable API is a hard scan failure (exit 1, `status: "error"`), so an +//! unpinned scan would phone home to the live proxy on every test run and go +//! red whenever the network (or an ambient `SOCKET_*` variable) misbehaved. //! //! # Running //! ```sh -//! cargo test -p socket-patch-cli --features maven --test e2e_maven +//! cargo test -p socket-patch-cli --test e2e_maven -- --ignored //! ``` -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Output}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -21,24 +28,77 @@ fn binary() -> PathBuf { env!("CARGO_BIN_EXE_socket-patch").into() } -fn run(args: &[&str], cwd: &std::path::Path, m2_repo: &std::path::Path) -> Output { - Command::new(binary()) - .args(args) - .current_dir(cwd) - // Point the crawler at the fake local repo. - .env("MAVEN_REPO_LOCAL", m2_repo) - // The Maven crawler is gated behind a runtime opt-in - // (`maven_runtime_enabled` in ecosystem_dispatch.rs); without - // this the crawl short-circuits to zero packages and the scan - // prints "No packages found." These tests are named for Maven - // *discovery*, so they must enable the real crawl path — otherwise - // they only ever exercise the disabled stub and pass vacuously. - .env("SOCKET_EXPERIMENTAL_MAVEN", "1") - // Keep the run hermetic: no ambient token, no inherited repo path. - .env_remove("SOCKET_API_TOKEN") - .env_remove("M2_HOME") - .output() - .expect("Failed to run socket-patch binary") +/// Start a mock Socket public proxy answering the scan's `POST /patch/batch` +/// with an empty (no-patch) result, so no scan in this file ever leaves +/// localhost. +async fn start_proxy() -> MockServer { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/patch/batch")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + server +} + +/// Run the binary as a blocking subprocess (off the async runtime so the +/// in-test proxy can service its requests concurrently), pinned to `proxy_url`. +/// +/// `SOCKET_API_TOKEN` is stripped so the binary deterministically takes the +/// public-proxy path (an ambient token would flip it onto the authenticated +/// API, bypassing `--proxy-url`), and every other variable that could +/// redirect the API elsewhere or disable it is scrubbed so an ambient value +/// can't quietly change what the scan reports. +async fn run(args: &[&str], cwd: &Path, m2_repo: &Path, proxy_url: &str) -> Output { + let mut args: Vec = args.iter().map(|s| s.to_string()).collect(); + args.extend(["--proxy-url".to_string(), proxy_url.to_string()]); + let cwd = cwd.to_path_buf(); + let m2_repo = m2_repo.to_path_buf(); + tokio::task::spawn_blocking(move || { + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + Command::new(binary()) + .args(&arg_refs) + .current_dir(&cwd) + // Point the crawler at the fake local repo. + .env("MAVEN_REPO_LOCAL", &m2_repo) + // The Maven crawler is gated behind a runtime opt-in + // (`maven_runtime_enabled` in ecosystem_dispatch.rs); without + // this the crawl short-circuits to zero packages and the scan + // prints "No packages found." These tests are named for Maven + // *discovery*, so they must enable the real crawl path — otherwise + // they only ever exercise the disabled stub and pass vacuously. + .env("SOCKET_EXPERIMENTAL_MAVEN", "1") + // Keep the run hermetic: no ambient token, no inherited repo path. + .env_remove("SOCKET_API_TOKEN") + .env_remove("M2_HOME") + .env_remove("SOCKET_API_URL") + .env_remove("SOCKET_OFFLINE") + .env_remove("SOCKET_PROXY_URL") + .env_remove("SOCKET_PATCH_PROXY_URL") + .env_remove("SOCKET_BATCH_SIZE") + .output() + .expect("Failed to run socket-patch binary") + }) + .await + .expect("socket-patch subprocess task panicked") +} + +/// Regression guard for the hermeticity fix: every scan in a test must have +/// routed its patch lookup through the in-test proxy. Fewer recorded requests +/// than scans means at least one binary invocation talked to the live API (or +/// skipped the lookup outright) despite the pinning — exactly the bug this +/// file used to have. +async fn assert_proxy_served_scans(server: &MockServer, scans: usize) { + let requests = server.received_requests().await.unwrap_or_default(); + assert!( + requests.len() >= scans, + "expected all {scans} scan invocations to hit the in-test proxy; \ + recorded only {} request(s)", + requests.len() + ); } // --------------------------------------------------------------------------- @@ -46,9 +106,11 @@ fn run(args: &[&str], cwd: &std::path::Path, m2_repo: &std::path::Path) -> Outpu // --------------------------------------------------------------------------- /// Verify that `socket-patch scan` discovers artifacts in a fake Maven local repo. -#[test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "experimental ecosystem (maven): not gating CI until the maven backend is implemented; run with --ignored"] -fn scan_discovers_maven_artifacts() { +async fn scan_discovers_maven_artifacts() { + let server = start_proxy().await; + let proxy_url = server.uri(); let dir = tempfile::tempdir().unwrap(); // Set up a fake Maven local repository @@ -110,7 +172,9 @@ fn scan_discovers_maven_artifacts() { &["scan", "--cwd", project_dir.to_str().unwrap()], &project_dir, &m2_repo, - ); + &proxy_url, + ) + .await; let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let combined = format!("{stdout}{stderr}"); @@ -144,7 +208,9 @@ fn scan_discovers_maven_artifacts() { &["scan", "--json", "--cwd", project_dir.to_str().unwrap()], &project_dir, &m2_repo, - ); + &proxy_url, + ) + .await; let json = String::from_utf8_lossy(&json_out.stdout); assert!( json_out.status.success(), @@ -162,12 +228,16 @@ fn scan_discovers_maven_artifacts() { json.contains("\"status\": \"success\""), "expected status == success in JSON output, got:\n{json}" ); + + assert_proxy_served_scans(&server, 2).await; } /// Verify that `socket-patch scan` discovers Gradle project artifacts. -#[test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "experimental ecosystem (maven): not gating CI until the maven backend is implemented; run with --ignored"] -fn scan_discovers_gradle_project_artifacts() { +async fn scan_discovers_gradle_project_artifacts() { + let server = start_proxy().await; + let proxy_url = server.uri(); let dir = tempfile::tempdir().unwrap(); // Set up a fake Maven local repository @@ -206,7 +276,9 @@ fn scan_discovers_gradle_project_artifacts() { &["scan", "--json", "--cwd", project_dir.to_str().unwrap()], &project_dir, &m2_repo, - ); + &proxy_url, + ) + .await; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); @@ -238,7 +310,9 @@ fn scan_discovers_gradle_project_artifacts() { &["scan", "--cwd", project_dir.to_str().unwrap()], &project_dir, &m2_repo, - ); + &proxy_url, + ) + .await; let h_combined = format!( "{}{}", String::from_utf8_lossy(&human.stdout), @@ -248,4 +322,6 @@ fn scan_discovers_gradle_project_artifacts() { h_combined.contains("Found 1 packages") && h_combined.contains("(1 maven)"), "expected the Gradle project to discover exactly 1 Maven artifact, got:\n{h_combined}" ); + + assert_proxy_served_scans(&server, 2).await; } diff --git a/crates/socket-patch-cli/tests/e2e_npm.rs b/crates/socket-patch-cli/tests/e2e_npm.rs index fa43262f..804fdd7a 100644 --- a/crates/socket-patch-cli/tests/e2e_npm.rs +++ b/crates/socket-patch-cli/tests/e2e_npm.rs @@ -65,12 +65,24 @@ fn git_sha256_file(path: &Path) -> String { /// Run the CLI binary with the given args, setting `cwd` as the working dir. /// Returns `(exit_code, stdout, stderr)`. fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let out: Output = Command::new(binary()) - .args(args) - .current_dir(cwd) - .env_remove("SOCKET_API_TOKEN") // force public proxy (free-tier) - .output() - .expect("failed to execute socket-patch binary"); + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + // The binary binds a wide `SOCKET_*` env surface (SOCKET_CWD, + // SOCKET_DRY_RUN, SOCKET_STRICT, SOCKET_ECOSYSTEMS, SOCKET_GLOBAL_PREFIX, + // ...). An ambient value silently changes what these tests exercise — + // SOCKET_DRY_RUN=true turns every real apply into a no-op, and + // SOCKET_GLOBAL_PREFIX flips commands into global mode, aiming mutations + // at the host's *real* global node_modules. Scrub the whole prefix so + // only the flags each test passes are in effect; removing + // SOCKET_API_TOKEN also forces the public proxy (free-tier). Telemetry + // opt-outs are deliberately kept so an opted-out dev stays opted out. + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + cmd.env_remove(&key); + } + } + let out: Output = cmd.output().expect("failed to execute socket-patch binary"); let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); @@ -551,14 +563,16 @@ fn test_npm_apply_force() { "corrupted file should have a different hash" ); - // Normal apply should fail *specifically* because of the hash mismatch - // — not for some unrelated reason (missing patch, crash, lock error) - // that would also yield a non-zero exit and let a regression hide. Use - // the JSON envelope to pin the failure to our PURL and its reason. - let (code, stdout, stderr) = run(cwd, &["apply", "--json"]); + // The default policy on a hash mismatch is warn-and-overwrite (the full + // patched blob is applied); `--strict` opts out and fails closed. The + // mismatch must fail *specifically* because of the hash mismatch — not + // for some unrelated reason (missing patch, crash, lock error) that + // would also yield a non-zero exit and let a regression hide. Use the + // JSON envelope to pin the failure to our PURL and its reason. + let (code, stdout, stderr) = run(cwd, &["apply", "--strict", "--json"]); assert_ne!( code, 0, - "apply without --force should fail on hash mismatch.\nstdout:\n{stdout}\nstderr:\n{stderr}" + "apply --strict should fail on hash mismatch.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); let env: serde_json::Value = serde_json::from_str(&stdout) .unwrap_or_else(|e| panic!("apply --json should emit JSON: {e}\nstdout:\n{stdout}")); @@ -581,6 +595,15 @@ fn test_npm_apply_force() { "failure should be a hash mismatch on the patched file, got error: {err_msg:?}" ); + // Strict must not have touched the file — the --force leg below is only + // meaningful if the mismatch is still on disk (a strict that wrote the + // patched content would leave --force a vacuous already-patched skip). + assert_ne!( + git_sha256_file(&index_js), + AFTER_HASH, + "apply --strict must leave the mismatched file unmodified" + ); + // Apply with --force should succeed. assert_run_ok(cwd, &["apply", "--force"], "apply --force"); diff --git a/crates/socket-patch-cli/tests/e2e_nuget.rs b/crates/socket-patch-cli/tests/e2e_nuget.rs index 59d5c525..0950b85a 100644 --- a/crates/socket-patch-cli/tests/e2e_nuget.rs +++ b/crates/socket-patch-cli/tests/e2e_nuget.rs @@ -1,18 +1,25 @@ -#![cfg(feature = "nuget")] //! End-to-end tests for the NuGet/.NET package patching lifecycle. //! //! These tests exercise crawling against a temporary directory with fake //! NuGet package layouts. They do **not** require network access or a real -//! .NET installation. +//! .NET installation: the scan's patch lookup is pinned to an in-test +//! [`wiremock`] public-proxy stand-in via `--proxy-url`. That pinning is +//! load-bearing, not cosmetic — since the all-batches-failed fix, an +//! unreachable API is a hard scan failure (exit 1, `status: "error"`), so an +//! unpinned scan would phone home to the live proxy on every test run and go +//! red whenever the network (or an ambient `SOCKET_*` variable) misbehaved. //! //! # Running //! ```sh -//! cargo test -p socket-patch-cli --features nuget --test e2e_nuget +//! cargo test -p socket-patch-cli --test e2e_nuget -- --ignored //! ``` -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Output}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -21,22 +28,58 @@ fn binary() -> PathBuf { env!("CARGO_BIN_EXE_socket-patch").into() } -fn run(args: &[&str], cwd: &std::path::Path, nuget_packages: &std::path::Path) -> Output { - Command::new(binary()) - .args(args) - .current_dir(cwd) - .env("NUGET_PACKAGES", nuget_packages) - // The NuGet crawler is gated behind a runtime opt-in - // (`nuget_runtime_enabled()` → `SOCKET_EXPERIMENTAL_NUGET`). Without - // this, `scan` skips NuGet entirely and reports "No packages found.", - // which would silently defeat any discovery assertion. Enabling it here - // is what makes these tests actually exercise the NuGet code path. - .env("SOCKET_EXPERIMENTAL_NUGET", "1") - // Keep discovery deterministic: never reach a real ~/.nuget cache or a - // populated public-proxy token from the developer's environment. - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("Failed to run socket-patch binary") +/// Start a mock Socket public proxy answering the scan's `POST /patch/batch` +/// with an empty (no-patch) result, so no scan in this file ever leaves +/// localhost. +async fn start_proxy() -> MockServer { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/patch/batch")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + server +} + +/// Run the binary as a blocking subprocess (off the async runtime so the +/// in-test proxy can service its requests concurrently), pinned to `proxy_url`. +/// +/// `SOCKET_API_TOKEN` is stripped so the binary deterministically takes the +/// public-proxy path (an ambient token would flip it onto the authenticated +/// API, bypassing `--proxy-url`), and every other variable that could +/// redirect the API elsewhere or disable it is scrubbed so an ambient value +/// can't quietly change what the scan reports. +async fn run(args: &[&str], cwd: &Path, nuget_packages: &Path, proxy_url: &str) -> Output { + let mut args: Vec = args.iter().map(|s| s.to_string()).collect(); + args.extend(["--proxy-url".to_string(), proxy_url.to_string()]); + let cwd = cwd.to_path_buf(); + let nuget_packages = nuget_packages.to_path_buf(); + tokio::task::spawn_blocking(move || { + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + Command::new(binary()) + .args(&arg_refs) + .current_dir(&cwd) + .env("NUGET_PACKAGES", &nuget_packages) + // The NuGet crawler is gated behind a runtime opt-in + // (`nuget_runtime_enabled()` → `SOCKET_EXPERIMENTAL_NUGET`). Without + // this, `scan` skips NuGet entirely and reports "No packages found.", + // which would silently defeat any discovery assertion. Enabling it here + // is what makes these tests actually exercise the NuGet code path. + .env("SOCKET_EXPERIMENTAL_NUGET", "1") + .env_remove("SOCKET_API_TOKEN") + .env_remove("SOCKET_API_URL") + .env_remove("SOCKET_OFFLINE") + .env_remove("SOCKET_PROXY_URL") + .env_remove("SOCKET_PATCH_PROXY_URL") + .env_remove("SOCKET_BATCH_SIZE") + .output() + .expect("Failed to run socket-patch binary") + }) + .await + .expect("socket-patch subprocess task panicked") } /// Extract the integer N from a `Found N packages` line in scan's stderr. @@ -89,17 +132,20 @@ fn assert_all_nuget(combined: &str, n: usize) { /// agrees that exactly `n` packages were scanned with overall success. This is /// a separate output formatter from the human-readable `Found N packages` line, /// so it guards against the human line and the JSON envelope drifting apart. -fn assert_json_scanned( - cwd: &std::path::Path, - nuget_packages: &std::path::Path, - project_dir: &std::path::Path, +async fn assert_json_scanned( + cwd: &Path, + nuget_packages: &Path, + project_dir: &Path, + proxy_url: &str, n: usize, ) { let output = run( &["scan", "--cwd", project_dir.to_str().unwrap(), "--json"], cwd, nuget_packages, - ); + proxy_url, + ) + .await; assert!( output.status.success(), "scan --json should exit 0 on clean discovery, got {:?}", @@ -116,14 +162,31 @@ fn assert_json_scanned( ); } +/// Regression guard for the hermeticity fix: every scan in a test must have +/// routed its patch lookup through the in-test proxy. Fewer recorded requests +/// than scans means at least one binary invocation talked to the live API (or +/// skipped the lookup outright) despite the pinning — exactly the bug this +/// file used to have. +async fn assert_proxy_served_scans(server: &MockServer, scans: usize) { + let requests = server.received_requests().await.unwrap_or_default(); + assert!( + requests.len() >= scans, + "expected all {scans} scan invocations to hit the in-test proxy; \ + recorded only {} request(s)", + requests.len() + ); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- /// Verify that `socket-patch scan` discovers packages in a fake global cache layout. -#[test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "experimental ecosystem (nuget): not gating CI until the nuget backend is implemented; run with --ignored"] -fn scan_discovers_global_cache_packages() { +async fn scan_discovers_global_cache_packages() { + let server = start_proxy().await; + let proxy_url = server.uri(); let dir = tempfile::tempdir().unwrap(); // Set up a fake global NuGet cache: // with .nuspec @@ -154,7 +217,9 @@ fn scan_discovers_global_cache_packages() { &["scan", "--cwd", project_dir.to_str().unwrap()], &project_dir, &nuget_cache, - ); + &proxy_url, + ) + .await; let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let combined = format!("{stdout}{stderr}"); @@ -179,13 +244,17 @@ fn scan_discovers_global_cache_packages() { // would let through. assert_all_nuget(&combined, 2); // Independently confirm via the JSON envelope (a different output path). - assert_json_scanned(&project_dir, &nuget_cache, &project_dir, 2); + assert_json_scanned(&project_dir, &nuget_cache, &project_dir, &proxy_url, 2).await; + + assert_proxy_served_scans(&server, 2).await; } /// Verify that `socket-patch scan` discovers packages in a fake legacy packages/ layout. -#[test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "experimental ecosystem (nuget): not gating CI until the nuget backend is implemented; run with --ignored"] -fn scan_discovers_legacy_packages() { +async fn scan_discovers_legacy_packages() { + let server = start_proxy().await; + let proxy_url = server.uri(); let dir = tempfile::tempdir().unwrap(); let project_dir = dir.path().join("project"); std::fs::create_dir_all(&project_dir).unwrap(); @@ -209,7 +278,9 @@ fn scan_discovers_legacy_packages() { &["scan", "--cwd", project_dir.to_str().unwrap()], &project_dir, &packages_dir, - ); + &proxy_url, + ) + .await; let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let combined = format!("{stdout}{stderr}"); @@ -227,5 +298,7 @@ fn scan_discovers_legacy_packages() { // attributed to NuGet via the contiguous `(1 nuget)` breakdown. assert_all_nuget(&combined, 1); // Independently confirm via the JSON envelope (a different output path). - assert_json_scanned(&project_dir, &packages_dir, &project_dir, 1); + assert_json_scanned(&project_dir, &packages_dir, &project_dir, &proxy_url, 1).await; + + assert_proxy_served_scans(&server, 2).await; } diff --git a/crates/socket-patch-cli/tests/e2e_pypi.rs b/crates/socket-patch-cli/tests/e2e_pypi.rs index 50c91fee..b5820913 100644 --- a/crates/socket-patch-cli/tests/e2e_pypi.rs +++ b/crates/socket-patch-cli/tests/e2e_pypi.rs @@ -17,6 +17,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use sha2::{Digest, Sha256}; +use socket_patch_cli::args::{GLOBAL_ARG_ENV_VARS, LOCAL_ARG_ENV_VARS}; // --------------------------------------------------------------------------- // Constants @@ -57,14 +58,47 @@ fn git_sha256_file(path: &Path) -> String { git_sha256(&content) } +/// The three legacy `SOCKET_PATCH_*` names still honored at runtime via +/// `socket_patch_core::env_compat` — not in the clap-bound lists, so they +/// need scrubbing separately. +const LEGACY_ENV_VARS: &[&str] = &[ + "SOCKET_PATCH_PROXY_URL", + "SOCKET_PATCH_DEBUG", + "SOCKET_PATCH_TELEMETRY_DISABLED", +]; + /// Run the CLI binary with the given args, setting `cwd` as the working dir. +/// +/// The environment is pinned hard: every env var the CLI binds (the canonical +/// `GLOBAL_ARG_ENV_VARS` / `LOCAL_ARG_ENV_VARS` lists plus [`LEGACY_ENV_VARS`]) +/// is scrubbed so ambient developer/CI configuration can't change the +/// lifecycle under test. Scrubbing `SOCKET_API_TOKEN` also forces the +/// public-proxy (free-tier) path this suite relies on. The stakes are higher +/// here than in the offline suites: an inherited `SOCKET_GLOBAL=true` takes +/// `get`/`apply` out of the temp venv and patches the host's real +/// site-packages, while `SOCKET_MANIFEST_PATH` relocates `.socket/` (verified +/// to fail `get` outright) and `SOCKET_DRY_RUN` / `SOCKET_SAVE_ONLY` turn the +/// apply steps into silent no-ops. The hostile seeds below are cleared by the +/// scrub loop — the child never sees them — but if the scrub is ever dropped +/// the seeds (rather than a developer's ambient shell, which this suite can't +/// rely on) turn the tests red immediately. fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let out: Output = Command::new(binary()) - .args(args) + let mut cmd = Command::new(binary()); + cmd.args(args) .current_dir(cwd) - .env_remove("SOCKET_API_TOKEN") // force public proxy (free-tier) - .output() - .expect("failed to execute socket-patch binary"); + .env("SOCKET_GLOBAL", "true") + .env("SOCKET_GLOBAL_PREFIX", "/nonexistent") + .env("SOCKET_DRY_RUN", "true") + .env("SOCKET_SAVE_ONLY", "true") + .env("SOCKET_MANIFEST_PATH", "/nonexistent/manifest.json"); + for var in GLOBAL_ARG_ENV_VARS + .iter() + .chain(LOCAL_ARG_ENV_VARS) + .chain(LEGACY_ENV_VARS) + { + cmd.env_remove(var); + } + let out: Output = cmd.output().expect("failed to execute socket-patch binary"); let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); diff --git a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs index bf11bf23..818d619e 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs @@ -316,6 +316,39 @@ async fn bun_hosted_project(tag: &str, tamper_served_tarball: bool) -> Option bool { } fn scrub_socket_env(cmd: &mut Command) { + // Seed-then-scrub (mirrors e2e_golang_redirect.rs): yarn berry lets EVERY + // `.yarnrc.yml` setting be overridden by a `YARN_*` env var (env outranks + // the project yarnrc), so an ambient `YARN_NODE_LINKER=pnp` was verified + // to turn both tests red — the fixture install builds a PnP tree and + // node_modules/left-pad never exists. The explicit env_remove below + // clears the seed too, but if the scrub is ever dropped the seed (rather + // than a developer's ambient shell, which this suite can't rely on) turns + // the tests red immediately. + cmd.env("YARN_NODE_LINKER", "pnp"); for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") { + let key = k.to_string_lossy(); + if key.starts_with("SOCKET_") || key.starts_with("YARN_") { cmd.env_remove(&k); } } cmd.env_remove("VIRTUAL_ENV"); - for v in [ - "YARN_CACHE_FOLDER", - "YARN_GLOBAL_FOLDER", - "YARN_ENABLE_GLOBAL_CACHE", - ] { - cmd.env_remove(v); - } + cmd.env_remove("YARN_NODE_LINKER"); } fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> Output { @@ -416,6 +420,39 @@ async fn berry_hosted_project( env["redirect"]["redirected"], 1, "one dep redirected: {env}" ); + // In-run VEX (step 3 of the module doc): the envelope's vex block plus the + // document's unverified `(redirected)` attestation. Without these, a scan + // that silently skips the VEX write (or emits the wrong statement) stays + // green — the exit code only catches a HARD vex failure. + assert_eq!(env["vex"]["path"], "out.vex.json", "vex block: {env}"); + assert_eq!(env["vex"]["statements"], 1, "vex block: {env}"); + assert_eq!(env["vex"]["format"], "openvex-0.2.0", "vex block: {env}"); + assert_eq!( + env["vex"]["verified"], false, + "in-run redirect VEX is attested from the ledger, not hash-verified: {env}" + ); + let vex_doc: serde_json::Value = + serde_json::from_slice(&std::fs::read(proj.join("out.vex.json")).unwrap()).unwrap(); + let stmts = vex_doc["statements"].as_array().unwrap(); + assert_eq!( + stmts.len(), + 1, + "exactly the redirected patch attested: {vex_doc}" + ); + assert_eq!( + stmts[0]["vulnerability"]["name"], GHSA, + "vex doc: {vex_doc}" + ); + assert_eq!(stmts[0]["status"], "not_affected", "vex doc: {vex_doc}"); + assert_eq!( + stmts[0]["products"][0]["subcomponents"][0]["@id"], PURL, + "vex doc: {vex_doc}" + ); + assert_eq!( + stmts[0]["impact_statement"].as_str().unwrap(), + format!("Patched via Socket patch {UUID} (redirected)"), + "the in-run attestation must carry the (redirected) marker: {vex_doc}" + ); // Lockfile pin: the encoded __archiveUrl + the 10c0 checksum. let lock = std::fs::read_to_string(proj.join("yarn.lock")).unwrap(); diff --git a/crates/socket-patch-cli/tests/e2e_safety_advisories.rs b/crates/socket-patch-cli/tests/e2e_safety_advisories.rs index 027a946a..b1b5939f 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_advisories.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_advisories.rs @@ -34,10 +34,8 @@ mod common; use common::{ git_sha256, parse_json_envelope, run_with_env, write_blob, write_minimal_manifest, PatchEntry, }; -// Only the cargo sidecar test needs the bare (un-framed) digest used in -// `.cargo-checksum.json`; gate the import so a `--no-default-features` -// (no `cargo`) build doesn't trip the unused-import lint under `-D warnings`. -#[cfg(feature = "cargo")] +// The cargo sidecar test needs the bare (un-framed) digest used in +// `.cargo-checksum.json`. use common::sha256_hex; /// Helper: stage a package layout + manifest + blob, run apply, and @@ -339,7 +337,6 @@ fn gem_apply_emits_gem_bundle_install_reverts_advisory() { /// path followed by `@/`. We pass both `--global-prefix` and /// `GOMODCACHE` for redundancy (the apply CLI consumes the former, /// some downstream code paths read the latter). -#[cfg(feature = "golang")] #[test] fn golang_apply_emits_go_mod_verify_fails_advisory() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -412,7 +409,6 @@ fn golang_apply_emits_go_mod_verify_fails_advisory() { /// hash sidecar) and records the deletion under /// `envelope.sidecars[].files[]`. No advisory is emitted for the /// unsigned case — the deletion alone is the operator surface. -#[cfg(feature = "nuget")] #[test] fn nuget_apply_deletes_metadata_and_records_files() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -503,7 +499,7 @@ fn nuget_apply_deletes_metadata_and_records_files() { /// also accept arbitrary byte sequences in filenames). Falls back /// to a portable shape on other Unices where the filesystem /// rejects non-UTF8 names. -#[cfg(all(unix, feature = "nuget"))] +#[cfg(unix)] #[test] fn nuget_apply_with_non_utf8_filename_in_pkg_dir() { use std::ffi::OsStr; @@ -612,7 +608,6 @@ fn nuget_apply_with_non_utf8_filename_in_pkg_dir() { /// success and signed-package tests can't reach. As with the /// cargo equivalent, the directory-as-file ruse beats chmod /// because it fails uniformly across uids and platforms. -#[cfg(feature = "nuget")] #[test] fn nuget_apply_with_metadata_directory_reports_sidecar_fixup_failed() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -693,7 +688,6 @@ fn nuget_apply_with_metadata_directory_reports_sidecar_fixup_failed() { /// at severity `warning`. The old single-variant `SidecarOutcome` /// design lost the advisory in this case; the typed schema keeps /// both visible. -#[cfg(feature = "nuget")] #[test] fn nuget_apply_signed_package_emits_files_and_advisory() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -786,10 +780,9 @@ fn nuget_apply_signed_package_emits_files_and_advisory() { // ───────────────────────────────────────────────────────────────────── // Cargo — file rewrite (no advisory), code path proves // `.cargo-checksum.json` is rewritten to the on-disk hash and recorded -// as `Rewritten`. This is the DEFAULT-feature sidecar and the only one -// in the shipped binary that *rewrites* a file, so it must have an -// end-to-end guard that runs under `--features cargo` (the recommended -// command) — not just core-crate unit tests on `cargo::fixup`. +// as `Rewritten`. This is the only sidecar in the shipped binary that +// *rewrites* a file, so it must have an end-to-end guard — not just +// core-crate unit tests on `cargo::fixup`. // ───────────────────────────────────────────────────────────────────── /// Cargo: patching a file inside a `-/` registry-cache @@ -804,7 +797,6 @@ fn nuget_apply_signed_package_emits_files_and_advisory() { /// checksum file back off disk and pins it — so a regression that /// stops rewriting, rewrites the wrong value, clobbers the untouched /// sibling / `package` tarball hash, or mislabels the action fires loudly. -#[cfg(feature = "cargo")] #[test] fn cargo_apply_rewrites_checksum_and_records_files() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs index 24ad8dbd..06e0a779 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs @@ -1,4 +1,3 @@ -#![cfg(feature = "cargo")] //! End-to-end: `socket-patch apply` against a Cargo vendor source //! followed by `cargo check` succeeds. //! @@ -48,8 +47,8 @@ use sha2::{Digest, Sha256}; mod common; use common::{ - assert_run_ok, cargo_run, has_command, parse_json_envelope, run, sha256_hex, write_blob, - write_minimal_manifest, PatchEntry, + assert_run_ok, cargo_run, has_command, parse_json_envelope, run, run_with_env, sha256_hex, + write_blob, write_minimal_manifest, PatchEntry, }; const ORIGINAL_LIB_RS: &str = "pub fn hello() -> &'static str { \"world\" }\n"; @@ -171,11 +170,21 @@ fn cargo_check(consumer: &Path, cargo_home: &Path) -> std::process::Output { // checksum verification happens at *unpack/copy* time, and once // a build has consumed the source cargo will short-circuit on // subsequent runs even if the underlying files changed. - let _ = std::fs::remove_dir_all(consumer.join("target")); + // + // CARGO_TARGET_DIR must be pinned to the dir we just wiped: an + // ambient redirect (shared target dirs are common) sends the build + // cache elsewhere, the wipe no-ops, and cargo short-circuits on the + // warm cache without re-verifying checksums — turning the negative + // control red and the positive round trips vacuous. + let target = consumer.join("target"); + let _ = std::fs::remove_dir_all(&target); cargo_run( consumer, &["check", "--offline", "--frozen"], - &[("CARGO_HOME", cargo_home.to_str().unwrap())], + &[ + ("CARGO_HOME", cargo_home.to_str().unwrap()), + ("CARGO_TARGET_DIR", target.to_str().unwrap()), + ], ) } @@ -876,8 +885,8 @@ fn apply_without_cargo_checksum_emits_no_sidecar_record() { ); // No cargo sidecar record emitted — the fixup returned None, so - // the apply loop never calls `record_sidecar`. The envelope's - // `sidecars` array is either absent or empty. + // the apply loop pushes nothing onto `Envelope.sidecars`. The + // envelope's `sidecars` array is either absent or empty. let has_cargo_record = env .get("sidecars") .and_then(|v| v.as_array()) @@ -1003,10 +1012,20 @@ fn apply_normalizes_package_prefix_in_cargo_checksum() { /// - patches-api.socket.dev (socket-patch get, public proxy) /// /// The traitobject 0.0.1 patch adds a `compile_error!` to `src/lib.rs` -/// guarded by the `allow-unmaintained` feature — so the consumer -/// declares the dep with `features = ["allow-unmaintained"]` to keep -/// the build green and let us assert "cargo check succeeded after the -/// real patch was applied." +/// gated on `#[cfg(not(feature = "allow-unmaintained"))]`, and adds +/// that feature to the crate's Cargo.toml. The consumer MUST declare +/// the dep bare: cargo resolves features from the crates.io INDEX, +/// which knows nothing of the patch-added feature, so declaring +/// `features = ["allow-unmaintained"]` fails resolution ("does not +/// have that feature") before fetch even runs — against both the +/// unpatched AND the patched crate. (This test originally shipped +/// with the feature declared; the resulting resolution error was +/// swallowed by the fetch skip path and the test silently skipped +/// itself on every machine.) The end-to-end oracle is therefore the +/// patch's own compile_error: cargo accepting the rewritten checksums +/// and then failing compilation with the "unmaintained" message +/// proves the sidecar fixup landed AND that rustc consumed the +/// patched bytes. #[test] #[ignore] fn traitobject_real_socket_patch_round_trip() { @@ -1019,9 +1038,9 @@ fn traitobject_real_socket_patch_round_trip() { let cargo_home = root.path().join(".cargo-home"); std::fs::create_dir_all(consumer.join("src")).unwrap(); - // Consumer crate that uses traitobject. The `allow-unmaintained` - // feature opts past the post-patch `compile_error!` guard so the - // build can actually link. + // Consumer crate that uses traitobject, declared bare (see the + // doc comment: the patch-added feature is index-invisible and can + // never be enabled on a registry dependency). std::fs::write( consumer.join("Cargo.toml"), r#"[package] @@ -1030,7 +1049,7 @@ version = "0.0.1" edition = "2021" [dependencies] -traitobject = { version = "0.0.1", features = ["allow-unmaintained"] } +traitobject = "0.0.1" "#, ) .unwrap(); @@ -1071,31 +1090,35 @@ traitobject = { version = "0.0.1", features = ["allow-unmaintained"] } } let traitobject_dir = traitobject_dir .expect("traitobject-0.0.1 should be unpacked under cargo registry/src after cargo fetch"); + // Modern cargo no longer materializes `.cargo-checksum.json` under + // registry/src — it writes a bare `.cargo-ok` marker and verifies the + // .crate tarball at unpack time, leaving no per-file hashes for the + // sidecar to fix up (fixup correctly returns "nothing to do"). Older + // toolchains DO ship the file; snapshot conditionally so the rewrite + // assertions still fire wherever the file exists. let checksum_path = traitobject_dir.join(".cargo-checksum.json"); - let pre_apply_checksum: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(&checksum_path) - .expect("traitobject-0.0.1 must ship .cargo-checksum.json"), - ) - .unwrap(); + let pre_apply_checksum: Option = std::fs::read_to_string(&checksum_path) + .ok() + .map(|raw| serde_json::from_str(&raw).expect("pre-apply .cargo-checksum.json must parse")); // 3. Run `socket-patch get` against the public proxy. This - // downloads + applies the real patch in one shot. - let socket_patch_run = Command::new(env!("CARGO_BIN_EXE_socket-patch")) - .args([ + // downloads + applies the real patch in one shot. Route through + // the scrubbed runner so ambient SOCKET_* (DRY_RUN, MANIFEST_PATH, + // GLOBAL, ...) can't no-op or redirect the apply; the scrub also + // strips SOCKET_API_TOKEN, forcing the public proxy. + let (get_code, get_stdout, get_stderr) = run_with_env( + &consumer, + &[ "get", "b15f2b7f-d5cb-43c9-b793-80f71682188f", "--cwd", consumer.to_str().unwrap(), - ]) - .env("CARGO_HOME", cargo_home_str) - .env_remove("SOCKET_API_TOKEN") // force public proxy - .output() - .expect("socket-patch get"); - if !socket_patch_run.status.success() { + ], + &[("CARGO_HOME", cargo_home_str)], + ); + if get_code != 0 { eprintln!( - "SKIP: socket-patch get failed (likely network):\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&socket_patch_run.stdout), - String::from_utf8_lossy(&socket_patch_run.stderr), + "SKIP: socket-patch get failed (likely network):\nstdout:\n{get_stdout}\nstderr:\n{get_stderr}" ); return; } @@ -1112,41 +1135,73 @@ traitobject = { version = "0.0.1", features = ["allow-unmaintained"] } "manifest should contain the traitobject patch: {manifest}" ); - // 5. The sidecar fixup must have rewritten .cargo-checksum.json. - // The patch covers src/lib.rs (and Cargo.toml, Cargo.lock, - // README.md), so those entries should have NEW SHA256 values - // while every unpatched-file entry stays put. - let post_apply_checksum: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&checksum_path).unwrap()).unwrap(); - let pre_files = pre_apply_checksum["files"].as_object().unwrap(); - let post_files = post_apply_checksum["files"].as_object().unwrap(); - let patched_paths = ["Cargo.toml", "Cargo.lock", "README.md", "src/lib.rs"]; - for f in patched_paths { - if let (Some(pre), Some(post)) = (pre_files.get(f), post_files.get(f)) { - assert_ne!( - pre, post, - ".cargo-checksum.json entry for {f} should change after apply" - ); + // 5. Where a `.cargo-checksum.json` existed, the sidecar fixup must + // have rewritten it: the patch covers src/lib.rs (and Cargo.toml, + // Cargo.lock, README.md), so those entries should have NEW SHA256 + // values while every unpatched-file entry stays put. Where cargo + // never wrote one (modern toolchains), the sidecar must not + // invent one. + match &pre_apply_checksum { + Some(pre) => { + let post_apply_checksum: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&checksum_path) + .expect("sidecar must not delete .cargo-checksum.json"), + ) + .unwrap(); + let pre_files = pre["files"].as_object().unwrap(); + let post_files = post_apply_checksum["files"].as_object().unwrap(); + let patched_paths = ["Cargo.toml", "Cargo.lock", "README.md", "src/lib.rs"]; + for f in patched_paths { + if let (Some(pre), Some(post)) = (pre_files.get(f), post_files.get(f)) { + assert_ne!( + pre, post, + ".cargo-checksum.json entry for {f} should change after apply" + ); + assert_eq!( + post.as_str().unwrap().len(), + 64, + "post-apply hash for {f} should be 64-hex SHA256" + ); + } + } + // `package` field is preserved (the .crate tarball hash didn't + // become honestly recomputable without the original .crate). assert_eq!( - post.as_str().unwrap().len(), - 64, - "post-apply hash for {f} should be 64-hex SHA256" + pre["package"], post_apply_checksum["package"], + ".cargo-checksum.json `package` field must survive the rewrite unchanged" + ); + } + None => { + assert!( + !checksum_path.exists(), + "apply must not create a .cargo-checksum.json cargo never wrote" ); } } - // `package` field is preserved (the .crate tarball hash didn't - // become honestly recomputable without the original .crate). - assert_eq!( - pre_apply_checksum["package"], post_apply_checksum["package"], - ".cargo-checksum.json `package` field must survive the rewrite unchanged" - ); - // 6. The whole point: cargo accepts the patched sources. + // 6. The whole point: cargo gets PAST checksum verification and + // compiles the PATCHED source. A green build is impossible here + // by the patch's design (the compile_error can only be silenced + // by a feature no registry consumer can enable), so the honest + // oracle is two-sided: + // * no "checksum ... has changed" rejection — the sidecar + // rewrite was accepted by cargo's registry-source + // verification, and + // * the patch's own compile_error text in stderr — the bytes + // rustc consumed are the patched bytes, not the originals + // (unpatched traitobject compiles clean, so a silent + // no-op apply would turn this check green and fail below). let check = cargo_check(&consumer, &cargo_home); + let check_stderr = String::from_utf8_lossy(&check.stderr); + assert!( + !(check_stderr.contains("checksum") && check_stderr.contains("changed")), + "cargo must accept the sidecar-rewritten checksums, not reject them:\n{check_stderr}" + ); assert!( - check.status.success(), - "cargo check should succeed against patched traitobject.\nstdout:\n{}\nstderr:\n{}", + !check.status.success() && check_stderr.contains("unmaintained"), + "cargo check must fail with the patch's `unmaintained` compile_error \ + (proving the patched source compiled); a success here means the \ + patch never reached the compiled bytes.\nstdout:\n{}\nstderr:\n{check_stderr}", String::from_utf8_lossy(&check.stdout), - String::from_utf8_lossy(&check.stderr), ); } diff --git a/crates/socket-patch-cli/tests/e2e_safety_cow.rs b/crates/socket-patch-cli/tests/e2e_safety_cow.rs index 3a9ebdf5..27dc5732 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_cow.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_cow.rs @@ -125,7 +125,7 @@ fn assert_applied(env: &serde_json::Value, purl: &str, expected_paths: &[&str]) /// Assert no patch-time temp files leaked into `pkg_dir`. /// /// Two distinct stagers write into the package directory: -/// * the atomic writer (`apply::write_atomic`) stages `.socket-stage-*`, +/// * the atomic writer (`utils::fs::atomic_write_bytes`) stages `.socket-stage-*`, /// * **CoW** (`cow::write_via_stage_rename`, the hardlink and symlink /// branches) stages `.socket-cow-*`. /// @@ -442,6 +442,32 @@ fn apply_breaks_hardlinks_on_multi_file_patch() { } } +/// Hermeticity RED guard: the binary binds a wide env surface via clap +/// `env =` fallbacks (`SOCKET_DRY_RUN`, `SOCKET_CWD`, +/// `SOCKET_MANIFEST_PATH`, `SOCKET_GLOBAL`, …), so an ambient value in +/// the developer's or CI's shell silently reconfigures every `apply` +/// this suite runs. `common::run` must scrub the `SOCKET_*` prefix; +/// bake the hostile value in with `set_var` so the suite fails +/// deterministically if the scrub regresses. `SOCKET_DRY_RUN=true` is +/// the sharpest probe: apply exits 0 with a success-shaped envelope +/// while writing nothing, so every CoW content assertion in this file +/// would chase a no-op. +#[test] +fn run_scrubs_ambient_socket_env() { + std::env::set_var("SOCKET_DRY_RUN", "true"); + let fx = Fixture::new(); + std::fs::write(fx.index_js(), ORIGINAL_BYTES).unwrap(); + fx.stage_patch(); + + let env = apply_json_ok(fx.root()); + assert_applied(&env, TEST_PURL, &["package/index.js"]); + assert_eq!( + git_sha256_file(&fx.index_js()), + git_sha256(PATCHED_BYTES), + "apply ran as a dry-run no-op — ambient SOCKET_DRY_RUN leaked through common::run" + ); +} + /// Regular files (no hardlink, no symlink) are the common case. /// CoW must be a no-op fast path: no stage litter in the parent /// directory, no extra inodes created, the file is rewritten in diff --git a/crates/socket-patch-cli/tests/e2e_safety_internals.rs b/crates/socket-patch-cli/tests/e2e_safety_internals.rs index 8e4aa455..912aa020 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_internals.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_internals.rs @@ -17,8 +17,6 @@ //! No network. No toolchain. Unix-gated for the chmod-based test; //! the rest are portable. -use std::collections::HashMap; - use socket_patch_core::patch::cow::{break_hardlink_if_needed, CowAction}; use socket_patch_core::patch::sidecars::dispatch_fixup; @@ -36,7 +34,7 @@ use socket_patch_core::patch::sidecars::dispatch_fixup; /// early-return would NOT change the result and the regression would /// stay green. We use `pkg:pypi/...` because the pypi arm /// *unconditionally* emits an advisory (`Some`) whenever it is reached -/// — and it is compiled in every feature configuration. So observing +/// — and it is always compiled in. So observing /// `None` here can ONLY mean the empty-patched short-circuit fired /// before PURL classification. (This mirrors the in-tree lib test /// `empty_patched_short_circuits_before_advisory`, which the original @@ -44,7 +42,7 @@ use socket_patch_core::patch::sidecars::dispatch_fixup; #[tokio::test] async fn dispatch_fixup_empty_patched_returns_none() { let tmp = tempfile::tempdir().unwrap(); - let out = dispatch_fixup("pkg:pypi/requests@2.28.0", tmp.path(), &[], &HashMap::new()) + let out = dispatch_fixup("pkg:pypi/requests@2.28.0", tmp.path(), &[]) .await .unwrap(); assert!( @@ -63,7 +61,6 @@ async fn dispatch_fixup_unknown_ecosystem_returns_none() { "pkg:totally-not-an-ecosystem/x@1", tmp.path(), &["x".to_string()], - &HashMap::new(), ) .await .unwrap(); @@ -87,7 +84,6 @@ async fn dispatch_fixup_unknown_ecosystem_returns_none() { /// `sha256_file(on_disk)`, and the open fails with NotFound. The /// `.map_err(|source| SidecarError::Io { ... })?` wraps it; the /// dispatcher returns `Err(SidecarError::Io)`. -#[cfg(feature = "cargo")] #[tokio::test] async fn dispatch_fixup_cargo_sha256_file_failure_arm() { use socket_patch_core::patch::sidecars::SidecarError; @@ -107,7 +103,6 @@ async fn dispatch_fixup_cargo_sha256_file_failure_arm() { "pkg:cargo/anything@1.0.0", pkg, &["package/missing-on-disk.txt".to_string()], - &HashMap::new(), ) .await; @@ -143,7 +138,6 @@ async fn dispatch_fixup_cargo_sha256_file_failure_arm() { /// /// Together with the no-metadata + signed-marker tests this nails /// down every branch in `has_signed_marker`'s setup. -#[cfg(feature = "nuget")] #[tokio::test] async fn dispatch_fixup_nuget_with_nonexistent_pkg_path() { let tmp = tempfile::tempdir().unwrap(); @@ -153,7 +147,6 @@ async fn dispatch_fixup_nuget_with_nonexistent_pkg_path() { "pkg:nuget/Anything@1.0.0", &absent, &["package/file.txt".to_string()], - &HashMap::new(), ) .await .unwrap(); diff --git a/crates/socket-patch-cli/tests/e2e_safety_lock.rs b/crates/socket-patch-cli/tests/e2e_safety_lock.rs index 1b37fa3c..21f3e007 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_lock.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_lock.rs @@ -178,6 +178,33 @@ fn lock_held_human_mode_mentions_other_process() { ); } +/// `--silent` is "errors only" (CLI_CONTRACT.md), never "nothing": +/// a lock_held contention under `apply --silent` must still put the +/// error line on stderr. Exit 1 with zero output is undiagnosable — +/// the same violation fixed for setup/scan/apply's other error exits. +#[test] +fn lock_held_silent_mode_still_reports_error() { + let dir = tempfile::tempdir().unwrap(); + let socket_dir = dir.path().join(".socket"); + setup_socket_dir(&socket_dir); + let _external = take_external_lock(&socket_dir); + + let (code, stdout, stderr) = run(dir.path(), &["apply", "--silent"]); + assert_eq!( + code, 1, + "silent-mode contention must still exit 1.\nstderr:\n{stderr}" + ); + assert!( + stdout.trim().is_empty(), + "silent human mode must not print to stdout, got:\n{stdout}" + ); + assert!( + stderr.contains("Error: another socket-patch process is operating in this directory"), + "--silent means errors only, not no errors: the lock_held line \ + must reach stderr, got:\n{stderr}" + ); +} + /// Release the lock; a fresh apply must succeed (or at least not /// return `lock_held`). Confirms the binary doesn't get into a /// stuck state if the lock file already exists from a prior run. diff --git a/crates/socket-patch-cli/tests/e2e_safety_unlock.rs b/crates/socket-patch-cli/tests/e2e_safety_unlock.rs index cfd78e14..dff7b6f1 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_unlock.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_unlock.rs @@ -40,7 +40,11 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_PROXY_URL", "SOCKET_ECOSYSTEMS", "SOCKET_DOWNLOAD_MODE", + "SOCKET_VENDOR_SOURCE", + "SOCKET_VENDOR_URL", + "SOCKET_PATCH_SERVER_URL", "SOCKET_OFFLINE", + "SOCKET_STRICT", "SOCKET_GLOBAL", "SOCKET_GLOBAL_PREFIX", "SOCKET_JSON", @@ -54,6 +58,15 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_TELEMETRY_DISABLED", ]; +/// Remove every scrub-listed SOCKET_* var from `cmd`'s environment. +/// Shared between [`run`] and the scrub-coverage regression test so the +/// test exercises the exact scrub the whole suite relies on. +fn scrub_socket_env(cmd: &mut Command) { + for var in SOCKET_ENV_VARS { + cmd.env_remove(var); + } +} + /// Run the CLI with `args` in `cwd`, with the entire SOCKET_* env /// surface scrubbed so the behavior under test is determined solely by /// the CLI flags — not by whatever the developer/CI happens to export. @@ -62,9 +75,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(common::binary()); cmd.args(args).current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); - } + scrub_socket_env(&mut cmd); let out = cmd.output().expect("failed to execute socket-patch binary"); let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); @@ -551,6 +562,100 @@ fn unlock_release_honors_manifest_path() { ); } +/// `--release --dry-run` must preview, not mutate: the leftover lock +/// file survives, `released` stays false (nothing was deleted), and +/// the flat envelope reports the dry run (`dryRun: true`, +/// `wouldRelease: true`). Regression guard: `unlock` ignored the +/// global `--dry-run` flag ("Preview, no mutations") and deleted the +/// file anyway. +#[test] +fn unlock_release_dry_run_previews_without_deleting() { + let dir = tempfile::tempdir().unwrap(); + let socket_dir = dir.path().join(".socket"); + std::fs::create_dir_all(&socket_dir).unwrap(); + let lock_file = socket_dir.join("apply.lock"); + std::fs::write(&lock_file, b"crashed-run-leftover").unwrap(); + + let (code, stdout, stderr) = run(dir.path(), &["unlock", "--json", "--release", "--dry-run"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert!( + lock_file.is_file(), + "--dry-run must not delete the lock file" + ); + let env = parse_json_envelope(&stdout); + assert_eq!(json_string(&env, "status"), Some("free")); + assert_eq!( + env.get("released").and_then(|v| v.as_bool()), + Some(false), + "a dry run deletes nothing, so released must be false: {stdout}" + ); + assert_eq!( + env.get("dryRun").and_then(|v| v.as_bool()), + Some(true), + "the envelope must report the dry run: {stdout}" + ); + assert_eq!( + env.get("wouldRelease").and_then(|v| v.as_bool()), + Some(true), + "a real run would have removed the leftover, so wouldRelease must be true: {stdout}" + ); +} + +/// Human-mode `--release --dry-run` with a leftover file announces the +/// would-be removal without claiming it happened, and leaves the file +/// on disk. +#[test] +fn unlock_human_mode_release_dry_run_previews_removal() { + let dir = tempfile::tempdir().unwrap(); + let socket_dir = dir.path().join(".socket"); + std::fs::create_dir_all(&socket_dir).unwrap(); + let lock_file = socket_dir.join("apply.lock"); + std::fs::write(&lock_file, b"crashed-run-leftover").unwrap(); + + let (code, stdout, stderr) = run(dir.path(), &["unlock", "--release", "--dry-run"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let lower = stdout.to_lowercase(); + assert!( + lower.contains("would remove"), + "dry-run --release should preview the removal, got:\n{stdout}" + ); + // Must not claim a removal actually happened. + assert!( + !lower.contains("removed"), + "dry-run --release must not claim a completed removal, got:\n{stdout}" + ); + assert!( + lock_file.is_file(), + "--dry-run must leave the leftover lock file on disk" + ); +} + +/// A held probe under `--dry-run` stamps the standard error envelope's +/// `dryRun` field truthfully. Regression guard: `unlock` hardcoded +/// `dry_run = false` into its `error_envelope` calls, so a +/// `--dry-run` invocation's failure envelope misreported itself as a +/// real run. +#[test] +fn unlock_dry_run_held_envelope_reports_dry_run() { + let dir = tempfile::tempdir().unwrap(); + let socket_dir = dir.path().join(".socket"); + let _external = take_external_lock(&socket_dir); + + let (code, stdout, stderr) = run(dir.path(), &["unlock", "--json", "--release", "--dry-run"]); + assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); + let env = parse_json_envelope(&stdout); + let code_field = env + .get("error") + .and_then(|e| e.get("code")) + .and_then(|c| c.as_str()); + assert_eq!(code_field, Some("lock_held"), "envelope: {stdout}"); + assert_eq!( + env.get("dryRun").and_then(|v| v.as_bool()), + Some(true), + "held-lock envelope must carry the invocation's dry-run flag: {stdout}" + ); +} + /// Human-mode (`unlock` without `--json`) emits a stderr hint /// pointing the user at `--break-lock` when the lock is held. /// Pinned at the substring level so the helpful guidance survives @@ -580,3 +685,46 @@ fn unlock_human_mode_hints_at_break_lock_when_held() { "plain held probe must not emit the --release-refusal wording, got:\n{stderr}" ); } + +/// The suite's scrub must cover EVERY flag-bound `SOCKET_*` env var — +/// clap validates env-bound values even for flags the invocation never +/// passes, so a single ambient junk value (e.g. `SOCKET_STRICT=banana` +/// exported in a dev shell) aborts the parse and turns the entire suite +/// red. Seed-then-scrub (pattern from `e2e_golang_redirect.rs`): every +/// var the production parser binds is seeded with a value its parser +/// rejects, then run through the suite's own scrub. Any binding the +/// scrub misses reaches the child and fails the probe. Regression +/// guard: the hand-rolled scrub list drifted from `GlobalArgs`, missing +/// `SOCKET_STRICT`, `SOCKET_VENDOR_SOURCE`, `SOCKET_VENDOR_URL`, and +/// `SOCKET_PATCH_SERVER_URL`. +#[test] +fn run_scrubs_every_flag_bound_socket_env_var() { + use socket_patch_cli::args::{GLOBAL_ARG_ENV_VARS, LOCAL_ARG_ENV_VARS}; + + let dir = tempfile::tempdir().unwrap(); + let mut cmd = Command::new(common::binary()); + cmd.args(["unlock", "--json"]).current_dir(dir.path()); + // Hostile seed: rejected by every restrictive parser in play + // (parse_bool_flag, parse_vendor_source, the ecosystems validator, + // the integer flags), so any var that escapes the scrub aborts the + // command instead of silently parsing. + for var in GLOBAL_ARG_ENV_VARS.iter().chain(LOCAL_ARG_ENV_VARS) { + cmd.env(var, "hostile-junk-not-a-valid-value"); + } + scrub_socket_env(&mut cmd); + let out = cmd.output().expect("failed to execute socket-patch binary"); + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + assert_eq!( + code, 0, + "a fully-scrubbed probe must succeed despite hostile ambient \ + SOCKET_* values; stdout={stdout}\nstderr={stderr}" + ); + let env = parse_json_envelope(&stdout); + assert_eq!( + json_string(&env, "status"), + Some("free"), + "envelope: {stdout}" + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs index 44c7a300..7dae2416 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs @@ -268,6 +268,51 @@ fn yarn_pnp_refuses_in_human_mode() { assert_pristine_package_dir(index.parent().unwrap()); } +/// `--silent` is "errors only" (CLI_CONTRACT.md), never "nothing": +/// the yarn-PnP refusal is an error exit, so it must still print the +/// refusal to stderr under `--silent`. Without this, `apply --silent` +/// on a PnP checkout exits 1 with zero output — undiagnosable in CI +/// logs (the same contract violation class fixed in `setup`/`scan`). +#[test] +fn yarn_pnp_refusal_still_prints_error_under_silent() { + let dir = tempfile::tempdir().unwrap(); + make_yarn_berry_project(dir.path()); + let index = stage_applicable_package(dir.path()); + + let (code, stdout, stderr) = run(dir.path(), &["apply", "--silent"]); + assert_eq!( + code, 1, + "expected exit 1.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + // The error itself must survive --silent, and it must be THIS + // error: pin both the layout and the workaround, same as the + // human-mode test. + assert!( + stderr.contains("Plug'n'Play"), + "--silent is errors-only, not nothing: stderr must still name the \ + Plug'n'Play layout, got:\n{stderr}" + ); + assert!( + stderr.contains("yarn patch"), + "--silent is errors-only, not nothing: stderr must still point at \ + `yarn patch`, got:\n{stderr}" + ); + // --silent must not smuggle the message onto stdout instead (that + // would break `2>/dev/null`-style CI splits) and must not emit a + // JSON envelope without --json. + assert!( + stdout.trim().is_empty(), + "--silent human mode should write the error to stderr only, got stdout:\n{stdout}" + ); + // Same pre-apply-bail guarantee as the other refusal tests. + assert_eq!( + std::fs::read(&index).unwrap(), + ORIGINAL_BYTES, + "yarn-PnP refusal (--silent) must NOT patch the on-disk file" + ); + assert_pristine_package_dir(index.parent().unwrap()); +} + /// Negative control: a plain npm layout (no `.pnp.cjs`) must NOT /// surface the yarn-pnp error code. The apply may still fail for /// unrelated reasons (no matching packages on disk, etc.) — we diff --git a/crates/socket-patch-cli/tests/e2e_scan.rs b/crates/socket-patch-cli/tests/e2e_scan.rs index 14b358ea..c888df3b 100644 --- a/crates/socket-patch-cli/tests/e2e_scan.rs +++ b/crates/socket-patch-cli/tests/e2e_scan.rs @@ -98,13 +98,24 @@ fn git_sha256_file(path: &Path) -> String { } fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let out: Output = Command::new(binary()) - .args(args) - .current_dir(cwd) - .env_remove("SOCKET_API_TOKEN") - .env_remove("SOCKET_API_URL") - .output() - .expect("failed to execute socket-patch binary"); + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + // The binary binds a wide `SOCKET_*` env surface (SOCKET_CWD, + // SOCKET_DRY_RUN, SOCKET_GLOBAL, SOCKET_GLOBAL_PREFIX, SOCKET_PROXY_URL, + // SOCKET_MANIFEST_PATH, ...). An ambient value silently changes what + // these tests exercise — SOCKET_DRY_RUN=true turns every `scan --apply` + // into a no-op preview, and SOCKET_GLOBAL aims mutations at the host's + // *real* global node_modules. Scrub the whole prefix so only the flags + // each test passes are in effect; removing SOCKET_API_TOKEN also forces + // the public proxy (free-tier). Telemetry opt-outs are deliberately kept + // so an opted-out dev stays opted out. + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + cmd.env_remove(&key); + } + } + let out: Output = cmd.output().expect("failed to execute socket-patch binary"); let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let stderr = String::from_utf8_lossy(&out.stderr).to_string(); diff --git a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs index 645c0e4d..ba588024 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs @@ -56,10 +56,11 @@ fn has_command(cmd: &str) -> bool { /// `SOCKET_*` var scrubbed. fn bun(cwd: &Path, args: &[&str], cache_dir: &Path) -> Output { let mut cmd = Command::new("bun"); - cmd.args(args) - .current_dir(cwd) - .env("BUN_INSTALL_CACHE_DIR", cache_dir); + cmd.args(args).current_dir(cwd); + // Scrub BEFORE seeding: scrub_socket_env removes BUN_INSTALL_CACHE_DIR, + // and Command's last env call wins. scrub_socket_env(&mut cmd); + cmd.env("BUN_INSTALL_CACHE_DIR", cache_dir); cmd.output().expect("failed to run bun") } @@ -179,6 +180,14 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() { ); return; } + // Hermeticity guard: the install must have gone through the PRIVATE cache. + // If BUN_INSTALL_CACHE_DIR never reached the child, bun silently used the + // user's global cache and the fresh-checkout "empty cache" premise is void. + assert!( + cache.is_dir() && std::fs::read_dir(&cache).unwrap().next().is_some(), + "fixture install did not populate the private BUN_INSTALL_CACHE_DIR at {}", + cache.display() + ); let installed_index = proj.join("node_modules").join(DEP).join("index.js"); let orig = std::fs::read(&installed_index).expect("installed index.js"); diff --git a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs index fafa4ea5..e35b96f2 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs @@ -1,4 +1,3 @@ -#![cfg(feature = "cargo")] //! Real-cargo capstone e2e for `socket-patch vendor` — the committability //! proof for the `[patch.crates-io]` + Cargo.lock-surgery wiring. //! diff --git a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs index 0ed861f9..b3dbe8f1 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs @@ -1,4 +1,3 @@ -#![cfg(feature = "composer")] //! Real-composer capstone e2e for `socket-patch vendor` — the composer //! committability proof on the HOST toolchain (the docker twin is //! `docker_e2e_vendor_composer.rs`; this suite adds coverage on developer/CI diff --git a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs index 374c4358..23e75e5d 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs @@ -1,4 +1,4 @@ -#![cfg(all(unix, feature = "golang"))] +#![cfg(unix)] //! Real-go capstone e2e for `socket-patch vendor` — the committability proof //! for the `go.mod` `replace`-directive vendoring, plus the apply↔vendor //! interplay (takeover + yield). diff --git a/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs index eb017c82..9a890ae7 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs @@ -69,12 +69,32 @@ fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { ) } +/// Run npm with ambient `npm_config_*` env scrubbed. npm reads any +/// `npm_config_` variable (case-insensitive) as config wherever the +/// invocation doesn't pin a flag: an ambient `npm_config_dry_run=true` turns +/// the fixture install into a no-op that still exits 0 (so the skip-gate +/// passes and the marker asserts panic), and `npm_config_save=false` +/// suppresses the package-lock.json every later oracle reads. Both verified +/// hostile values are seeded and then scrubbed — `env_remove` clears the seed +/// too, so the child never sees it, but if a scrub line is ever dropped the +/// seed (not a developer's shell) turns the suite red immediately. fn npm(cwd: &Path, args: &[&str]) -> Output { - Command::new("npm") - .args(args) + let mut cmd = Command::new("npm"); + cmd.args(args) .current_dir(cwd) - .output() - .expect("failed to run npm") + .env("npm_config_dry_run", "true") + .env("npm_config_save", "false") + .env_remove("npm_config_dry_run") + .env_remove("npm_config_save"); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy() + .to_ascii_lowercase() + .starts_with("npm_config_") + { + cmd.env_remove(&k); + } + } + cmd.output().expect("failed to run npm") } /// Git-blob SHA-256 (`sha256("blob \0" ++ bytes)`) — the hash format diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs index 70341bfb..9d8386a2 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs @@ -65,18 +65,30 @@ fn corepack(cwd: &Path, pm: &str, args: &[&str]) -> Output { cmd.output().expect("failed to run corepack") } -/// Remove ambient `SOCKET_*` vars and the pnpm store env the harness controls -/// (the `--store-dir` flag is always passed explicitly). +/// Remove ambient `SOCKET_*` / `PNPM_*` / `npm_config_*` vars (the +/// `--store-dir` flag is always passed explicitly). +/// +/// Seed-then-scrub (mirrors e2e_redirect_yarn_berry_build.rs): pnpm lets +/// EVERY `.npmrc` setting be overridden by an `npm_config_*` env var (env +/// outranks the project npmrc), so an ambient `npm_config_node_linker=pnp` +/// was verified to turn the capstone red — pnpm emits a `.pnp.cjs` and +/// `vendor` refuses the project as unsupported Plug'n'Play. The explicit +/// env_remove below clears the seed too, but if the prefix scrub is ever +/// dropped the seed (rather than a developer's ambient shell, which this +/// suite can't rely on) turns the test red immediately. fn scrub_socket_env(cmd: &mut Command) { + cmd.env("npm_config_node_linker", "pnp"); for (k, _) in std::env::vars_os() { - let k = k.to_string_lossy(); - if k.starts_with("SOCKET_") { - cmd.env_remove(k.as_ref()); + let key = k.to_string_lossy(); + if key.starts_with("SOCKET_") + || key.starts_with("PNPM_") + || key.to_ascii_lowercase().starts_with("npm_config_") + { + cmd.env_remove(&k); } } cmd.env_remove("VIRTUAL_ENV"); - cmd.env_remove("PNPM_HOME"); - cmd.env_remove("npm_config_store_dir"); + cmd.env_remove("npm_config_node_linker"); } fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs index e112c3f7..9aa3db16 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs @@ -99,10 +99,21 @@ fn find_uv() -> Option { candidate.is_file().then_some(candidate) } -/// Run a toolchain command with a scrubbed VIRTUAL_ENV + explicit env. +/// Run a toolchain command with ambient python/uv/pip env scrubbed — +/// `PYTHON*` (a `PYTHONPATH` shadow hijacks the marker oracle), `UV_*` +/// (`UV_PROJECT_ENVIRONMENT` moves the venv away from `.venv`), `PIP_*`, +/// and `VIRTUAL_ENV` are all toolchain behavior inputs and must not leak +/// from the developer's shell. Scrub BEFORE seeding the explicit env — the +/// last env call wins. fn tool(exe: &Path, cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> Output { let mut cmd = Command::new(exe); cmd.args(args).current_dir(cwd); + for (k, _) in std::env::vars_os() { + let name = k.to_string_lossy(); + if name.starts_with("PYTHON") || name.starts_with("UV_") || name.starts_with("PIP_") { + cmd.env_remove(&k); + } + } cmd.env_remove("VIRTUAL_ENV"); for (k, v) in env { cmd.env(k, v); @@ -229,6 +240,28 @@ fn python_oracle(venv: &Path, cwd: &Path) -> String { String::from_utf8_lossy(&out.stdout).trim().to_string() } +/// RED guards for the `tool()` hermeticity scrub: bake the hostile ambient +/// values in so this suite fails deterministically if the leak returns. +/// `UV_PROJECT_ENVIRONMENT` must be scrubbed (or `uv sync` builds the venv +/// away from `.venv` and `site_packages` panics) and the `PYTHONPATH` shadow +/// `six` must be scrubbed (or the marker oracle imports the shadow and every +/// patched-wheel assert dies on AttributeError). Constant paths only — both +/// tests share this process's environment. +fn bake_leak_guards() { + let shadow = std::env::temp_dir().join("socket-e2e-pypi-shadow"); + std::fs::create_dir_all(&shadow).unwrap(); + std::fs::write( + shadow.join("six.py"), + "# ambient shadow module - no SOCKET_PATCHED attr\n", + ) + .unwrap(); + std::env::set_var("PYTHONPATH", &shadow); + std::env::set_var( + "UV_PROJECT_ENVIRONMENT", + std::env::temp_dir().join("socket-e2e-uv-env-leak"), + ); +} + fn copy_dir_recursive(src: &Path, dst: &Path) { std::fs::create_dir_all(dst).unwrap(); for entry in std::fs::read_dir(src).unwrap() { @@ -250,6 +283,7 @@ fn uv_vendor_fresh_checkout_frozen_offline_and_revert() { println!("SKIP e2e_vendor_pypi_build(uv): `uv` not on PATH or at ~/.local/bin/uv"); return; }; + bake_leak_guards(); let tmp = tempfile::tempdir().unwrap(); let proj = tmp.path().join("proj"); std::fs::create_dir_all(&proj).unwrap(); @@ -447,6 +481,7 @@ fn pip_requirements_vendor_fresh_checkout_no_index_and_revert() { println!("SKIP e2e_vendor_pypi_build(pip): no python3/python on PATH"); return; }; + bake_leak_guards(); let tmp = tempfile::tempdir().unwrap(); let proj = tmp.path().join("proj"); std::fs::create_dir_all(&proj).unwrap(); diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs index e715b8f7..d770a699 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs @@ -57,34 +57,37 @@ fn has_corepack_pm(pm: &str) -> bool { fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> Output { let mut cmd = Command::new("corepack"); - cmd.arg(pm) - .args(args) - .current_dir(cwd) - .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cmd.arg(pm).args(args).current_dir(cwd); + // Scrub FIRST (it removes YARN_* / SOCKET_* from the inherited env), then + // seed the hermetic flags so they survive (Command: last env call wins). + scrub_socket_env(&mut cmd); + cmd.env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); for (k, v) in extra_env { cmd.env(k, v); } - scrub_socket_env(&mut cmd); cmd.output().expect("failed to run corepack") } -/// Remove ambient `SOCKET_*` vars and the yarn cache/global env the harness -/// controls (so a developer's settings can't leak into the child). +/// Remove ambient `SOCKET_*` and `YARN_*` vars (so a developer's settings +/// can't leak into the child). fn scrub_socket_env(cmd: &mut Command) { + // Seed-then-scrub (mirrors e2e_redirect_yarn_berry_build.rs): yarn berry + // lets EVERY `.yarnrc.yml` setting be overridden by a `YARN_*` env var + // (env outranks the project yarnrc), so an ambient `YARN_NODE_LINKER=pnp` + // was verified to turn this test red — the fixture install builds a PnP + // tree and node_modules/left-pad never exists. The explicit env_remove + // below clears the seed too, but if the scrub is ever dropped the seed + // (rather than a developer's ambient shell, which this suite can't rely + // on) turns the test red immediately. + cmd.env("YARN_NODE_LINKER", "pnp"); for (k, _) in std::env::vars_os() { - let k = k.to_string_lossy(); - if k.starts_with("SOCKET_") { - cmd.env_remove(k.as_ref()); + let key = k.to_string_lossy(); + if key.starts_with("SOCKET_") || key.starts_with("YARN_") { + cmd.env_remove(&k); } } cmd.env_remove("VIRTUAL_ENV"); - for v in [ - "YARN_CACHE_FOLDER", - "YARN_GLOBAL_FOLDER", - "YARN_ENABLE_GLOBAL_CACHE", - ] { - cmd.env_remove(v); - } + cmd.env_remove("YARN_NODE_LINKER"); } fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { @@ -182,6 +185,23 @@ fn yarn_berry_vendor_fresh_checkout_immutable_check_cache_and_revert() { // 1. REAL fixture: yarn berry install (network allowed here, private // global cache). let global = tmp.path().join("yarn-global"); + // RED guard (e2e_vendor_bun_build bug class): the seeded YARN_GLOBAL_FOLDER + // must actually reach the corepack child — a scrub that runs AFTER the + // extra_env seed silently wipes it (Command: last env call wins) and every + // install below quietly uses the developer's real `~/.yarn/berry`. + let probe = corepack( + &proj, + YARN_BERRY, + &["config", "get", "globalFolder"], + &[("YARN_GLOBAL_FOLDER", global.to_str().unwrap())], + ); + let reported = String::from_utf8_lossy(&probe.stdout); + assert!( + probe.status.success() && reported.trim().ends_with("yarn-global"), + "seeded YARN_GLOBAL_FOLDER must survive the env scrub (scrub must run \ + before the seed); yarn reports globalFolder = `{}`", + reported.trim() + ); let install = corepack( &proj, YARN_BERRY, diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs index 465ad4e4..a5763a62 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs @@ -63,14 +63,16 @@ fn has_corepack_pm(pm: &str) -> bool { /// prompt disabled, and every `SOCKET_*` var scrubbed. fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> Output { let mut cmd = Command::new("corepack"); - cmd.arg(pm) - .args(args) - .current_dir(cwd) - .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cmd.arg(pm).args(args).current_dir(cwd); + // Scrub FIRST (it removes YARN_CACHE_FOLDER / SOCKET_* from the inherited + // env), then seed the hermetic flags so they survive (Command: last env + // call wins). Scrubbing last wiped the caller's private cache override, + // so the fixture install silently used the developer's global cache. + scrub_socket_env(&mut cmd); + cmd.env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); for (k, v) in extra_env { cmd.env(k, v); } - scrub_socket_env(&mut cmd); cmd.output().expect("failed to run corepack") } @@ -201,6 +203,16 @@ fn yarn_classic_vendor_fresh_checkout_frozen_offline_install_and_revert() { return; } + // Hermeticity guard: the install must have gone through the PRIVATE cache. + // If YARN_CACHE_FOLDER never reached the child, yarn silently used the + // user's global cache and the fresh-checkout "empty cache" premise is + // void (a leaked run even parks the PATCHED tarball in the global cache). + assert!( + cache.is_dir() && std::fs::read_dir(&cache).unwrap().next().is_some(), + "fixture install did not populate the private YARN_CACHE_FOLDER at {}", + cache.display() + ); + let installed_index = proj.join("node_modules").join(DEP).join("index.js"); let orig = std::fs::read(&installed_index).expect("installed index.js"); assert!( @@ -369,6 +381,15 @@ fn yarn_classic_vendor_fresh_checkout_frozen_offline_install_and_revert() { String::from_utf8_lossy(&ci.stdout), String::from_utf8_lossy(&ci.stderr), ); + // Same guard for the fresh install: yarn unpacks even `file:` tarballs + // through its cache, so an untouched fresh_cache means the GLOBAL cache + // served the install and the offline-from-vendored-tarball proof is + // vacuous. + assert!( + fresh_cache.is_dir() && std::fs::read_dir(&fresh_cache).unwrap().next().is_some(), + "fresh install did not populate the private YARN_CACHE_FOLDER at {}", + fresh_cache.display() + ); let fresh_installed = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); assert!( diff --git a/crates/socket-patch-cli/tests/e2e_vex.rs b/crates/socket-patch-cli/tests/e2e_vex.rs index 3803b54c..939c9614 100644 --- a/crates/socket-patch-cli/tests/e2e_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_vex.rs @@ -23,29 +23,19 @@ use socket_patch_core::manifest::schema::{ PatchFileInfo, PatchManifest, PatchRecord, SetupConfig, VulnerabilityInfo, }; -/// Always-compiled setup-supported ecosystems, declared `manual` in test -/// fixtures so the property-7 setup-state filter -/// (`commands/setup::configured_ecosystems`) does not drop these patches — -/// these tests exercise VEX document GENERATION, not setup state, so they opt -/// every patch in via the `manual` escape hatch. Feature-gated apply-only -/// ecosystems (maven/nuget) are appended by [`all_manual`] when their cargo -/// feature is on. (`cargo`/`golang`/`composer` are listed here unconditionally: -/// when their feature is absent `ecosystem_from_manual_name` maps them to -/// `None`, so the entry is a harmless no-op — the same reason maven/nuget are -/// safe to add, just made explicit for the two that gate a matrix test.) +/// Setup-supported ecosystems, declared `manual` in test fixtures so the +/// property-7 setup-state filter (`commands/setup::configured_ecosystems`) +/// does not drop these patches — these tests exercise VEX document +/// GENERATION, not setup state, so they opt every patch in via the `manual` +/// escape hatch. The apply-only ecosystems (maven/nuget) are appended by +/// [`all_manual`]. const ALL_MANUAL: &[&str] = &["npm", "pypi", "cargo", "golang", "gem", "composer"]; -/// [`ALL_MANUAL`] plus the feature-gated apply-only ecosystems (maven/nuget), -/// appended only under their cargo feature — mirroring the `#[cfg]` arms in -/// `vex::ecosystem_from_manual_name`. Declaring an ecosystem `manual` whose -/// feature is not compiled in is a silent no-op (the name resolves to `None`), -/// but gating the append keeps the emitted `setup.manual` honest to the build -/// and lets the all-ecosystem agent matrix below declare every one of the 8. +/// [`ALL_MANUAL`] plus the apply-only ecosystems (maven/nuget), so the +/// all-ecosystem agent matrix below can declare every one of the 8. fn all_manual() -> Vec { let mut names: Vec = ALL_MANUAL.iter().map(|s| (*s).to_string()).collect(); - #[cfg(feature = "maven")] names.push("maven".to_string()); - #[cfg(feature = "nuget")] names.push("nuget".to_string()); names } @@ -289,21 +279,10 @@ fn two_patches_sharing_ghsa_merge_subcomponents() { // // Unlike the redirect matrix — whose patches bypass BOTH property 7 and // `Ecosystem::from_purl` via the `redirected` set — an agent patch routes -// through `Ecosystem::from_purl` + the `manual` allowlist. That only resolves -// for ecosystems whose cargo feature is compiled in, so getting all 8 -// statements requires every ecosystem feature present: hence the all-features -// cfg gate (cargo/golang/nuget are default; maven/composer are the opt-ins -// this gate additionally demands). Each statement must carry a PLAIN impact -// statement (NO `(vendored)`/`(redirected)` marker — that is what distinguishes -// agent provenance) and preserve the (possibly qualified) PURL verbatim as the -// subcomponent id. -#[cfg(all( - feature = "cargo", - feature = "golang", - feature = "composer", - feature = "maven", - feature = "nuget" -))] +// through `Ecosystem::from_purl` + the `manual` allowlist. Each statement must +// carry a PLAIN impact statement (NO `(vendored)`/`(redirected)` marker — that +// is what distinguishes agent provenance) and preserve the (possibly +// qualified) PURL verbatim as the subcomponent id. #[test] fn no_verify_attests_agent_patches_across_ecosystems() { let tmp = tempfile::tempdir().unwrap(); @@ -368,8 +347,8 @@ fn no_verify_attests_agent_patches_across_ecosystems() { ), ); } - // write_manifest stamps setup.manual = all_manual(), which under this - // all-features cfg declares every one of the 8 ecosystems. + // write_manifest stamps setup.manual = all_manual(), which declares + // every one of the 8 ecosystems. write_manifest(cwd, &manifest); let out = cli() diff --git a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs index 8facf6d5..7833aaec 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs @@ -354,11 +354,10 @@ fn redirected_no_verify_attests_without_installed_tree() { // ────────────────────────────────────────────────────────────────────── // 5. every ecosystem attests through the redirect ledger, including the -// ones whose crawler/feature isn't compiled by default (maven/nuget/ -// composer) and the qualified-PURL variants (pypi `?artifact_id=`, gem -// `?platform=`, maven `?classifier=&ext=`). The redirect bypass means these -// need neither the ecosystem's cargo feature nor a real toolchain, and the -// qualified PURL must survive verbatim as the subcomponent id. +// qualified-PURL variants (pypi `?artifact_id=`, gem `?platform=`, maven +// `?classifier=&ext=`). The redirect bypass means these need no real +// toolchain, and the qualified PURL must survive verbatim as the +// subcomponent id. // ────────────────────────────────────────────────────────────────────── #[test] diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs index 77b767a9..c4d06e59 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -378,6 +378,89 @@ fn property7_vendored_purl_bypasses_setup_manual_filter() { ); } +/// The property-7 vendored exemption (and the "(vendored)" phrasing) must +/// survive `--no-verify`: the exemption's rationale — the committed +/// `.socket/vendor/` artifact + lockfile wiring IS the persistence +/// mechanism — is about how the patch persists, not about whether this run +/// hashed it. The vendored classification comes from the committed ledger, +/// which `--no-verify` can read without hashing anything (the artifact dir +/// is deliberately ABSENT here to pin that no hashing happens). +#[test] +fn property7_vendored_exemption_survives_no_verify() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let vendored_purl = "pkg:cargo/serde@1.0.0"; + + // Ledger only — no artifact on disk. `--no-verify` must not care. + write_vendor_state(cwd, vendored_purl, ".socket/vendor/cargo/absent"); + + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + vendored_purl.to_string(), + make_record( + UUID, + "src/lib.rs", + &"b".repeat(64), + "GHSA-vend-dddd", + &["CVE-2024-6"], + ), + ); + // Control: an npm patch with no hook configured and no `manual` + // declaration — property 7 must still drop it under `--no-verify` + // (the filter runs regardless of verification mode). + manifest.patches.insert( + "pkg:npm/unconfigured-pkg@1.0.0".to_string(), + make_record( + "11111111-1111-4111-8111-111111111111", + "package/index.js", + &"c".repeat(64), + "GHSA-npm-control", + &["CVE-2024-7"], + ), + ); + // NO setup section: nothing configured, nothing manual. + write_manifest(cwd, &manifest, false); + + let out = cli() + .args([ + "vex", + "--no-verify", + "--cwd", + cwd.to_str().unwrap(), + "--product", + "pkg:cargo/app@1.0.0", + ]) + .output() + .expect("invoke vex"); + assert!( + out.status.success(), + "--no-verify must keep the vendored patch's property-7 exemption. stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + let stdout = String::from_utf8(out.stdout).unwrap(); + let doc: Value = serde_json::from_str(&stdout).unwrap(); + let stmts = doc["statements"].as_array().unwrap(); + assert_eq!( + stmts.len(), + 1, + "only the vendored patch bypasses property 7 under --no-verify; the \ + unconfigured npm control must still be dropped. doc:\n{stdout}" + ); + assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-vend-dddd"); + let impact = stmts[0]["impact_statement"].as_str().unwrap(); + assert_eq!( + impact, + format!("Patched via Socket patch {UUID} (vendored)"), + "--no-verify must not lose the (vendored) provenance marker" + ); + assert!( + !stdout.contains("GHSA-npm-control"), + "the non-vendored, non-configured npm patch must be filtered even \ + under --no-verify:\n{stdout}" + ); +} + // ────────────────────────────────────────────────────────────────────── // 4. legacy go-patches redirect regression — an apply-redirected Go patch // must verify against the `.socket/go-patches/` copy dir (the bytes the diff --git a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs index 0d844acb..0235fb08 100644 --- a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs +++ b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs @@ -32,9 +32,6 @@ //! reported `rolledBack == 1` for that exact PURL. A broken/removed //! rollback dispatch branch yields zero discovered packages → the //! assertions fail loudly. -//! -//! Feature-gated ecosystems (cargo/golang/maven/composer/nuget) are -//! `#[cfg(feature = "X")]`-gated so they only run with that feature on. use std::path::{Path, PathBuf}; use std::process::Command; @@ -66,6 +63,39 @@ fn write_root_package_json(root: &Path) { .unwrap(); } +/// Hermeticity scrub for the apply/rollback helpers. The binary binds a wide +/// `SOCKET_*` env surface; an ambient value silently changes the branch under +/// test — `SOCKET_DRY_RUN=true` turns every rollback into a no-op +/// (`rolledBack: 0`, bytes never restored) and `SOCKET_MANIFEST_PATH` points +/// apply at a manifest that isn't there (`noManifest`, exit 0). Both verified +/// red against unscrubbed helpers. Seed-then-scrub: hostile values for the +/// vars that break these tests are set first, then the whole prefix is +/// removed — if the scrub ever stops running, the seeds turn every test in +/// this file red immediately. Telemetry opt-outs are deliberately kept so an +/// opted-out dev stays opted out (`--offline` already disables telemetry). +fn scrub_socket_env(cmd: &mut Command) { + const HOSTILE_SEEDS: &[(&str, &str)] = &[ + ("SOCKET_DRY_RUN", "true"), + ("SOCKET_GLOBAL", "true"), + ("SOCKET_GLOBAL_PREFIX", "/nonexistent"), + ("SOCKET_MANIFEST_PATH", "/nonexistent/manifest.json"), + ]; + for (k, v) in HOSTILE_SEEDS { + cmd.env(k, v); + } + // Explicit removes cover the seeds (they are not in the parent env); + // the vars_os() sweep covers whatever the ambient shell/CI exported. + for (k, _) in HOSTILE_SEEDS { + cmd.env_remove(k); + } + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + cmd.env_remove(&key); + } + } +} + /// Write a minimal manifest with one (file-less) patch for the given PURL. fn write_manifest(root: &Path, purl: &str) { let socket = root.join(".socket"); @@ -91,19 +121,18 @@ fn write_manifest(root: &Path, purl: &str) { /// Run `socket-patch apply --offline --json --ecosystems ` and return /// the exit code + parsed envelope. fn run_apply_for_ecosystem(cwd: &Path, ecosystem: &str) -> (i32, Value) { - let out = Command::new(binary()) - .args([ - "apply", - "--offline", - "--json", - "--ecosystems", - ecosystem, - "--silent", - ]) - .current_dir(cwd) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run socket-patch"); + let mut cmd = Command::new(binary()); + cmd.args([ + "apply", + "--offline", + "--json", + "--ecosystems", + ecosystem, + "--silent", + ]) + .current_dir(cwd); + scrub_socket_env(&mut cmd); + let out = cmd.output().expect("run socket-patch"); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let env: Value = serde_json::from_str(stdout.trim()) .unwrap_or_else(|e| panic!("apply envelope must parse ({e}); stdout={stdout}")); @@ -201,7 +230,7 @@ fn assert_apply_not_dispatched(env: &Value, ecosystem: &str, out_of_scope_purls: } // --------------------------------------------------------------------------- -// Default-feature ecosystems: npm, pypi, gem +// Unconditional install-hook ecosystems: npm, pypi, gem // --------------------------------------------------------------------------- #[test] @@ -235,10 +264,9 @@ fn dispatch_branch_gem() { } // --------------------------------------------------------------------------- -// Feature-gated ecosystems +// Remaining ecosystems // --------------------------------------------------------------------------- -#[cfg(feature = "cargo")] #[test] fn dispatch_branch_cargo() { let tmp = tempfile::tempdir().unwrap(); @@ -249,7 +277,6 @@ fn dispatch_branch_cargo() { assert_apply_dispatched(code, &env, "cargo", &[purl]); } -#[cfg(feature = "golang")] #[test] fn dispatch_branch_golang() { let tmp = tempfile::tempdir().unwrap(); @@ -260,11 +287,10 @@ fn dispatch_branch_golang() { assert_apply_dispatched(code, &env, "golang", &[purl]); } -#[cfg(feature = "maven")] #[test] // Experimental ecosystem: the maven backend is unfinished, so this dispatch // e2e is kept OFF the blocking CI suite (it must not gate progress on maven). -// Still compiled, and runnable on demand: `--features maven -- --ignored`. +// Still compiled, and runnable on demand with `-- --ignored`. #[ignore = "experimental ecosystem (maven): not gating CI until the maven backend is implemented; run with --ignored"] fn dispatch_branch_maven() { let tmp = tempfile::tempdir().unwrap(); @@ -275,7 +301,6 @@ fn dispatch_branch_maven() { assert_apply_dispatched(code, &env, "maven", &[purl]); } -#[cfg(feature = "composer")] #[test] fn dispatch_branch_composer() { let tmp = tempfile::tempdir().unwrap(); @@ -286,11 +311,10 @@ fn dispatch_branch_composer() { assert_apply_dispatched(code, &env, "composer", &[purl]); } -#[cfg(feature = "nuget")] #[test] // Experimental ecosystem: the nuget backend is unfinished, so this dispatch // e2e is kept OFF the blocking CI suite (it must not gate progress on nuget). -// Still compiled, and runnable on demand: `--features nuget -- --ignored`. +// Still compiled, and runnable on demand with `-- --ignored`. #[ignore = "experimental ecosystem (nuget): not gating CI until the nuget backend is implemented; run with --ignored"] fn dispatch_branch_nuget() { let tmp = tempfile::tempdir().unwrap(); @@ -456,7 +480,11 @@ fn run_rollback( if global { cmd.arg("--global"); } - cmd.current_dir(cwd).env_remove("SOCKET_API_TOKEN"); + cmd.current_dir(cwd); + // Scrub BEFORE seeding fixture envs: the fixture list includes + // SOCKET_-prefixed vars (SOCKET_EXPERIMENTAL_MAVEN/NUGET) that the + // prefix sweep would otherwise wipe (last env call per key wins). + scrub_socket_env(&mut cmd); for (k, v) in envs { cmd.env(k, v); } @@ -678,15 +706,15 @@ fn rollback_dispatch_filter_excludes_out_of_scope_package() { let tmp = tempfile::tempdir().unwrap(); write_root_package_json(tmp.path()); let fixture = fixture_npm(tmp.path()); - // Sanity: an in-scope rollback DOES restore (proves the fixture is valid - // and the differential below is meaningful, not vacuously a no-op). + // Out of scope first: the pypi crawler must not discover (or touch) the + // npm fixture — the file stays PATCHED. assert_rollback_not_dispatched(tmp.path(), "pypi", &fixture); - // After the out-of-scope no-op the file is still PATCHED; now the matching - // ecosystem must actually restore it to ORIGINAL. + // Then in scope: the SAME fixture must actually restore to ORIGINAL. + // This doubles as the sanity proof that the fixture is valid, so the + // out-of-scope no-op above was meaningful, not vacuous. assert_rollback_restored(tmp.path(), "npm", &fixture); } -#[cfg(feature = "cargo")] #[test] fn rollback_dispatch_branch_cargo() { let tmp = tempfile::tempdir().unwrap(); @@ -723,7 +751,6 @@ fn rollback_dispatch_branch_cargo() { assert_rollback_restored(root, "cargo", &fixture); } -#[cfg(feature = "golang")] #[test] fn rollback_dispatch_branch_golang() { let tmp = tempfile::tempdir().unwrap(); @@ -750,10 +777,9 @@ fn rollback_dispatch_branch_golang() { assert_rollback_restored(root, "golang", &fixture); } -#[cfg(feature = "maven")] #[test] // Experimental ecosystem (maven), kept OFF the blocking CI suite — see the -// note on `dispatch_branch_maven`. Run with `--features maven -- --ignored`. +// note on `dispatch_branch_maven`. Run with `-- --ignored`. #[ignore = "experimental ecosystem (maven): not gating CI until the maven backend is implemented; run with --ignored"] fn rollback_dispatch_branch_maven() { let tmp = tempfile::tempdir().unwrap(); @@ -781,7 +807,6 @@ fn rollback_dispatch_branch_maven() { assert_rollback_restored(root, "maven", &fixture); } -#[cfg(feature = "composer")] #[test] fn rollback_dispatch_branch_composer() { let tmp = tempfile::tempdir().unwrap(); @@ -973,12 +998,11 @@ fn setup_check_json_global_prefix_stdout_is_pure_json() { assert!(report["files"].is_array(), "stdout={stdout:?}"); } -#[cfg(feature = "nuget")] #[test] // Experimental ecosystem (nuget), kept OFF the blocking CI suite — see the // note on `dispatch_branch_nuget`. This is the test that was failing in CI // (the nuget rollback crawler discovers 0 packages). Run with -// `--features nuget -- --ignored`. +// `-- --ignored`. #[ignore = "experimental ecosystem (nuget): not gating CI until the nuget backend is implemented; run with --ignored"] fn rollback_dispatch_branch_nuget() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs b/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs index 5c41d3c1..9f1a9020 100644 --- a/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/get_batch_paths_e2e.rs @@ -24,39 +24,26 @@ const ORG_SLUG: &str = "test-org"; const UUID_A: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const UUID_B: &str = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; -/// Every `SOCKET_*` env var that `get`/`GlobalArgs` reads as an `#[arg(env=…)]` -/// fallback. These subprocess tests assert an EXACT envelope, so any one of -/// these leaking in from the ambient shell (CI, a dev's `.envrc`, etc.) could -/// silently redirect the command to a different path (offline mode, a real -/// api-url, download-only, …) and make a broken impl look green. We scrub the -/// whole surface so behavior is fully determined by the explicit CLI flags. -const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_API_TOKEN", - "SOCKET_API_URL", - "SOCKET_ORG_SLUG", - "SOCKET_SAVE_ONLY", - "SOCKET_YES", - "SOCKET_JSON", - "SOCKET_OFFLINE", - "SOCKET_FORCE", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_DOWNLOAD_ONLY", - "SOCKET_ALL_RELEASES", - "SOCKET_BATCH_SIZE", - "SOCKET_CWD", - "SOCKET_DEBUG", - "SOCKET_DRY_RUN", - "SOCKET_ECOSYSTEMS", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_MANIFEST_PATH", - "SOCKET_ONE_OFF", - "SOCKET_PROXY_URL", - "SOCKET_SILENT", - "SOCKET_SKIP_ROLLBACK", - "SOCKET_VERBOSE", - "SOCKET_VEX", -]; +/// Scrub the binary's entire `SOCKET_*` env surface (keeping telemetry +/// opt-outs, so an opted-out dev stays opted out) before spawning. These +/// subprocess tests assert an EXACT envelope, so any `#[arg(env=…)]` +/// fallback leaking in from the ambient shell (CI, a dev's `.envrc`, …) +/// can silently redirect the command to a different path (offline mode, a +/// real api-url, …) — or, for value-parsed args like `SOCKET_LOCK_TIMEOUT` +/// (u64) and `SOCKET_VENDOR_SOURCE` (enum), turn every invocation into an +/// exit-2 usage error before `get` even runs. A fixed allowlist here +/// rotted as flags were added (it predated `SOCKET_LOCK_TIMEOUT`, +/// `SOCKET_STRICT`, `SOCKET_VENDOR_*`, `SOCKET_PATCH_SERVER_URL`, +/// `SOCKET_BREAK_LOCK` — ambient `SOCKET_LOCK_TIMEOUT=bogus` failed 6 of +/// these 7 tests); the prefix scrub can't rot. +fn scrub_socket_env(cmd: &mut Command) { + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + cmd.env_remove(&key); + } + } +} /// Run `socket-patch get ` with `--json --save-only --yes` /// against `api_url` (authenticated mode). Returns (code, stdout, stderr). @@ -82,9 +69,7 @@ fn run_get_auth( args.extend_from_slice(extra); let mut cmd = Command::new(binary()); cmd.args(&args).current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); - } + scrub_socket_env(&mut cmd); let out = cmd.output().expect("run socket-patch"); ( out.status.code().unwrap_or(-1), @@ -187,11 +172,20 @@ async fn get_by_purl_with_multiple_patches_emits_selection_required() { ); } - // The error text must instruct the user how to disambiguate. + // The error text must instruct the user how to disambiguate — and the + // instruction must be one the CLI actually accepts. `--id` is a boolean + // type-tag (see get_id_flag_does_not_accept_a_value below), so selection + // happens by re-running with the chosen UUID as the positional + // identifier; the old "Specify --id " wording sent users straight + // into a clap usage error. let err = v["error"].as_str().unwrap_or(""); assert!( - err.contains("--id"), - "selection_required error must instruct the user to specify --id; got {err:?}" + !err.contains("--id <"), + "error must not instruct the value-taking `--id ` form the CLI rejects; got {err:?}" + ); + assert!( + err.to_lowercase().contains("re-run") && err.to_lowercase().contains("uuid"), + "error must direct the user to re-run with one of the listed UUIDs; got {err:?}" ); } @@ -200,11 +194,13 @@ async fn get_by_purl_with_multiple_patches_emits_selection_required() { /// usage error: exit code 2, a clap error on stderr naming the stray /// argument, and crucially NO JSON envelope on stdout. /// -/// Production inconsistency (reported, not fixed here): the -/// `selection_required` message instructs users to "Specify --id ", -/// which contradicts `--id` being a boolean flag — there is no -/// value-taking UUID selector to drive a "specified UUID didn't match a -/// candidate" branch. This test locks the *actual* CLI contract. +/// This contract is why the `selection_required` wording matters: +/// selection happens by re-running with the chosen UUID as the positional +/// identifier (`get --id`), never by passing a value to `--id`. +/// The envelope's error text used to instruct the impossible +/// "Specify --id " form; the selection test above pins the +/// corrected instruction, and this test locks the boolean CLI contract +/// it depends on. #[tokio::test] async fn get_id_flag_does_not_accept_a_value() { let mock = MockServer::start().await; // must never be reached diff --git a/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs b/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs index 01428810..eff3139e 100644 --- a/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs +++ b/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs @@ -4,15 +4,17 @@ //! match) and a few error paths the main get_invariants suite doesn't //! reach. -use std::path::PathBuf; -use std::process::Command; - use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() -} +// Every invocation must go through `common::run`/`run_with_env`: the binary +// binds a wide `SOCKET_*` env surface, and a raw `Command::new(binary())` +// inherits the developer's shell — an exported `SOCKET_ONE_OFF=true` aborts +// every `get` here, `SOCKET_PROXY_URL` outranks the proxy these tests pin, +// and `SOCKET_MANIFEST_PATH` makes a *passing* test write its manifest and +// blobs into whatever real project the variable points at. +#[path = "common/mod.rs"] +mod common; const ORG_SLUG: &str = "test-org"; const UUID_A: &str = "11111111-1111-4111-8111-111111111111"; @@ -34,8 +36,9 @@ async fn received_paths(mock: &MockServer) -> Vec { fn get_one_off_and_save_only_together_errors() { // The two flags are mutually exclusive — using both must fail. let tmp = tempfile::tempdir().unwrap(); - let out = Command::new(binary()) - .args([ + let (code, stdout, _stderr) = common::run( + tmp.path(), + &[ "get", UUID_A, "--one-off", @@ -48,12 +51,9 @@ fn get_one_off_and_save_only_together_errors() { "fake", "--org", ORG_SLUG, - ]) - .current_dir(tmp.path()) - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(1)); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + ], + ); + assert_eq!(code, 1); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "error"); let err = v["error"].as_str().expect("error message"); @@ -118,8 +118,9 @@ async fn get_with_id_flag_selects_specific_patch() { let tmp = tempfile::tempdir().unwrap(); let _ = purl; let _ = encoded; - let out = Command::new(binary()) - .args([ + let (code, stdout, _stderr) = common::run( + tmp.path(), + &[ "get", UUID_B, "--id", @@ -132,12 +133,8 @@ async fn get_with_id_flag_selects_specific_patch() { "fake", "--org", ORG_SLUG, - ]) - .current_dir(tmp.path()) - .output() - .expect("run"); - let code = out.status.code().unwrap_or(-1); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + ], + ); assert_eq!( code, 0, "--id fetch-by-UUID of a free patch must succeed; stdout={stdout}" @@ -209,8 +206,9 @@ async fn get_with_no_matching_purl_emits_not_found() { .await; let tmp = tempfile::tempdir().unwrap(); - let out = Command::new(binary()) - .args([ + let (code, stdout, _stderr) = common::run( + tmp.path(), + &[ "get", purl, "--save-only", @@ -222,16 +220,12 @@ async fn get_with_no_matching_purl_emits_not_found() { "fake", "--org", ORG_SLUG, - ]) - .current_dir(tmp.path()) - .output() - .expect("run"); + ], + ); assert_eq!( - out.status.code(), - Some(0), + code, 0, "an empty (but successful) lookup is exit 0, not an error" ); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "not_found", "stdout={stdout}"); assert_eq!(v["found"], 0, "stdout={stdout}"); @@ -274,27 +268,29 @@ async fn get_by_package_with_single_paid_patch_emits_paid_required() { .await; let tmp = tempfile::tempdir().unwrap(); - let out = Command::new(binary()) - .args([ + let uri = mock.uri(); + // Seed the proxy under its MODERN name: `SOCKET_PROXY_URL` outranks the + // legacy `SOCKET_PATCH_PROXY_URL` in `proxy_url_from_env`, so pinning the + // legacy name alone loses to an ambient modern one. The scrub in + // `run_with_env` also strips any ambient `SOCKET_API_TOKEN`, forcing the + // public-proxy (free-tier) client this test is about. + let (code, stdout, _stderr) = common::run_with_env( + tmp.path(), + &[ "get", purl, "--save-only", "--yes", "--json", "--api-url", - &mock.uri(), - ]) - .current_dir(tmp.path()) - .env("SOCKET_PATCH_PROXY_URL", mock.uri()) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run"); + &uri, + ], + &[("SOCKET_PROXY_URL", uri.as_str())], + ); assert_eq!( - out.status.code(), - Some(0), + code, 0, "a recognized-but-paywalled patch is not an error exit" ); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); // The mock returned exactly one paid patch and canAccessPaidPatches=false, // so the deterministic outcome is paid_required — not a vague "anything @@ -347,8 +343,9 @@ async fn get_with_invalid_search_purl_falls_through() { .await; let tmp = tempfile::tempdir().unwrap(); - let out = Command::new(binary()) - .args([ + let (code, stdout, _stderr) = common::run( + tmp.path(), + &[ "get", "just-a-package-name", "--save-only", @@ -360,16 +357,12 @@ async fn get_with_invalid_search_purl_falls_through() { "fake", "--org", ORG_SLUG, - ]) - .current_dir(tmp.path()) - .output() - .expect("run"); + ], + ); assert_eq!( - out.status.code(), - Some(0), + code, 0, "package-name fallback over an empty workspace is a clean exit 0" ); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); // Deterministic outcome: the un-typed identifier fell through to the // package search, which found nothing installed. @@ -422,8 +415,9 @@ async fn get_uuid_returns_paid_patch_with_token_succeeds() { .await; let tmp = tempfile::tempdir().unwrap(); - let out = Command::new(binary()) - .args([ + let (code, stdout, _stderr) = common::run( + tmp.path(), + &[ "get", UUID_A, "--save-only", @@ -435,12 +429,8 @@ async fn get_uuid_returns_paid_patch_with_token_succeeds() { "real-token-but-not-validated-by-mock", "--org", ORG_SLUG, - ]) - .current_dir(tmp.path()) - .output() - .expect("run"); - let code = out.status.code().unwrap_or(-1); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + ], + ); assert_eq!( code, 0, "paid patch via authenticated path must succeed; stdout={stdout}" @@ -472,12 +462,9 @@ async fn get_uuid_returns_paid_patch_with_token_succeeds() { #[test] fn get_help_lists_all_identifier_flags() { - let out = Command::new(binary()) - .args(["get", "--help"]) - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + let tmp = tempfile::tempdir().unwrap(); + let (code, stdout, _stderr) = common::run(tmp.path(), &["get", "--help"]); + assert_eq!(code, 0); for flag in [ "--id", "--cve", @@ -540,8 +527,9 @@ async fn get_on_vendored_purl_warns_about_uuid_drift() { ) .unwrap(); - let out = Command::new(binary()) - .args([ + let (code, stdout, _stderr) = common::run( + tmp.path(), + &[ "get", UUID_B, "--id", @@ -554,12 +542,8 @@ async fn get_on_vendored_purl_warns_about_uuid_drift() { "fake", "--org", ORG_SLUG, - ]) - .current_dir(tmp.path()) - .output() - .expect("run"); - let code = out.status.code().unwrap_or(-1); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + ], + ); assert_eq!(code, 0, "explicit get still succeeds; stdout={stdout}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success", "stdout={stdout}"); @@ -579,3 +563,101 @@ async fn get_on_vendored_purl_warns_about_uuid_drift() { "warning must point at the remedy; got: {w}" ); } + +#[tokio::test] +async fn get_uuid_replacing_existing_manifest_entry_reports_updated() { + // CLI_CONTRACT.md's `PatchAction` vocabulary: `updated` — emitted by + // `apply`, `scan --sync`, `get` — means "a different UUID replaced an + // older one for this PURL. `oldUuid` set." The fetch-by-UUID save path + // (`save_and_apply_patch`) must classify against the pre-insert manifest + // exactly like `download_and_apply_patches` does; reporting the + // replacement as `added` (without `oldUuid`) hides the overwrite from + // consumers that diff on `action == "updated"` — including the jq + // recipe the contract itself documents. + let mock = MockServer::start().await; + let purl = "pkg:npm/replace-me@1.0.0"; + + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{UUID_B}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID_B, + "purl": purl, + "publishedAt": "2024-02-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "Newer patch for the same purl", + "license": "MIT", + "tier": "free", + }))) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().unwrap(); + // The manifest already records this purl at UUID_A. + let socket_dir = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket_dir).unwrap(); + std::fs::write( + socket_dir.join("manifest.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "patches": { purl: { + "uuid": UUID_A, + "exportedAt": "2024-01-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "Older patch", + "license": "MIT", + "tier": "free", + }} + })) + .unwrap(), + ) + .unwrap(); + + let (code, stdout, _stderr) = common::run( + tmp.path(), + &[ + "get", + UUID_B, + "--id", + "--save-only", + "--yes", + "--json", + "--api-url", + &mock.uri(), + "--api-token", + "fake", + "--org", + ORG_SLUG, + ], + ); + assert_eq!( + code, 0, + "replacing an existing entry succeeds; stdout={stdout}" + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!(v["status"], "success", "stdout={stdout}"); + assert_eq!( + v["downloaded"], 1, + "an update is a real download; stdout={stdout}" + ); + assert_eq!( + v["patches"][0]["action"], "updated", + "a different uuid at the same purl is `updated`, not `added`; stdout={stdout}" + ); + assert_eq!( + v["patches"][0]["oldUuid"], UUID_A, + "`updated` must carry the uuid it replaced; stdout={stdout}" + ); + // Contract: the metadata block rides `added` AND `updated` records. + assert_eq!( + v["patches"][0]["description"], "Newer patch for the same purl", + "updated records must carry patch metadata; stdout={stdout}" + ); + // And the manifest really moved to the new uuid. + let body = std::fs::read_to_string(socket_dir.join("manifest.json")).unwrap(); + let m: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!( + m["patches"][purl]["uuid"], UUID_B, + "manifest must now record the replacement uuid; manifest={m}" + ); +} diff --git a/crates/socket-patch-cli/tests/get_invariants.rs b/crates/socket-patch-cli/tests/get_invariants.rs index c22e70be..a96531b0 100644 --- a/crates/socket-patch-cli/tests/get_invariants.rs +++ b/crates/socket-patch-cli/tests/get_invariants.rs @@ -3,15 +3,13 @@ //! package-name search) plus the save-and-apply / paid / not-found //! error paths. Real-API integration stays in `e2e_npm.rs`. -use std::path::{Path, PathBuf}; -use std::process::Command; +use std::path::Path; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() -} +#[path = "common/mod.rs"] +mod common; const ORG_SLUG: &str = "test-org"; const UUID: &str = "11111111-1111-4111-8111-111111111111"; @@ -20,6 +18,13 @@ const AFTER_HASH: &str = "111111111111111111111111111111111111111111111111111111 /// base64 "cGF0Y2hlZAo=" decodes to exactly these bytes. const BLOB_BYTES: &[u8] = b"patched\n"; +/// Run `get` via `common::run_with_env`, which scrubs the ambient +/// `SOCKET_*` environment before spawning. The binary binds a wide env +/// surface (`SOCKET_ONE_OFF`, `SOCKET_MANIFEST_PATH`, `SOCKET_CWD`, +/// `SOCKET_OFFLINE`, ...); an ambient value silently changes what these +/// tests exercise — `SOCKET_ONE_OFF=true` alone fails every invocation +/// here ("--one-off and --save-only cannot be used together"), and +/// `SOCKET_MANIFEST_PATH` aims the manifest write OUTSIDE the tempdir. fn run_get(cwd: &Path, api_url: &str, identifier: &str, extra: &[&str]) -> (i32, String, String) { let mut args = vec![ "get", @@ -35,16 +40,7 @@ fn run_get(cwd: &Path, api_url: &str, identifier: &str, extra: &[&str]) -> (i32, ORG_SLUG, ]; args.extend_from_slice(extra); - let out = Command::new(binary()) - .args(&args) - .current_dir(cwd) - .output() - .expect("run socket-patch"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) + common::run_with_env(cwd, &args, &[]) } /// PatchResponse JSON suitable as a `view/{uuid}` response. All fields @@ -462,29 +458,34 @@ async fn get_uuid_paid_patch_via_public_proxy_emits_paid_required_envelope() { .await; let tmp = tempfile::tempdir().expect("tempdir"); - let out = Command::new(binary()) - .args([ + // No --api-token / --org: the scrubbed env (common::run_with_env + // strips ambient SOCKET_*, including SOCKET_API_TOKEN and the + // canonical SOCKET_PROXY_URL, which outranks the legacy var seeded + // below) makes the binary fall back to the public proxy — the mock. + let uri = mock.uri(); + let (code, stdout, stderr) = common::run_with_env( + tmp.path(), + &[ "get", UUID, "--json", "--save-only", "--yes", "--api-url", - &mock.uri(), - ]) - .current_dir(tmp.path()) - .env("SOCKET_PATCH_PROXY_URL", mock.uri()) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run socket-patch"); - - let stdout = String::from_utf8_lossy(&out.stdout); + &uri, + ], + &[("SOCKET_PATCH_PROXY_URL", uri.as_str())], + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { - panic!( - "invalid JSON envelope: {e}\nstdout:\n{stdout}\nstderr:\n{}", - String::from_utf8_lossy(&out.stderr) - ) + panic!("invalid JSON envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") }); + // paid_required is a clean (non-error) outcome — same contract as + // not_found: status and $? must agree, and 0 is the documented code. + assert_eq!( + code, 0, + "paid_required must exit 0; stdout={stdout}; stderr={stderr}" + ); assert_eq!( v["status"], "paid_required", "UUID-fetched paid patch via public proxy must emit paid_required; got {v}" @@ -507,8 +508,11 @@ async fn get_uuid_paid_patch_via_public_proxy_emits_paid_required_envelope() { async fn get_paid_patch_via_public_proxy_returns_paid_required() { // When using the public proxy (no api-token + no org), a paid patch // returns a `paid_required` status. To simulate this we DON'T pass - // --api-token / --org so the binary falls back to the public proxy. - // We also have to point SOCKET_PATCH_PROXY_URL at the mock. + // --api-token / --org so the binary falls back to the public proxy + // (the scrubbed env guarantees no ambient SOCKET_API_TOKEN / + // SOCKET_PROXY_URL interferes). We also have to point + // SOCKET_PATCH_PROXY_URL (the legacy alias, injected post-scrub) at + // the mock. let mock = MockServer::start().await; let purl = "pkg:npm/paidpkg@1.0.0"; let encoded = "pkg%3Anpm%2Fpaidpkg%401.0.0"; @@ -532,28 +536,31 @@ async fn get_paid_patch_via_public_proxy_returns_paid_required() { .await; let tmp = tempfile::tempdir().expect("tempdir"); - let out = Command::new(binary()) - .args([ + let uri = mock.uri(); + let (code, stdout, stderr) = common::run_with_env( + tmp.path(), + &[ "get", purl, "--json", "--save-only", "--yes", "--api-url", - &mock.uri(), - ]) - .current_dir(tmp.path()) - .env("SOCKET_PATCH_PROXY_URL", mock.uri()) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run socket-patch"); - - let stdout = String::from_utf8_lossy(&out.stdout); + &uri, + ], + &[("SOCKET_PATCH_PROXY_URL", uri.as_str())], + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); // A single paid patch with no paid access must emit `paid_required` // with zero downloads/applies and the patch echoed back as paid. // Asserting merely `!= success` would let a generic error envelope // (or any other status) pass and mask a broken paid-path branch. + // Like not_found, paid_required is a clean outcome: exit 0. + assert_eq!( + code, 0, + "paid_required must exit 0; stdout={stdout}; stderr={stderr}" + ); assert_eq!( v["status"], "paid_required", "paid patch without token must emit paid_required; got: {v}" diff --git a/crates/socket-patch-cli/tests/global_packages_e2e.rs b/crates/socket-patch-cli/tests/global_packages_e2e.rs index 28104e04..78646c18 100644 --- a/crates/socket-patch-cli/tests/global_packages_e2e.rs +++ b/crates/socket-patch-cli/tests/global_packages_e2e.rs @@ -27,6 +27,36 @@ fn binary() -> PathBuf { env!("CARGO_BIN_EXE_socket-patch").into() } +/// Build a `socket-patch` `Command` with the ambient `SOCKET_*` env +/// surface scrubbed (mirrors `common::run_with_env`). The binary binds +/// ~20 `SOCKET_*` vars via clap, so an ambient value silently changes +/// what these tests exercise — `SOCKET_DRY_RUN=true` flips every apply +/// envelope's `dryRun` field, and `SOCKET_GLOBAL_PREFIX` bypasses the +/// npm/yarn/pnpm resolution chain this file exists to cover. The +/// highest-risk vars are seeded with hostile values and then scrubbed — +/// `env_remove` clears the seed too, so the child never sees it, but if +/// a scrub line is ever dropped the seed (rather than a developer's +/// ambient shell, which the suite can't rely on) turns the tests red +/// immediately. Telemetry opt-outs are kept so an opted-out dev stays +/// opted out. +fn cli(cwd: &Path) -> Command { + let mut cmd = Command::new(binary()); + cmd.current_dir(cwd); + cmd.env("SOCKET_DRY_RUN", "true") + .env("SOCKET_GLOBAL_PREFIX", "/nonexistent") + .env("SOCKET_MANIFEST_PATH", "/nonexistent/manifest.json") + .env_remove("SOCKET_DRY_RUN") + .env_remove("SOCKET_GLOBAL_PREFIX") + .env_remove("SOCKET_MANIFEST_PATH"); + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + cmd.env_remove(&key); + } + } + cmd +} + fn write_manifest(root: &Path, purl: &str) { let socket = root.join(".socket"); std::fs::create_dir_all(&socket).unwrap(); @@ -174,10 +204,8 @@ fn apply_global_resolves_real_npm_prefix() { let tmp = tempfile::tempdir().unwrap(); write_manifest(tmp.path(), "pkg:npm/__global_test__@1.0.0"); - let out = Command::new(binary()) + let out = cli(tmp.path()) .args(["apply", "--global", "--offline", "--json", "--silent"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") .output() .expect("run socket-patch"); let code = out.status.code().unwrap_or(-1); @@ -194,10 +222,8 @@ fn rollback_global_resolves_real_npm_prefix() { let tmp = tempfile::tempdir().unwrap(); write_manifest(tmp.path(), "pkg:npm/__rollback_global__@1.0.0"); - let out = Command::new(binary()) + let out = cli(tmp.path()) .args(["rollback", "--global", "--offline", "--json", "--silent"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") .output() .expect("run socket-patch"); let code = out.status.code().unwrap_or(-1); @@ -232,7 +258,7 @@ fn apply_global_prefix_uses_explicit_path() { write_manifest(tmp.path(), PREFIX_PURL); let run = |cwd: &Path| { - let out = Command::new(binary()) + let out = cli(cwd) .args([ "apply", "--global", @@ -242,8 +268,6 @@ fn apply_global_prefix_uses_explicit_path() { "--json", "--silent", ]) - .current_dir(cwd) - .env_remove("SOCKET_API_TOKEN") .output() .expect("run socket-patch"); ( @@ -281,7 +305,7 @@ fn rollback_global_prefix_uses_explicit_path() { write_manifest(tmp.path(), PREFIX_PURL); let run = || { - let out = Command::new(binary()) + let out = cli(tmp.path()) .args([ "rollback", "--global", @@ -291,8 +315,6 @@ fn rollback_global_prefix_uses_explicit_path() { "--json", "--silent", ]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") .output() .expect("run socket-patch"); ( @@ -356,10 +378,8 @@ fn apply_global_with_empty_path_handles_missing_npm() { let tmp = tempfile::tempdir().unwrap(); write_manifest(tmp.path(), "pkg:npm/__missing_npm__@1.0.0"); - let out = Command::new(binary()) + let out = cli(tmp.path()) .args(["apply", "--global", "--offline", "--json", "--silent"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") // Empty PATH so no package-manager binary can be located. .env("PATH", "/nonexistent-dir-for-test") .output() @@ -378,10 +398,8 @@ fn rollback_global_with_empty_path_handles_missing_npm() { let tmp = tempfile::tempdir().unwrap(); write_manifest(tmp.path(), "pkg:npm/__missing_npm__@1.0.0"); - let out = Command::new(binary()) + let out = cli(tmp.path()) .args(["rollback", "--global", "--offline", "--json", "--silent"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") .env("PATH", "/nonexistent-dir-for-test") .output() .expect("run socket-patch"); @@ -443,10 +461,8 @@ fn apply_global_with_stub_npm_root_resolves_path() { write_manifest(tmp.path(), "pkg:npm/__stubbed_npm__@1.0.0"); - let out = Command::new(binary()) + let out = cli(tmp.path()) .args(["apply", "--global", "--offline", "--json", "--silent"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") .env("PATH", stub_dir.to_str().unwrap()) .output() .expect("run socket-patch"); @@ -486,10 +502,8 @@ fn apply_global_with_empty_npm_root_output_handles_error() { write_manifest(tmp.path(), "pkg:npm/__empty_npm__@1.0.0"); - let out = Command::new(binary()) + let out = cli(tmp.path()) .args(["apply", "--global", "--offline", "--json", "--silent"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") .env("PATH", stub_dir.to_str().unwrap()) .output() .expect("run socket-patch"); @@ -519,10 +533,8 @@ fn apply_global_with_failing_npm_handles_error() { write_manifest(tmp.path(), "pkg:npm/__failing_npm__@1.0.0"); - let out = Command::new(binary()) + let out = cli(tmp.path()) .args(["apply", "--global", "--offline", "--json", "--silent"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") .env("PATH", stub_dir.to_str().unwrap()) .output() .expect("run socket-patch"); diff --git a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs index ba527e21..0cb8e237 100644 --- a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs +++ b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs @@ -52,6 +52,47 @@ fn has(cmd: &str) -> bool { .unwrap_or(false) } +/// Build a package-manager `Command` with ambient PM-config env scrubbed by +/// case-insensitive prefix. Package managers read config from env and env +/// outranks project config: an ambient `npm_config_dry_run=true` turns a +/// fixture install into an exit-0 no-op (npm/pnpm/bun honor `npm_config_*`), +/// `YARN_NODE_LINKER=pnp` flips berry to a PnP layout with no `node_modules` +/// (env outranks `.yarnrc.yml`), and `BUNDLE_FROZEN=true` fails the bundler +/// install into a silent SKIP — all false verdicts unrelated to the code +/// under test. +/// +/// For prefixes with a verified-hostile knob the hostile value is seeded and +/// then explicitly removed: the child never sees it, but if a scrub line is +/// ever dropped the seed (not a developer's shell) turns the suite red +/// immediately. Seed test-specific env AFTER this helper — `Command` env +/// calls apply in order, so a later scrub would wipe the seed. +fn pm_command(program: &str, prefixes: &[&str]) -> Command { + let mut cmd = Command::new(program); + for p in prefixes { + match *p { + "npm_config_" => { + cmd.env("npm_config_dry_run", "true") + .env_remove("npm_config_dry_run"); + } + "YARN_" => { + cmd.env("YARN_NODE_LINKER", "pnp") + .env_remove("YARN_NODE_LINKER"); + } + _ => {} + } + } + for (k, _) in std::env::vars_os() { + let name = k.to_string_lossy().to_ascii_lowercase(); + if prefixes + .iter() + .any(|p| name.starts_with(&p.to_ascii_lowercase())) + { + cmd.env_remove(&k); + } + } + cmd +} + fn default_apply(cwd: &Path) -> ApplyArgs { ApplyArgs { common: socket_patch_cli::args::GlobalArgs { @@ -111,7 +152,7 @@ async fn yarn_install_then_apply_patches_file() { ) .unwrap(); - let status = Command::new("yarn") + let status = pm_command("yarn", &["npm_config_", "YARN_"]) .args(["install", "--silent", "--no-progress"]) .current_dir(tmp.path()) .stdout(std::process::Stdio::piped()) @@ -171,7 +212,7 @@ async fn pnpm_install_then_apply_patches_file() { ) .unwrap(); - let status = Command::new("pnpm") + let status = pm_command("pnpm", &["npm_config_"]) .args(["install", "--silent", "--no-frozen-lockfile"]) .current_dir(tmp.path()) .stdout(std::process::Stdio::piped()) @@ -271,7 +312,7 @@ async fn npm_workspaces_monorepo_apply() { r#"{ "name": "a", "version": "1.0.0", "dependencies": { "ms": "2.1.3" } }"#, ) .unwrap(); - let status = Command::new("npm") + let status = pm_command("npm", &["npm_config_"]) .args(["install", "--silent", "--no-audit", "--no-fund"]) .current_dir(tmp.path()) .output() @@ -337,9 +378,12 @@ gem 'colorize', '1.1.0' ) .unwrap(); // Install into a local vendor/bundle path to avoid touching the - // user's gem environment. - let status = Command::new("bundle") - .args(["install", "--path", "vendor/bundle", "--quiet"]) + // user's gem environment. The path is passed via `BUNDLE_PATH` (not the + // legacy `--path` flag, which Bundler ≥3 removed — with it this leg + // silently SKIPped on every modern machine and never ran). + let status = pm_command("bundle", &["BUNDLE_"]) + .args(["install", "--quiet"]) + .env("BUNDLE_PATH", "vendor/bundle") .current_dir(tmp.path()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -433,7 +477,7 @@ async fn bun_install_then_apply_patches_file() { // Private cache so the fixture install never touches the user's bun cache. let cache = tmp.path().join("bun-cache"); - let status = Command::new("bun") + let status = pm_command("bun", &["npm_config_", "BUN_"]) .args(["install", "--no-progress"]) .current_dir(tmp.path()) .env("BUN_INSTALL_CACHE_DIR", &cache) @@ -517,7 +561,7 @@ async fn yarn_berry_node_modules_linker_apply_patches_file() { .unwrap(); let global = tmp.path().join("yarn-global"); - let status = Command::new("corepack") + let status = pm_command("corepack", &["npm_config_", "YARN_"]) .args(["yarn@4.12.0", "install"]) .current_dir(tmp.path()) .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") diff --git a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs index 2d0de09a..8fbf8c53 100644 --- a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs @@ -1,4 +1,3 @@ -#![cfg(feature = "cargo")] //! In-process full-apply test for the cargo (Rust) ecosystem. //! //! Adds `cfg-if = "=1.0.0"` to a Cargo.toml, runs `cargo fetch` against diff --git a/crates/socket-patch-cli/tests/in_process_get.rs b/crates/socket-patch-cli/tests/in_process_get.rs index 7c822c85..ca859448 100644 --- a/crates/socket-patch-cli/tests/in_process_get.rs +++ b/crates/socket-patch-cli/tests/in_process_get.rs @@ -538,12 +538,35 @@ async fn get_with_explicit_package_flag_resolves_installed_and_saves() { // Conflict flags (--one-off + --save-only) // --------------------------------------------------------------------------- +/// Assert the mounted mock saw zero requests — the up-front-rejection +/// oracle for the flag-validation tests below. A dead (unreachable) API +/// cannot prove "rejected before any fetch": a run that ignored the flag, +/// fetched, and failed on the dead socket produces the same exit 1 and +/// the same absent manifest. Against a LIVE mock the regressed flow +/// instead fetches successfully and saves, so all three oracles trip. +async fn assert_no_api_requests(server: &MockServer) { + let requests = server.received_requests().await.unwrap(); + assert!( + requests.is_empty(), + "flag must be rejected before any API call, saw: {:?}", + requests + .iter() + .map(|r| r.url.path().to_string()) + .collect::>() + ); +} + #[tokio::test] #[serial] async fn get_one_off_with_save_only_errors() { + // Live mock (not a dead socket) so the zero-request oracle below can + // distinguish up-front rejection from fetch-and-fail. + let (server, url) = start_wiremock().await; + make_view_mock(&server, UUID, PURL, "free").await; + let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(UUID, tmp.path()); - args.common.api_url = "http://127.0.0.1:1".to_string(); // unreachable + args.common.api_url = url; args.one_off = true; args.save_only = true; @@ -551,26 +574,34 @@ async fn get_one_off_with_save_only_errors() { assert_eq!(code, 1, "conflicting flags must exit 1"); // The conflict is rejected up front, before any fetch — nothing saved. assert_no_manifest(tmp.path()); + assert_no_api_requests(&server).await; } #[tokio::test] #[serial] -async fn get_one_off_without_identifier_validation() { - // CAVEAT: `--one-off` is NOT specially handled in the UUID path — there - // is no "not yet implemented" stub (the original comment here was wrong). - // With the API unreachable, the UUID fetch fails and `report_fetch_failure` - // returns exit 1. So this test really exercises the network-failure path - // with one_off set, not a one-off stub. We pin the observable contract: - // exit 1 and nothing written. +async fn get_one_off_is_an_honest_not_implemented_error() { + // `--one-off` was a silent no-op for three majors: the flag parsed but + // was never read past the `--save-only` conflict check, so the patch + // was saved to the manifest anyway — lying about persistence. It now + // fails honestly, BEFORE any network or disk activity. The previous + // version of this test used an unreachable API, which proved nothing: + // the regressed flow's fetch failed on the dead socket with the same + // exit 1 and no manifest, so the exact historical regression passed. + // With a live view mock the regressed flow fetches and saves, so it + // now trips all three oracles (exit 0, manifest written, request seen). + let (server, url) = start_wiremock().await; + make_view_mock(&server, UUID, PURL, "free").await; + let tmp = tempfile::tempdir().unwrap(); let mut args = default_args(UUID, tmp.path()); - args.common.api_url = "http://127.0.0.1:1".to_string(); + args.common.api_url = url; args.one_off = true; args.save_only = false; let code = run(args).await; - assert_eq!(code, 1, "unreachable API fetch must exit 1"); + assert_eq!(code, 1, "--one-off must fail as not-yet-implemented"); assert_no_manifest(tmp.path()); + assert_no_api_requests(&server).await; } // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/in_process_get_corrupt_manifest.rs b/crates/socket-patch-cli/tests/in_process_get_corrupt_manifest.rs new file mode 100644 index 00000000..eb878d8c --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_get_corrupt_manifest.rs @@ -0,0 +1,85 @@ +//! In-process regression test for `get `'s save step +//! (`save_and_apply_patch`): a manifest that EXISTS but cannot be parsed +//! must be a hard error. Historically the read error was swallowed into +//! an EMPTY manifest which the save step then unconditionally rewrote +//! with just the one fetched patch — silently destroying every +//! previously tracked record. The download flow's identical guard lives +//! in `in_process_get_update_count.rs`. + +use serial_test::serial; +use socket_patch_cli::args::GlobalArgs; +use socket_patch_cli::commands::get::{run, GetArgs}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const UUID: &str = "33333333-3333-4333-8333-333333333333"; +const PURL: &str = "pkg:npm/corrupt-manifest-pkg@1.0.0"; + +#[tokio::test] +#[serial] +async fn uuid_get_with_corrupt_manifest_fails_without_clobbering() { + let server = MockServer::start().await; + // The patch fetch itself succeeds: the failure must come from the + // manifest read in the save step, and must not rewrite the file. + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2024-06-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": "0000000000000000000000000000000000000000000000000000000000000000", + "afterHash": "1111111111111111111111111111111111111111111111111111111111111111", + "blobContent": "cGF0Y2hlZAo=", + "beforeBlobContent": "b3JpZ2luYWwK", + } + }, + "vulnerabilities": {}, + "description": "corrupt manifest test patch", "license": "MIT", "tier": "free", + }))) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + // E.g. a git merge left conflict markers in a committed manifest. + let corrupt = "<<<<<<< HEAD\n{ \"patches\": {} }\n"; + std::fs::write(socket.join("manifest.json"), corrupt).unwrap(); + + let args = GetArgs { + identifier: UUID.to_string(), + common: GlobalArgs { + cwd: tmp.path().to_path_buf(), + api_url: server.uri(), + api_token: Some("fake-token".to_string()), + org: Some(ORG.to_string()), + proxy_url: server.uri(), + json: true, + no_telemetry: true, + ..GlobalArgs::default() + }, + id: true, + cve: false, + ghsa: false, + package: false, + // save_only isolates the save path from the apply step. + save_only: true, + one_off: false, + all_releases: false, + }; + + let code = run(args).await; + assert_eq!( + code, 1, + "an unreadable manifest must fail the run, not be treated as empty" + ); + + let body = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); + assert_eq!( + body, corrupt, + "a corrupt manifest must be left untouched, never overwritten" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs b/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs new file mode 100644 index 00000000..b361b985 --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs @@ -0,0 +1,253 @@ +//! In-process regression tests: `get` must honor the global +//! `--manifest-path` flag and a relative `--cwd`. +//! +//! Regression guards: +//! +//! 1. `--manifest-path` / `SOCKET_MANIFEST_PATH` is a documented global +//! flag ("Manifest location, resolved relative to `--cwd`") honored by +//! apply/list/remove/rollback/repair/vendor/unlock — and by scan's own +//! discovery and GC — but `get`'s save paths hardcoded +//! `/.socket/manifest.json` (and `/.socket/blobs`). A `get` +//! under a custom manifest path saved the patch to a location every +//! other command then ignores: `list`/`apply` with the same +//! `SOCKET_MANIFEST_PATH` reported no patches at all. +//! +//! 2. The nested apply step was handed `/.socket/manifest.json` as a +//! STRING that apply re-resolves against `--cwd` +//! (`resolved_manifest_path`), double-joining any relative cwd: +//! `get --cwd proj ` made the nested apply look for +//! `proj/proj/.socket/manifest.json`, hit the no-manifest clean no-op, +//! and report success (`applied: 1`, exit 0) without patching anything. + +use std::path::Path; + +use serial_test::serial; +use sha2::{Digest, Sha256}; +use socket_patch_cli::args::GlobalArgs; +use socket_patch_cli::commands::get::{run, GetArgs}; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const UUID: &str = "33333333-3333-4333-8333-333333333333"; +const PURL: &str = "pkg:npm/manifest-path-test@1.0.0"; + +const ORIGINAL: &[u8] = b"original\n"; +const PATCHED: &[u8] = b"patched\n"; +/// base64 of `PATCHED` / `ORIGINAL`. +const PATCHED_B64: &str = "cGF0Y2hlZAo="; +const ORIGINAL_B64: &str = "b3JpZ2luYWwK"; + +/// Git-blob-style sha256 — the hash shape apply verifies against. +fn git_sha256(content: &[u8]) -> String { + let header = format!("blob {}\0", content.len()); + let mut hasher = Sha256::new(); + hasher.update(header.as_bytes()); + hasher.update(content); + hex::encode(hasher.finalize()) +} + +/// Mount the patch-view endpoint for `UUID`/`PURL` with real (git-blob) +/// hashes so the nested apply can verify and patch. Returns `after_hash`. +async fn mount_view_mock(server: &MockServer) -> String { + let before_hash = git_sha256(ORIGINAL); + let after_hash = git_sha256(PATCHED); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": before_hash, + "afterHash": after_hash, + "blobContent": PATCHED_B64, + "beforeBlobContent": ORIGINAL_B64, + } + }, + "vulnerabilities": {}, + "description": "manifest-path test fixture", + "license": "MIT", + "tier": "free", + }))) + .mount(server) + .await; + after_hash +} + +fn get_args(identifier: &str, cwd: &Path, api_url: String) -> GetArgs { + GetArgs { + common: GlobalArgs { + org: Some(ORG.to_string()), + cwd: cwd.to_path_buf(), + yes: true, + api_token: Some("fake-token-for-tests".to_string()), + api_url, + json: true, + no_telemetry: true, + download_mode: "diff".to_string(), + ..GlobalArgs::default() + }, + identifier: identifier.to_string(), + id: false, + cve: false, + ghsa: false, + package: false, + save_only: true, + one_off: false, + all_releases: false, + } +} + +/// Assert the patch record + blob landed under the CUSTOM manifest +/// location and nothing was written to the default `.socket/`. +fn assert_saved_at(manifest_path: &Path, after_hash: &str, default_socket: &Path) { + let body = std::fs::read_to_string(manifest_path) + .unwrap_or_else(|e| panic!("manifest must be at {}: {e}", manifest_path.display())); + let m: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!( + m["patches"][PURL]["uuid"], UUID, + "custom-path manifest must record the patch; manifest={m}" + ); + // The blob must live NEXT TO the manifest — apply/rollback resolve + // blobs from the manifest's parent dir, not from `/.socket`. + let blob = manifest_path + .parent() + .unwrap() + .join("blobs") + .join(after_hash); + assert!( + blob.exists(), + "blob must be written next to the manifest at {}", + blob.display() + ); + assert!( + !default_socket.join("manifest.json").exists(), + "nothing must be written to the default .socket/ when --manifest-path points elsewhere" + ); +} + +/// Restore the process cwd when a test that changes it exits (pass or +/// panic) so later `#[serial]` tests in this binary aren't poisoned. +struct CwdGuard(std::path::PathBuf); +impl CwdGuard { + fn change_to(dir: &Path) -> Self { + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir).unwrap(); + Self(prev) + } +} +impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.0); + } +} + +// --------------------------------------------------------------------------- +// 1. --manifest-path honored on the UUID flow (save_and_apply_patch) +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn get_by_uuid_honors_custom_manifest_path() { + let server = MockServer::start().await; + let after_hash = mount_view_mock(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let mut args = get_args(UUID, tmp.path(), server.uri()); + args.common.manifest_path = "custom/mp.json".to_string(); + + let code = run(args).await; + assert_eq!(code, 0, "save-only get must succeed"); + + assert_saved_at( + &tmp.path().join("custom/mp.json"), + &after_hash, + &tmp.path().join(".socket"), + ); +} + +// --------------------------------------------------------------------------- +// 2. --manifest-path honored on the search flow (download_and_apply_patches) +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn get_by_purl_honors_custom_manifest_path() { + let server = MockServer::start().await; + let after_hash = mount_view_mock(&server).await; + // PURL identifier → package search → one free patch auto-selected. + Mock::given(method("GET")) + .and(path_regex(format!( + r"^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + let mut args = get_args(PURL, tmp.path(), server.uri()); + args.common.manifest_path = "custom/mp.json".to_string(); + + let code = run(args).await; + assert_eq!(code, 0, "save-only get by PURL must succeed"); + + assert_saved_at( + &tmp.path().join("custom/mp.json"), + &after_hash, + &tmp.path().join(".socket"), + ); +} + +// --------------------------------------------------------------------------- +// 3. Relative --cwd: the nested apply must find the manifest it just wrote +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn get_with_relative_cwd_actually_applies() { + let server = MockServer::start().await; + mount_view_mock(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + // A project in `proj/` with the target package installed. + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("package.json"), + r#"{"name":"r","version":"0.0.0"}"#, + ) + .unwrap(); + let pkg = proj.join("node_modules/manifest-path-test"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + r#"{"name":"manifest-path-test","version":"1.0.0"}"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIGINAL).unwrap(); + + // `--cwd proj`, exactly as a user types it: RELATIVE to the process cwd. + let _cwd = CwdGuard::change_to(tmp.path()); + let mut args = get_args(UUID, Path::new("proj"), server.uri()); + args.save_only = false; // exercise the nested apply step + + let code = run(args).await; + assert_eq!(code, 0, "get + apply under a relative --cwd must succeed"); + assert_eq!( + std::fs::read(pkg.join("index.js")).unwrap(), + PATCHED, + "the nested apply must actually patch the file — reporting success \ + while leaving it untouched means the manifest path was resolved \ + against --cwd twice and apply no-op'd on a missing manifest" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_get_update_count.rs b/crates/socket-patch-cli/tests/in_process_get_update_count.rs index 905a5d0a..04bb8ccf 100644 --- a/crates/socket-patch-cli/tests/in_process_get_update_count.rs +++ b/crates/socket-patch-cli/tests/in_process_get_update_count.rs @@ -57,10 +57,10 @@ fn search_result(uuid: &str, purl: &str) -> PatchSearchResult { fn params(root: &Path, server: &MockServer) -> DownloadParams { DownloadParams { cwd: root.to_path_buf(), + manifest_path: root.join(".socket/manifest.json"), org: Some(ORG.to_string()), // save_only isolates download bookkeeping from the apply step. save_only: true, - one_off: false, global: false, global_prefix: None, json: true, @@ -178,3 +178,58 @@ async fn successful_update_is_counted_once() { "manifest={manifest}" ); } + +/// A manifest that EXISTS but cannot be parsed must be a hard error. +/// `read_manifest` reserves `Ok(None)` for "file missing"; swallowing the +/// `Err` case into an empty manifest means the unconditional +/// `write_manifest` at the end of the run silently REPLACES the corrupt +/// file — destroying every previously tracked patch record — while the +/// run reports a clean `added` success. +#[tokio::test] +#[serial] +async fn corrupt_manifest_is_a_hard_error_not_silently_clobbered() { + let server = MockServer::start().await; + // The detail fetch itself would succeed: the failure must come from + // the manifest read, before anything is downloaded or rewritten. + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{NEW_UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": NEW_UUID, + "purl": PURL, + "publishedAt": "2024-06-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": "0000000000000000000000000000000000000000000000000000000000000000", + "afterHash": "1111111111111111111111111111111111111111111111111111111111111111", + "blobContent": "cGF0Y2hlZAo=", + "beforeBlobContent": "b3JpZ2luYWwK", + } + }, + "vulnerabilities": {}, + "description": "new", "license": "MIT", "tier": "free", + }))) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + // E.g. a git merge left conflict markers in a committed manifest. + let corrupt = "<<<<<<< HEAD\n{ \"patches\": {} }\n"; + std::fs::write(socket.join("manifest.json"), corrupt).unwrap(); + + let selected = vec![search_result(NEW_UUID, PURL)]; + let (code, json) = download_and_apply_patches(&selected, ¶ms(tmp.path(), &server)).await; + + assert_eq!( + code, 1, + "an unreadable manifest must fail the run, not be treated as empty; json={json}" + ); + assert_eq!(json["status"], "error", "json={json}"); + + let body = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); + assert_eq!( + body, corrupt, + "a corrupt manifest must be left untouched, never overwritten" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs index 729494ac..703cbf24 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs @@ -39,7 +39,7 @@ fn git_sha256(content: &[u8]) -> String { /// crawler so the test environment matches what the crawler probes. fn find_python() -> Option<&'static str> { for cmd in ["python3", "python", "py"] { - let ok = Command::new(cmd) + let ok = python_cmd(cmd) .arg("--version") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -57,6 +57,33 @@ fn has_python3() -> bool { find_python().is_some() } +/// Build a `Command` for a python/pip spawn with the hostile ambient env +/// scrubbed. Any `PIP_*` var silently reconfigures every pip invocation: +/// `PIP_DRY_RUN=1` turns `pip install` into an exit-0 no-op and +/// `PIP_TARGET` diverts the install outside the venv — both verified to +/// leave the venv without `six.py`, stranding all four tests at the +/// "six.py not found" assert. `PYTHONHOME`/`PYTHONPATH` reshape the +/// interpreter the venv is built from, so they're cleared too. The two +/// verified hostile values are seeded and then scrubbed — `env_remove` +/// clears the seed as well, so the child never sees it, but if the scrub +/// is ever dropped the seeds (not a developer's ambient shell) turn the +/// suite red immediately. +fn python_cmd(program: impl AsRef) -> Command { + let mut cmd = Command::new(program); + cmd.env("PIP_DRY_RUN", "1") + .env("PIP_TARGET", "/nonexistent") + .env_remove("PIP_DRY_RUN") + .env_remove("PIP_TARGET") + .env_remove("PYTHONHOME") + .env_remove("PYTHONPATH"); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("PIP_") { + cmd.env_remove(&k); + } + } + cmd +} + /// Path to `pip` inside the given venv. PEP-405 mandates a different /// layout per platform: `Scripts\pip.exe` on Windows, /// `bin/pip` on Unix. @@ -73,14 +100,14 @@ fn venv_pip(venv: &Path) -> PathBuf { fn install_six(tmp: &Path) -> PathBuf { let venv = tmp.join(".venv"); let python = find_python().expect("python interpreter not on PATH"); - let status = Command::new(python) + let status = python_cmd(python) .args(["-m", "venv", venv.to_str().unwrap()]) .status() .expect("python venv"); assert!(status.success(), "failed to create venv"); let pip = venv_pip(&venv); - let status = Command::new(&pip) + let status = python_cmd(&pip) .args([ "install", "--disable-pip-version-check", @@ -472,6 +499,24 @@ async fn pypi_apply_dry_run_does_not_modify_file() { the unchanged file does not prove dry-run suppressed a real patch; \ bodies: {batch_bodies:?}" ); + // Discovery alone still doesn't pin the APPLY path: a scan that + // degraded to plain listing (e.g. a broken `--apply` → agent-mode + // fold in `resolve_mode_flags`) also queries batch with the purl, + // exits 0, and leaves the file untouched — vacuously green. In JSON + // mode only the agent-mode apply branch fetches per-package patch + // details (`discover_selected`, which runs before the dry-run gate), + // so requiring that fetch proves dry-run reached the apply path with + // a real patch selected and then declined to write. Mutation-verified: + // dropping the `--apply` fold passes every assert above but fails here. + assert!( + requests.iter().any(|r| r + .url + .path() + .starts_with(&format!("/v0/orgs/{ORG}/patches/by-package/"))), + "dry-run never fetched per-package patch details — the agent-mode \ + apply branch did not run, so the unchanged file proves nothing \ + about dry-run apply" + ); } // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/in_process_python_envs.rs b/crates/socket-patch-cli/tests/in_process_python_envs.rs index 6c83180e..66f93a89 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -97,6 +97,21 @@ fn assert_not_discovered(bodies: &[String], needle: &str) { ); } +/// Run `scan` with the ambient `VIRTUAL_ENV` scrubbed first. +/// +/// `find_local_venv_site_packages` honors `VIRTUAL_ENV` FIRST and, when it +/// yields a site-packages dir, early-returns WITHOUT scanning `.venv`/`venv` +/// in the cwd. Running this suite from an activated virtualenv (or under +/// direnv auto-activation) therefore made every test scan the shell's venv +/// instead of the planted fixture — false reds across the whole file. Tests +/// are `#[serial]`, so the scrub cannot race another test; +/// `pypi_virtual_env_env_var_override` sets the var deliberately and calls +/// `scan_run` directly. +async fn scan_scrubbed(args: ScanArgs) -> i32 { + std::env::remove_var("VIRTUAL_ENV"); + scan_run(args).await +} + fn default_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { common: socket_patch_cli::args::GlobalArgs { @@ -140,7 +155,10 @@ async fn pypi_venv_layout_discovered() { let server = MockServer::start().await; mock_batch_empty(&server).await; - assert_eq!(scan_run(default_args(tmp.path(), server.uri())).await, 0); + assert_eq!( + scan_scrubbed(default_args(tmp.path(), server.uri())).await, + 0 + ); assert_discovered(&batch_bodies(&server).await, "pkg:pypi/venv-pkg@1.0.0"); } @@ -158,7 +176,10 @@ async fn pypi_venv_python312_layout_discovered() { let server = MockServer::start().await; mock_batch_empty(&server).await; - assert_eq!(scan_run(default_args(tmp.path(), server.uri())).await, 0); + assert_eq!( + scan_scrubbed(default_args(tmp.path(), server.uri())).await, + 0 + ); assert_discovered(&batch_bodies(&server).await, "pkg:pypi/venv-pkg-312@1.0.0"); } @@ -176,7 +197,10 @@ async fn pypi_venv_python313_layout_discovered() { let server = MockServer::start().await; mock_batch_empty(&server).await; - assert_eq!(scan_run(default_args(tmp.path(), server.uri())).await, 0); + assert_eq!( + scan_scrubbed(default_args(tmp.path(), server.uri())).await, + 0 + ); assert_discovered(&batch_bodies(&server).await, "pkg:pypi/venv-pkg-313@1.0.0"); } @@ -220,7 +244,7 @@ async fn pypi_alternate_venv_dir_names() { let server = MockServer::start().await; mock_batch_empty(&server).await; - let res = scan_run(default_args(tmp.path(), server.uri())).await; + let res = scan_scrubbed(default_args(tmp.path(), server.uri())).await; assert_eq!(res, 0, "venv name {venv_name} should scan cleanly"); let bodies = batch_bodies(&server).await; @@ -249,6 +273,7 @@ async fn pypi_virtual_env_env_var_override() { let server = MockServer::start().await; mock_batch_empty(&server).await; + // Deliberately NOT `scan_scrubbed`: this test IS the VIRTUAL_ENV path. std::env::set_var("VIRTUAL_ENV", &custom_venv); let res = scan_run(default_args(tmp.path(), server.uri())).await; std::env::remove_var("VIRTUAL_ENV"); @@ -280,7 +305,10 @@ async fn pypi_dist_info_only_layout() { let server = MockServer::start().await; mock_batch_empty(&server).await; - assert_eq!(scan_run(default_args(tmp.path(), server.uri())).await, 0); + assert_eq!( + scan_scrubbed(default_args(tmp.path(), server.uri())).await, + 0 + ); // A package with no source dir is still a real install and must be // discovered from its dist-info alone. assert_discovered(&batch_bodies(&server).await, "pkg:pypi/dist-only@1.0.0"); @@ -307,7 +335,10 @@ async fn pypi_canonical_name_normalization() { let server = MockServer::start().await; mock_batch_empty(&server).await; - assert_eq!(scan_run(default_args(tmp.path(), server.uri())).await, 0); + assert_eq!( + scan_scrubbed(default_args(tmp.path(), server.uri())).await, + 0 + ); let bodies = batch_bodies(&server).await; // Must be canonicalized to lowercase before hitting the API... assert_discovered(&bodies, "pkg:pypi/sqlalchemy@2.0.30"); @@ -334,7 +365,10 @@ async fn pypi_multiple_python_versions_in_venvs() { let server = MockServer::start().await; mock_batch_empty(&server).await; - assert_eq!(scan_run(default_args(tmp.path(), server.uri())).await, 0); + assert_eq!( + scan_scrubbed(default_args(tmp.path(), server.uri())).await, + 0 + ); // BOTH venvs must be scanned — discovering only one would still exit 0. let bodies = batch_bodies(&server).await; assert_discovered(&bodies, "pkg:pypi/pkg311@1.0.0"); @@ -362,7 +396,10 @@ async fn pypi_empty_site_packages_safe() { let server = MockServer::start().await; mock_batch_empty(&server).await; - assert_eq!(scan_run(default_args(tmp.path(), server.uri())).await, 0); + assert_eq!( + scan_scrubbed(default_args(tmp.path(), server.uri())).await, + 0 + ); let bodies = batch_bodies(&server).await; // The one real package must be discovered (proves the crawl happened). @@ -400,7 +437,10 @@ async fn pypi_malformed_metadata_handled_gracefully() { let server = MockServer::start().await; mock_batch_empty(&server).await; - assert_eq!(scan_run(default_args(tmp.path(), server.uri())).await, 0); + assert_eq!( + scan_scrubbed(default_args(tmp.path(), server.uri())).await, + 0 + ); assert_discovered(&batch_bodies(&server).await, "pkg:pypi/malformed@1.0.0"); } @@ -438,7 +478,7 @@ async fn pypi_egg_info_layout_handled() { let server = MockServer::start().await; mock_batch_empty(&server).await; - let res = scan_run(default_args(tmp.path(), server.uri())).await; + let res = scan_scrubbed(default_args(tmp.path(), server.uri())).await; assert_eq!(res, 0, "egg-info layout must scan cleanly without crashing"); let bodies = batch_bodies(&server).await; // Control: proves the crawler genuinely walked this site-packages dir. @@ -447,3 +487,39 @@ async fn pypi_egg_info_layout_handled() { assert_not_discovered(&bodies, "pkg:pypi/legacy-pkg@1.0.0"); assert_not_discovered(&bodies, "pkg:pypi/legacy_pkg@1.0.0"); } + +// --------------------------------------------------------------------------- +// Ambient VIRTUAL_ENV (activated shell venv) must not hijack the suite +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn pypi_ambient_virtual_env_does_not_hijack_scan() { + // Simulate running the suite from an activated venv: VIRTUAL_ENV points + // at a populated venv OUTSIDE the project. Without the scrub in + // `scan_scrubbed`, the crawler early-returns with the ambient venv's + // site-packages and never reaches the project's `.venv` — the decoy is + // discovered and the local package is not (this reddened 9 of 11 tests + // in this file before the scrub existed). This guard fails if + // `scan_scrubbed` ever stops scrubbing. + let shell = tempfile::tempdir().unwrap(); + let decoy_site = venv_site_packages(&shell.path().join("shell-venv"), "python3.11"); + std::fs::create_dir_all(&decoy_site).unwrap(); + write_dist_info(&decoy_site, "ambient_decoy", "6.6.6"); + std::env::set_var("VIRTUAL_ENV", shell.path().join("shell-venv")); + + let tmp = tempfile::tempdir().unwrap(); + let site = venv_site_packages(&tmp.path().join(".venv"), "python3.11"); + std::fs::create_dir_all(&site).unwrap(); + write_dist_info(&site, "local_pkg", "1.0.0"); + + let server = MockServer::start().await; + mock_batch_empty(&server).await; + assert_eq!( + scan_scrubbed(default_args(tmp.path(), server.uri())).await, + 0 + ); + let bodies = batch_bodies(&server).await; + assert_discovered(&bodies, "pkg:pypi/local-pkg@1.0.0"); + assert_not_discovered(&bodies, "pkg:pypi/ambient-decoy@6.6.6"); +} diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 16fa6f7c..1e7ad5f3 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -910,6 +910,256 @@ async fn scan_redirect_migrates_bun_lockb_then_redirects() { ); } +/// A `socket-patch` Command with the ambient `SOCKET_*` env surface scrubbed, +/// for the subprocess tests below: the binary binds a wide clap env surface +/// (SOCKET_DRY_RUN, SOCKET_OFFLINE, SOCKET_ECOSYSTEMS, SOCKET_PROXY_URL, ...), +/// and an ambient value silently changes what these tests exercise — ambient +/// `SOCKET_DRY_RUN=true` turns the rewrite into a no-op and every on-disk +/// oracle red. Seed-then-scrub (the `common/mod.rs` pattern): the hostile +/// seeds never reach the child because `env_remove` clears them too, but if a +/// scrub line is ever dropped the seed turns the suite red immediately. +/// Telemetry opt-outs are deliberately kept so an opted-out dev stays opted +/// out. The in-process tests above don't need this — their literal `ScanArgs` +/// bypass clap's env bindings entirely. +fn scrubbed_cli() -> std::process::Command { + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.env("SOCKET_DRY_RUN", "true") + .env("SOCKET_OFFLINE", "true") + .env("SOCKET_ECOSYSTEMS", "cargo") + .env("SOCKET_MANIFEST_PATH", "/nonexistent/manifest.json") + .env_remove("SOCKET_DRY_RUN") + .env_remove("SOCKET_OFFLINE") + .env_remove("SOCKET_ECOSYSTEMS") + .env_remove("SOCKET_MANIFEST_PATH"); + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + cmd.env_remove(&key); + } + } + cmd +} + +/// The bun migration's child output must NOT leak into the `--json` stdout +/// envelope: bun prints its own install chatter, and inheriting the parent's +/// stdout would interleave that chatter before the JSON document, breaking +/// every consumer that parses stdout. A deliberately chatty shim emulates a +/// noisy bun. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn bun_migration_output_does_not_corrupt_json_envelope() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "^{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = tmp.path().join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), b"BUN-BINARY-PLACEHOLDER").unwrap(); + + let bin_dir = tmp.path().join("fakebin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let shim = bin_dir.join("bun"); + let bun_lock_body = format!( + "{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n \ + \"{NAME}\": [\"{NAME}@{VERSION}\", \"\", {{}}, \"sha512-UPSTREAMupstream==\"],\n \ + }}\n}}\n" + ); + std::fs::write( + &shim, + format!( + "#!/bin/sh\n\ + # a chatty bun: install progress goes to STDOUT\n\ + echo \"bun install v1.2.19 (canary)\"\n\ + echo \"Saved bun.lock\"\n\ + cat > bun.lock <<'LOCK'\n{bun_lock_body}LOCK\n\ + rm -f bun.lockb\n\ + exit 0\n" + ), + ) + .unwrap(); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + // Subprocess (not in-process) so the shim's PATH injection is scoped to + // the child and the child's stdout can be parsed back. + let out = scrubbed_cli() + .args([ + "scan", + "--redirect", + "--json", + "--yes", + "--cwd", + tmp.path().to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .env( + "PATH", + format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ), + ) + .output() + .expect("run socket-patch"); + assert_eq!( + out.status.code(), + Some(0), + "scan --redirect must succeed; stdout=\n{}\nstderr=\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + let env_json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + panic!( + "--json stdout must be a pure JSON envelope (bun's migration chatter \ + must not leak into it): {e}\nstdout:\n{}", + String::from_utf8_lossy(&out.stdout) + ) + }); + assert_eq!( + env_json["redirect"]["redirected"], 1, + "envelope: {env_json}" + ); + let lock = std::fs::read_to_string(tmp.path().join("bun.lock")).unwrap(); + assert!( + lock.contains(HOSTED_URL), + "the migrated bun.lock must be redirected; got:\n{lock}" + ); +} + +/// A bun.lockb project where NOTHING is redirectable (the only patch's +/// reference is denied) must NOT have its lockfile migrated: the migration +/// exists solely so the bun rewriter can edit a text lock, and running it with +/// no npm override would destructively re-lock the project — bun.lockb deleted, +/// bun.lock created, a ledger written — as a pure side effect of a no-op scan. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn no_redirectable_patch_leaves_bun_lockb_alone() { + let server = MockServer::start().await; + mock_discovery(&server).await; + // Reference endpoint: the patch exists but this org may not download it. + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { UUID: { "status": "forbidden", "url": null, "purl": PURL, "artifacts": [], "registryOverride": null } } + }))) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "^{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = tmp.path().join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), b"BUN-BINARY-PLACEHOLDER").unwrap(); + + // A fake `bun` on PATH that WOULD migrate if invoked — the assertion below + // is that it never runs (bun.lockb survives untouched). + let bin_dir = tmp.path().join("fakebin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let shim = bin_dir.join("bun"); + std::fs::write( + &shim, + "#!/bin/sh\n\ + echo '{ \"lockfileVersion\": 1, \"packages\": {} }' > bun.lock\n\ + rm -f bun.lockb\n\ + exit 0\n", + ) + .unwrap(); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let orig_path = std::env::var("PATH").unwrap_or_default(); + // SAFETY: single-threaded #[serial] test; PATH restored below. + unsafe { + std::env::set_var("PATH", format!("{}:{orig_path}", bin_dir.display())); + } + + let code = run(redirect_args(tmp.path(), server.uri())).await; + + unsafe { + std::env::set_var("PATH", orig_path); + } + assert_eq!(code, 0, "a fully-skipped redirect still exits 0"); + assert!( + tmp.path().join("bun.lockb").exists(), + "bun.lockb must survive a scan that redirected nothing" + ); + assert!( + !tmp.path().join("bun.lock").exists(), + "no text lock may be created when nothing is redirectable" + ); + assert!( + !tmp.path() + .join(".socket/vendor/redirect-state.json") + .exists(), + "no ledger may be written when nothing was redirected" + ); +} + +/// A ledger that cannot be written is an ERROR, not a silent success: the +/// lockfile has already been rewritten, and +/// `.socket/vendor/redirect-state.json` is the only revert path (and the VEX +/// record store), so swallowing the write failure would leave the repo +/// redirected with no way back while reporting success. +#[tokio::test] +#[serial] +async fn unwritable_ledger_fails_the_run() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + // Occupy the ledger path with a DIRECTORY so the ledger write must fail. + std::fs::create_dir_all(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); + + let code = run(redirect_args(tmp.path(), server.uri())).await; + assert_eq!(code, 1, "a failed ledger write must flip the exit code"); + // The failure is about the ledger, not the rewrite: the lockfile edit + // landed before the ledger write was attempted. + let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert!( + lock.contains(HOSTED_URL), + "the lockfile rewrite precedes the ledger write; got:\n{lock}" + ); +} + // ── Rush monorepo ──────────────────────────────────────────────────────── /// A Rush pnpm lock (v9) resolving the patched package under `packages:`, so @@ -1018,7 +1268,7 @@ async fn scan_redirect_rewrites_rush_common_and_subspace_locks() { /// hosting test can't read back. No package-manager binary is needed: the /// rewrite is pure text over the fixture locks. fn run_redirect_subprocess(cwd: &Path, api_url: &str) -> serde_json::Value { - let out = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")) + let out = scrubbed_cli() .args([ "scan", "--redirect", @@ -1062,6 +1312,75 @@ fn warning_codes(env: &serde_json::Value) -> Vec { .unwrap_or_default() } +/// The rewriters' own warnings must reach HUMAN mode too, not just the +/// `--json` envelope: they carry the load-bearing "why nothing happened / +/// what you must do" guidance (`redirect_npm_no_lockfile`, +/// `redirect_gradle_manual_snippet`, the missing-integrity family). +/// Regression guard: the human branch printed the skipped/record/migration/ +/// rush warnings but dropped `rewrite.warnings` entirely, so a default-mode +/// `scan --redirect` in a lockfile-less project reported "Redirected 0 +/// package(s)" with no explanation at all. Subprocess (not in-process) so +/// stderr can be read back. +#[tokio::test] +#[serial] +async fn redirect_human_mode_prints_rewriter_warnings() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + // Installed tree + package.json only — NO lockfile, so the npm rewriter + // emits `redirect_npm_no_lockfile` for the granted override. + std::fs::write( + tmp.path().join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = tmp.path().join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + + let out = scrubbed_cli() + .args([ + "scan", + "--redirect", + "--yes", + "--cwd", + tmp.path().to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(0), + "a no-op redirect still exits 0; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + assert!( + stdout.contains("Redirected 0 package(s)"), + "anchor: the run must have taken the human-mode redirect branch; \ + stdout=\n{stdout}" + ); + assert!( + stderr.contains("no package-lock.json"), + "human mode must print the rewriter's no-lockfile warning (JSON mode \ + already carries it); stderr=\n{stderr}" + ); +} + /// The `redirect_rush_repo_state_stale` warning fires exactly when a Rush lock /// was rewritten AND common/config/rush/repo-state.json is present (the file /// that carries pnpmShrinkwrapHash, which an out-of-band lock edit desyncs). @@ -1116,3 +1435,60 @@ async fn rush_repo_state_stale_warning_is_gated_on_repo_state_presence() { "the common lock must still be redirected without repo-state.json; got:\n{lock}" ); } + +/// The `redirect_rush_repo_state_stale` warning claims a Rush lock "was edited +/// outside `rush update`" — so it must fire only when the rewrite actually +/// landed in a Rush lock. A Rush repo whose locks resolve only an UNRELATED +/// package (the granted patch's dep is installed but absent from every lock) +/// gets no lock edit and therefore no stale-hash warning, even with +/// repo-state.json present. +#[tokio::test] +#[serial] +async fn rush_stale_warning_requires_an_actual_lock_edit() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("rush.json"), + r#"{ "rushVersion": "5.100.0" }"#, + ) + .unwrap(); + let common = tmp.path().join("common/config/rush"); + std::fs::create_dir_all(&common).unwrap(); + // The lock resolves a package the granted patch does NOT cover. + std::fs::write( + common.join("pnpm-lock.yaml"), + rush_pnpm_lock("unrelated-pkg"), + ) + .unwrap(); + std::fs::write( + common.join("repo-state.json"), + "{\n \"pnpmShrinkwrapHash\": \"deadbeef\",\n \"preventManualShrinkwrapChanges\": true\n}\n", + ) + .unwrap(); + // Installed copy so discovery still selects the patch. + write_installed(tmp.path(), NAME, VERSION, b"unpatched installed bytes\n"); + let lock_before = + std::fs::read_to_string(tmp.path().join("common/config/rush/pnpm-lock.yaml")).unwrap(); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!( + env["redirect"]["redirected"], 0, + "nothing pins the patch, so nothing may count as redirected: {env}" + ); + assert!( + !warning_codes(&env).contains(&"redirect_rush_repo_state_stale".to_string()), + "no lock was edited, so the stale-hash warning must not fire; got warnings {:?}", + warning_codes(&env) + ); + let lock_after = + std::fs::read_to_string(tmp.path().join("common/config/rush/pnpm-lock.yaml")).unwrap(); + assert_eq!( + lock_before, lock_after, + "the unrelated lock must be untouched" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 3ab8f79c..5623660e 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -256,6 +256,144 @@ async fn hosted_rewrites_pnpm_root_lock_resolution() { ); } +/// (a2) SCOPED package: pnpm lockfileVersion 9 single-quotes `packages:` keys +/// that begin with `@` (`'@scope/name@1.0.0':` — YAML forbids a plain scalar +/// starting with `@`; verified against pnpm 10 output), and the API serves +/// scoped purls percent-encoded (`pkg:npm/%40scope/name@version`). The +/// rewriter must splice the resolution under the QUOTED key, and the run must +/// count the dep as redirected (ledger edit present) — a silent +/// entry-not-found here would leave every scoped npm package unredirected. +#[tokio::test] +#[serial] +async fn hosted_rewrites_pnpm_quoted_scoped_key() { + const SCOPED_NAME: &str = "@socktest/in-proc-redirect-pnpm"; + const SCOPED_PURL: &str = "pkg:npm/%40socktest/in-proc-redirect-pnpm@1.0.0"; + const SCOPED_HOSTED_URL: &str = "http://patch.test/patch/npm/%40socktest/in-proc-redirect-pnpm/1.0.0/22222222-2222-4222-8222-222222222222/11111111-1111-4111-8111-111111111111/in-proc-redirect-pnpm-1.0.0.tgz"; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": SCOPED_PURL, + "patches": [{ + "uuid": UUID, "purl": SCOPED_PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "pnpm scoped redirect fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, "purl": SCOPED_PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { + UUID: { + "status": "granted", + "url": SCOPED_HOSTED_URL, + "purl": SCOPED_PURL, + "artifacts": [{ + "kind": "tarball", + "url": SCOPED_HOSTED_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + } + } + }))) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{SCOPED_NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(SCOPED_NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{SCOPED_NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + // The quoted-key shape below is byte-for-byte what pnpm 10 (lockfile 9.0) + // emits for a scoped dependency. + std::fs::write( + root.join("pnpm-lock.yaml"), + format!( + "lockfileVersion: '9.0' + +importers: + .: + dependencies: + '{SCOPED_NAME}': + specifier: {VERSION} + version: {VERSION} + +packages: + + '{SCOPED_NAME}@{VERSION}': + resolution: {{integrity: {UPSTREAM_SHA512}}} + +snapshots: + + '{SCOPED_NAME}@{VERSION}': {{}} +" + ), + ) + .unwrap(); + + let code = run(hosted_args(root, server.uri())).await; + assert_eq!(code, 0, "scan --mode hosted should succeed for scoped pnpm"); + + let lock = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + assert!( + lock.contains(&format!( + " '{SCOPED_NAME}@{VERSION}':\n resolution: {{integrity: {PATCHED_SHA512}, tarball: {SCOPED_HOSTED_URL}}}" + )), + "the QUOTED scoped key's resolution must be spliced (quotes preserved); got:\n{lock}" + ); + assert!( + !lock.contains("UPSTREAMupstream"), + "the upstream integrity must be replaced; got:\n{lock}" + ); + + let ledger: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(root.join(".socket/vendor/redirect-state.json")).unwrap(), + ) + .unwrap(); + assert!( + ledger["edits"] + .as_array() + .unwrap() + .iter() + .any(|e| e["kind"] == "redirect_pnpm_resolution" + && e["key"] == format!("{SCOPED_NAME}@{VERSION}")), + "the ledger must record the scoped redirect edit: {ledger}" + ); +} + /// (b) `scan --mode hosted --vex`: the redirected pnpm patch is attested with /// the `(redirected)` provenance marker (bytes are remote until install, so /// this is the NO-VERIFY attestation built from the ledger record — the same diff --git a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs index a90a9999..50164be9 100644 --- a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs @@ -13,13 +13,6 @@ //! produce. The Docker e2e tests verify that real installers produce //! the same layouts. -// Each test is feature-gated on its ecosystem (e.g. `cfg(feature = -// "golang")` for the gin tests). With default features (no ecosystems -// enabled) every test and helper compiles out — quiet the resulting -// dead-code/unused-import noise so non-feature builds stay warning- -// clean. -#![allow(dead_code, unused_imports)] - use std::path::{Path, PathBuf}; use base64::Engine; @@ -202,7 +195,6 @@ fn assert_manifest_records(cwd: &Path, purl: &str, uuid: &str) { // golang // --------------------------------------------------------------------------- -#[cfg(feature = "golang")] #[tokio::test] #[serial] async fn golang_handcrafted_install_apply_patches_file() { @@ -262,7 +254,6 @@ async fn golang_handcrafted_install_apply_patches_file() { // maven // --------------------------------------------------------------------------- -#[cfg(feature = "maven")] #[tokio::test] #[serial] async fn maven_handcrafted_install_apply_patches_file() { @@ -330,7 +321,6 @@ async fn maven_handcrafted_install_apply_patches_file() { /// the (default, narrow) apply path keeps and patches *every* present /// classifier variant — exercising the plural `select_installed_variants` /// selector — rather than just the first. -#[cfg(feature = "maven")] #[tokio::test] #[serial] async fn maven_multi_classifier_patches_every_present_jar() { @@ -451,7 +441,6 @@ async fn maven_multi_classifier_patches_every_present_jar() { // composer // --------------------------------------------------------------------------- -#[cfg(feature = "composer")] #[tokio::test] #[serial] async fn composer_handcrafted_install_apply_patches_file() { @@ -520,7 +509,6 @@ async fn composer_handcrafted_install_apply_patches_file() { // nuget // --------------------------------------------------------------------------- -#[cfg(feature = "nuget")] #[tokio::test] #[serial] async fn nuget_handcrafted_install_apply_patches_file() { @@ -585,7 +573,6 @@ async fn nuget_handcrafted_install_apply_patches_file() { // Discovery-only tests for each handcrafted layout // --------------------------------------------------------------------------- -#[cfg(feature = "golang")] #[tokio::test] #[serial] async fn golang_handcrafted_discovery() { @@ -619,7 +606,6 @@ async fn golang_handcrafted_discovery() { std::env::remove_var("GOMODCACHE"); } -#[cfg(feature = "maven")] #[tokio::test] #[serial] async fn maven_handcrafted_discovery() { @@ -650,7 +636,6 @@ async fn maven_handcrafted_discovery() { std::env::remove_var("SOCKET_EXPERIMENTAL_MAVEN"); } -#[cfg(feature = "nuget")] #[tokio::test] #[serial] async fn nuget_handcrafted_discovery() { @@ -754,7 +739,6 @@ async fn npm_handcrafted_scan_apply_writes_manifest_and_patches() { assert_manifest_records(tmp.path(), purl, uuid); } -#[cfg(feature = "composer")] #[tokio::test] #[serial] async fn composer_handcrafted_scan_apply_writes_manifest() { @@ -817,7 +801,6 @@ async fn composer_handcrafted_scan_apply_writes_manifest() { assert_manifest_records(tmp.path(), purl, uuid); } -#[cfg(feature = "maven")] #[tokio::test] #[serial] async fn maven_handcrafted_scan_apply_writes_manifest() { @@ -876,7 +859,6 @@ async fn maven_handcrafted_scan_apply_writes_manifest() { std::env::remove_var("SOCKET_EXPERIMENTAL_MAVEN"); } -#[cfg(feature = "nuget")] #[tokio::test] #[serial] async fn nuget_handcrafted_scan_apply_writes_manifest() { diff --git a/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs b/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs index 2b653594..e4c9307c 100644 --- a/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs +++ b/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs @@ -719,3 +719,152 @@ async fn repair_offline_with_present_blobs_succeeds() { "the surviving blob's content must be unchanged" ); } + +/// Regression: `remove` is the documented per-purl exit path for detached +/// vendored patches (`scan --vendor --detached`), and detached mode writes +/// NO manifest (scan_vendor_e2e pins "detached mode must not create a +/// manifest"). But `remove`'s pre-flight manifest-existence gate returned +/// `manifest_not_found` (exit 1) before the detached branch could run, so +/// on a pure-detached project — the primary detached scenario — the exit +/// path was unreachable. The ledger stayed wired forever. +#[tokio::test] +#[serial] +async fn remove_detached_vendored_without_manifest_reverts() { + let tmp = tempfile::tempdir().unwrap(); + let purl = "pkg:npm/detached-only@1.0.0"; + let uuid = "55555555-5555-4555-8555-555555555555"; + + // What `scan --vendor --detached` leaves behind: ledger + artifact, + // no `.socket/manifest.json`. Empty wiring makes the npm revert a + // pure offline artifact-dir delete. + let vendor = tmp.path().join(".socket/vendor"); + let artifact_dir = vendor.join("npm").join(uuid); + std::fs::create_dir_all(&artifact_dir).unwrap(); + std::fs::write(artifact_dir.join("package.tgz"), b"tgz").unwrap(); + std::fs::write( + vendor.join("state.json"), + format!( + r#"{{ + "version": 1, + "entries": {{ + "{purl}": {{ + "ecosystem": "npm", + "basePurl": "{purl}", + "uuid": "{uuid}", + "artifact": {{ "path": ".socket/vendor/npm/{uuid}/package.tgz" }}, + "detached": true, + "wiring": [] + }} + }} + }}"# + ), + ) + .unwrap(); + + let args = RemoveArgs { + common: socket_patch_cli::args::GlobalArgs { + cwd: tmp.path().to_path_buf(), + manifest_path: ".socket/manifest.json".to_string(), + yes: true, + global: false, + global_prefix: None, + json: true, + ..socket_patch_cli::args::GlobalArgs::default() + }, + identifier: purl.to_string(), + skip_rollback: false, + }; + assert_eq!( + remove_run(args).await, + 0, + "remove must revert a detached vendored patch even with no manifest" + ); + // The revert happened: ledger entry dropped (empty ledger deleted) + // and the vendored artifact removed. + assert!( + !vendor.join("state.json").exists(), + "detached ledger entry must be reverted (empty ledger deleted)" + ); + assert!( + !artifact_dir.exists(), + "the vendored artifact must be deleted on remove" + ); + // And no manifest was conjured into being along the way. + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "remove must not create a manifest on a pure-detached project" + ); +} + +/// Regression: `repair` passed only the `--api-token`/`--org` FLAG values +/// to its telemetry calls, while every sibling command (apply, rollback, +/// remove) resolves credentials through the API client — which falls back +/// to `SOCKET_API_TOKEN`/`SOCKET_ORG_SLUG`. With env-provided credentials +/// (the standard configuration) repair's telemetry therefore went +/// unauthenticated to the PUBLIC proxy endpoint instead of the org-scoped +/// `/v0/orgs//telemetry`, losing org attribution entirely. +#[tokio::test] +#[serial] +async fn repair_telemetry_attributed_to_env_credentials() { + let tmp = tempfile::tempdir().unwrap(); + let blob = b"present blob for telemetry test\n"; + let hash = git_sha256(blob); + + // Blob already present → nothing to download; the only request the + // mock should see is the org-scoped telemetry POST. + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write( + socket.join("manifest.json"), + format!( + r#"{{ "patches": {{ + "pkg:npm/telemetry-test@1.0.0": {{ + "uuid": "18181818-1818-4181-8181-181818181818", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{ "package/x.js": {{ + "beforeHash": "0000000000000000000000000000000000000000000000000000000000000000", + "afterHash": "{hash}" + }}}}, + "vulnerabilities": {{}}, "description": "x", + "license": "MIT", "tier": "free" + }} + }}}}"# + ), + ) + .unwrap(); + let blobs = socket.join("blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + std::fs::write(blobs.join(&hash), blob).unwrap(); + + let server = MockServer::start().await; + + std::env::set_var("SOCKET_API_URL", server.uri()); + std::env::set_var("SOCKET_API_TOKEN", "fake"); + std::env::set_var("SOCKET_ORG_SLUG", ORG); + // The telemetry kill-switch must not be ambiently on, or the oracle + // below would fail for the wrong reason (`is_telemetry_disabled` + // reads these at runtime). + std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); + std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); + std::env::remove_var("SOCKET_OFFLINE"); + std::env::remove_var("VITEST"); + let code = repair_run(make_repair_args(tmp.path(), "file")).await; + std::env::remove_var("SOCKET_API_URL"); + std::env::remove_var("SOCKET_API_TOKEN"); + std::env::remove_var("SOCKET_ORG_SLUG"); + assert_eq!(code, 0, "repair with all blobs present must succeed"); + + // The success event must land on the org-scoped endpoint of the + // configured API URL — not on the public proxy. + let telemetry_hits = server + .received_requests() + .await + .unwrap() + .into_iter() + .filter(|r| r.url.path() == format!("/v0/orgs/{ORG}/telemetry")) + .count(); + assert_eq!( + telemetry_hits, 1, + "repair telemetry must use the env-resolved token/org (org-scoped endpoint)" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs b/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs index f07f04ba..4e28f5d9 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs @@ -79,6 +79,19 @@ fn write_manifest_with_patch( std::fs::write(socket.join("manifest.json"), body).unwrap(); } +/// Run `rollback` with the ambient `VIRTUAL_ENV` scrubbed first. +/// +/// `find_local_venv_site_packages` honors `VIRTUAL_ENV` FIRST and, when it +/// holds a site-packages dir, early-returns — the fixture's `.venv` is never +/// probed, rollback exits 0 with zero results, and the byte-restore +/// assertion fails. Running this suite from an activated shell venv turned +/// the pypi test red. Tests are `#[serial]`, so the scrub cannot race +/// another test. +async fn rollback_scrubbed(args: RollbackArgs) -> i32 { + std::env::remove_var("VIRTUAL_ENV"); + rollback_run(args).await +} + fn default_rollback_args(cwd: &Path, eco: &str) -> RollbackArgs { RollbackArgs { common: socket_patch_cli::args::GlobalArgs { @@ -231,7 +244,26 @@ async fn rollback_pypi_restores_original_content() { "precondition: file must be in patched state before rollback" ); - let code = rollback_run(default_rollback_args(tmp.path(), "pypi")).await; + // Simulate an activated shell venv: point VIRTUAL_ENV at a populated + // venv OUTSIDE the project. Without the scrub in `rollback_scrubbed` + // the crawler early-returns with this decoy's site-packages and never + // reaches the fixture's `.venv` (exactly the pre-fix ambient failure), + // so this guard goes red if the scrub is ever dropped. The decoy + // tempdir must outlive the rollback call. + let decoy = tempfile::tempdir().unwrap(); + let decoy_site = if cfg!(windows) { + decoy.path().join("Lib").join("site-packages") + } else { + decoy + .path() + .join("lib") + .join("python3.11") + .join("site-packages") + }; + std::fs::create_dir_all(decoy_site.join("decoypkg-1.0.0.dist-info")).unwrap(); + std::env::set_var("VIRTUAL_ENV", decoy.path()); + + let code = rollback_scrubbed(default_rollback_args(tmp.path(), "pypi")).await; assert_eq!(code, 0, "pypi rollback must report success (exit 0)"); let after = std::fs::read(pkg_dir.join("__init__.py")).unwrap(); assert_eq!(after, original, "pypi rollback must restore original bytes"); @@ -296,7 +328,6 @@ async fn rollback_gem_restores_original_content() { // cargo // --------------------------------------------------------------------------- -#[cfg(feature = "cargo")] #[tokio::test] #[serial] async fn rollback_cargo_restores_original_content() { @@ -355,7 +386,6 @@ version = "1.0.0" // golang // --------------------------------------------------------------------------- -#[cfg(feature = "golang")] #[tokio::test] #[serial] async fn rollback_golang_restores_original_content() { @@ -406,7 +436,6 @@ async fn rollback_golang_restores_original_content() { // maven // --------------------------------------------------------------------------- -#[cfg(feature = "maven")] #[tokio::test] #[serial] async fn rollback_maven_restores_original_content() { @@ -467,7 +496,6 @@ async fn rollback_maven_restores_original_content() { // composer // --------------------------------------------------------------------------- -#[cfg(feature = "composer")] #[tokio::test] #[serial] async fn rollback_composer_restores_original_content() { @@ -523,7 +551,6 @@ async fn rollback_composer_restores_original_content() { // nuget // --------------------------------------------------------------------------- -#[cfg(feature = "nuget")] #[tokio::test] #[serial] async fn rollback_nuget_restores_original_content() { diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index 2b40cb91..2d18e033 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -178,6 +178,21 @@ fn req_body(req: &wiremock::Request) -> String { String::from_utf8_lossy(&req.body).into_owned() } +/// Run `scan` with the ambient `VIRTUAL_ENV` scrubbed first. +/// +/// `find_local_venv_site_packages` honors `VIRTUAL_ENV` FIRST — an absolute +/// path unrelated to `cwd` — so running this suite from an activated +/// virtualenv (or under direnv auto-activation) injected the shell's venv +/// packages into every scan: 5 of 17 tests went red (extra pypi purls broke +/// the "no batch POST" / exact-post-count oracles). Same fix as +/// `in_process_python_envs.rs::scan_scrubbed`. Tests are `#[serial]`, so the +/// scrub cannot race another test; `scan_ignores_ambient_virtual_env` is the +/// mutation guard that goes red if this scrub is removed. +async fn run_scrubbed(args: ScanArgs) -> i32 { + std::env::remove_var("VIRTUAL_ENV"); + run(args).await +} + // --------------------------------------------------------------------------- // Discovery — read-only --json mode // --------------------------------------------------------------------------- @@ -193,7 +208,7 @@ async fn scan_empty_project_json() { let mut args = default_args(tmp.path()); args.common.api_url = server.uri(); - assert_eq!(run(args).await, 0); + assert_eq!(run_scrubbed(args).await, 0); // An empty project crawls zero packages, so the batch API must never // be queried. (Asserting only exit 0 would also pass if the crawler // silently found nothing on a *non-empty* project.) @@ -217,7 +232,7 @@ async fn scan_installed_package_discovers_patch() { let mut args = default_args(tmp.path()); args.common.api_url = server.uri(); - assert_eq!(run(args).await, 0); + assert_eq!(run_scrubbed(args).await, 0); // The installed package must actually be discovered by the crawler and // sent to the batch endpoint. Without this, a regression that crawled // nothing would still exit 0 and pass the old test. @@ -250,7 +265,7 @@ async fn scan_apply_dry_run_does_not_write() { args.apply = true; args.common.dry_run = true; - assert_eq!(run(args).await, 0); + assert_eq!(run_scrubbed(args).await, 0); assert!( !tmp.path().join(".socket/manifest.json").exists(), "dry-run must not write manifest" @@ -287,7 +302,7 @@ async fn scan_apply_wet_writes_manifest_and_blob() { args.common.api_url = server.uri(); args.apply = true; - let code = run(args).await; + let code = run_scrubbed(args).await; // Apply over our handcrafted node_modules deterministically reports // partial_failure (exit 1): the on-disk "package/index.js" doesn't // match the fixture's beforeHash, so the patch can't be applied. The @@ -363,7 +378,7 @@ async fn scan_prune_only_dry_run_reports_orphans() { args.prune = true; args.common.dry_run = true; - assert_eq!(run(args).await, 0); + assert_eq!(run_scrubbed(args).await, 0); // Dry-run preserves the manifest *entirely* unchanged — the stale entry // must survive and remain the sole entry (a buggy preview that actually // pruned, or that added/dropped entries, must fail here). @@ -420,7 +435,7 @@ async fn scan_prune_only_wet_removes_orphans() { args.common.api_url = server.uri(); args.prune = true; - assert_eq!(run(args).await, 0); + assert_eq!(run_scrubbed(args).await, 0); let body = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); let m: serde_json::Value = serde_json::from_str(&body).unwrap(); let patches = m["patches"].as_object().unwrap(); @@ -458,7 +473,7 @@ async fn scan_sync_full_cycle_against_clean_project() { args.common.api_url = server.uri(); args.sync = true; - let code = run(args).await; + let code = run_scrubbed(args).await; // --sync == --apply --prune; apply over the hash-mismatched fixture file // deterministically partial-fails (exit 1) just like the apply-wet case. assert_eq!( @@ -504,7 +519,7 @@ async fn scan_small_batch_size_chunks_requests() { let mut args = default_args(tmp.path()); args.common.api_url = server.uri(); args.batch_size = 1; // force 3 separate API calls - assert_eq!(run(args).await, 0); + assert_eq!(run_scrubbed(args).await, 0); // The whole point of this test: batch_size=1 over 3 discovered packages // must produce exactly 3 separate batch requests, each carrying one // package. The original test asserted *nothing* about chunking. @@ -558,7 +573,7 @@ async fn scan_ecosystems_filter_excludes_others() { let mut args = default_args(tmp.path()); args.common.api_url = server.uri(); args.common.ecosystems = Some(vec!["pypi".to_string()]); - assert_eq!(run(args).await, 0); + assert_eq!(run_scrubbed(args).await, 0); // The npm package must be filtered out by `--ecosystems pypi`. With no // surviving packages the batch API is never queried — proving the // filter actually excluded the npm package rather than the scan just @@ -591,7 +606,7 @@ async fn scan_non_json_with_patches_prints_table() { args.common.api_url = server.uri(); args.common.json = false; - let code = run(args).await; + let code = run_scrubbed(args).await; // Non-JSON path: discovery → batch query → render table → fetch // per-package details. We only mount the batch mock, so detail-fetch // 404s and scan exits 1 ("Could not fetch patch details"). That exit is @@ -625,7 +640,7 @@ async fn scan_non_json_empty_project_friendly_message() { args.common.api_url = server.uri(); args.common.json = false; - assert_eq!(run(args).await, 0); + assert_eq!(run_scrubbed(args).await, 0); // No packages crawled → the friendly "No packages found" path → no API // call at all. let reqs = recorded(&server).await; @@ -640,18 +655,17 @@ async fn scan_non_json_empty_project_friendly_message() { // // The original `assert!(code == 0 || code == 1)` here was the headline // loophole of this file: a disjoint-outcome assertion that passes whether -// the scan correctly surfaces the failure OR silently swallows it. scan.rs -// itself documents the intended behavior (see the `if batch_error_count == -// total_batches` block): "surface this as a full scan failure rather than -// silently reporting zero patches." The implementation only emits a -// telemetry event there — it does NOT set status="error" or a non-zero exit -// — so when *every* batch errors, `run` returns 0 and prints -// status="success" with an empty package list. +// the scan correctly surfaces the failure OR silently swallows it. The +// implementation used to only emit a telemetry event when every batch +// errored — returning 0 and printing status="success" with an empty package +// list — so the assertions below were first committed RED to encode the +// documented intent ("surface this as a full scan failure rather than +// silently reporting zero patches"). // -// The assertions below encode the documented intent. They are EXPECTED TO -// FAIL against the current (buggy) implementation and are left RED on -// purpose to guard the fix — matching the project's existing convention for -// this same bug (see memory: scan-all-batches-failed-reports-success). +// That bug is now FIXED (scan/mod.rs bails with exit 1 when +// batch_error_count == total_batches, and discover_selected likewise errors +// when every detail query fails); these tests pass and stay as regression +// guards for both levels. // --------------------------------------------------------------------------- #[tokio::test] @@ -670,7 +684,7 @@ async fn scan_api_500_does_not_panic() { let mut args = default_args(tmp.path()); args.common.api_url = server.uri(); - let code = run(args).await; + let code = run_scrubbed(args).await; // Real path actually executed: the batch endpoint was queried (and 500'd) // and no spurious manifest was written. @@ -685,8 +699,8 @@ async fn scan_api_500_does_not_panic() { "a fully-failed scan must not write a manifest" ); - // Intended behavior (currently a KNOWN BUG — left RED to guard the fix): - // when every batch errors, the scan must NOT report plain success. + // Regression guard (bug fixed — see section comment above): when every + // batch errors, the scan must NOT report plain success. assert_ne!( code, 0, "scan must report failure (non-zero exit) when ALL API batches fail; \ @@ -703,21 +717,60 @@ async fn scan_unreachable_api_does_not_panic() { let mut args = default_args(tmp.path()); args.common.api_url = "http://127.0.0.1:1".to_string(); - let code = run(args).await; + let code = run_scrubbed(args).await; assert!( !tmp.path().join(".socket/manifest.json").exists(), "an unreachable-API scan must not write a manifest" ); - // Same KNOWN BUG as above (left RED): a connection failure on every - // batch must surface as a non-zero exit, not a silent success. + // Same regression guard as above: a connection failure on every batch + // must surface as a non-zero exit, not a silent success. assert_ne!( code, 0, "scan must report failure when the API is unreachable for every batch" ); } +#[tokio::test] +#[serial] +async fn scan_apply_all_detail_queries_failed_is_an_error() { + // The batch phase succeeds (one package with a patch), but EVERY + // per-package detail query 500s. Discovery then has no trustworthy + // patch data to select from — the same "total failure must not look + // like 'no patches'" rule the batch loop enforces one level up. A 404 + // is different (the client maps it to an empty result = genuinely no + // patches); this guards genuine errors only. + let server = MockServer::start().await; + mock_batch_one(&server).await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "in-proc-scan", "1.0.0"); + let mut args = default_args(tmp.path()); + args.common.api_url = server.uri(); + args.apply = true; + + let code = run_scrubbed(args).await; + + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "a fully-failed detail phase must not write a manifest" + ); + assert_ne!( + code, 0, + "scan --json --apply must report failure when EVERY patch-detail \ + query errors; exit 0 masks a total API outage as 'no patches'" + ); +} + // --------------------------------------------------------------------------- // Regression: --batch-size 0 must not panic // --------------------------------------------------------------------------- @@ -742,7 +795,7 @@ async fn scan_batch_size_zero_does_not_panic() { // No panic, and the discovered package still reaches the batch endpoint // (proving the loop ran rather than being skipped). - assert_eq!(run(args).await, 0); + assert_eq!(run_scrubbed(args).await, 0); let reqs = recorded(&server).await; let posts = batch_posts(&reqs); assert_eq!( @@ -817,7 +870,7 @@ async fn scan_prune_with_ecosystem_filter_keeps_other_ecosystem() { args.common.ecosystems = Some(vec!["npm".to_string()]); args.prune = true; - assert_eq!(run(args).await, 0); + assert_eq!(run_scrubbed(args).await, 0); let body = std::fs::read_to_string(socket.join("manifest.json")).unwrap(); let m: serde_json::Value = serde_json::from_str(&body).unwrap(); @@ -842,6 +895,56 @@ async fn scan_prune_with_ecosystem_filter_keeps_other_ecosystem() { ); } +// --------------------------------------------------------------------------- +// Regression: ambient VIRTUAL_ENV must not leak into the scan. +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn scan_ignores_ambient_virtual_env() { + // Mutation guard for `run_scrubbed`: plants a populated decoy venv in + // VIRTUAL_ENV and scans an empty project — no purl may reach the API. + // Without the scrub the python crawler honors VIRTUAL_ENV first and + // "discovers" the decoy package (proven RED: the batch POST carried + // pkg:pypi/decoy-pkg@9.9.9). Tests are #[serial], so the env mutation + // cannot race another test. + let server = MockServer::start().await; + mock_batch_empty(&server).await; + + let decoy = tempfile::tempdir().unwrap(); + let dist_info = decoy + .path() + .join("lib/python3.11/site-packages/decoy_pkg-9.9.9.dist-info"); + std::fs::create_dir_all(&dist_info).unwrap(); + std::fs::write( + dist_info.join("METADATA"), + "Metadata-Version: 2.1\nName: decoy-pkg\nVersion: 9.9.9\n", + ) + .unwrap(); + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + let mut args = default_args(tmp.path()); + args.common.api_url = server.uri(); + + std::env::set_var("VIRTUAL_ENV", decoy.path()); + let code = run_scrubbed(args).await; + std::env::remove_var("VIRTUAL_ENV"); + + assert_eq!(code, 0); + let reqs = recorded(&server).await; + assert!( + batch_posts(&reqs).is_empty(), + "ambient VIRTUAL_ENV must not inject its packages into a scan of an \ + unrelated project; saw {} batch POST(s): {:?}", + batch_posts(&reqs).len(), + batch_posts(&reqs) + .iter() + .map(|p| req_body(p)) + .collect::>() + ); +} + // --------------------------------------------------------------------------- // Regression: non-JSON --dry-run must not mutate (apply or prune). // --------------------------------------------------------------------------- @@ -881,7 +984,7 @@ async fn scan_non_json_dry_run_does_not_mutate() { args.prune = true; args.common.dry_run = true; - assert_eq!(run(args).await, 0); + assert_eq!(run_scrubbed(args).await, 0); // Manifest is byte-for-byte unchanged: neither the apply nor the prune // GC touched it. diff --git a/crates/socket-patch-cli/tests/in_process_variant_apply_failure.rs b/crates/socket-patch-cli/tests/in_process_variant_apply_failure.rs index 54650d55..e70f6319 100644 --- a/crates/socket-patch-cli/tests/in_process_variant_apply_failure.rs +++ b/crates/socket-patch-cli/tests/in_process_variant_apply_failure.rs @@ -32,6 +32,45 @@ fn binary() -> PathBuf { env!("CARGO_BIN_EXE_socket-patch").into() } +/// Spawn the CLI with the ambient environment scrubbed, so the flags each +/// test passes are the only thing deciding behaviour. +/// +/// The binary binds a wide `SOCKET_*` env surface: an ambient +/// `SOCKET_DRY_RUN=true` turns both real applies here into no-op dry runs +/// (every variant `verified`, exit 0 — both tests red), and +/// `SOCKET_GLOBAL` / `SOCKET_GLOBAL_PREFIX` aim the crawl — and the patch +/// WRITES — at the host's real site-packages. Seed-then-scrub (mirrors +/// `common::run_with_env`): the highest-risk vars are seeded with hostile +/// values so a dropped scrub line turns the tests red immediately; +/// telemetry opt-outs are deliberately kept. +/// +/// `VIRTUAL_ENV` must go too: the PyPI crawler early-returns on it, so an +/// activated ambient venv hijacks discovery away from the tmp venv (six is +/// "not installed" — both tests red) or, if that venv holds six@1.16.0, +/// aims the patch at the developer's own environment. A hostile seed is +/// impossible here (only a *real* venv path triggers the early return), so +/// it is plain-removed. +fn run_apply_scrubbed(args: &[&str]) -> std::process::Output { + let mut cmd = Command::new(binary()); + cmd.args(args) + .env("SOCKET_DRY_RUN", "true") + .env("SOCKET_GLOBAL", "true") + .env("SOCKET_GLOBAL_PREFIX", "/nonexistent") + .env("SOCKET_MANIFEST_PATH", "/nonexistent/manifest.json") + .env_remove("SOCKET_DRY_RUN") + .env_remove("SOCKET_GLOBAL") + .env_remove("SOCKET_GLOBAL_PREFIX") + .env_remove("SOCKET_MANIFEST_PATH") + .env_remove("VIRTUAL_ENV"); + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + cmd.env_remove(&key); + } + } + cmd.output().expect("run socket-patch apply") +} + fn git_sha256(content: &[u8]) -> String { let header = format!("blob {}\0", content.len()); let mut hasher = Sha256::new(); @@ -169,27 +208,17 @@ fn failed_installed_variant_is_not_also_reported_not_installed() { // child's stdout. This is reliable under cargo's test-output capture, unlike // an in-process `gag`-based stdout redirect (which races libtest's own // capture). NOT `--force`: exercises the variant-matches-installed path, - // exactly where the misreport happened. SOCKET_* are scrubbed so the flags - // decide behaviour. - let output = Command::new(binary()) - .args([ - "apply", - "--offline", - "--ecosystems", - "pypi", - "--json", - "--cwd", - tmp.path().to_str().unwrap(), - ]) - .env_remove("SOCKET_API_TOKEN") - .env_remove("SOCKET_OFFLINE") - .env_remove("SOCKET_ECOSYSTEMS") - .env_remove("SOCKET_JSON") - .env_remove("SOCKET_FORCE") - .env_remove("SOCKET_CWD") - .env_remove("SOCKET_MANIFEST_PATH") - .output() - .expect("run socket-patch apply"); + // exactly where the misreport happened. SOCKET_* and VIRTUAL_ENV are + // scrubbed so the flags decide behaviour. + let output = run_apply_scrubbed(&[ + "apply", + "--offline", + "--ecosystems", + "pypi", + "--json", + "--cwd", + tmp.path().to_str().unwrap(), + ]); let code = output.status.code().unwrap_or(-1); let out = String::from_utf8_lossy(&output.stdout).to_string(); @@ -318,26 +347,16 @@ fn partial_multi_variant_failure_fails_the_command() { ) .expect("write manifest"); - let output = Command::new(binary()) - .args([ - "apply", - "--force", - "--offline", - "--ecosystems", - "pypi", - "--json", - "--cwd", - tmp.path().to_str().unwrap(), - ]) - .env_remove("SOCKET_API_TOKEN") - .env_remove("SOCKET_OFFLINE") - .env_remove("SOCKET_ECOSYSTEMS") - .env_remove("SOCKET_JSON") - .env_remove("SOCKET_FORCE") - .env_remove("SOCKET_CWD") - .env_remove("SOCKET_MANIFEST_PATH") - .output() - .expect("run socket-patch apply"); + let output = run_apply_scrubbed(&[ + "apply", + "--force", + "--offline", + "--ecosystems", + "pypi", + "--json", + "--cwd", + tmp.path().to_str().unwrap(), + ]); let code = output.status.code().unwrap_or(-1); let out = String::from_utf8_lossy(&output.stdout).to_string(); diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 8b7586ec..ba1ef544 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -487,15 +487,11 @@ async fn revert_works_without_manifest() { /// and the supported npm patch still vendors. The exemplar is `pkg:jsr/...` /// (Deno's JSR registry) — the one compiled-in ecosystem with no vendor /// backend now that nuget and maven vendor (`vendor/path.rs` pins jsr as -/// having no vendor dir by design). How the entry surfaces depends on whether -/// this build compiled the `deno` ecosystem in: -/// * compiled out (default features): the purl is unknown, dropped by -/// `partition_purls` before vendor's is_vendorable partition → no event. -/// * compiled in (`--all-features`, as CI runs): the purl is recognized but -/// not vendorable → a `skipped` event carrying `vendor_unsupported_ecosystem`. +/// having no vendor dir by design). The purl is recognized but not +/// vendorable → a `skipped` event carrying `vendor_unsupported_ecosystem`. /// -/// Either way the jsr purl is never `applied`, the npm patch vendors, and the -/// run exits 0. +/// The jsr purl is never `applied`, the npm patch vendors, and the run +/// exits 0. #[tokio::test] async fn unsupported_ecosystem_purl_is_a_benign_skip() { let fx = npm_fixture_with_purls(&[PURL, "pkg:jsr/@std/path@1.0.0"]); @@ -510,21 +506,16 @@ async fn unsupported_ecosystem_purl_is_a_benign_skip() { .iter() .find(|e| e["purl"].as_str().is_some_and(|p| p.contains("jsr"))) .cloned(); - // The jsr purl is never vendored, on any feature set. + // The jsr purl is never vendored. assert!( jsr_event.as_ref().is_none_or(|e| e["action"] != "applied"), "jsr purl must never be applied: {env:#}" ); - if cfg!(feature = "deno") { - // Recognized but not vendorable ⇒ an explicit, informative skip. - let ev = jsr_event.expect("deno compiled in ⇒ explicit skip event"); - assert_eq!(ev["action"], "skipped", "{env:#}"); - assert_eq!(ev["errorCode"], "vendor_unsupported_ecosystem", "{env:#}"); - } else { - // Compiled out ⇒ the unknown purl is dropped before vendor sees it. - assert!(jsr_event.is_none(), "deno compiled out ⇒ no event: {env:#}"); - } + // Recognized but not vendorable ⇒ an explicit, informative skip. + let ev = jsr_event.expect("jsr purl must produce an explicit skip event"); + assert_eq!(ev["action"], "skipped", "{env:#}"); + assert_eq!(ev["errorCode"], "vendor_unsupported_ecosystem", "{env:#}"); assert!(fx.tgz_path().is_file(), "the npm patch still vendors"); } @@ -1015,7 +1006,6 @@ async fn remove_detached_only_purl_reverts() { /// reason `vendored` — apply must never repoint the vendor-owned `replace` /// back at `.socket/go-patches/`. The ledger entry is seeded by hand (the /// exact state `vendor` persists) so the test needs no full go vendor run. -#[cfg(feature = "golang")] #[tokio::test] async fn vendored_golang_purl_skipped_by_apply() { use socket_patch_core::patch::vendor::state::{VendorArtifact, VendorEntry, VendorState}; @@ -1415,3 +1405,58 @@ fn vendor_resolves_percent_encoded_scope_purl() { ); assert!(!fx.vendor_dir().join("npm").exists(), "artifacts removed"); } + +// ───────────────────────────────────────────────────────────────────── +// 11. --dry-run --vex: skipped, not generated +// ───────────────────────────────────────────────────────────────────── + +/// A dry run vendors nothing, so there is no vendored state to attest: +/// generating VEX here verified the deliberately untouched tree, spuriously +/// failed the whole command with `no_applicable_patches`, and would write an +/// attestation file during --dry-run (the same contract `apply --dry-run +/// --vex` already honors by skipping generation). +#[tokio::test] +async fn dry_run_vex_is_skipped_not_generated() { + let fx = npm_fixture(); + let vex_path = fx.root().join("vendor-dry.vex.json"); + let mut args = vendor_args(fx.root()); + args.common.dry_run = true; + args.vex.vex = Some(vex_path.clone()); + + let code = vendor_run(args).await; + assert_eq!(code, 0, "vendor --dry-run --vex must not fail"); + assert!( + !vex_path.exists(), + "--dry-run must never write an attestation file" + ); + // The dry run itself stayed read-only. + assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); + assert!(!fx.vendor_dir().exists(), "no artifacts staged"); +} + +// ───────────────────────────────────────────────────────────────────── +// 12. fail-closed --vendor-source=service refuses --offline +// ───────────────────────────────────────────────────────────────────── + +/// `--vendor-source=service` promises "prebuilt service artifacts only", +/// and `--offline` forbids the network the service needs — the combination +/// can never be satisfied. It must refuse up front; silently falling back +/// to a local build (what `service_enabled() == false` otherwise causes in +/// every backend) violates the fail-closed contract. +#[tokio::test] +async fn offline_service_mode_refuses_instead_of_building() { + let fx = npm_fixture(); + let mut args = vendor_args(fx.root()); + args.common.vendor_source = "service".to_string(); + + let code = vendor_run(args).await; + assert_ne!( + code, 0, + "--offline --vendor-source=service cannot be satisfied and must refuse" + ); + assert!( + !fx.tgz_path().exists(), + "service mode must not silently build a local artifact" + ); + assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); +} diff --git a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs index 377f917a..f77a52c3 100644 --- a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs +++ b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs @@ -60,7 +60,34 @@ fn run_in_pty_bytes(args: &[&str], cwd: &Path, input: &[u8], timeout: Duration) cmd.arg(a); } cmd.cwd(cwd); - cmd.env_remove("SOCKET_API_TOKEN"); + // The binary binds a wide `SOCKET_*` env surface (SOCKET_YES, + // SOCKET_JSON, SOCKET_DRY_RUN, SOCKET_SILENT, SOCKET_MANIFEST_PATH, + // ...). An ambient value silently reroutes what these tests exercise — + // SOCKET_YES=true skips the very confirm prompts this file exists to + // drive, and SOCKET_SILENT=true suppresses the output the oracles + // match. The highest-risk vars are seeded with hostile values and then + // scrubbed — `env_remove` clears the seed too, so the child never sees + // it, but if a scrub line is ever dropped the seed (rather than a + // developer's ambient shell, which this suite can't rely on) turns the + // tests red immediately. + cmd.env("SOCKET_YES", "true"); + cmd.env("SOCKET_JSON", "true"); + cmd.env("SOCKET_DRY_RUN", "true"); + cmd.env("SOCKET_SILENT", "true"); + cmd.env_remove("SOCKET_YES"); + cmd.env_remove("SOCKET_JSON"); + cmd.env_remove("SOCKET_DRY_RUN"); + cmd.env_remove("SOCKET_SILENT"); + // Prefix-scrub whatever else the ambient shell carries (SOCKET_CWD, + // SOCKET_MANIFEST_PATH, SOCKET_API_TOKEN — removing the token also + // forces the public proxy). Telemetry opt-outs are deliberately kept + // so an opted-out dev stays opted out. + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + cmd.env_remove(&key); + } + } let mut child = pair .slave @@ -225,6 +252,56 @@ fn setup_interactive_default_no_aborts() { assert_eq!(pkg, original, "default-N must not modify package.json"); } +#[test] +fn setup_interactive_non_utf8_answer_aborts_without_panic() { + // Same regression class as remove_interactive_non_utf8_answer_ + // declines_without_panic below, but for setup's own prompt reader + // (`confirm_proceed`), a separate implementation from + // `output::confirm`: a Latin-1 paste (`é` = 0xE9) at + // "Proceed with these changes? (y/N): " makes `read_line` return + // InvalidData, and unwrapping it panics the CLI (exit 101) instead + // of treating the unreadable answer as "not yes" (abort). + let tmp = tempfile::tempdir().unwrap(); + let original = r#"{ "name": "p", "version": "1.0.0" } +"#; + std::fs::write(tmp.path().join("package.json"), original).unwrap(); + + let (code, output) = run_in_pty_bytes( + &["setup"], + tmp.path(), + b"\xE9\n", + Duration::from_secs(15), + ); + assert!( + !output.contains("panicked"), + "non-UTF-8 answer must not panic the CLI; got: {output}" + ); + assert_eq!( + code, 0, + "non-UTF-8 answer must abort cleanly, not crash; got: {output}" + ); + // The interactive prompt MUST have run (vacuity guard as above), and + // the unreadable answer must land on the default-N abort path. + assert!( + output.contains("Proceed with these changes?"), + "setup must have shown the interactive confirm prompt; got: {output}" + ); + assert!( + !output.contains("Non-interactive mode detected"), + "setup must NOT have taken the non-interactive branch in a PTY; got: {output}" + ); + assert!( + output.contains("Aborted"), + "non-UTF-8 answer must be treated as 'no' and abort; got: {output}" + ); + assert!( + !output.contains("Applying changes..."), + "non-UTF-8 answer must abort before applying; got: {output}" + ); + let pkg = std::fs::read_to_string(tmp.path().join("package.json")).unwrap(); + assert_eq!(pkg, original, "aborted setup must not modify package.json"); +} + // --------------------------------------------------------------------------- // `remove` interactive confirmation // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/output_modes_e2e.rs b/crates/socket-patch-cli/tests/output_modes_e2e.rs index e4c28504..8f39bb3d 100644 --- a/crates/socket-patch-cli/tests/output_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/output_modes_e2e.rs @@ -3,15 +3,20 @@ //! output; these tests exercise the table printers, verbose //! verification details, and `--silent` short-circuits that the JSON //! tests don't reach. +//! +//! All binary invocations go through `common::run_with_env`, which +//! scrubs the ambient `SOCKET_*` environment. That scrub is load-bearing +//! here: the binary env-binds the very dimension this suite tests +//! (SOCKET_JSON, SOCKET_SILENT, SOCKET_VERBOSE) plus behavior toggles +//! like SOCKET_DRY_RUN — an ambient SOCKET_DRY_RUN=true turned every +//! apply below into a silent no-op and failed the on-disk assertions. -use std::path::{Path, PathBuf}; -use std::process::Command; +use std::path::Path; use sha2::{Digest, Sha256}; -fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() -} +#[path = "common/mod.rs"] +mod common; fn git_sha256(content: &[u8]) -> String { let header = format!("blob {}\0", content.len()); @@ -95,14 +100,8 @@ fn apply_non_json_prints_human_readable_summary() { write_npm_package(tmp.path(), "non-json-target", "1.0.0", before); write_manifest(tmp.path(), "pkg:npm/non-json-target@1.0.0", before, after); - let out = Command::new(binary()) - .args(["apply", "--offline"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &["apply", "--offline"], &[]); + assert_eq!(code, 0); // The human-readable summary must report the count *and* name the // patched package — not merely print one of two loosely-OR'd words. assert!( @@ -130,14 +129,9 @@ fn apply_verbose_prints_per_file_details() { write_npm_package(tmp.path(), "verbose-target", "1.0.0", before); write_manifest(tmp.path(), "pkg:npm/verbose-target@1.0.0", before, after); - let out = Command::new(binary()) - .args(["apply", "--offline", "--verbose"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + let (code, stdout, _stderr) = + common::run_with_env(tmp.path(), &["apply", "--offline", "--verbose"], &[]); + assert_eq!(code, 0); // `--verbose` is the whole point of this test: it MUST emit the // per-file "Detailed verification" block. The old `|| "Summary"` // escape made this vacuous because the non-verbose path also prints @@ -174,17 +168,12 @@ fn apply_silent_emits_no_stdout() { write_npm_package(tmp.path(), "silent-target", "1.0.0", before); write_manifest(tmp.path(), "pkg:npm/silent-target@1.0.0", before, after); - let out = Command::new(binary()) - .args(["apply", "--offline", "--silent"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); + let (code, stdout, _stderr) = + common::run_with_env(tmp.path(), &["apply", "--offline", "--silent"], &[]); + assert_eq!(code, 0); assert!( - out.stdout.is_empty(), - "--silent must suppress stdout; got: {:?}", - String::from_utf8_lossy(&out.stdout) + stdout.is_empty(), + "--silent must suppress stdout; got: {stdout:?}" ); // Silence must mean "quiet", not "skip the work": the patch must // still be applied to disk. A no-op apply that prints nothing would @@ -199,14 +188,8 @@ fn apply_silent_emits_no_stdout() { #[test] fn apply_no_manifest_non_json_prints_message() { let tmp = tempfile::tempdir().unwrap(); - let out = Command::new(binary()) - .args(["apply"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &["apply"], &[]); + assert_eq!(code, 0); assert!( stdout.contains("No .socket folder found, skipping patch application"), "non-JSON no-manifest must print friendly message; got: {stdout}" @@ -222,14 +205,9 @@ fn apply_dry_run_non_json_prints_verification_summary() { write_npm_package(tmp.path(), "dry-target", "1.0.0", before); write_manifest(tmp.path(), "pkg:npm/dry-target@1.0.0", before, after); - let out = Command::new(binary()) - .args(["apply", "--offline", "--dry-run"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + let (code, stdout, _stderr) = + common::run_with_env(tmp.path(), &["apply", "--offline", "--dry-run"], &[]); + assert_eq!(code, 0); assert!( stdout.contains("Patch verification complete") && stdout.contains("can be patched"), "dry-run non-JSON should print the verification summary; got: {stdout}" @@ -259,13 +237,8 @@ fn list_non_json_prints_table() { let tmp = tempfile::tempdir().unwrap(); write_manifest(tmp.path(), "pkg:npm/list-target@1.0.0", before, after); - let out = Command::new(binary()) - .args(["list"]) - .current_dir(tmp.path()) - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &["list"], &[]); + assert_eq!(code, 0); // Require BOTH the PURL and the concrete CVE id (not the weaker // "Vulnerabilities" header alternative), so a table that drops the // vuln detail can't pass. @@ -290,13 +263,8 @@ fn list_empty_manifest_non_json() { std::fs::create_dir_all(&socket).unwrap(); std::fs::write(socket.join("manifest.json"), r#"{"patches":{}}"#).unwrap(); - let out = Command::new(binary()) - .args(["list"]) - .current_dir(tmp.path()) - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &["list"], &[]); + assert_eq!(code, 0); assert!( stdout.contains("No patches found"), "empty manifest non-JSON message; got: {stdout}" @@ -306,13 +274,8 @@ fn list_empty_manifest_non_json() { #[test] fn list_no_manifest_non_json_prints_error_to_stderr() { let tmp = tempfile::tempdir().unwrap(); - let out = Command::new(binary()) - .args(["list"]) - .current_dir(tmp.path()) - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(1)); - let stderr = String::from_utf8_lossy(&out.stderr); + let (code, _stdout, stderr) = common::run_with_env(tmp.path(), &["list"], &[]); + assert_eq!(code, 1); assert!( stderr.contains("Manifest not found") || stderr.contains("not found"), "non-JSON list-without-manifest must print to stderr; got: {stderr}" @@ -328,28 +291,23 @@ fn scan_non_json_no_packages_prints_friendly_message() { let tmp = tempfile::tempdir().unwrap(); write_root(tmp.path()); // Scan needs network normally, but with no packages crawled it - // short-circuits before the network call. - let out = Command::new(binary()) - .args(["scan"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") - // Point SOCKET_API_URL at a closed port so any accidental - // network call fails fast. - .env("SOCKET_API_URL", "http://127.0.0.1:1") - .output() - .expect("run"); + // short-circuits before the network call. Point SOCKET_API_URL at a + // closed port so any accidental network call fails fast (injected + // AFTER the ambient scrub, so it is the only SOCKET_* var in effect). + let (code, stdout, stderr) = common::run_with_env( + tmp.path(), + &["scan"], + &[("SOCKET_API_URL", "http://127.0.0.1:1")], + ); // With no installed packages, scan short-circuits BEFORE the network // call (we point SOCKET_API_URL at a dead port to prove no request is // made) and exits cleanly with the friendly message. The old test // accepted literally any non-empty output on either stream, which a // crash or a network-error spew would also satisfy. assert_eq!( - out.status.code(), - Some(0), - "scan with no packages must short-circuit to a clean exit; stderr={}", - String::from_utf8_lossy(&out.stderr) + code, 0, + "scan with no packages must short-circuit to a clean exit; stderr={stderr}" ); - let stdout = String::from_utf8_lossy(&out.stdout); assert!( stdout.contains("No packages found"), "scan non-JSON must print the no-packages message; got: {stdout}" @@ -378,14 +336,8 @@ fn repair_non_json_no_orphans_prints_summary() { "fixture precondition: afterHash blob present" ); - let out = Command::new(binary()) - .args(["repair", "--offline"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &["repair", "--offline"], &[]); + assert_eq!(code, 0); // With exactly one in-use blob and no orphans, repair must report the // all-in-use status (not a removal) and finish. The old check accepted // any output containing "Repair complete.", so a repair that wrongly @@ -422,14 +374,8 @@ fn repair_non_json_with_orphans_prints_cleanup_summary() { "fixture precondition: afterHash blob present" ); - let out = Command::new(binary()) - .args(["repair", "--offline"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &["repair", "--offline"], &[]); + assert_eq!(code, 0); // The test name promises a *cleanup* summary, so assert the cleanup // actually happened — both in the printed summary and on disk. Pin the // exact count (the orphan blob + the by-design-unused beforeHash blob = @@ -465,19 +411,17 @@ fn remove_non_json_prints_what_will_be_removed() { let tmp = tempfile::tempdir().unwrap(); write_manifest(tmp.path(), "pkg:npm/remove-target@1.0.0", b"a", b"b"); - let out = Command::new(binary()) - .args([ + let (code, stdout, _stderr) = common::run_with_env( + tmp.path(), + &[ "remove", "pkg:npm/remove-target@1.0.0", "--yes", "--skip-rollback", - ]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + ], + &[], + ); + assert_eq!(code, 0); assert!( stdout.contains("Removed 1 patch(es) from manifest") && stdout.contains("pkg:npm/remove-target@1.0.0"), @@ -505,14 +449,8 @@ fn rollback_non_json_prints_summary() { write_npm_package(tmp.path(), "rb-non-json", "1.0.0", after); write_manifest(tmp.path(), "pkg:npm/rb-non-json@1.0.0", before, after); - let out = Command::new(binary()) - .args(["rollback", "--offline"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &["rollback", "--offline"], &[]); + assert_eq!(code, 0); assert!( stdout.contains("Rolled back packages:") && stdout.contains("pkg:npm/rb-non-json@1.0.0"), "non-JSON rollback should print summary naming the PURL; got: {stdout}" @@ -536,14 +474,9 @@ fn rollback_verbose_prints_per_file_details() { write_npm_package(tmp.path(), "rb-verbose", "1.0.0", after); write_manifest(tmp.path(), "pkg:npm/rb-verbose@1.0.0", before, after); - let out = Command::new(binary()) - .args(["rollback", "--offline", "--verbose"]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + let (code, stdout, _stderr) = + common::run_with_env(tmp.path(), &["rollback", "--offline", "--verbose"], &[]); + assert_eq!(code, 0); // `--verbose` must add the per-file "Detailed verification" block. // The old `|| "Rolled"` alternative matched the non-verbose summary, // making the verbose-specific assertion vacuous. @@ -571,8 +504,9 @@ fn get_non_json_invalid_uuid_falls_through_to_package_search() { // Invalid identifier without --cve/--ghsa/--package etc. The binary // should fall through to package-name search and either succeed or // exit 1 cleanly. We're exercising the type-detection branch. - let out = Command::new(binary()) - .args([ + let (code, stdout, _stderr) = common::run_with_env( + tmp.path(), + &[ "get", "not-a-real-package", "--save-only", @@ -583,12 +517,9 @@ fn get_non_json_invalid_uuid_falls_through_to_package_search() { "fake", "--org", "test-org", - ]) - .current_dir(tmp.path()) - .output() - .expect("run"); - let code = out.status.code().unwrap_or(-1); - let stdout = String::from_utf8_lossy(&out.stdout); + ], + &[], + ); // The point of the test is the type-detection branch: an identifier // that is neither CVE/GHSA/UUID nor an explicit flag must fall through // to a *package-name search*. The old `0 || 1` accepted any outcome — @@ -608,8 +539,9 @@ fn get_non_json_invalid_uuid_falls_through_to_package_search() { #[test] fn get_with_explicit_cve_flag_works() { let tmp = tempfile::tempdir().unwrap(); - let out = Command::new(binary()) - .args([ + let (code, stdout, _stderr) = common::run_with_env( + tmp.path(), + &[ "get", "CVE-2099-99999", "--cve", @@ -622,18 +554,15 @@ fn get_with_explicit_cve_flag_works() { "fake", "--org", "test-org", - ]) - .current_dir(tmp.path()) - .output() - .expect("run"); + ], + &[], + ); // The API is unreachable (dead port), so this must surface a network // error — exit 1 with a structured JSON error payload whose URL proves // the `--cve` flag routed to the by-cve endpoint. The old test accepted // exit 0-or-1 and only parsed JSON "if non-empty", so an empty stdout // or a wrong endpoint would have passed. - let code = out.status.code().unwrap_or(-1); assert_eq!(code, 1, "unreachable API must yield a failure exit"); - let stdout = String::from_utf8_lossy(&out.stdout); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("must emit parseable JSON"); assert_eq!( @@ -652,8 +581,9 @@ fn get_with_explicit_ghsa_flag_works() { let tmp = tempfile::tempdir().unwrap(); // Non-JSON so we can assert the human-readable routing line on stdout // and the network error (with the by-ghsa endpoint) on stderr. - let out = Command::new(binary()) - .args([ + let (code, stdout, stderr) = common::run_with_env( + tmp.path(), + &[ "get", "GHSA-1111-2222-3333", "--ghsa", @@ -665,14 +595,10 @@ fn get_with_explicit_ghsa_flag_works() { "fake", "--org", "test-org", - ]) - .current_dir(tmp.path()) - .output() - .expect("run"); - let code = out.status.code().unwrap_or(-1); + ], + &[], + ); assert_eq!(code, 1, "unreachable API must yield a failure exit"); - let stdout = String::from_utf8_lossy(&out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); assert!( stdout.contains("Searching patches for GHSA: GHSA-1111-2222-3333"), "--ghsa must announce a GHSA search; got: {stdout}" @@ -686,8 +612,9 @@ fn get_with_explicit_ghsa_flag_works() { #[test] fn get_with_explicit_package_flag_works() { let tmp = tempfile::tempdir().unwrap(); - let out = Command::new(binary()) - .args([ + let (code, stdout, _stderr) = common::run_with_env( + tmp.path(), + &[ "get", "some-package", "--package", @@ -700,20 +627,17 @@ fn get_with_explicit_package_flag_works() { "fake", "--org", "test-org", - ]) - .current_dir(tmp.path()) - .output() - .expect("run"); + ], + &[], + ); // `--package` forces a package-name search. With no installed packages // it short-circuits locally (never reaching the dead API), exits 0, and // emits the structured "no_packages" JSON. The old `0 || 1` would have // accepted a crash or a misrouted vuln lookup. - let code = out.status.code().unwrap_or(-1); assert_eq!( code, 0, "package search with no packages should exit cleanly" ); - let stdout = String::from_utf8_lossy(&out.stdout); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("must emit parseable JSON"); assert_eq!(v["status"], "no_packages", "got: {stdout}"); @@ -727,13 +651,8 @@ fn get_with_explicit_package_flag_works() { #[test] fn setup_no_files_non_json_prints_friendly_message() { let tmp = tempfile::tempdir().unwrap(); - let out = Command::new(binary()) - .args(["setup"]) - .current_dir(tmp.path()) - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &["setup"], &[]); + assert_eq!(code, 0); assert!( stdout.contains("No package.json"), "non-JSON setup must report missing package.json; got: {stdout}" @@ -749,13 +668,9 @@ fn setup_dry_run_non_json_prints_preview() { ) .unwrap(); let before = std::fs::read_to_string(tmp.path().join("package.json")).unwrap(); - let out = Command::new(binary()) - .args(["setup", "--dry-run", "--yes"]) - .current_dir(tmp.path()) - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + let (code, stdout, _stderr) = + common::run_with_env(tmp.path(), &["setup", "--dry-run", "--yes"], &[]); + assert_eq!(code, 0); assert!( stdout.contains("would be updated") && stdout.contains("postinstall"), "non-JSON setup dry-run should preview the postinstall hook; got: {stdout}" @@ -779,8 +694,9 @@ fn setup_dry_run_non_json_prints_preview() { #[test] fn bare_uuid_fallback_treats_uuid_as_get_identifier() { let tmp = tempfile::tempdir().unwrap(); - let out = Command::new(binary()) - .args([ + let (code, stdout, _stderr) = common::run_with_env( + tmp.path(), + &[ "11111111-1111-4111-8111-111111111111", "--save-only", "--yes", @@ -791,18 +707,15 @@ fn bare_uuid_fallback_treats_uuid_as_get_identifier() { "fake", "--org", "test-org", - ]) - .current_dir(tmp.path()) - .output() - .expect("run"); - let code = out.status.code().unwrap_or(-1); + ], + &[], + ); // The bare UUID must be rewritten to `get ` and routed to the // patch-view endpoint. We prove the rewrite happened by inspecting the // failed-request URL in the JSON error: it must hit // `patches/view/`. The old `0 || 1` would have passed even if the // UUID were treated as an unknown command or misrouted. assert_eq!(code, 1, "unreachable API must yield a failure exit"); - let stdout = String::from_utf8_lossy(&out.stdout); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("must emit parseable JSON"); assert_eq!(v["status"], "error", "got: {stdout}"); @@ -819,16 +732,13 @@ fn bare_uuid_fallback_treats_uuid_as_get_identifier() { #[test] fn each_subcommand_help_prints_usage() { + let tmp = tempfile::tempdir().unwrap(); let subcommands = [ "apply", "rollback", "get", "scan", "list", "remove", "setup", "repair", "gc", ]; for sub in subcommands { - let out = Command::new(binary()) - .args([sub, "--help"]) - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0), "subcommand {sub} --help failed"); - let stdout = String::from_utf8_lossy(&out.stdout); + let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &[sub, "--help"], &[]); + assert_eq!(code, 0, "subcommand {sub} --help failed"); assert!( stdout.contains("Usage:") || stdout.contains("USAGE"), "{sub} --help must print usage; got: {stdout}" @@ -838,12 +748,9 @@ fn each_subcommand_help_prints_usage() { #[test] fn top_level_help_prints_all_subcommands() { - let out = Command::new(binary()) - .args(["--help"]) - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + let tmp = tempfile::tempdir().unwrap(); + let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &["--help"], &[]); + assert_eq!(code, 0); for sub in [ "apply", "rollback", "get", "scan", "list", "remove", "setup", "repair", ] { @@ -858,12 +765,9 @@ fn top_level_help_prints_all_subcommands() { #[test] fn version_flag_prints_version() { - let out = Command::new(binary()) - .args(["--version"]) - .output() - .expect("run"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8_lossy(&out.stdout); + let tmp = tempfile::tempdir().unwrap(); + let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &["--version"], &[]); + assert_eq!(code, 0); // Derive the expected version from the crate metadata at compile time // rather than a hardcoded literal. The old test OR'd in a stale // "3.0.0", so a binary reporting any (even wrong) version still passed. diff --git a/crates/socket-patch-cli/tests/remove_invariants.rs b/crates/socket-patch-cli/tests/remove_invariants.rs index fe2a593a..d9b69b40 100644 --- a/crates/socket-patch-cli/tests/remove_invariants.rs +++ b/crates/socket-patch-cli/tests/remove_invariants.rs @@ -6,11 +6,9 @@ //! installed packages. use std::path::{Path, PathBuf}; -use std::process::Command; -fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() -} +#[path = "common/mod.rs"] +mod common; const TWO_PATCH_MANIFEST: &str = r#"{ "patches": { @@ -52,19 +50,16 @@ fn make_socket_dir(root: &Path) -> PathBuf { socket } +/// All spawns go through `common::run_with_env`, which scrubs the ambient +/// `SOCKET_*` environment: an inherited SOCKET_DRY_RUN=true silently turns +/// every wet remove below into a no-op preview, and an inherited +/// SOCKET_MANIFEST_PATH / SOCKET_PROXY_URL aims the mutation (or the +/// rollback blob fetch) outside the tempdir. fn run_remove(cwd: &Path, identifier: &str, extra: &[&str]) -> (i32, String) { let mut args = vec!["remove", identifier, "--json", "--yes", "--skip-rollback"]; args.extend_from_slice(extra); - let out = Command::new(binary()) - .args(&args) - .current_dir(cwd) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run socket-patch"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - ) + let (code, stdout, _stderr) = common::run_with_env(cwd, &args, &[]); + (code, stdout) } fn read_manifest(socket: &Path) -> serde_json::Value { @@ -254,33 +249,34 @@ fn remove_event_has_required_envelope_fields() { /// manifest entry must NOT be deleted (fail-closed — never drop a patch from /// the manifest while leaving patched files un-restored on disk). /// -/// Here we drive the real path. The synthetic patch references blobs/files -/// that don't exist on disk, so rollback cannot complete and `remove` must -/// abort with `rollback_failed`, leaving the manifest fully intact. A -/// regression that swallowed the rollback failure and deleted the entry -/// anyway would flip this test red. +/// Here we drive the real path. The synthetic patch's beforeHash blob does +/// not exist in `.socket/blobs`, and `--offline` forbids fetching it, so +/// rollback cannot complete and `remove` must abort with `rollback_failed`, +/// leaving the manifest fully intact. A regression that swallowed the +/// rollback failure and deleted the entry anyway would flip this test red. +/// +/// `--offline` is what keeps this hermetic: without it, rollback fetches the +/// missing before-blob from the live proxy (`GET /patch/blob/`) +/// and the test only passes because that request 404s. #[test] fn remove_without_skip_rollback_fails_closed_and_keeps_manifest() { let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); let before = std::fs::read(socket.join("manifest.json")).expect("read before"); - let out = Command::new(binary()) - .args([ + let (code, stdout, _stderr) = common::run_with_env( + tmp.path(), + &[ "remove", "pkg:npm/__remove_test_a__@1.0.0", "--json", "--yes", - ]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") - .env_remove("SOCKET_SKIP_ROLLBACK") - .output() - .expect("run socket-patch"); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + "--offline", + ], + &[], + ); assert_eq!( - out.status.code(), - Some(1), + code, 1, "a failed rollback must abort remove; stdout=\n{stdout}" ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); @@ -396,8 +392,9 @@ fn remove_honors_manifest_path_override() { std::fs::create_dir_all(&custom_dir).unwrap(); std::fs::write(custom_dir.join("patches.json"), TWO_PATCH_MANIFEST).unwrap(); - let out = Command::new(binary()) - .args([ + let (code, stdout, _stderr) = common::run_with_env( + tmp.path(), + &[ "remove", "pkg:npm/__remove_test_a__@1.0.0", "--json", @@ -405,13 +402,10 @@ fn remove_honors_manifest_path_override() { "--skip-rollback", "--manifest-path", "custom/patches.json", - ]) - .current_dir(tmp.path()) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("run socket-patch"); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - assert_eq!(out.status.code(), Some(0), "stdout=\n{stdout}"); + ], + &[], + ); + assert_eq!(code, 0, "stdout=\n{stdout}"); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "success"); assert_eq!(v["summary"]["removed"], 1); @@ -432,3 +426,140 @@ fn remove_honors_manifest_path_override() { "remove must not create a default .socket manifest when --manifest-path is given" ); } + +// --------------------------------------------------------------------------- +// --dry-run (global contract row: "Preview, no mutations") +// --------------------------------------------------------------------------- + +/// `remove --dry-run` must mutate NOTHING — the manifest keeps every entry — +/// while the envelope reports the preview: `dryRun: true`, per-purl +/// `Verified` events (the apply/vendor/repair dry-run convention), and +/// `summary.removed` stays 0 because no entry was actually deleted. +#[test] +fn remove_dry_run_keeps_manifest_and_emits_verified_previews() { + let tmp = tempfile::tempdir().unwrap(); + let socket = make_socket_dir(tmp.path()); + + let (code, stdout) = run_remove( + tmp.path(), + "pkg:npm/__remove_test_a__@1.0.0", + &["--dry-run"], + ); + assert_eq!(code, 0, "stdout=\n{stdout}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["command"], "remove"); + assert_eq!(v["dryRun"], true); + assert_eq!( + v["summary"]["removed"], 0, + "a preview must not count as a removal" + ); + + let events = v["events"].as_array().expect("events array"); + assert!( + events + .iter() + .any(|e| e["action"] == "verified" && e["purl"] == "pkg:npm/__remove_test_a__@1.0.0"), + "expected a Verified preview event for the matched purl: {events:?}" + ); + assert!( + events.iter().all(|e| e["action"] != "removed"), + "dry-run must not emit Removed events: {events:?}" + ); + + // The on-disk manifest is untouched: both entries survive. + let manifest = read_manifest(&socket); + let patches = manifest["patches"].as_object().unwrap(); + assert_eq!(patches.len(), 2, "dry-run must not delete manifest entries"); + assert!(patches.contains_key("pkg:npm/__remove_test_a__@1.0.0")); + assert!(patches.contains_key("pkg:npm/__remove_test_b__@2.0.0")); +} + +/// The blob sweep runs in preview mode on `--dry-run`: the artifact-level +/// carrier event reports how many blobs WOULD be swept (as `Verified`, +/// with `details.blobsRemoved`), but the blob files stay on disk. +#[test] +fn remove_dry_run_previews_blob_sweep_without_deleting() { + let tmp = tempfile::tempdir().unwrap(); + let socket = make_socket_dir(tmp.path()); + + // A's afterHash blob: referenced only by entry A, so removing A + // makes it sweepable. + let blobs = socket.join("blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + let blob_a = blobs.join("1111111111111111111111111111111111111111111111111111111111111111"); + std::fs::write(&blob_a, b"patched contents").unwrap(); + + let (code, stdout) = run_remove( + tmp.path(), + "pkg:npm/__remove_test_a__@1.0.0", + &["--dry-run"], + ); + assert_eq!(code, 0, "stdout=\n{stdout}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["dryRun"], true); + + let events = v["events"].as_array().expect("events array"); + let carrier = events + .iter() + .find(|e| e["action"] == "verified" && e["details"]["blobsRemoved"].is_number()) + .unwrap_or_else(|| panic!("expected a Verified blob-sweep carrier event: {events:?}")); + assert_eq!( + carrier["details"]["blobsRemoved"], 1, + "the preview must count A's now-unreferenced blob" + ); + + assert!(blob_a.exists(), "dry-run must not delete blobs from disk"); + let manifest = read_manifest(&socket); + assert_eq!(manifest["patches"].as_object().unwrap().len(), 2); +} + +/// The full-path preview (no --skip-rollback) must not create `.socket/blobs` +/// either: rollback's preview previously `create_dir_all`'d it (and, online, +/// downloaded before-blobs into it) — leaving new files a wet remove's sweep +/// would have deleted. Offline keeps this hermetic: the preview reports the +/// missing-blob failure (accurate — a wet offline run fails the same way) +/// without inventing directories. +#[test] +fn remove_dry_run_with_rollback_does_not_create_blobs_dir() { + let tmp = tempfile::tempdir().unwrap(); + let socket = make_socket_dir(tmp.path()); + assert!(!socket.join("blobs").exists(), "precondition: no blobs dir"); + + let (code, stdout, _stderr) = common::run_with_env( + tmp.path(), + &[ + "remove", + "pkg:npm/__remove_test_a__@1.0.0", + "--json", + "--yes", + "--dry-run", + "--offline", + ], + &[], + ); + // Offline + missing before-blobs: the preview accurately reports the + // rollback failure a wet run would hit (exit 1, rollback_failed)... + assert_eq!(code, 1, "stdout=\n{stdout}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["error"]["code"], "rollback_failed"); + assert_eq!( + v["dryRun"], true, + "preview failures must still report dryRun:true" + ); + // ...but mutates nothing: no blobs dir, manifest intact, no stage litter. + assert!( + !socket.join("blobs").exists(), + "dry-run must not create .socket/blobs" + ); + assert_eq!( + read_manifest(&socket)["patches"].as_object().unwrap().len(), + 2 + ); + let litter: Vec<_> = std::fs::read_dir(&socket) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with(".socket-stage-")) + .collect(); + assert!(litter.is_empty(), "no stage litter: {litter:?}"); +} diff --git a/crates/socket-patch-cli/tests/remove_network.rs b/crates/socket-patch-cli/tests/remove_network.rs index a9fb9bdc..04c18f05 100644 --- a/crates/socket-patch-cli/tests/remove_network.rs +++ b/crates/socket-patch-cli/tests/remove_network.rs @@ -28,40 +28,6 @@ fn binary() -> PathBuf { env!("CARGO_BIN_EXE_socket-patch").into() } -/// Every `SOCKET_*` env var the `remove` binary consults (clap-flattened -/// `GlobalArgs` plus runtime toggles). The spawned child inherits the parent -/// process environment, so any of these leaking in from the developer's shell -/// or CI could satisfy the behaviour under test *instead of* the argv — -/// masking a regression. Most dangerous here: an ambient `SOCKET_OFFLINE` -/// would make the `--offline` test (2) pass even if the `--offline` *flag* -/// handling regressed, and `SOCKET_MANIFEST_PATH`/`SOCKET_CWD` could point the -/// binary at a different manifest than the one `manifest_has_entry` reads back. -/// `run_remove` scrubs all of these from the child, then sets only the four it -/// controls, so each assertion exercises the flag/argv path and nothing else. -const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_API_TOKEN", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_OFFLINE", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_FORCE", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", -]; - const ORG_SLUG: &str = "test-org"; const PURL: &str = "pkg:npm/remove-network-test@1.0.0"; const UUID: &str = "11111111-1111-4111-8111-111111111111"; @@ -127,14 +93,24 @@ fn run_remove(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, String) { argv.extend_from_slice(extra); let mut cmd = Command::new(binary()); cmd.args(&argv).current_dir(cwd); - // Hermeticity: drop every SOCKET_* var the child might inherit from the - // ambient environment before re-setting only the four this test controls. - // Without this, an ambient `SOCKET_OFFLINE` could make the `--offline` - // test pass for the wrong reason (env, not flag), and a stray - // `SOCKET_MANIFEST_PATH`/`SOCKET_CWD` could aim the binary at the wrong - // manifest. The set must be re-set *after* scrubbing. - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); + // Hermeticity: drop every ambient SOCKET_* var by prefix before + // re-setting only the four this test controls. The spawned child + // inherits the parent environment, and the binary binds a wide + // `SOCKET_*` env surface (clap-flattened `GlobalArgs`, per-command + // flags, runtime toggles) — an ambient value satisfies (or defeats) + // the behaviour under test *instead of* the argv. An ambient + // `SOCKET_OFFLINE` would make the `--offline` test pass even if the + // *flag* handling regressed; `SOCKET_MANIFEST_PATH`/`SOCKET_CWD` + // could aim the binary at the wrong manifest. Scrub by prefix, not + // an enumerated list: a list drifts from the binary's env surface — + // it missed `SOCKET_SKIP_ROLLBACK` (remove's own env-bound flag), + // under which `remove --offline` skipped rollback entirely, exited + // 0, and deleted the entry, failing both tests for the wrong + // reason. The controlled set must be seeded *after* scrubbing. + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&key); + } } let out = cmd .env("SOCKET_API_URL", api_url) diff --git a/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs b/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs new file mode 100644 index 00000000..cf19613d --- /dev/null +++ b/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs @@ -0,0 +1,242 @@ +//! Regression test: flag-passed API overrides must reach the blob download +//! inside `remove`'s pre-removal rollback. +//! +//! `remove` builds its own API client from `GlobalArgs::api_client_overrides()` +//! (so `--api-url` / `--api-token` / `--org` / `--proxy-url` work for its +//! telemetry client), but `rollback_patches` reconstructed a from-scratch +//! `GlobalArgs::default()` whose override fields are all empty. The +//! missing-before-blob download inside the nested rollback therefore fell +//! back to env vars / hardcoded defaults: with credentials passed as flags +//! the nested client was UNAUTHENTICATED and pointed at the public proxy, +//! so the download failed (the configured `--api-url` was never contacted — +//! and in proxied environments the request leaked outside it) and the whole +//! `remove` aborted with `rollback_failed`. +//! +//! The stub server below plays the authenticated API; `SOCKET_PROXY_URL` is +//! pointed at a dead localhost port so the buggy fallback path fails fast +//! and hermetically instead of touching the real network. + +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::process::Command; +use std::sync::{Arc, Mutex}; + +use sha2::{Digest, Sha256}; + +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +/// Every `SOCKET_*` env var `GlobalArgs` reads as a flag fallback — scrubbed +/// so behavior is driven only by the explicit flags under test (an ambient +/// `SOCKET_API_TOKEN` would let the buggy env-fallback path pass). +const SOCKET_ENV_VARS: &[&str] = &[ + "SOCKET_API_TOKEN", + "SOCKET_CWD", + "SOCKET_MANIFEST_PATH", + "SOCKET_API_URL", + "SOCKET_ORG_SLUG", + "SOCKET_PROXY_URL", + "SOCKET_ECOSYSTEMS", + "SOCKET_DOWNLOAD_MODE", + "SOCKET_VENDOR_SOURCE", + "SOCKET_VENDOR_URL", + "SOCKET_PATCH_SERVER_URL", + "SOCKET_OFFLINE", + "SOCKET_STRICT", + "SOCKET_GLOBAL", + "SOCKET_GLOBAL_PREFIX", + "SOCKET_JSON", + "SOCKET_VERBOSE", + "SOCKET_SILENT", + "SOCKET_DRY_RUN", + "SOCKET_YES", + "SOCKET_LOCK_TIMEOUT", + "SOCKET_BREAK_LOCK", + "SOCKET_DEBUG", + "SOCKET_TELEMETRY_DISABLED", + "SOCKET_ONE_OFF", + "SOCKET_SKIP_ROLLBACK", +]; + +/// Drift guard: the scrub must cover every env var `GlobalArgs` binds — the +/// production `GLOBAL_ARG_ENV_VARS` list is the source of truth. A var +/// missing here escapes the scrub, so an ambient value in the developer's +/// shell or CI (e.g. `SOCKET_STRICT=garbage`) aborts every invocation in +/// this file. Mirrors `cli_parse_vendor.rs`. +#[test] +fn env_scrub_covers_every_global_arg_env_var() { + for var in socket_patch_cli::args::GLOBAL_ARG_ENV_VARS { + assert!( + SOCKET_ENV_VARS.contains(var), + "{var} is bound by GlobalArgs but missing from SOCKET_ENV_VARS — the scrub won't strip it", + ); + } +} + +/// Git-SHA256: SHA256("blob \0" ++ content). +fn git_sha256(content: &[u8]) -> String { + let header = format!("blob {}\0", content.len()); + let mut hasher = Sha256::new(); + hasher.update(header.as_bytes()); + hasher.update(content); + hex::encode(hasher.finalize()) +} + +/// Minimal HTTP stub playing the authenticated API: serves `blob` bytes at +/// any path ending in `/patches/blob/`, 404 otherwise, and records +/// every request path. The accept thread is detached; it dies with the test +/// process. +fn spawn_blob_server(hash: String, blob: Vec) -> (u16, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub server"); + let port = listener.local_addr().unwrap().port(); + let paths: Arc>> = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&paths); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + // Read until the end of the request head (GET has no body). + let mut head = Vec::new(); + let mut buf = [0u8; 1024]; + loop { + match stream.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + head.extend_from_slice(&buf[..n]); + if head.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + } + } + let head = String::from_utf8_lossy(&head).to_string(); + let path = head + .lines() + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .unwrap_or("") + .to_string(); + seen.lock().unwrap().push(path.clone()); + let response = if path.ends_with(&format!("/patches/blob/{hash}")) { + let mut r = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n", + blob.len() + ) + .into_bytes(); + r.extend_from_slice(&blob); + r + } else { + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec() + }; + let _ = stream.write_all(&response); + let _ = stream.shutdown(std::net::Shutdown::Both); + } + }); + (port, paths) +} + +/// A localhost port with nothing listening (bind-then-drop): a connect +/// attempt is refused immediately, keeping the buggy env-fallback path fast +/// and off the real network. +fn dead_port() -> u16 { + let l = TcpListener::bind("127.0.0.1:0").expect("bind dead port"); + l.local_addr().unwrap().port() +} + +#[test] +fn remove_rollback_downloads_missing_blob_via_flag_overrides() { + let before = b"original-content\n"; + let before_hash = git_sha256(before); + + let (port, seen_paths) = spawn_blob_server(before_hash.clone(), before.to_vec()); + let dead = dead_port(); + + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + // The before-blob is deliberately ABSENT from .socket/blobs: the + // rollback gate must download it through the flag-configured client. + let manifest = format!( + r#"{{ + "patches": {{ + "pkg:npm/__ovr_test__@1.0.0": {{ + "uuid": "44444444-4444-4444-8444-444444444444", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{ + "package/index.js": {{ + "beforeHash": "{before_hash}", + "afterHash": "1111111111111111111111111111111111111111111111111111111111111111" + }} + }}, + "vulnerabilities": {{}}, + "description": "synthetic override-plumbing test patch", + "license": "MIT", + "tier": "free" + }} + }} +}}"# + ); + std::fs::write(socket.join("manifest.json"), manifest).unwrap(); + + let mut cmd = Command::new(binary()); + cmd.args([ + "remove", + "pkg:npm/__ovr_test__@1.0.0", + "--yes", + "--api-url", + &format!("http://127.0.0.1:{port}"), + "--api-token", + "test-token", + "--org", + "testorg", + ]) + .current_dir(tmp.path()); + for var in SOCKET_ENV_VARS { + cmd.env_remove(var); + } + cmd.env("SOCKET_PROXY_URL", format!("http://127.0.0.1:{dead}")); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + + let out = cmd.output().expect("run socket-patch remove"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + let blob_requests: Vec = seen_paths + .lock() + .unwrap() + .iter() + .filter(|p| p.contains("/patches/blob/")) + .cloned() + .collect(); + assert_eq!( + blob_requests, + vec![format!("/v0/orgs/testorg/patches/blob/{before_hash}")], + "the missing-blob download inside remove's rollback must use the \ + flag-passed --api-url/--api-token/--org (authenticated endpoint), \ + not fall back to env/default settings.\nstdout: {stdout}\nstderr: {stderr}" + ); + // The blob's on-disk lifecycle: it must have LANDED in .socket/blobs for + // remove to proceed (the post-download `still_missing` re-check reads the + // dir; exit 0 below is unreachable otherwise), and then remove's + // unused-blob sweep deletes it again — beforeHash blobs are by design + // downloaded on-demand and never retained (`cleanup_unused_blobs` keeps + // only afterHash blobs, and this patch was just removed anyway). + assert!( + !socket.join("blobs").join(&before_hash).exists(), + "remove's unused-blob sweep must not retain the on-demand before-blob.\n\ + stdout: {stdout}\nstderr: {stderr}" + ); + assert_eq!( + out.status.code(), + Some(0), + "remove must succeed once the blob download works.\n\ + stdout: {stdout}\nstderr: {stderr}" + ); + let manifest_after = std::fs::read_to_string(socket.join("manifest.json")).unwrap(); + assert!( + !manifest_after.contains("__ovr_test__"), + "the manifest entry must be removed; manifest now: {manifest_after}" + ); +} diff --git a/crates/socket-patch-cli/tests/repair_invariants.rs b/crates/socket-patch-cli/tests/repair_invariants.rs index b1abc81d..8487532b 100644 --- a/crates/socket-patch-cli/tests/repair_invariants.rs +++ b/crates/socket-patch-cli/tests/repair_invariants.rs @@ -16,9 +16,13 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; const ORG_SLUG: &str = "test-org"; -/// Every `SOCKET_*` env var that maps onto a `GlobalArgs`/`RepairArgs` field. -/// The child binary must NOT inherit any of these from the ambient -/// environment, or the assertions stop testing what they claim: +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +/// A `socket-patch` command rooted at `cwd` with every ambient `SOCKET_*` +/// env var scrubbed, so every assertion exercises the flag/argv path and +/// nothing the ambient environment happened to leak in: /// * an ambient `SOCKET_OFFLINE` would make every `--offline` test pass even /// if the `--offline` *flag* path regressed (the binary would be offline /// for the wrong reason); @@ -28,45 +32,20 @@ const ORG_SLUG: &str = "test-org"; /// * `SOCKET_DOWNLOAD_ONLY` / `SOCKET_DOWNLOAD_MODE` / `SOCKET_DRY_RUN` /// could flip the cleanup-vs-download branch out from under the test. /// -/// We scrub the whole set and then re-set only the handful a given test -/// deliberately controls. -const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_API_TOKEN", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_DOWNLOAD_ONLY", - "SOCKET_OFFLINE", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_FORCE", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", -]; - -fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() -} - -/// A `socket-patch` command rooted at `cwd` with the full `SOCKET_*` env -/// scrubbed, so every assertion exercises the flag/argv path and nothing the -/// ambient environment happened to leak in. +/// Scrubbing is by prefix, not an explicit list: an explicit list drifts +/// stale as `GlobalArgs` grows (it had already missed `SOCKET_STRICT` / +/// `SOCKET_VENDOR_SOURCE`, whose validating parsers abort every invocation +/// with exit 2 on ambient garbage), and `main` migrates legacy +/// `SOCKET_PATCH_*` names into `SOCKET_*` at startup, which the prefix also +/// covers. Tests re-seed (via `.env()`, after this scrub) only the handful +/// they deliberately control. fn socket_cmd(cwd: &Path) -> Command { let mut cmd = Command::new(binary()); cmd.current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); + for (name, _) in std::env::vars_os() { + if name.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(name); + } } cmd } @@ -475,6 +454,102 @@ fn repair_download_only_skips_cleanup() { ); } +/// Regression: a FAILED cleanup pass must not be silently swallowed by +/// `--json` / `--silent`. The human loud path warns on stderr +/// ("Warning: blob cleanup failed: ...") and continues with exit 0 — but +/// both warnings in `repair_inner`'s cleanup arms were gated on +/// `!(json || silent)`, so: +/// * `repair --json` emitted a clean `status: success` envelope with zero +/// events — a machine consumer could not distinguish "cleaned up fine" +/// from "cleanup failed with EACCES and the orphan is still there"; +/// * `repair --silent` ("suppress non-error output") muted the failure +/// entirely, though an error is exactly what --silent must still print. +/// +/// The fixture makes cleanup fail deterministically: the blobs dir is +/// read-only (r-x), so the orphan unlink fails EACCES while directory +/// listing and stat still work. +#[cfg(unix)] +#[test] +fn repair_cleanup_failure_is_reported_in_json_and_silent_modes() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = make_socket_dir(tmp.path()); + write_blob(&socket, REFERENCED_HASH, b"kept"); + let orphan = "0badf00d".repeat(8); // 64 chars, not referenced + write_blob(&socket, &orphan, b"orphan bytes"); + let blobs_dir = socket.join("blobs"); + std::fs::set_permissions(&blobs_dir, std::fs::Permissions::from_mode(0o555)) + .expect("chmod blobs dir read-only"); + + // Control (loud human mode): proves the fixture actually trips the + // cleanup-failure path — the warning is on stderr and the run still + // exits 0 (cleanup failure is warn-and-continue, not fatal). If this + // fails, the environment can unlink from a r-x dir (e.g. running as + // root) and the assertions below would be vacuous. + let loud = socket_cmd(tmp.path()) + .args(["repair", "--offline"]) + .output() + .expect("run socket-patch"); + assert_eq!( + loud.status.code(), + Some(0), + "control: cleanup failure must stay non-fatal; stderr=\n{}", + String::from_utf8_lossy(&loud.stderr) + ); + assert!( + String::from_utf8_lossy(&loud.stderr).contains("blob cleanup failed"), + "control: loud human mode must warn about the failed cleanup; stderr=\n{}", + String::from_utf8_lossy(&loud.stderr) + ); + assert!( + blobs_dir.join(&orphan).exists(), + "control: the orphan must have survived the failed cleanup" + ); + + // JSON mode: the envelope must carry the cleanup failure as an + // informational skip event (warn-and-continue semantics preserved: + // status stays success, exit stays 0, nothing was removed). + let (code, stdout) = run_repair(tmp.path(), &[]); + assert_eq!(code, 0, "json: cleanup failure stays non-fatal; stdout=\n{stdout}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("envelope JSON"); + assert_eq!(v["status"], "success"); + assert_eq!(v["summary"]["removed"], 0); + let events = v["events"].as_array().expect("events array"); + let skip = events + .iter() + .find(|e| e["action"] == "skipped" && e["errorCode"] == "cleanup_failed") + .unwrap_or_else(|| { + panic!("json: envelope must record the failed cleanup; got events={events:?}") + }); + assert!( + skip["reason"].as_str().unwrap_or("").contains("blob"), + "the skip reason must name the failing cleanup pass; got {skip}" + ); + + // Silent mode: stdout stays empty, but the failure warning must still + // reach stderr — --silent suppresses non-ERROR output only. + let silent = socket_cmd(tmp.path()) + .args(["repair", "--offline", "--silent"]) + .output() + .expect("run socket-patch"); + assert_eq!(silent.status.code(), Some(0)); + assert!( + String::from_utf8_lossy(&silent.stdout).trim().is_empty(), + "--silent must keep stdout empty; got:\n{}", + String::from_utf8_lossy(&silent.stdout) + ); + assert!( + String::from_utf8_lossy(&silent.stderr).contains("blob cleanup failed"), + "--silent must NOT mute the cleanup-failure warning; stderr=\n{}", + String::from_utf8_lossy(&silent.stderr) + ); + + // Restore permissions so the tempdir can be cleaned up. + std::fs::set_permissions(&blobs_dir, std::fs::Permissions::from_mode(0o755)) + .expect("restore blobs dir permissions"); +} + // --------------------------------------------------------------------------- // gc alias parity // --------------------------------------------------------------------------- @@ -620,7 +695,7 @@ async fn repair_online_downloads_missing_blob() { fn repair_honors_manifest_path_override() { // Put the manifest somewhere other than `.socket/manifest.json` and // confirm `--manifest-path` finds it. This exercises the - // `resolve_manifest_path` codepath. + // `resolved_manifest_path` codepath. let tmp = tempfile::tempdir().expect("tempdir"); let custom_dir = tmp.path().join("custom"); std::fs::create_dir_all(&custom_dir).unwrap(); diff --git a/crates/socket-patch-cli/tests/repair_vendor_e2e.rs b/crates/socket-patch-cli/tests/repair_vendor_e2e.rs index a6003feb..6db59886 100644 --- a/crates/socket-patch-cli/tests/repair_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/repair_vendor_e2e.rs @@ -516,6 +516,185 @@ async fn repair_reconstructs_ledger_from_lockfile_references() { assert!(!tgz.exists(), "revert removed the artifact"); } +/// 7b. Only `state.json` was lost; the committed artifact survived INTACT. +/// Repair restores the ledger entry from the lockfile reference without +/// rebuilding — the artifact bytes stay untouched and the re-synthesized +/// entry fingerprints them. +#[tokio::test] +async fn repair_restores_ledger_for_intact_surviving_artifact() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture( + tmp.path(), + "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "sha512-orig==", + ); + let tgz = vendor_project(tmp.path(), &mock.uri(), &[]); + let vendored_bytes = std::fs::read(&tgz).unwrap(); + + std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); + + mount_blob(&mock).await; + let (code, stdout, stderr) = run_cli( + tmp.path(), + &mock.uri(), + &["repair", "--download-mode", "file"], + ); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["action"] == "rebuilt" && e["details"]["ledgerRestored"] == true), + "envelope={v}" + ); + assert_eq!( + std::fs::read(&tgz).unwrap(), + vendored_bytes, + "an intact artifact is restored, not rebuilt" + ); + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + state["entries"][PURL]["artifact"]["sha256"], + sha256_hex(&vendored_bytes), + "state={state}" + ); +} + +/// 7c. `state.json` lost AND the surviving artifact DRIFTED from the wired +/// lock integrity while its patched members still verify (an unpatched +/// member was altered — exactly the drift the whole-file ledger sha +/// would have caught, but the re-synthesized entry has no sha yet). +/// Reconstruction must not bless the drifted bytes into the new ledger: +/// the artifact is rebuilt and reproduces the wired integrity. +#[tokio::test] +async fn repair_ledger_reconstruction_rejects_drifted_surviving_artifact() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture( + tmp.path(), + "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "sha512-orig==", + ); + let tgz = vendor_project(tmp.path(), &mock.uri(), &[]); + let lock: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(), + ) + .unwrap(); + let wired_sri = lock["packages"]["node_modules/left-pad"]["integrity"] + .as_str() + .expect("vendor wired the lock integrity") + .to_string(); + + std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); + // Drift: an UNPATCHED member changes; the patched member keeps its + // AFTER bytes, so per-file afterHashes still verify. + let mut drifted = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )); + for (p, bytes) in [ + ( + "package/package.json", + br#"{"name":"left-pad","version":"1.3.0","scripts":{"postinstall":"evil"}}"#.as_slice(), + ), + ("package/index.js", AFTER), + ] { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + drifted.append_data(&mut header, p, bytes).unwrap(); + } + let drifted = drifted.into_inner().unwrap().finish().unwrap(); + assert_ne!(sri_of(&drifted), wired_sri, "fixture must actually drift"); + std::fs::write(&tgz, &drifted).unwrap(); + + mount_blob(&mock).await; + let (code, stdout, stderr) = run_cli( + tmp.path(), + &mock.uri(), + &["repair", "--download-mode", "file"], + ); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + // THE regression: after a successful repair the committed artifact must + // be the bytes the rewired lock records — not the drifted ones blessed + // into the reconstructed ledger. + assert_eq!( + sri_of(&std::fs::read(&tgz).unwrap()), + wired_sri, + "envelope={v}" + ); + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + state["entries"][PURL]["artifact"]["sha256"], + sha256_hex(&std::fs::read(&tgz).unwrap()), + "state={state}" + ); +} + +/// 7d. `.socket/vendor` deleted wholesale AND the installed copy's UNPATCHED +/// member tampered (the patched file keeps its pristine bytes, so the +/// backend's per-file checks all pass). The reconstruction rebuilds from +/// the installed copy, so the rebuilt artifact cannot reproduce the wired +/// lock integrity — the same trust anchor tests 9/10 enforce for the +/// unverified-fetch rung. It must be rejected fail-closed (nothing kept, +/// exit 1), never blessed into the reconstructed ledger while `npm ci` +/// stays broken. +#[tokio::test] +async fn repair_reconstruction_rejects_tampered_installed_copy() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture( + tmp.path(), + "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "sha512-orig==", + ); + let tgz = vendor_project(tmp.path(), &mock.uri(), &[]); + + std::fs::remove_dir_all(tmp.path().join(".socket/vendor")).unwrap(); + // Tamper the installed copy's UNPATCHED member; name/version stay intact + // so the crawler still finds the package, and the patched index.js keeps + // its BEFORE bytes so per-file hash checks pass. + std::fs::write( + tmp.path().join("node_modules/left-pad/package.json"), + br#"{"name":"left-pad","version":"1.3.0","scripts":{"postinstall":"evil"}}"#, + ) + .unwrap(); + + mount_blob(&mock).await; + let (code, stdout, stderr) = run_cli( + tmp.path(), + &mock.uri(), + &["repair", "--download-mode", "file"], + ); + assert_eq!(code, 1, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v).iter().any(|e| e["action"] == "failed" + && e["errorCode"] == "vendor_artifact_rebuild_failed" + && e["error"] + .as_str() + .unwrap_or("") + .contains("integrity the lockfile records")), + "envelope={v}" + ); + assert!( + !tgz.exists(), + "a rebuild that cannot reproduce the wired integrity must not be kept" + ); +} + /// 8. No ledger AND no manifest — only the rewired lockfile: the uuid in /// the lock path drives an API view fetch and the entry is re-created /// DETACHED (manifest-invisible), with the artifact rebuilt. diff --git a/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs b/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs index aba8bd5c..64782803 100644 --- a/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs +++ b/crates/socket-patch-cli/tests/repair_vendor_flavors_e2e.rs @@ -21,15 +21,13 @@ //! view-fetched patch content. use std::path::{Path, PathBuf}; -use std::process::Command; use sha2::{Digest, Sha256}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() -} +#[path = "common/mod.rs"] +mod common; const ORG_SLUG: &str = "test-org"; const UUID: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab"; @@ -266,6 +264,11 @@ async fn mount_blob(mock: &MockServer) { .await; } +/// Runs through `common::run_with_env`, which seed-then-scrubs the ambient +/// `SOCKET_*` surface the binary binds via clap `env=` (SOCKET_DRY_RUN, +/// SOCKET_ECOSYSTEMS, SOCKET_CWD, ...) — an ambient value would silently +/// change what every test here exercises (SOCKET_DRY_RUN=true turns the +/// vendor setup and every repair into a no-op). fn run_cli(root: &Path, mock_uri: &str, argv: &[&str]) -> (i32, String, String) { let mut full = argv.to_vec(); full.extend_from_slice(&[ @@ -277,17 +280,7 @@ fn run_cli(root: &Path, mock_uri: &str, argv: &[&str]) -> (i32, String, String) "--org", ORG_SLUG, ]); - let out = Command::new(binary()) - .args(&full) - .current_dir(root) - .env("SOCKET_TELEMETRY_DISABLED", "1") - .output() - .expect("run"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).into_owned(), - String::from_utf8_lossy(&out.stderr).into_owned(), - ) + common::run_with_env(root, &full, &[("SOCKET_TELEMETRY_DISABLED", "1")]) } /// `scan --vendor --yes` to establish a vendored flavor project; returns the @@ -512,6 +505,82 @@ async fn ledger_gone_reconstructs_from_lock(flavor: Flavor) { assert_wiring_intact(tmp.path(), flavor); } +// ── (e) ledger gone + drifted installed copy → fail-closed ───────────────── +// +// The reconstructed entry records no sha; the rewired lockfile's integrity +// (pnpm `integrity:`, berry `checksum: 10c0/…`, bun tuple sha512) is the ONLY +// anchor for the rebuilt bytes. A rebuild packed from an installed copy that +// drifted since vendoring (a file added by a build tool, an edited unpatched +// file) can never match that integrity — the package manager rejects the +// artifact on its next install. Repair must fail closed, not report success +// and bless the drifted bytes into a fresh ledger (which would make every +// later repair see Healthy and never fix it). + +async fn ledger_gone_drifted_copy_fails_closed(flavor: Flavor) { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture(tmp.path(), flavor); + let tgz = vendor_project(tmp.path(), &mock.uri()); + let lock1 = std::fs::read(tmp.path().join(flavor.lock_name())).unwrap(); + + // Drift an UNPATCHED part of the installed copy (patched-file tampering + // is already caught by the beforeHash gate; this is invisible to it). + std::fs::write( + tmp.path().join("node_modules").join(DEP).join("drifted.js"), + b"injected after vendoring\n", + ) + .unwrap(); + std::fs::remove_dir_all(tmp.path().join(".socket/vendor")).unwrap(); + + mount_blob(&mock).await; + let (code, stdout, stderr) = run_cli( + tmp.path(), + &mock.uri(), + &["repair", "--download-mode", "file"], + ); + assert_eq!( + code, 1, + "{}: a rebuild that cannot match the lockfile's recorded integrity must fail closed: \ + stdout={stdout} stderr={stderr}", + flavor.tag() + ); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["action"] == "failed" && e["errorCode"] == "vendor_artifact_rebuild_failed"), + "{}: envelope={v}", + flavor.tag() + ); + assert!( + !tgz.exists(), + "{}: an artifact the lockfile rejects must not be left on disk", + flavor.tag() + ); + assert_eq!( + std::fs::read(tmp.path().join(flavor.lock_name())).unwrap(), + lock1, + "{}: the lockfile (the trust anchor) stays untouched", + flavor.tag() + ); +} + +#[tokio::test] +async fn repair_fails_closed_on_drifted_copy_pnpm() { + ledger_gone_drifted_copy_fails_closed(Flavor::Pnpm).await; +} + +#[tokio::test] +async fn repair_fails_closed_on_drifted_copy_yarn_berry() { + ledger_gone_drifted_copy_fails_closed(Flavor::YarnBerry).await; +} + +#[tokio::test] +async fn repair_fails_closed_on_drifted_copy_bun() { + ledger_gone_drifted_copy_fails_closed(Flavor::Bun).await; +} + #[tokio::test] async fn repair_reconstructs_pnpm_ledger_from_lockfile() { ledger_gone_reconstructs_from_lock(Flavor::Pnpm).await; diff --git a/crates/socket-patch-cli/tests/rollback_invariants.rs b/crates/socket-patch-cli/tests/rollback_invariants.rs index 5fa0e72a..c7c170bc 100644 --- a/crates/socket-patch-cli/tests/rollback_invariants.rs +++ b/crates/socket-patch-cli/tests/rollback_invariants.rs @@ -14,46 +14,26 @@ fn binary() -> PathBuf { env!("CARGO_BIN_EXE_socket-patch").into() } -/// Every `SOCKET_*` env var that `GlobalArgs` / `RollbackArgs` read as a flag -/// fallback. The child process inherits the parent's environment, so an -/// ambient value here would let a test pass via the environment instead of via -/// the flag (and the real code path) it is named after — e.g. an ambient -/// `SOCKET_OFFLINE=true` would satisfy the `--offline` tests even if `--offline` -/// were broken, and `SOCKET_MANIFEST_PATH` would silently redirect the manifest -/// out from under the no-manifest / override tests. Scrub the whole surface so -/// behavior is driven only by the explicit args we pass. -const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_API_TOKEN", - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_OFFLINE", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_ONE_OFF", -]; - /// A `rollback` command with the full `SOCKET_*` environment scrubbed and the /// working directory pinned. All tests build their child process through here /// so none can be satisfied by ambient environment instead of the code path. +/// +/// The child process inherits the parent's environment, so an ambient value +/// would let a test pass via the environment instead of via the flag (and the +/// real code path) it is named after — e.g. an ambient `SOCKET_OFFLINE=true` +/// would satisfy the `--offline` tests even if `--offline` were broken, and +/// `SOCKET_MANIFEST_PATH` would silently redirect the manifest out from under +/// the no-manifest / override tests. Scrub by prefix, not by list: an explicit +/// list rots as flags are added (it missed `SOCKET_VENDOR_SOURCE`, whose +/// ambient garbage aborted every invocation with a clap usage error). Tests +/// that need a `SOCKET_*` var seed it AFTER this scrub via `.env()`. fn rollback_cmd(cwd: &Path) -> Command { let mut cmd = Command::new(binary()); cmd.arg("rollback").current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&key); + } } cmd } diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index a76bc228..d59669ca 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -145,6 +145,37 @@ async fn mount_patch_api(mock: &MockServer, uuid: &str) { .await; } +/// Spawn the built binary in `root` with `extra_env` injected into the +/// child environment. +fn run_cli_env(root: &Path, argv: &[&str], extra_env: &[(&str, &str)]) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(argv).current_dir(root); + // Scrub the ambient `SOCKET_*` surface (prefix scrub — fixed lists rot) + // so a developer's shell can't steer the child, then force the telemetry + // kill-switch: telemetry resolves its endpoint from `SOCKET_API_URL` / + // `SOCKET_PROXY_URL` env ONLY (`--api-url` is invisible to it), so an + // ambient value would send every run's events to the LIVE API with the + // fake bearer token. Caller-supplied env lands last so explicit + // injections survive the scrub — + // `scan_vendor_emits_no_telemetry_even_with_endpoint_env` seeds those + // endpoint vars deliberately and proves the kill-switch still holds. + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + for (k, v) in extra_env { + cmd.env(k, v); + } + let out = cmd.output().expect("run"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + fn run_scan_vendor(root: &Path, mock_uri: &str, extra: &[&str]) -> (i32, String, String) { let mut argv = vec![ "scan", @@ -159,16 +190,7 @@ fn run_scan_vendor(root: &Path, mock_uri: &str, extra: &[&str]) -> (i32, String, ORG_SLUG, ]; argv.extend_from_slice(extra); - let out = Command::new(binary()) - .args(&argv) - .current_dir(root) - .output() - .expect("run"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).into_owned(), - String::from_utf8_lossy(&out.stderr).into_owned(), - ) + run_cli_env(root, &argv, &[]) } /// Vendor flows hold patch content in MEMORY: `.socket/` must end up with @@ -409,6 +431,95 @@ async fn scan_vendor_dry_run_previews_without_touching_disk() { ); } +/// Interactive (non-JSON) `scan --vendor --detached` with a failing patch +/// view fetch must SAY what failed: exit 1 with a `[fail]` line naming the +/// purl on stderr. Regression guard: `download_patch_records`' failure arms +/// recorded the error only in their JSON report, so the human path exited +/// non-zero with no error output at all (the JSON report is discarded and +/// the vendor engine just says "No vendorable patches in scope"). +#[tokio::test] +async fn scan_vendor_detached_fetch_failure_reports_error() { + let mock = MockServer::start().await; + // Discovery succeeds (batch + per-package search, same shapes as + // `mount_patch_api`), but the view fetch fails. + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, + "purl": PURL, + "tier": "free", + "cveIds": ["CVE-2026-0001"], + "ghsaIds": [], + "severity": "high", + "title": "vendor target" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(&mock) + .await; + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{ENCODED}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "Vendor patch", + "license": "MIT", + "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(&mock) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(500)) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_fixture(tmp.path()); + + let out = Command::new(binary()) + .args([ + "scan", + "--vendor", + "--detached", + "--yes", + "--api-url", + &mock.uri(), + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + ]) + .current_dir(tmp.path()) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .output() + .expect("run"); + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + assert_eq!( + code, 1, + "a failed download must exit non-zero; stdout={stdout}; stderr={stderr}" + ); + assert!( + stderr.contains("[fail]") && stderr.contains("left-pad"), + "the failed fetch must be reported on stderr, not swallowed; \ + stdout={stdout}; stderr={stderr}" + ); +} + #[tokio::test] async fn scan_vendor_flag_conflicts_are_clap_errors() { // --vendor conflicts with --apply/--sync; --detached requires --vendor. @@ -435,6 +546,70 @@ async fn scan_vendor_flag_conflicts_are_clap_errors() { } } +/// No invocation in this suite may emit telemetry. Telemetry resolves its +/// endpoint from `SOCKET_API_URL` / `SOCKET_PROXY_URL` env ONLY (the +/// `--api-url` flag is invisible to it — utils::telemetry), so the +/// unhardened harness sent every successful run's `patch_vendored` event to +/// the LIVE `/v0/orgs/test-org/telemetry` with the fake bearer token. Seed +/// the child env with a reachable endpoint (worst case for the kill-switch) +/// and prove not a single telemetry request escapes. +#[tokio::test] +async fn scan_vendor_emits_no_telemetry_even_with_endpoint_env() { + let mock = MockServer::start().await; + mount_patch_api(&mock, UUID).await; + // Accept both telemetry arms: authenticated (`/v0/orgs//telemetry`) + // and public-proxy (`/patch/telemetry`). Unmatched requests are recorded + // by wiremock anyway; mounting keeps the child's send path realistic. + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/telemetry"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .mount(&mock) + .await; + Mock::given(method("POST")) + .and(path("/patch/telemetry")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .mount(&mock) + .await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture(tmp.path()); + + let mock_uri = mock.uri(); + let (code, stdout, stderr) = run_cli_env( + tmp.path(), + &[ + "scan", + "--json", + "--vendor", + "--yes", + "--api-url", + &mock_uri, + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + ], + &[ + ("SOCKET_API_URL", mock_uri.as_str()), + ("SOCKET_PROXY_URL", mock_uri.as_str()), + ], + ); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + + let telemetry: Vec = mock + .received_requests() + .await + .unwrap() + .iter() + .map(|r| r.url.path().to_string()) + .filter(|p| p.contains("telemetry")) + .collect(); + assert!( + telemetry.is_empty(), + "test runs must never phone telemetry home (live-API leak when \ + SOCKET_API_URL is unset); observed: {telemetry:?}" + ); +} + // ───────────── percent-encoded scoped purls (API canonical form) ───────────── const SCOPED_CRAWLER_PURL: &str = "pkg:npm/@scope/left-pad@1.3.0"; @@ -1145,6 +1320,142 @@ async fn scan_discovers_lockfile_only_packages_with_warning() { ); } +/// The not-installed flag must survive the API's purl spelling: the +/// patches API serves purls in canonical percent-encoded form +/// (`pkg:npm/%40scope/...` — see `utils::purl`), while the lockfile +/// supplement records the literal on-disk form (`pkg:npm/@scope/...`). +/// The apply-path skip partitions already bridge the encodings via +/// `normalize_purl`; the JSON `notInstalled` flag and the table's +/// `[NOT INSTALLED]` marker must agree with them. +#[tokio::test] +async fn scan_flags_scoped_lockfile_only_package_despite_api_purl_encoding() { + const SCOPED_ENCODED: &str = "pkg:npm/%40scope/left-pad@1.3.0"; + let mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": SCOPED_ENCODED, + "patches": [{ + "uuid": UUID, + "purl": SCOPED_ENCODED, + "tier": "free", + "cveIds": ["CVE-2026-0001"], + "ghsaIds": [], + "severity": "high", + "title": "scoped fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(&mock) + .await; + // Detail route for the human run's fetch phase (the purl is + // URL-encoded into the path — match any by-package request). + Mock::given(method("GET")) + .and(wiremock::matchers::path_regex(format!( + "^/v0/orgs/{ORG_SLUG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, + "purl": SCOPED_ENCODED, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "Scoped patch", + "license": "MIT", + "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(&mock) + .await; + + // Lockfile-only fixture for @scope/left-pad (literal on-disk spelling, + // no node_modules). + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("package.json"), + r#"{ "name": "scoped-test", "version": "0.0.0", "dependencies": { "@scope/left-pad": "^1.3.0" } }"#, + ) + .unwrap(); + let lock = serde_json::json!({ + "name": "scoped-test", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scoped-test", + "version": "0.0.0", + "dependencies": { "@scope/left-pad": "^1.3.0" } + }, + "node_modules/@scope/left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@scope/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-unused==", + "license": "WTFPL" + } + } + }); + std::fs::write( + tmp.path().join("package-lock.json"), + serde_json::to_vec_pretty(&lock).unwrap(), + ) + .unwrap(); + + // JSON: the additive notInstalled flag must be set even though the + // API spelled the purl percent-encoded. + let out = Command::new(binary()) + .args([ + "scan", + "--json", + "--api-url", + &mock.uri(), + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + ]) + .current_dir(tmp.path()) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .output() + .expect("run"); + let stdout = String::from_utf8_lossy(&out.stdout); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!(v["lockfileOnlyPackages"], 1, "{v}"); + assert_eq!(v["packages"][0]["purl"], SCOPED_ENCODED, "{v}"); + assert_eq!( + v["packages"][0]["notInstalled"], true, + "notInstalled must bridge the API's percent-encoded purl: {v}" + ); + + // Human table: the [NOT INSTALLED] marker must match through the + // encoding difference too. + let out = Command::new(binary()) + .args([ + "scan", + "--api-url", + &mock.uri(), + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + "--dry-run", + "--yes", + ]) + .current_dir(tmp.path()) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .output() + .expect("run"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stdout.contains("[NOT INSTALLED]"), + "stdout={stdout}; stderr={stderr}" + ); +} + /// `scan --apply` skips lockfile-only patches calmly: exit 0, a skipped /// record with package_not_installed, and NO manifest entry written. #[tokio::test] diff --git a/crates/socket-patch-cli/tests/setup_contract_gaps.rs b/crates/socket-patch-cli/tests/setup_contract_gaps.rs index 3c6e2216..d8780eea 100644 --- a/crates/socket-patch-cli/tests/setup_contract_gaps.rs +++ b/crates/socket-patch-cli/tests/setup_contract_gaps.rs @@ -1,18 +1,14 @@ -//! **Executable spec for the not-yet-implemented parts of the `setup` contract.** +//! **Executable spec for the once-unimplemented parts of the `setup` contract.** //! //! Every test in this file encodes a property from the "Setup command contract" -//! section of `crates/socket-patch-cli/CLI_CONTRACT.md` that the current binary -//! does **not** yet satisfy. They are intentionally RED — exactly like the -//! pre-existing all-batches-failed guard in `scan_invariants.rs::scan_handles_ -//! api_500_error_gracefully`. They are NOT regressions: a failure here means the -//! gap is still open. When the corresponding property is implemented, the test -//! flips green and protects it thereafter. +//! section of `crates/socket-patch-cli/CLI_CONTRACT.md` that the binary did not +//! originally satisfy. They began life intentionally RED (executable +//! documentation of the open gaps); every property here has since SHIPPED — +//! see the per-section comments — so today these are ordinary, active +//! regression guards. A failure now IS a regression. Do not "fix" one by +//! weakening the assertions. //! -//! This work was scoped as *documentation + tests only* — we deliberately did -//! not change production behavior, so these stay RED on purpose. Do not "fix" -//! them by weakening the assertions. -//! -//! Each test names the property it guards and explains why it is currently RED. +//! Each test names the property it guards. use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; @@ -22,35 +18,19 @@ fn binary() -> PathBuf { env!("CARGO_BIN_EXE_socket-patch").into() } -/// `SOCKET_*` vars scrubbed from every child so behaviour is decided by flags + -/// fixtures alone (mirrors setup_invariants.rs). Critically includes -/// `SOCKET_ECOSYSTEMS` (whose ambient value would defeat the prop-2 scoping -/// test) and the cargo-backend `SOCKET_PATCH_*` knobs. -const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_TOKEN", - "SOCKET_ECOSYSTEMS", - "SOCKET_OFFLINE", - "SOCKET_JSON", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_SETUP_EXCLUDE", - "SOCKET_VEX_NO_VERIFY", - "SOCKET_VEX_PRODUCT", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_PATCH_BIN", - "SOCKET_PATCH_DEBUG", -]; - -/// Run the binary with a scrubbed environment, telemetry off, and HOME pointed -/// at `home`. Returns (exit code, stdout). +/// Run the binary with every ambient `SOCKET_*` var scrubbed (prefix scrub — +/// a fixed list rots as flags grow: ambient `SOCKET_GLOBAL=true` alone sent +/// the patch-consistency crawl to the global prefix, hiding the drifted +/// package and turning the prop-4 test green-for-the-wrong-reason; ambient +/// `SOCKET_ECOSYSTEMS` would likewise defeat the prop-2 scoping test), +/// telemetry off, and HOME pointed at `home`. Returns (exit code, stdout). fn run(cwd: &Path, home: &Path, args: &[&str]) -> (i32, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); + for (name, _) in std::env::vars() { + if name.starts_with("SOCKET_") { + cmd.env_remove(name); + } } cmd.env("HOME", home); cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); @@ -191,6 +171,151 @@ fn setup_check_detects_unapplied_manifest_patch() { ); } +// =========================================================================== +// Property 4 (vendored) — the patch-consistency pass must judge a VENDORED +// patch by its committed `.socket/vendor/` artifact, which core's +// `applied_patches_with_vendor` contract makes the SOLE evidence: an +// unpatched installed tree is EXPECTED after vendoring (the next install +// re-materializes it from the artifact; go redirects leave the module cache +// pristine forever), and a patched-looking installed tree must not launder a +// tampered artifact. `vex` builds that vendor context +// (commands/vex.rs::load_vendor_context); `setup --check` must too — without +// it a healthy vendored repo false-fails `--check` with `not_applied`, and a +// tampered artifact passes. +// =========================================================================== + +/// Canonical-grammar patch UUID — the vendored-artifact verifier validates +/// the uuid path level against the record, so fixtures must use the real +/// shape (mirrors e2e_vex_vendor.rs). +const VENDOR_UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + +/// Lay down the shared vendored fixture: hook wired, an installed +/// `node_modules/vendpkg` at `installed` bytes, a committed dir-shaped +/// vendored artifact at `vendored` bytes, the `.socket/vendor/state.json` +/// ledger entry binding the purl to it, and a manifest record whose +/// afterHash is the hash of `patched`. +fn setup_vendored_fixture(proj: &Path, home: &Path, installed: &[u8], vendored: &[u8]) { + use socket_patch_core::patch::vendor::state::{VendorArtifact, VendorEntry, VendorState}; + + write( + &proj.join("package.json"), + r#"{ "name": "x", "version": "1.0.0" }"#, + ); + let (c, _) = run(proj, home, &["setup", "--json", "--yes"]); + assert_eq!(c, 0, "precondition: initial setup wires the hook"); + + let original = b"original\n"; + let patched = b"patched\n"; + + let pkg = proj.join("node_modules/vendpkg"); + write( + &pkg.join("package.json"), + r#"{ "name": "vendpkg", "version": "1.0.0" }"#, + ); + write(&pkg.join("index.js"), &String::from_utf8_lossy(installed)); + + // The committed vendored artifact (dir-shaped copy). + let rel = format!(".socket/vendor/npm/{VENDOR_UUID}/vendpkg-1.0.0"); + write( + &proj.join(&rel).join("index.js"), + &String::from_utf8_lossy(vendored), + ); + + let mut state = VendorState::new(); + state.entries.insert( + "pkg:npm/vendpkg@1.0.0".to_string(), + VendorEntry { + ecosystem: "npm".to_string(), + base_purl: "pkg:npm/vendpkg@1.0.0".to_string(), + uuid: VENDOR_UUID.to_string(), + artifact: VendorArtifact { + path: rel, + sha256: String::new(), + size: None, + platform_locked: None, + }, + wiring: Vec::new(), + lock: None, + took_over_go_patches: false, + detached: false, + record: None, + flavor: None, + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + }, + ); + write( + &proj.join(".socket/vendor/state.json"), + &serde_json::to_string_pretty(&state).unwrap(), + ); + + write( + &proj.join(".socket/manifest.json"), + &format!( + r#"{{ "patches": {{ + "pkg:npm/vendpkg@1.0.0": {{ + "uuid": "{VENDOR_UUID}", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{ "package/index.js": {{ "beforeHash": "{before}", "afterHash": "{after}" }} }}, + "vulnerabilities": {{ "GHSA-aaaa-bbbb-cccc": {{ "cves": ["CVE-2024-0001"], "summary": "x", "severity": "high", "description": "d" }} }}, + "description": "d", "license": "MIT", "tier": "free" + }} +}} }}"#, + before = git_sha256(original), + after = git_sha256(patched), + ), + ); +} + +#[test] +fn setup_check_judges_vendored_patch_by_committed_artifact() { + let proj = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + + // Healthy vendored state: the artifact carries the patch; the installed + // tree still holds the ORIGINAL bytes (expected until the next install). + setup_vendored_fixture(proj.path(), home.path(), b"original\n", b"patched\n"); + + let (code, stdout) = run(proj.path(), home.path(), &["setup", "--check", "--json"]); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!( + code, 0, + "check must trust the committed vendored artifact — an unpatched \ + installed tree is EXPECTED after vendoring, not drift; stdout=\n{stdout}" + ); + assert_eq!( + v["status"], "configured", + "a healthy vendored repo must report `configured`; stdout=\n{stdout}" + ); +} + +#[test] +fn setup_check_flags_tampered_vendored_artifact_despite_patched_tree() { + let proj = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + + // Laundering attempt: the committed artifact was tampered with, but the + // installed tree LOOKS patched. The artifact is the sole evidence — the + // consumed bytes on the next install — so check must fail. + setup_vendored_fixture(proj.path(), home.path(), b"patched\n", b"TAMPERED\n"); + + let (code, stdout) = run(proj.path(), home.path(), &["setup", "--check", "--json"]); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!( + code, 1, + "a tampered vendored artifact must fail check even when the installed \ + tree looks patched; stdout=\n{stdout}" + ); + assert_ne!( + v["status"], "configured", + "a patched-looking installed tree must not launder a tampered vendor \ + artifact; stdout=\n{stdout}" + ); +} + // =========================================================================== // Property 7 — reflected in VEX. A patch contributes a VEX statement only for an // ecosystem that is actually set up (or declared `manual`). Here the manifest diff --git a/crates/socket-patch-cli/tests/setup_invariants.rs b/crates/socket-patch-cli/tests/setup_invariants.rs index 334220d7..c3b13b64 100644 --- a/crates/socket-patch-cli/tests/setup_invariants.rs +++ b/crates/socket-patch-cli/tests/setup_invariants.rs @@ -44,52 +44,30 @@ fn copy_tree(src: &Path, dst: &Path) { } } -/// Every `SOCKET_*` env var that `setup` (via `GlobalArgs`) honours as a -/// fallback for a CLI flag. These tests drive `setup` purely through flags and -/// on-disk fixtures, so ANY of these leaking in from the developer's shell or -/// CI would let an assertion pass for the wrong reason — e.g. an ambient -/// `SOCKET_DRY_RUN=true` would keep a regressed `--check`/`--yes` path from -/// writing (satisfying the "must not modify" checks vacuously), and an ambient -/// `SOCKET_ECOSYSTEMS`/`SOCKET_YES`/`SOCKET_CWD` would silently change which -/// manifest is touched and how the script is rendered. Scrub the whole set -/// from every child so behaviour is decided by flags alone. Mirrors the -/// hardened helpers in remove_network.rs / repair_invariants.rs. -const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_API_TOKEN", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_DOWNLOAD_ONLY", - "SOCKET_OFFLINE", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_FORCE", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - // Other SOCKET_PATCH_* knobs that could steer setup behaviour. - "SOCKET_PATCH_BIN", - "SOCKET_PATCH_DEBUG", - "SOCKET_PATCH_PROXY_URL", - "SOCKET_PATCH_TELEMETRY_DISABLED", -]; - -/// Build a `setup` invocation with the full `SOCKET_*` environment scrubbed. +/// Build a `setup` invocation with every ambient `SOCKET_*` env var scrubbed +/// by prefix. These tests drive `setup` purely through flags and on-disk +/// fixtures, so ANY `SOCKET_*` fallback leaking in from the developer's shell +/// or CI would let an assertion pass (or fail) for the wrong reason — e.g. an +/// ambient `SOCKET_DRY_RUN=true` would keep a regressed `--check`/`--yes` path +/// from writing (satisfying the "must not modify" checks vacuously), and an +/// ambient `SOCKET_ECOSYSTEMS`/`SOCKET_YES`/`SOCKET_CWD` would silently change +/// which manifest is touched and how the script is rendered. A fixed name +/// list is the trap the sibling suites already fell into (remove_network.rs +/// missed `SOCKET_SKIP_ROLLBACK`; this file's list missed +/// `SOCKET_SETUP_EXCLUDE`, setup's own env-bound `--exclude` fallback, which +/// silently dropped workspace members AND persisted the ambient exclude into +/// `.socket/manifest.json`), so scrub by prefix like common::run_with_env. +/// Telemetry opt-outs are deliberately kept: they only suppress phone-home +/// and cannot steer the behaviour under test, and an opted-out dev should +/// stay opted out. fn setup_command(cwd: &Path, args: &[&str]) -> Command { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + cmd.env_remove(&key); + } } cmd } @@ -538,6 +516,44 @@ fn setup_check_configured_project_exits_zero() { assert_eq!(files[0]["status"], "configured"); } +/// npm and Node strip a UTF-8 BOM in package.json — files saved by Windows +/// editors commonly carry one — and every other setup surface already +/// tolerates it: `setup` wires a BOM'd file (update.rs pins) and reports a +/// BOM'd configured file `already_configured`. `--check` must agree: a BOM'd, +/// fully-configured file is `configured` (exit 0), NOT `Error: Invalid +/// package.json` (exit 1). Regression guard: `run_check` raw-parsed the file +/// without stripping the BOM, so the same file `setup` calls configured +/// failed `--check`. +#[test] +fn setup_check_tolerates_bom_like_npm() { + let tmp = tempfile::tempdir().expect("tempdir"); + write( + &tmp.path().join("package.json"), + "\u{feff}{\"scripts\":{\"postinstall\":\"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\",\"dependencies\":\"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\"}}", + ); + + // `setup` itself treats the file as valid and already configured (and + // therefore leaves it byte-identical, BOM included). + let (setup_code, setup_stdout) = run_setup(tmp.path(), &["--yes"]); + assert_eq!( + setup_code, 0, + "setup must accept a BOM'd configured package.json; stdout=\n{setup_stdout}" + ); + let v: serde_json::Value = serde_json::from_str(&setup_stdout).expect("valid JSON"); + assert_eq!(v["status"], "already_configured"); + + // `--check` must reach the same verdict npm (and `setup`) do. + let (code, stdout) = run_setup(tmp.path(), &["--check"]); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!( + code, 0, + "a BOM'd configured package.json must pass --check; stdout=\n{stdout}" + ); + assert_eq!(v["status"], "configured", "stdout=\n{stdout}"); + assert_eq!(v["errors"], 0, "stdout=\n{stdout}"); + assert_eq!(v["files"][0]["status"], "configured", "stdout=\n{stdout}"); +} + #[test] fn setup_check_unconfigured_project_exits_nonzero() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -798,14 +814,10 @@ fn setup_writes_only_inside_repo() { "sentinel HOME must start empty" ); - let mut cmd = Command::new(binary()); - cmd.args(["setup", "--json", "--yes"]) - .current_dir(proj.path()); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); - } + let mut cmd = setup_command(proj.path(), &["setup", "--json", "--yes"]); // Redirect HOME at the sentinel and disable telemetry so the only writes we - // could observe are setup's own manifest edits. + // could observe are setup's own manifest edits. (Seed after the scrub — + // the helper keeps TELEMETRY vars anyway, but the write matters here.) cmd.env("HOME", home.path()); cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); let out = cmd.output().expect("run socket-patch"); diff --git a/crates/socket-patch-cli/tests/setup_matrix_common/mod.rs b/crates/socket-patch-cli/tests/setup_matrix_common/mod.rs index 4d1a2e74..2d57c366 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_common/mod.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_common/mod.rs @@ -111,8 +111,10 @@ fn image_present(image: &str) -> bool { } /// One concrete case = a (target, scenario) pair from matrix.json. +/// `pub` (with private fields) so wrapper suites can obtain real cases via +/// [`load_section`] and regression-test the validators without docker. #[derive(Clone)] -struct Case { +pub struct Case { id: String, ecosystem: String, pm: String, @@ -185,7 +187,8 @@ impl Case { /// spec section: ("targets","scenarios") for single projects, /// ("workspace_targets","workspace_scenarios") for nested workspaces, /// ("monorepo_targets","monorepo_scenarios") for the polyglot monorepo. -fn load_section( +/// `pub` so wrapper suites can load real cases for validator regression tests. +pub fn load_section( targets_key: &str, scenarios_key: &str, layout: &str, @@ -241,10 +244,61 @@ fn load_section( cases } -struct RunResult { - actual_applied: bool, - raw: String, - parsed: Option, +/// `pub` (like [`host_driver_command`]) so wrapper suites can synthesize +/// driver results and regression-test [`round_trip_failure`] without docker. +pub struct RunResult { + pub actual_applied: bool, + pub raw: String, + pub parsed: Option, +} + +/// Build the host-mode driver invocation: `bash run-case.sh` with the +/// case's `SM_*` env, the binary under test, and (for pypi) the hook +/// wheel. `pub` so the `setup_matrix_env_guard` wrapper can inspect the +/// exact `Command` `run_case` spawns. +pub fn host_driver_command(env: &[(String, String)], wheel: Option<&Path>) -> Command { + let mut cmd = Command::new("bash"); + cmd.arg(driver_path()); + // Host mode inherits the parent process's environment, and the driver + // passes it straight through to the binary under test AND the native + // package-manager installs. Scrub the ambient surface that can flip a + // verdict for the wrong reason BEFORE seeding the case env (docker mode + // is naturally immune — only the explicit `-e` vars cross over): + // * SOCKET_* — global-flag fallbacks of the binary under test: + // SOCKET_DRY_RUN=true no-ops the hook's apply, SOCKET_CWD recreates + // the workspace-breaking mode run-case.sh documents it must avoid, + // SOCKET_MANIFEST_PATH/SOCKET_GLOBAL retarget it. The driver + // re-exports the ones it needs (OFFLINE/FORCE/API_TOKEN/...); + // telemetry opt-outs are kept so an opted-out dev stays opted out. + // Also covers a stale ambient SOCKET_PATCH_HOOK_WHEEL. + // * SM_* — the driver's own contract; an ambient SM_WORKDIR + // would make every parallel case share one scratch dir (the races + // the driver's blob_tmp comment warns about). + // * npm_config_* / YARN_* — PM config that changes whether lifecycle + // hooks even fire (npm_config_ignore_scripts, YARN_ENABLE_SCRIPTS) + // or where installs resolve from. + // * VIRTUAL_ENV / SETUP_MATRIX_SHIM_DIR — hijack the python crawler / + // the shims' PATH-cleanup logic. + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + let hit = ["SOCKET_", "SM_", "YARN_"] + .iter() + .any(|p| name.starts_with(p)) + || name.to_ascii_lowercase().starts_with("npm_config_") + || name == "VIRTUAL_ENV" + || name == "SETUP_MATRIX_SHIM_DIR"; + if hit && !name.contains("TELEMETRY") { + cmd.env_remove(&key); + } + } + for (k, v) in env { + cmd.env(k, v); + } + cmd.env("SOCKET_PATCH_BIN", binary()); + if let Some(w) = wheel { + cmd.env("SOCKET_PATCH_HOOK_WHEEL", w); + } + cmd } /// Execute one case via the bash driver (container or host) and parse @@ -262,16 +316,9 @@ fn run_case(case: &Case) -> RunResult { }; let output = if host_mode() { - let mut cmd = Command::new("bash"); - cmd.arg(&driver); - for (k, v) in &env { - cmd.env(k, v); - } - cmd.env("SOCKET_PATCH_BIN", binary()); - if let Some(w) = &wheel { - cmd.env("SOCKET_PATCH_HOOK_WHEEL", w); - } - cmd.output().expect("spawn bash driver") + host_driver_command(&env, wheel.as_deref()) + .output() + .expect("spawn bash driver") } else { let script = std::fs::read_to_string(&driver).unwrap_or_else(|e| panic!("read driver: {e}")); @@ -481,14 +528,15 @@ fn run_cases(label: &str, cases: Vec) { /// Validate the behavioral `(setup)·(install)` round-trip emitted by the driver. /// Verifies — through real install cycles, not by reading package.json — that: /// -/// 1. `setup --check` fails before setup, passes after setup, fails after -/// `setup --remove` (and remove itself succeeds); +/// 1. `setup --check` fails before setup, passes after the post-setup install +/// (hook present AND on-disk patch consistency, per contract property 4), +/// fails after `setup --remove` (and setup + remove themselves succeed); /// 2. the patch is NOT applied before setup and NOT applied after remove /// (the after-setup application is covered separately by the main /// `actual_applied == expect_applied` assertion). /// /// Returns a failure message describing any violation, or `None` on success. -fn round_trip_failure(case: &Case, res: &RunResult) -> Option { +pub fn round_trip_failure(case: &Case, res: &RunResult) -> Option { // The main loop already turns a missing result line into a hard failure // and `continue`s before reaching here, so this branch is defensive: never // silently treat an absent result as a passing round-trip. @@ -551,6 +599,20 @@ fn round_trip_failure(case: &Case, res: &RunResult) -> Option { ); } + // `setup --yes` itself must succeed. `check-after-setup == 0` alone cannot + // catch a partial failure: setup aggregates errors across every manifest + // kind it edits (npm + python + gem + composer) and exits 1 on + // `partial_failure`, so it can land the npm hook (check passes, the patch + // applies) and still choke on another manifest — for the polyglot + // monorepo that IS the headline regression this suite exists to catch. + let setup = int("setup_exit"); + if setup != Some(0) { + problems.push(format!( + "setup exit={setup:?} (want 0: `setup --yes` must succeed; non-zero means setup \ + choked even if the npm hook landed)" + )); + } + // (1) `setup --check` exit code must track the configured state: // non-zero before setup → 0 after setup → non-zero after remove. Each // must be present; a null exit means the check step never ran. diff --git a/crates/socket-patch-cli/tests/setup_matrix_composer.rs b/crates/socket-patch-cli/tests/setup_matrix_composer.rs index 22112100..c6eeabb3 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_composer.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_composer.rs @@ -1,7 +1,6 @@ -//! setup-matrix: composer ecosystem (PHP). Composer DOES expose a -//! `post-install-cmd` event hook, but `setup` does not wire it today, -//! so the with-setup cases are an EXPECTED BASELINE GAP — and a clear -//! candidate for the first non-npm ecosystem `setup` could support. +//! setup-matrix: composer ecosystem (PHP). `setup` wires `socket-patch +//! apply` into composer's `post-install-cmd` / `post-update-cmd` script +//! events. //! //! IMPORTANT — why this file carries a real assertion of its own: //! `smc::run_pm("composer", "composer")` routes composer through the @@ -9,26 +8,15 @@ //! whenever Docker or the `composer` image is absent (the common case //! locally and in this eval). composer is also NOT npm-family, so the //! harness's check/remove behavioral round-trip is skipped entirely for -//! it, and — because `baseline_supported` is false in matrix.json — the -//! only thing the matrix could ever assert is that the patch is *not* -//! applied (a verdict that defaults to the same `false` on a crashed or -//! never-run case). The net effect: the matrix call can never turn red -//! for a genuine composer `setup` regression. On its own it protects -//! nothing. +//! it. The net effect: the matrix call can never turn red for a genuine +//! composer `setup` regression. On its own it protects nothing. //! //! To close that loophole WITHOUT touching the shared harness, -//! [`host_guard::composer_setup_is_a_clean_noop_host`] runs -//! unconditionally (no Docker, no network, no PHP / composer toolchain) -//! and pins composer `setup`'s *actual current contract*: because no -//! composer install hook is wired, `setup` / `setup --check` / -//! `setup --remove` against a composer-only project must each be a clean -//! no-op (`status: "no_files"`, exit 0) that leaves `composer.json` -//! byte-for-byte intact and never injects a foreign npm `package.json` -//! hook. It fails loudly if composer setup ever starts erroring, -//! crashing, mutating the PHP manifest, or silently mis-reporting the -//! project as configured — and it will also (correctly) go red the day -//! real composer support lands, flagging that this expectation must be -//! updated rather than the gap quietly persisting. +//! [`host_guard::composer_setup_round_trips_host`] runs unconditionally +//! (no Docker, no network, no PHP / composer toolchain — `setup` edits +//! `composer.json` directly) and pins the full wiring contract: +//! `--check` fails pre-setup, `setup` wires the hook, `--check` then +//! passes, and `--remove` restores the manifest byte-for-byte. //! //! Run: `cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_composer` #![cfg(feature = "setup-e2e")] @@ -36,6 +24,9 @@ #[path = "setup_matrix_common/mod.rs"] mod smc; +#[path = "common/mod.rs"] +mod common; + /// Documentation/negative-control pass through the shared Docker matrix. /// Kept for parity with the other ecosystems and to run the composer /// negative controls when Docker + the `composer` image are present. @@ -47,42 +38,31 @@ fn composer() { } // ───────────────────────────────────────────────────────────────────────── -// Real, non-skippable regression guard for composer `setup`. -// -// Locks in the BASELINE GAP as a concrete, machine-checkable contract: -// composer is unsupported, therefore setup must treat a composer-only -// project as "nothing to do" — exit 0, status "no_files", manifest -// untouched, and crucially WITHOUT inventing an npm package.json hook in -// a PHP project. +// Real, non-skippable regression guard for composer `setup`: the full +// wire → check → remove round-trip against a composer-only project, +// driven entirely on the host (no PHP toolchain — `setup` edits +// `composer.json` directly). // ───────────────────────────────────────────────────────────────────────── mod host_guard { use std::path::Path; - use std::process::Command; /// A realistic composer-only project: a PHP manifest requiring the /// same package the matrix targets, and nothing the npm/Python/Cargo /// detectors would recognise. const COMPOSER_JSON: &str = "{\n \"name\": \"acme/widget\",\n \"require\": {\n \"monolog/monolog\": \"3.5.0\"\n }\n}\n"; - /// Absolute path to the binary under test, via cargo's `CARGO_BIN_EXE_*`. - fn binary() -> std::path::PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() - } - /// Run the CLI with `args` in `cwd`; returns `(exit_code, stdout, stderr)`. - /// `SOCKET_API_TOKEN` is stripped so nothing reaches authed endpoints. + /// Delegates to the shared `common::run_with_env`, which seeds-then-scrubs + /// the binary's entire ambient `SOCKET_*` surface — stripping only + /// `SOCKET_API_TOKEN` (as this helper originally did) left the guard at + /// the mercy of the parent shell: an ambient `SOCKET_ECOSYSTEMS=cargo` + /// filtered composer out of scope (first `--check` exits 0 as `no_files`), + /// and `SOCKET_DRY_RUN=true` no-ops the very write under test. + /// `SOCKET_TELEMETRY_DISABLED=1` is injected because this file promises + /// "no network": each real `setup` run otherwise fire-and-forgets a live + /// `patch_setup` POST to the telemetry endpoint. fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let out = Command::new(binary()) - .args(args) - .current_dir(cwd) - .env_remove("SOCKET_API_TOKEN") - .output() - .expect("failed to execute socket-patch binary"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) + super::common::run_with_env(cwd, args, &[("SOCKET_TELEMETRY_DISABLED", "1")]) } /// Parse the CLI's `--json` stdout into the single top-level object the @@ -95,32 +75,6 @@ mod host_guard { }) } - /// Assert the parsed result is a genuine clean no-op for an unsupported - /// ecosystem: `status == "no_files"` AND an *empty* `files` array. The - /// `files` check is not redundant — a regression that mis-detected the - /// composer manifest could keep `status: "no_files"` while listing files - /// it touched, or vice versa; both must agree that nothing was done. - /// Only meaningful when composer setup is NOT compiled in. - #[cfg(not(feature = "composer"))] - fn assert_no_files_status(stdout: &str, who: &str) { - let v = parse_obj(stdout, who); - assert_eq!( - v.get("status").and_then(|s| s.as_str()), - Some("no_files"), - "{who}: must report status=no_files for a composer-only project; \ - any other status (esp. \"configured\") would falsely claim composer is supported.\n{stdout}" - ); - let files = v - .get("files") - .and_then(|f| f.as_array()) - .unwrap_or_else(|| panic!("{who}: JSON has no `files` array:\n{stdout}")); - assert!( - files.is_empty(), - "{who}: a no_files result must carry an EMPTY files array; \ - a non-empty list means setup acted on something it claims not to have.\n{stdout}" - ); - } - /// Immediate entry names under `root`, sorted — for proving the directory /// was not littered with foreign artifacts. fn dir_entries(root: &Path) -> Vec { @@ -156,88 +110,12 @@ mod host_guard { ); } - // Built WITHOUT the `composer` feature, composer setup stubs out, so a - // composer-only project is a clean no-op. CI's `--all-features` build enables - // `composer`, where the positive round-trip in `composer_setup_round_trips_host` - // runs instead (the two are mutually exclusive by cfg). - #[cfg(not(feature = "composer"))] - #[test] - fn composer_setup_is_a_clean_noop_host() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - std::fs::write(root.join("composer.json"), COMPOSER_JSON).unwrap(); - let root_s = root.to_str().unwrap(); - - // ── check (before any setup) ──────────────────────────────────────── - // A composer-only project is unsupported, so check must report - // "no_files" and exit 0 — NOT "configured" (a false positive that - // would mask the gap), NOT "needs_configuration", NOT "error", and - // not a non-zero crash. - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 0, - "setup --check on a composer-only project must exit 0.\nstdout:\n{out}\nstderr:\n{err}" - ); - assert_no_files_status(&out, "check (pristine)"); - assert_manifest_pristine(root, "after check (pristine)"); - - // ── setup ──────────────────────────────────────────────────────────── - let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); - assert_eq!( - code, 0, - "setup on a composer-only project must exit 0 (clean no-op).\nstdout:\n{out}\nstderr:\n{err}" - ); - assert_no_files_status(&out, "setup"); - let v = parse_obj(&out, "setup"); - // It must claim to have changed nothing — not silently report work. - assert_eq!( - v.get("updated").and_then(|n| n.as_i64()), - Some(0), - "setup must report updated=0 for a composer-only project.\n{out}" - ); - assert_eq!( - v.get("errors").and_then(|n| n.as_i64()), - Some(0), - "setup must report errors=0 for a composer-only project.\n{out}" - ); - // ...and must NOT falsely claim the project was already configured — - // that would mask a regression that mis-classifies composer as set up. - assert_eq!( - v.get("alreadyConfigured").and_then(|n| n.as_i64()), - Some(0), - "setup must report alreadyConfigured=0 for an unsupported composer-only project.\n{out}" - ); - assert_manifest_pristine(root, "after setup"); - - // ── check (after setup): the no-op must not have configured anything ── - let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); - assert_eq!( - code, 0, - "setup --check (post-setup) must still exit 0.\nstdout:\n{out}\nstderr:\n{err}" - ); - assert_no_files_status(&out, "check (post-setup)"); - assert_manifest_pristine(root, "after check (post-setup)"); - - // ── remove: also a clean no-op, manifest still pristine ─────────────── - let (code, out, err) = run( - root, - &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], - ); - assert_eq!( - code, 0, - "setup --remove on a composer-only project must exit 0.\nstdout:\n{out}\nstderr:\n{err}" - ); - assert_no_files_status(&out, "remove"); - assert_manifest_pristine(root, "after remove"); - } - - /// With the `composer` feature compiled in, composer is a REAL setup + /// Composer is a REAL setup /// ecosystem: `setup` wires `socket-patch apply` into `composer.json`'s /// post-install/post-update script events, `--check` reflects it, and /// `--remove` restores the manifest byte-for-byte. Non-skippable (no Docker, /// no PHP toolchain) — it edits composer.json directly. This is the positive /// twin of `composer_setup_is_a_clean_noop_host` (the two never co-exist). - #[cfg(feature = "composer")] #[test] fn composer_setup_round_trips_host() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-cli/tests/setup_matrix_deno.rs b/crates/socket-patch-cli/tests/setup_matrix_deno.rs index 0226e2de..fb1c20ad 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_deno.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_deno.rs @@ -78,55 +78,31 @@ mod host_guard { const DENO_JSON: &str = "{ \"name\": \"sm-proj\", \"version\": \"0.0.0\", \"nodeModulesDir\": \"auto\" }\n"; - /// Every `SOCKET_*` env var clap consults for the `setup` surface this - /// test drives. The round-trip's whole signal is the contrast between - /// flag-present and flag-absent runs (`--check`, `--yes`, `--cwd`, - /// `--remove`); an ambient `SOCKET_CWD` / `SOCKET_YES` / `SOCKET_OFFLINE` - /// / `SOCKET_MANIFEST_PATH` etc. in the shell or CI could stand in for a - /// flag and mask a flag-handling regression (e.g. `--cwd` being ignored, - /// or `--check` silently succeeding). Strip the full surface so behaviour - /// reflects the explicit flags alone. Mirrors `setup_matrix_pypi.rs`. - const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_API_TOKEN", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_OFFLINE", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_SAVE_ONLY", - "SOCKET_ONE_OFF", - "SOCKET_ALL_RELEASES", - ]; - /// Absolute path to the binary under test, via cargo's `CARGO_BIN_EXE_*`. fn binary() -> std::path::PathBuf { env!("CARGO_BIN_EXE_socket-patch").into() } /// Run the CLI with `args` in `cwd`; returns `(exit_code, stdout, stderr)`. - /// The entire `SOCKET_*` surface is stripped so behaviour reflects the - /// explicit flags alone (see [`SOCKET_ENV_VARS`]) — nothing reaches authed - /// endpoints and no ambient var can stand in for a flag. + /// The entire `SOCKET_*` surface is stripped BY PREFIX — a fixed list rots + /// (this file's missed `SOCKET_STRICT` / `SOCKET_VENDOR_SOURCE`, both + /// parsed on every `setup` invocation; see [`HOSTILE_DECOYS`]) — so + /// behaviour reflects the explicit flags alone: nothing reaches authed + /// endpoints and no ambient var can stand in for a flag. Mirrors + /// `setup_matrix_pypi.rs`. fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&key); + } } + // This guard's contract is "no network" (module docs): `setup` fires a + // usage-telemetry POST when telemetry is enabled, and the scrub above + // would strip a developer's own opt-out. Force it off for the child — + // no assertion here concerns telemetry. + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); let out = cmd.output().expect("failed to execute socket-patch binary"); ( out.status.code().unwrap_or(-1), @@ -176,8 +152,28 @@ mod host_guard { ); } + /// Ambient decoys `run()`'s scrub must strip. Two failure classes: + /// clap parses env-bound values on EVERY invocation whether or not the + /// command uses the flag, so an invalid ambient `SOCKET_STRICT` / + /// `SOCKET_VENDOR_SOURCE` aborts the parse (exit 2) before `setup` even + /// runs; and `SOCKET_SETUP_EXCLUDE` stands in for `setup --exclude` — + /// the exact surface under test. Planted by the roundtrip test itself so + /// the scrub is exercised on every run, not only in hostile shells. + /// (Safe to set process-wide: the only other test in this binary routes + /// through `smc::host_driver_command`, which prefix-scrubs `SOCKET_*`.) + const HOSTILE_DECOYS: &[(&str, &str)] = &[ + ("SOCKET_STRICT", "banana"), + ("SOCKET_VENDOR_SOURCE", "bogus-decoy"), + ("SOCKET_VENDOR_URL", "http://127.0.0.1:9/decoy"), + ("SOCKET_PATCH_SERVER_URL", "http://127.0.0.1:9/decoy"), + ("SOCKET_SETUP_EXCLUDE", "decoy-member"), + ]; + #[test] fn deno_setup_roundtrip_host() { + for (k, v) in HOSTILE_DECOYS { + std::env::set_var(k, v); + } let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); std::fs::write(root.join("package.json"), PACKAGE_JSON).unwrap(); @@ -333,5 +329,9 @@ mod host_guard { Some(0), "no manifest may report configured after the hook is removed.\n{out}" ); + + for (k, _) in HOSTILE_DECOYS { + std::env::remove_var(k); + } } } diff --git a/crates/socket-patch-cli/tests/setup_matrix_env_guard.rs b/crates/socket-patch-cli/tests/setup_matrix_env_guard.rs new file mode 100644 index 00000000..0309aa6a --- /dev/null +++ b/crates/socket-patch-cli/tests/setup_matrix_env_guard.rs @@ -0,0 +1,114 @@ +//! Env-hygiene guard for setup-matrix HOST mode (`SOCKET_PATCH_TEST_HOST=1`). +//! +//! `run_case`'s host branch spawns `bash run-case.sh` as a plain child +//! process, so — unlike docker mode, where only the explicit `-e SM_*` +//! vars cross into the container — the driver, the binary under test, +//! and the native package-manager installs all inherit the parent +//! shell's environment. An ambient `SOCKET_DRY_RUN=true` turns the +//! install hook's apply into a no-op (every baseline case red for the +//! wrong reason), an ambient `SOCKET_CWD` recreates exactly the +//! workspace-breaking mode run-case.sh documents it must avoid, an +//! ambient `SM_WORKDIR` makes every parallel case share one scratch +//! dir (the blob/proj races the driver warns about), and an ambient +//! `npm_config_ignore_scripts=true` stops lifecycle hooks from firing +//! at all. This guard pins the scrub-then-seed contract of +//! `smc::host_driver_command` — the same `Command` `run_case` spawns — +//! by planting hostile decoys in the parent env and asserting each is +//! explicitly removed while the case's own seeds survive. +//! +//! Deliberately its own test binary: it mutates the process env, and +//! nothing else runs in this binary, so the decoys cannot race a live +//! matrix case in another test thread. +#![cfg(feature = "setup-e2e")] + +#[path = "setup_matrix_common/mod.rs"] +mod smc; + +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; +use std::path::Path; +use std::process::Command; + +/// Hostile ambient vars: each one, if inherited by the driver, flips a +/// real verdict for the wrong reason (dry-run apply, retargeted cwd or +/// manifest, global mode, shared scratch dir, stale hook wheel, dead +/// registry, disabled lifecycle scripts, hijacked venv, poisoned shim +/// PATH cleanup). +const DECOYS: &[(&str, &str)] = &[ + ("SOCKET_DRY_RUN", "true"), + ("SOCKET_CWD", "/nonexistent/decoy"), + ("SOCKET_MANIFEST_PATH", "/nonexistent/decoy/manifest.json"), + ("SOCKET_GLOBAL", "true"), + ("SM_WORKDIR", "/nonexistent/decoy-shared-workdir"), + ("SOCKET_PATCH_HOOK_WHEEL", "/nonexistent/decoy.whl"), + ("npm_config_ignore_scripts", "true"), + ("NPM_CONFIG_REGISTRY", "http://127.0.0.1:9/decoy"), + ("YARN_ENABLE_SCRIPTS", "false"), + ("VIRTUAL_ENV", "/nonexistent/decoy-venv"), + ("SETUP_MATRIX_SHIM_DIR", "/nonexistent/decoy-shims"), +]; + +/// Snapshot the command's explicit env ops: `Some(value)` = seeded, +/// `None` = removed, absent key = silently inherited from the parent. +fn env_map(cmd: &Command) -> HashMap> { + cmd.get_envs() + .map(|(k, v)| (k.to_os_string(), v.map(OsStr::to_os_string))) + .collect() +} + +#[test] +fn host_driver_command_scrubs_ambient_env_and_keeps_case_seeds() { + for (k, v) in DECOYS { + std::env::set_var(k, v); + } + + let case_env = vec![ + ("SM_ID".to_string(), "guard/npm/decoy".to_string()), + ("SM_ECOSYSTEM".to_string(), "npm".to_string()), + ]; + + // No wheel: SOCKET_PATCH_HOOK_WHEEL must be scrubbed, not inherited + // stale from the shell. + let cmd = smc::host_driver_command(&case_env, None); + let envs = env_map(&cmd); + + for (k, _) in DECOYS { + assert_eq!( + envs.get(OsStr::new(k)), + Some(&None), + "ambient decoy {k} must be explicitly removed (env_remove) from the \ + host driver invocation; it currently leaks into run-case.sh, the \ + binary under test, and the native package-manager installs" + ); + } + + // Scrub-then-seed ordering: the SM_* case env and the binary path are + // set AFTER the prefix scrub, so they must survive as real values (a + // scrub running last would wipe its own seeds — last env call wins). + assert_eq!( + envs.get(OsStr::new("SM_ID")).cloned().flatten().as_deref(), + Some(OsStr::new("guard/npm/decoy")), + "case SM_* env must survive the SM_ prefix scrub (scrub must run before seeding)" + ); + assert!( + matches!(envs.get(OsStr::new("SOCKET_PATCH_BIN")), Some(Some(p)) if !p.is_empty()), + "SOCKET_PATCH_BIN must survive the SOCKET_ prefix scrub" + ); + + // With a wheel, the explicit seed must survive the scrub too. + let wheel = Path::new("/tmp/socket_patch_hook-0.0.0-py3-none-any.whl"); + let cmd = smc::host_driver_command(&case_env, Some(wheel)); + let envs = env_map(&cmd); + assert_eq!( + envs.get(OsStr::new("SOCKET_PATCH_HOOK_WHEEL")) + .cloned() + .flatten() + .as_deref(), + Some(wheel.as_os_str()), + "an explicitly provided hook wheel must survive the SOCKET_ prefix scrub" + ); + + for (k, _) in DECOYS { + std::env::remove_var(k); + } +} diff --git a/crates/socket-patch-cli/tests/setup_matrix_gem.rs b/crates/socket-patch-cli/tests/setup_matrix_gem.rs index b08b5a7b..7aef360b 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_gem.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_gem.rs @@ -11,13 +11,12 @@ //! or the `gem` image is absent (the common case locally and in this //! eval). gem is also NOT npm-family (see `is_npm_family` in the harness //! and `run-case.sh`), so the harness's check/remove behavioral -//! round-trip is skipped entirely for it; and because gem's -//! `baseline_supported` is false in matrix.json the only thing the matrix -//! could ever assert is the coarse `actual_applied == expect_applied` -//! verdict — which, on a crashed or never-run case, defaults to the same -//! `false` that satisfies every negative-control scenario. The net -//! effect: the matrix call can never turn red for a genuine gem `setup` -//! regression. On its own it protects nothing. +//! round-trip is skipped entirely for it. When Docker + the image ARE +//! present the matrix does assert the coarse +//! `actual_applied == expect_applied` verdict against a real +//! `bundle install` (it caught the uncloneable `git:` plugin source), but +//! that protection is environment-conditional — a machine without the +//! image gets silent green. //! //! To close that loophole WITHOUT touching the shared harness or the bash //! driver, [`host_guard::gem_setup_roundtrip_host`] runs unconditionally @@ -71,40 +70,6 @@ mod host_guard { /// the test disagrees with a renamed/removed marker rather than copying it. const MANAGED_MARKER: &str = "# >>> socket-patch:managed"; - /// Every `SOCKET_*` env var clap consults for the surface this test - /// drives. Stripped from the child so the run reflects ONLY the explicit - /// flags (`--cwd`, `--yes`, `--check`, `--remove`, `--json`). Without - /// this, an ambient `SOCKET_CWD` / `SOCKET_JSON` / `SOCKET_OFFLINE` in - /// the shell or CI could satisfy an assertion via the environment rather - /// than the flag under test. (Mirrors the scrub used by the - /// `cli_parse_*` and `setup_matrix_pypi` suites.) - const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_API_TOKEN", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_OFFLINE", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_SAVE_ONLY", - "SOCKET_ONE_OFF", - "SOCKET_ALL_RELEASES", - "SOCKET_PATCH_BIN", - ]; - /// Absolute path to the binary under test, via cargo's `CARGO_BIN_EXE_*`. fn binary() -> std::path::PathBuf { env!("CARGO_BIN_EXE_socket-patch").into() @@ -117,9 +82,24 @@ mod host_guard { fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); + // Prefix-scrub the whole ambient `SOCKET_*` surface (mirrors + // `tests/common::run_with_env`). clap binds ~30 `SOCKET_*` vars across + // the global + per-command flags and the set keeps growing, so an + // itemized list rots: `SOCKET_STRICT`, `SOCKET_VENDOR_SOURCE`, and + // setup's own `SOCKET_SETUP_EXCLUDE` were all missing from the list + // this replaced — an ambient `SOCKET_VENDOR_SOURCE=bogus` aborted + // every invocation with a clap parse error (exit 2) and turned this + // guard red for an environmental reason. + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&key); + } } + // This guard's contract is "no network" (module docs): `setup` fires a + // usage-telemetry POST when telemetry is enabled, and the scrub above + // would strip a developer's own opt-out. Force it off for the child — + // no assertion here concerns telemetry. + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); let out = cmd.output().expect("failed to execute socket-patch binary"); ( out.status.code().unwrap_or(-1), @@ -225,9 +205,31 @@ mod host_guard { body.contains("plugin 'socket-patch'"), "Gemfile must reference the socket-patch plugin:\n{body}" ); + // The directive must use a `path:` source. A `git:` source makes + // Bundler `git clone` the directory, and `.socket/bundler-plugin/` is + // a plain generated dir (committing it to the PARENT repo does not + // give it a `.git`), so every `bundle install` on a wired project + // fails with "repository ... does not exist" (exit 11) and the plugin + // never loads. Verified against real Bundler in the gem Docker image. + assert!( + body.contains("plugin 'socket-patch', path:"), + "the plugin directive must be `path:`-sourced (a `git:` dir source \ + is uncloneable and breaks every `bundle install`):\n{body}" + ); // and the generated plugin carries the two triggers + fail-loud applier. assert!(plugins_rb.exists(), "plugins.rb must be generated"); assert!(gemspec.exists(), "the plugin gemspec must be generated"); + // Bundler refuses to LOAD a plugin whose gemspec require paths are + // missing on disk ("The following plugin paths don't exist: .../lib. + // ... Continuing without installing plugin"). The plugin dir is flat + // (no lib/), so the gemspec must pin `require_paths = ["."]` or the + // plugin is silently skipped on every install. + let spec = std::fs::read_to_string(&gemspec).unwrap(); + assert!( + spec.contains("s.require_paths = [\".\"]"), + "gemspec must set require_paths to the flat plugin dir, or Bundler \ + silently skips loading the plugin:\n{spec}" + ); let rb = std::fs::read_to_string(&plugins_rb).unwrap(); assert!( rb.contains("Bundler::Plugin.add_hook(\"after-install-all\")"), @@ -306,4 +308,57 @@ mod host_guard { "needs_configuration" ); } + + /// `bundle` resolves the Gemfile by walking UP from the invocation dir, + /// and `discover_bundler_project` documents the same contract. Run from a + /// subdirectory with NO `--cwd` flag the CLI defaults to the RELATIVE + /// `--cwd .` — whose lexical `Path::parent()` chain is `Some("")` → `None` + /// without ever reaching the real parent directories — so the walk-up must + /// re-root itself on the process cwd to find the ancestor Gemfile, and the + /// wiring must land at the Gemfile's dir, never the invocation subdir. + #[test] + fn gem_setup_discovers_root_project_from_subdirectory() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join("Gemfile"), GEMFILE).unwrap(); + let sub = root.join("lib").join("widgets"); + std::fs::create_dir_all(&sub).unwrap(); + + // check from the subdir: the (unconfigured) root project must be found. + let (code, out, err) = run(&sub, &["setup", "--check", "--json"]); + assert_eq!( + code, 1, + "check from a subdirectory must find the unconfigured ancestor \ + Gemfile (exit 1), not report no_files (exit 0).\n{out}\n{err}" + ); + assert_eq!( + json_str(&parse_json(&out, "check (subdir)"), "status", "check (subdir)"), + "needs_configuration" + ); + + // setup from the subdir: wires the ROOT project. + let (code, out, err) = run(&sub, &["setup", "--yes", "--json"]); + assert_eq!(code, 0, "setup from a subdirectory must exit 0.\n{out}\n{err}"); + assert_eq!( + json_str(&parse_json(&out, "setup (subdir)"), "status", "setup (subdir)"), + "success" + ); + let body = gemfile_body(root); + assert!( + body.contains(MANAGED_MARKER), + "managed block lands in the ROOT Gemfile:\n{body}" + ); + assert!( + root.join(PLUGIN_DIR).join("plugins.rb").exists(), + "plugin dir lands at the Gemfile's dir (the project root)" + ); + assert!( + !sub.join(PLUGIN_DIR).exists(), + "no plugin dir may be generated in the invocation subdir" + ); + assert!( + !sub.join("Gemfile").exists(), + "no Gemfile may be synthesized in the invocation subdir" + ); + } } diff --git a/crates/socket-patch-cli/tests/setup_matrix_maven.rs b/crates/socket-patch-cli/tests/setup_matrix_maven.rs index 1f2e50a3..8e5baa1a 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_maven.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_maven.rs @@ -34,7 +34,7 @@ //! configurable, or mutating the project on disk. //! //! Run: `cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_maven` -#![cfg(all(feature = "setup-e2e", feature = "maven"))] +#![cfg(feature = "setup-e2e")] #[path = "setup_matrix_common/mod.rs"] mod smc; @@ -91,39 +91,25 @@ mod host_guard { const PACKAGE_JSON: &str = "{ \"name\": \"sm-proj\", \"version\": \"0.0.0\", \"private\": true, \"dependencies\": { \"minimist\": \"1.2.2\" } }\n"; - /// Every `SOCKET_*` env var clap consults for the `setup` surface this - /// test drives. The verdict's whole signal is that `setup` reflects ONLY - /// the explicit flags (`--check`, `--yes`, `--cwd`, `--remove`, `--json`); - /// an ambient `SOCKET_CWD` could retarget the run away from our maven - /// fixture, and `SOCKET_EXPERIMENTAL_MAVEN` is scrubbed too so an enabled - /// gate in the shell/CI can never quietly turn maven into a configurable - /// surface behind the test's back. Mirrors `setup_matrix_pypi.rs` / - /// `setup_matrix_deno.rs`. - const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_API_TOKEN", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_OFFLINE", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_SAVE_ONLY", - "SOCKET_ONE_OFF", - "SOCKET_ALL_RELEASES", - "SOCKET_EXPERIMENTAL_MAVEN", + /// Ambient decoys [`run`]'s prefix scrub must strip, planted by the test + /// itself so the scrub is exercised on every run, not only in hostile + /// shells. Three demonstrated failure classes on the old fixed-list scrub: + /// clap parses env-bound `GlobalArgs` values on EVERY invocation whether + /// or not the command uses the flag, so an invalid ambient `SOCKET_STRICT` + /// / `SOCKET_VENDOR_SOURCE` aborts the parse (exit 2) before `setup` even + /// runs; a (perfectly valid!) ambient `SOCKET_SETUP_EXCLUDE` stands in for + /// `setup --exclude`, which a real `setup` run PERSISTS — creating + /// `.socket/manifest.json` inside the maven fixture and failing + /// `assert_pristine`; and an enabled `SOCKET_EXPERIMENTAL_MAVEN` gate in + /// the shell/CI could quietly change maven's surface behind the test's + /// back. (Safe to set process-wide: the only other test in this binary is + /// the `#[ignore]`d matrix pass, which routes through + /// `smc::host_driver_command`'s own `SOCKET_*` prefix scrub.) + const HOSTILE_DECOYS: &[(&str, &str)] = &[ + ("SOCKET_STRICT", "banana"), + ("SOCKET_VENDOR_SOURCE", "bogus-decoy"), + ("SOCKET_SETUP_EXCLUDE", "decoy-member"), + ("SOCKET_EXPERIMENTAL_MAVEN", "true"), ]; /// Absolute path to the binary under test, via cargo's `CARGO_BIN_EXE_*`. @@ -132,14 +118,19 @@ mod host_guard { } /// Run the CLI with `args` in `cwd`; returns `(exit_code, stdout, stderr)`. - /// The entire `SOCKET_*` surface is stripped so behaviour reflects the - /// explicit flags alone (see [`SOCKET_ENV_VARS`]) — nothing reaches authed - /// endpoints and no ambient var can stand in for a flag. + /// The entire `SOCKET_*` surface is stripped BY PREFIX — a fixed list rots + /// (it missed `SOCKET_SETUP_EXCLUDE` / `SOCKET_VENDOR_SOURCE` / + /// `SOCKET_STRICT`, all parsed on every `setup` invocation; see + /// [`HOSTILE_DECOYS`]) — so behaviour reflects the explicit flags alone: + /// nothing reaches authed endpoints and no ambient var can stand in for a + /// flag. fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&key); + } } let out = cmd.output().expect("failed to execute socket-patch binary"); ( @@ -234,6 +225,14 @@ mod host_guard { #[test] fn maven_setup_is_a_clean_noop_host() { + // Committed regression guard for the env scrub itself: with the old + // fixed-list scrub these leaked into the child — SOCKET_STRICT / + // SOCKET_VENDOR_SOURCE aborted every parse (exit 2) and + // SOCKET_SETUP_EXCLUDE made the real `setup` run write + // `.socket/manifest.json` into the fixture (assert_pristine RED). + for (k, v) in HOSTILE_DECOYS { + std::env::set_var(k, v); + } let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); std::fs::write(root.join("pom.xml"), POM_XML).unwrap(); @@ -317,5 +316,9 @@ mod host_guard { Some(1), "positive control: exactly the package.json must count as needing configuration.\n{out}" ); + + for (k, _) in HOSTILE_DECOYS { + std::env::remove_var(k); + } } } diff --git a/crates/socket-patch-cli/tests/setup_matrix_monorepo.rs b/crates/socket-patch-cli/tests/setup_matrix_monorepo.rs index c2314b6c..e19a8aa6 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_monorepo.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_monorepo.rs @@ -253,6 +253,65 @@ fn monorepo_scenarios_keep_their_negative_controls() { ); } +/// The headline guarantee — `setup` must NOT choke on the foreign manifests — +/// is the driver's `setup_exit` field. `setup --yes` aggregates errors across +/// ALL manifest kinds it edits (npm + python + gem + composer; see +/// `run_setup` in commands/setup.rs) and exits 1 on `partial_failure`, so in +/// the polyglot monorepo it can land the npm hook (⇒ `check` passes, the +/// patch applies, every other round-trip probe looks healthy) and STILL choke +/// on a foreign slice. The round-trip validator must flag that; if it only +/// watches the check/install/remove exits, the exact regression this suite +/// exists to catch passes green. Hermetic: feeds the validator a synthetic +/// driver result, no docker. +#[test] +fn round_trip_flags_setup_choke_even_when_hook_lands() { + let case = smc::load_section( + "monorepo_targets", + "monorepo_scenarios", + "monorepo", + "monorepo", + "mono", + ) + .into_iter() + .next() + .expect("at least one monorepo case"); + + // A driver result where every probe EXCEPT setup_exit is healthy — + // exactly what a hook-landed-then-choked setup produces. + let result = |setup_exit: i64| smc::RunResult { + actual_applied: true, + raw: String::new(), + parsed: Some(serde_json::json!({ + "actual_applied": true, + "applied_before_setup": false, + "applied_after_remove": false, + "primary_marker_present": true, + "setup_exit": setup_exit, + "install_exit": 0, + "check_before_setup_exit": 2, + "check_after_setup_exit": 0, + "remove_exit": 0, + "check_after_remove_exit": 2, + })), + }; + + // Sanity: a fully healthy round trip must not be flagged. + assert_eq!( + smc::round_trip_failure(&case, &result(0)), + None, + "healthy synthetic round-trip result must pass" + ); + + let failure = smc::round_trip_failure(&case, &result(1)).expect( + "round_trip_failure must flag setup_exit=1: a `setup` that chokes on a foreign \ + manifest after landing the npm hook currently passes the whole suite", + ); + assert!( + failure.contains("setup exit"), + "failure message should name the setup exit problem, got: {failure}" + ); +} + /// Defensive cross-check on the harness routing: `layout == "monorepo"` is the /// ONLY thing that makes a non-npm-family `pm` (here `mono`) take the /// round-trip + LEAK-detection path. If the driver's `is_npm_family` gate ever diff --git a/crates/socket-patch-cli/tests/setup_matrix_npm.rs b/crates/socket-patch-cli/tests/setup_matrix_npm.rs index a38863e9..2652fe57 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_npm.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_npm.rs @@ -89,36 +89,23 @@ mod host_guard { /// (prepend the patch command before it, never clobber it). const USER_POSTINSTALL: &str = "echo user-build-step"; - /// Every `SOCKET_*` env var clap consults for the surface this test drives, - /// stripped from the child so behaviour reflects ONLY the explicit flags - /// (`--cwd`, `--yes`, `--check`, `--remove`). Without this, an ambient - /// `SOCKET_CWD` / `SOCKET_YES` in the shell or CI could satisfy an assertion - /// via the environment rather than the flag under test. (Mirrors the scrub - /// used by the `cli_parse_*` and gem host-guard suites.) - const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_API_TOKEN", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_OFFLINE", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_SAVE_ONLY", - "SOCKET_ONE_OFF", - "SOCKET_ALL_RELEASES", + /// Ambient decoys [`run`]'s prefix scrub must strip, planted by the test + /// itself so the scrub is exercised on every run, not only in hostile + /// shells. Two demonstrated failure classes on the old fixed-list scrub + /// (same as the maven twin): clap parses env-bound `GlobalArgs` values on + /// EVERY invocation whether or not the command uses the flag, so an + /// invalid ambient `SOCKET_STRICT` / `SOCKET_VENDOR_SOURCE` aborts the + /// parse (exit 2) before `setup` even runs — turning the whole roundtrip + /// red; and a (perfectly valid!) ambient `SOCKET_SETUP_EXCLUDE` stands in + /// for `setup --exclude`, silently altering the run under test. (Safe to + /// set process-wide: every other test in this binary routes its children + /// through `smc::host_driver_command`'s own `SOCKET_*` prefix scrub, and + /// the harness's only ambient `SOCKET_*` read is `SOCKET_PATCH_TEST_HOST`, + /// which the decoys don't touch.) + const HOSTILE_DECOYS: &[(&str, &str)] = &[ + ("SOCKET_STRICT", "banana"), + ("SOCKET_VENDOR_SOURCE", "bogus-decoy"), + ("SOCKET_SETUP_EXCLUDE", "decoy-member"), ]; fn binary() -> std::path::PathBuf { @@ -126,13 +113,18 @@ mod host_guard { } /// Run the CLI with `args` in `cwd`; returns `(exit_code, stdout, stderr)`. - /// The whole `SOCKET_*` surface is stripped so behaviour reflects the - /// explicit flags alone — no ambient var can stand in for a flag. + /// The entire `SOCKET_*` surface is stripped BY PREFIX — a fixed list rots + /// (it missed `SOCKET_SETUP_EXCLUDE` / `SOCKET_VENDOR_SOURCE` / + /// `SOCKET_STRICT`, all parsed on every `setup` invocation; see + /// [`HOSTILE_DECOYS`]) — so behaviour reflects the explicit flags alone: + /// no ambient var can stand in for a flag or abort the parse. fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&key); + } } let out = cmd.output().expect("failed to execute socket-patch binary"); ( @@ -183,6 +175,14 @@ mod host_guard { /// matrix can never make. #[test] fn npm_setup_roundtrip_host() { + // Committed regression guard for the env scrub itself: with the old + // fixed-list scrub these leaked into the child — SOCKET_STRICT / + // SOCKET_VENDOR_SOURCE aborted every parse (exit 2, so the very first + // `--check` assertion went red) and SOCKET_SETUP_EXCLUDE stood in for + // `setup --exclude` on the real run. + for (k, v) in HOSTILE_DECOYS { + std::env::set_var(k, v); + } let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); stage_project(root); diff --git a/crates/socket-patch-cli/tests/setup_matrix_nuget.rs b/crates/socket-patch-cli/tests/setup_matrix_nuget.rs index 381816db..adc0357f 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_nuget.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_nuget.rs @@ -38,7 +38,7 @@ //! gap closed, not that something broke. //! //! Run: `cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_nuget` -#![cfg(all(feature = "setup-e2e", feature = "nuget"))] +#![cfg(feature = "setup-e2e")] #[path = "setup_matrix_common/mod.rs"] mod smc; @@ -83,38 +83,27 @@ mod host_guard { \n \ \n\n"; - /// Every `SOCKET_*` env var clap consults for the surface this test - /// drives. Stripped from the child so the run reflects ONLY the explicit - /// flags (`--cwd`, `--yes`, `--check`, `--remove`, `--json`). Without - /// this, an ambient `SOCKET_CWD` / `SOCKET_JSON` / `SOCKET_OFFLINE` in - /// the shell or CI could satisfy an assertion via the environment rather - /// than the flag under test. (Mirrors the scrub used by the - /// `cli_parse_*` and `setup_matrix_gem` suites.) - const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_API_TOKEN", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_OFFLINE", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_SAVE_ONLY", - "SOCKET_ONE_OFF", - "SOCKET_ALL_RELEASES", - "SOCKET_EXPERIMENTAL_NUGET", + /// Ambient decoys [`run`]'s prefix scrub must strip, planted by the test + /// itself so the scrub is exercised on every run, not only in hostile + /// shells. Three demonstrated failure classes on the old fixed-list scrub + /// (same trio as `setup_matrix_maven`): clap parses env-bound + /// `GlobalArgs` values on EVERY invocation whether or not the command + /// uses the flag, so an invalid ambient `SOCKET_STRICT` / + /// `SOCKET_VENDOR_SOURCE` aborts the parse (exit 2) before `setup` even + /// runs; and a (perfectly valid!) ambient `SOCKET_SETUP_EXCLUDE` stands + /// in for `setup --exclude`, which a real `setup` run PERSISTS — + /// creating `.socket/manifest.json` inside the dotnet fixture and + /// failing the final only-the-csproj assertion. `SOCKET_EXPERIMENTAL_NUGET` + /// rides along so the experimental gate can never quietly change nuget's + /// surface behind the test's back. (Safe to set process-wide: the only + /// other test in this binary is the `#[ignore]`d matrix pass, which + /// routes through `smc::host_driver_command`'s own `SOCKET_*` prefix + /// scrub.) + const HOSTILE_DECOYS: &[(&str, &str)] = &[ + ("SOCKET_STRICT", "banana"), + ("SOCKET_VENDOR_SOURCE", "bogus-decoy"), + ("SOCKET_SETUP_EXCLUDE", "decoy-member"), + ("SOCKET_EXPERIMENTAL_NUGET", "true"), ]; /// Absolute path to the binary under test, via cargo's `CARGO_BIN_EXE_*`. @@ -123,14 +112,19 @@ mod host_guard { } /// Run the CLI with `args` in `cwd`; returns `(exit_code, stdout, stderr)`. - /// The entire `SOCKET_*` surface is stripped so behaviour reflects the - /// explicit flags alone — nothing reaches authed endpoints and no ambient - /// var can stand in for a flag. + /// The entire `SOCKET_*` surface is stripped BY PREFIX — a fixed list rots + /// (it missed `SOCKET_SETUP_EXCLUDE` / `SOCKET_VENDOR_SOURCE` / + /// `SOCKET_STRICT`, all parsed on every `setup` invocation; see + /// [`HOSTILE_DECOYS`]) — so behaviour reflects the explicit flags alone: + /// nothing reaches authed endpoints and no ambient var can stand in for a + /// flag. fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&key); + } } let out = cmd.output().expect("failed to execute socket-patch binary"); ( @@ -205,6 +199,14 @@ mod host_guard { /// assertion the Docker matrix can never make for nuget. #[test] fn nuget_setup_roundtrip_host() { + // Committed regression guard for the env scrub itself: with the old + // fixed-list scrub these leaked into the child — SOCKET_STRICT / + // SOCKET_VENDOR_SOURCE aborted every parse (exit 2) and + // SOCKET_SETUP_EXCLUDE made the real `setup` run write + // `.socket/manifest.json` into the fixture (final entries check RED). + for (k, v) in HOSTILE_DECOYS { + std::env::set_var(k, v); + } let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); std::fs::write(root.join(CSPROJ_NAME), CSPROJ).unwrap(); diff --git a/crates/socket-patch-cli/tests/setup_matrix_pypi.rs b/crates/socket-patch-cli/tests/setup_matrix_pypi.rs index 9ab51214..848624f0 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_pypi.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_pypi.rs @@ -100,37 +100,22 @@ mod host_guard { /// implementation that reorders, rewrites, or mangles the manifest. const REQ_WITH_HOOK: &str = "requests==2.31.0\nsocket-patch[hook]\n"; - /// Every `SOCKET_*` env var clap consults for the surface this test - /// drives. Stripped from the child so the run reflects ONLY the explicit - /// flags (`--cwd`, `--yes`, `--check`, `--remove`, `--json`). Without - /// this, an ambient `SOCKET_CWD` / `SOCKET_JSON` / `SOCKET_OFFLINE` in - /// the shell or CI could satisfy an assertion via the environment rather - /// than the flag under test. (Mirrors the scrub used by the - /// `cli_parse_*` and `setup_matrix_gem` suites.) - const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_API_TOKEN", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_DOWNLOAD_MODE", - "SOCKET_OFFLINE", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_BREAK_LOCK", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_SAVE_ONLY", - "SOCKET_ONE_OFF", - "SOCKET_ALL_RELEASES", + /// Ambient decoys [`run`]'s prefix scrub must strip, planted by + /// [`pypi_setup_roundtrip_host`] itself so the scrub is exercised on every + /// run, not only in hostile shells. Three demonstrated failure classes on + /// the old fixed-list scrub: clap parses env-bound `GlobalArgs` values on + /// EVERY invocation whether or not the command uses the flag, so an + /// invalid ambient `SOCKET_STRICT` / `SOCKET_VENDOR_SOURCE` aborts the + /// parse (exit 2) before `setup` even runs; and a (perfectly valid!) + /// ambient `SOCKET_SETUP_EXCLUDE` stands in for `setup --exclude`, which + /// a real `setup` run PERSISTS into `.socket/manifest.json` inside the + /// fixture. (Safe to set process-wide: every other test in this binary + /// routes through either this module's [`run`] or + /// `smc::host_driver_command`, both of which prefix-scrub `SOCKET_*`.) + const HOSTILE_DECOYS: &[(&str, &str)] = &[ + ("SOCKET_STRICT", "banana"), + ("SOCKET_VENDOR_SOURCE", "bogus-decoy"), + ("SOCKET_SETUP_EXCLUDE", "decoy-member"), ]; /// Absolute path to the binary under test, via cargo's `CARGO_BIN_EXE_*`. @@ -139,15 +124,25 @@ mod host_guard { } /// Run the CLI with `args` in `cwd`; returns `(exit_code, stdout, stderr)`. - /// The entire `SOCKET_*` surface is stripped so behaviour reflects the - /// explicit flags alone — nothing reaches authed endpoints and no ambient - /// var can stand in for a flag. + /// The entire `SOCKET_*` surface is stripped BY PREFIX — a fixed list rots + /// (it missed `SOCKET_SETUP_EXCLUDE` / `SOCKET_VENDOR_SOURCE` / + /// `SOCKET_STRICT`, all parsed on every `setup` invocation; see + /// [`HOSTILE_DECOYS`]) — so behaviour reflects the explicit flags alone: + /// nothing reaches authed endpoints and no ambient var can stand in for a + /// flag. fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&key); + } } + // This guard's contract is "no network" (module docs): `setup` fires a + // usage-telemetry POST when telemetry is enabled, and the scrub above + // would strip a developer's own opt-out. Force it off for the child — + // no assertion here concerns telemetry. + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); let out = cmd.output().expect("failed to execute socket-patch binary"); ( out.status.code().unwrap_or(-1), @@ -217,6 +212,14 @@ mod host_guard { /// This is the assertion the Docker matrix can never make for pypi. #[test] fn pypi_setup_roundtrip_host() { + // Committed regression guard for the env scrub itself: with the old + // fixed-list scrub these leaked into the child — SOCKET_STRICT / + // SOCKET_VENDOR_SOURCE aborted every parse (exit 2) and + // SOCKET_SETUP_EXCLUDE made the real `setup` run write + // `.socket/manifest.json` into the fixture. + for (k, v) in HOSTILE_DECOYS { + std::env::set_var(k, v); + } let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); std::fs::write(root.join("requirements.txt"), REQ_INITIAL).unwrap(); @@ -387,6 +390,10 @@ mod host_guard { "needs_configuration", "after remove the project must report needs_configuration again:\n{v}" ); + + for (k, _) in HOSTILE_DECOYS { + std::env::remove_var(k); + } } /// Regression: a commented-out hook line is NOT a configured project. diff --git a/crates/socket-patch-cli/tests/setup_pth_invariants.rs b/crates/socket-patch-cli/tests/setup_pth_invariants.rs index 322f4031..dffc2830 100644 --- a/crates/socket-patch-cli/tests/setup_pth_invariants.rs +++ b/crates/socket-patch-cli/tests/setup_pth_invariants.rs @@ -3,27 +3,26 @@ //! editing + audit record) and need no network. use std::collections::BTreeSet; -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn binary() -> PathBuf { - env!("CARGO_BIN_EXE_socket-patch").into() -} - +use std::path::Path; + +#[path = "common/mod.rs"] +mod common; + +/// Run `setup --json --yes [extra]` in `cwd` through the shared hermetic +/// runner. The binary binds a wide `SOCKET_*` env surface (SOCKET_DRY_RUN, +/// SOCKET_ECOSYSTEMS, SOCKET_CWD, SOCKET_SETUP_EXCLUDE, ...); an ambient +/// value silently flips what every test here exercises (SOCKET_DRY_RUN=true +/// turns each real run into a dry run, SOCKET_ECOSYSTEMS=npm hides the +/// Python branch entirely), so `common::run_with_env`'s seed-then-scrub is +/// load-bearing, not hygiene. fn run_setup(cwd: &Path, extra: &[&str]) -> (i32, serde_json::Value) { let mut args = vec!["setup", "--json", "--yes"]; args.extend_from_slice(extra); - let out = Command::new(binary()) - .args(&args) - .current_dir(cwd) - .env_remove("SOCKET_API_TOKEN") - .env("SOCKET_TELEMETRY_DISABLED", "1") - .output() - .expect("run socket-patch"); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let (code, stdout, _stderr) = + common::run_with_env(cwd, &args, &[("SOCKET_TELEMETRY_DISABLED", "1")]); let v = serde_json::from_str(&stdout) .unwrap_or_else(|e| panic!("stdout must be JSON ({e}):\n{stdout}")); - (out.status.code().unwrap_or(-1), v) + (code, v) } fn write(path: &Path, content: &str) { @@ -259,6 +258,40 @@ fn idempotent_second_run_reports_already_configured() { ); } +#[test] +fn pep503_equivalent_hook_spellings_are_already_configured() { + // pip installs the hook from `socket_patch[hook]` exactly as from the + // canonical spelling (PEP 503: `-`/`_`/`.` are interchangeable in names) + // and from a combined-extras spec like `socket-patch[cli,hook]` (PEP 508). + // Setup must recognize both as configured: appending a second spelling of + // the same requirement is non-idempotent, `--check` would fail a CI gate + // on a correctly configured repo, and `--remove` would report nothing to + // remove while the hook stays wired. + for spec in ["socket_patch[hook]\n", "socket-patch[cli,hook]>=1.0\n"] { + let tmp = tempfile::tempdir().unwrap(); + write(&tmp.path().join("requirements.txt"), spec); + + let (code, v) = run_setup(tmp.path(), &[]); + assert_eq!(code, 0, "spec {spec:?}: payload={v}"); + assert_eq!( + v["status"], "already_configured", + "spec {spec:?} already declares the hook; setup must not re-add it: {v}" + ); + assert_eq!( + read(&tmp.path().join("requirements.txt")), + spec, + "spec {spec:?}: requirements.txt must be untouched" + ); + + let (code, v) = run_setup(tmp.path(), &["--check"]); + assert_eq!( + code, 0, + "spec {spec:?}: --check must pass on a configured project: {v}" + ); + assert_eq!(v["status"], "configured", "spec {spec:?}: {v}"); + } +} + #[test] fn dry_run_does_not_modify_or_create_files() { let tmp = tempfile::tempdir().unwrap(); @@ -404,22 +437,15 @@ fn setup_python_writes_only_inside_repo() { "sentinel HOME must start empty" ); - let out = Command::new(binary()) - .args(["setup", "--json", "--yes"]) - .current_dir(proj.path()) - .env_remove("SOCKET_API_TOKEN") - .env_remove("SOCKET_ECOSYSTEMS") - .env_remove("SOCKET_CWD") - .env("HOME", home.path()) - .env("SOCKET_TELEMETRY_DISABLED", "1") - .output() - .expect("run socket-patch"); - assert_eq!( - out.status.code(), - Some(0), - "setup should succeed; stderr=\n{}", - String::from_utf8_lossy(&out.stderr) + let (code, _stdout, stderr) = common::run_with_env( + proj.path(), + &["setup", "--json", "--yes"], + &[ + ("HOME", home.path().to_str().unwrap()), + ("SOCKET_TELEMETRY_DISABLED", "1"), + ], ); + assert_eq!(code, 0, "setup should succeed; stderr=\n{stderr}"); assert!( files_under(home.path()).is_empty(), @@ -467,3 +493,137 @@ fn setup_python_state_is_clone_portable() { "--check must not modify the clone" ); } + +// --------------------------------------------------------------------------- +// Property 7 — the post-edit lockfile refresh must not rewrite the user's +// pinned dependency set. Poetry 1.x's bare `poetry lock` re-resolves EVERY +// dependency to the newest compatible version (the pin-preserving spelling is +// `lock --no-update`); Poetry 2.x makes pin-preserving the default and +// REMOVES `--no-update`, so setup must try the 1.x spelling first and fall +// back to the bare form when the flag is unknown. Same shape for PDM +// (`--update-reuse`). A fake package manager on PATH records the argv setup +// actually invokes. +// --------------------------------------------------------------------------- + +/// Lay a fake `name` executable into `bin_dir` that appends its argv to `log` +/// and exits 0 — unless the argv contains `reject_arg`, in which case it prints +/// an unknown-option error and exits 1 without logging (a tool that does not +/// know the flag, e.g. Poetry 2.x and `--no-update`). +#[cfg(unix)] +fn write_pm_shim(bin_dir: &Path, name: &str, log: &Path, reject_arg: Option<&str>) { + use std::os::unix::fs::PermissionsExt; + std::fs::create_dir_all(bin_dir).expect("create shim dir"); + let reject = match reject_arg { + Some(flag) => format!( + "case \"$*\" in *{flag}*) echo 'The \"{flag}\" option does not exist.' >&2; exit 1;; esac\n" + ), + None => String::new(), + }; + let body = format!( + "#!/bin/sh\n{reject}printf '%s\\n' \"$*\" >> '{}'\nexit 0\n", + log.display() + ); + let p = bin_dir.join(name); + std::fs::write(&p, body).expect("write shim"); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).expect("chmod shim"); +} + +/// `run_setup` with the shim dir prepended to PATH so the spawned lockfile +/// refresh resolves to the fake package manager. +#[cfg(unix)] +fn run_setup_with_shims(cwd: &Path, bin_dir: &Path) -> (i32, serde_json::Value) { + let path_env = format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + let (code, stdout, _stderr) = common::run_with_env( + cwd, + &["setup", "--json", "--yes"], + &[("SOCKET_TELEMETRY_DISABLED", "1"), ("PATH", &path_env)], + ); + let v = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("stdout must be JSON ({e}):\n{stdout}")); + (code, v) +} + +const POETRY_PYPROJECT: &str = "[tool.poetry]\nname = \"x\"\nversion = \"0.1.0\"\ndescription = \"\"\nauthors = []\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\n"; + +#[cfg(unix)] +#[test] +fn poetry_lock_refresh_asks_for_pin_preserving_lock_first() { + let tmp = tempfile::tempdir().unwrap(); + write(&tmp.path().join("pyproject.toml"), POETRY_PYPROJECT); + write(&tmp.path().join("poetry.lock"), "# stub lock\n"); + let log = tmp.path().join("poetry-argv.log"); + // Poetry 1.x: knows both `lock` and `lock --no-update`. + write_pm_shim(&tmp.path().join("bin"), "poetry", &log, None); + + let (code, v) = run_setup_with_shims(tmp.path(), &tmp.path().join("bin")); + assert_eq!(code, 0, "setup must succeed: {v}"); + let argvs = read(&log); + let first = argvs.lines().next().unwrap_or_default(); + assert_eq!( + first, "lock --no-update", + "on a tool that accepts it, the FIRST lock invocation must be the \ + pin-preserving `poetry lock --no-update` — the bare `poetry lock` \ + re-resolves the user's entire pinned set on Poetry 1.x; got argv log:\n{argvs}" + ); + assert!( + v.get("warnings").is_none(), + "successful pin-preserving refresh must not warn: {v}" + ); +} + +#[cfg(unix)] +#[test] +fn poetry_2x_without_no_update_falls_back_to_bare_lock() { + let tmp = tempfile::tempdir().unwrap(); + write(&tmp.path().join("pyproject.toml"), POETRY_PYPROJECT); + write(&tmp.path().join("poetry.lock"), "# stub lock\n"); + let log = tmp.path().join("poetry-argv.log"); + // Poetry 2.x: `--no-update` was removed (pin-preserving became the + // default), so that spelling exits non-zero. + write_pm_shim( + &tmp.path().join("bin"), + "poetry", + &log, + Some("--no-update"), + ); + + let (code, v) = run_setup_with_shims(tmp.path(), &tmp.path().join("bin")); + assert_eq!(code, 0, "setup must succeed: {v}"); + let argvs = read(&log); + assert_eq!( + argvs.lines().last().unwrap_or_default(), + "lock", + "when `--no-update` is unknown the bare `poetry lock` must still run \ + (it is pin-preserving on 2.x); got argv log:\n{argvs}" + ); + assert!( + v.get("warnings").is_none(), + "a successful fallback is not a failure — no lockfile warning: {v}" + ); +} + +#[cfg(unix)] +#[test] +fn pdm_lock_refresh_asks_for_pin_preserving_lock_first() { + let tmp = tempfile::tempdir().unwrap(); + write( + &tmp.path().join("pyproject.toml"), + "[project]\nname = \"x\"\nversion = \"0.1.0\"\ndependencies = [\"requests\"]\n\n[tool.pdm]\n", + ); + write(&tmp.path().join("pdm.lock"), "# stub lock\n"); + let log = tmp.path().join("pdm-argv.log"); + write_pm_shim(&tmp.path().join("bin"), "pdm", &log, None); + + let (code, v) = run_setup_with_shims(tmp.path(), &tmp.path().join("bin")); + assert_eq!(code, 0, "setup must succeed: {v}"); + let argvs = read(&log); + assert_eq!( + argvs.lines().next().unwrap_or_default(), + "lock --update-reuse", + "the first PDM lock invocation must reuse the user's pins; got argv log:\n{argvs}" + ); +} diff --git a/crates/socket-patch-cli/tests/telemetry_e2e.rs b/crates/socket-patch-cli/tests/telemetry_e2e.rs index e3cd19b5..5f24922b 100644 --- a/crates/socket-patch-cli/tests/telemetry_e2e.rs +++ b/crates/socket-patch-cli/tests/telemetry_e2e.rs @@ -59,12 +59,48 @@ fn run_cmd( args.extend_from_slice(extra_args); let mut cmd = Command::new(binary()); cmd.args(&args).current_dir(cwd); + // The binary binds a wide `SOCKET_*` env surface; an ambient value + // silently changes what these tests exercise — SOCKET_ECOSYSTEMS=cargo + // makes scan skip the npm fixture (scannedPackages 0), SOCKET_CWD aims + // the crawl outside the tempdir, SOCKET_GLOBAL flips to global mode. + // The highest-risk vars are seeded with hostile values and then + // scrubbed — `env_remove` clears the seed too, so the child never sees + // it, but if a scrub line is ever dropped the seed (rather than a + // developer's ambient shell, which this suite can't rely on) turns the + // tests red immediately. Same pattern as `common::run_with_env`. + cmd.env("SOCKET_CWD", "/nonexistent") + .env("SOCKET_ECOSYSTEMS", "cargo") + .env("SOCKET_GLOBAL", "true") + .env("SOCKET_GLOBAL_PREFIX", "/nonexistent") + .env("SOCKET_DRY_RUN", "true") + .env("SOCKET_MANIFEST_PATH", "/nonexistent/manifest.json") + .env_remove("SOCKET_CWD") + .env_remove("SOCKET_ECOSYSTEMS") + .env_remove("SOCKET_GLOBAL") + .env_remove("SOCKET_GLOBAL_PREFIX") + .env_remove("SOCKET_DRY_RUN") + .env_remove("SOCKET_MANIFEST_PATH"); + // Prefix-scrub whatever else the ambient shell carries (SOCKET_YES, + // SOCKET_STRICT, SOCKET_VENDOR_SOURCE, ...). Unlike common/mod.rs this + // scrub covers the telemetry opt-outs too: this suite's entire point is + // asserting telemetry POSTs against a local wiremock, so an opted-out + // dev shell must not vacuously green the count-0 asserts. + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") { + cmd.env_remove(&key); + } + } // Default: disable the test-environment short-circuit // (`is_telemetry_disabled()` flips on `VITEST=true`). cmd.env_remove("VITEST"); cmd.env_remove("SOCKET_TELEMETRY_DISABLED"); cmd.env_remove("SOCKET_PATCH_TELEMETRY_DISABLED"); cmd.env_remove("SOCKET_OFFLINE"); + // An ambient VIRTUAL_ENV hijacks the python crawler (its site-packages + // get crawled as project packages), breaking the exact + // `scannedPackages` oracles below. + cmd.env_remove("VIRTUAL_ENV"); // `send_telemetry_event` reads SOCKET_API_URL from the environment // directly (not the clap arg), so pointing it at the mock here is // how the telemetry POST also lands on our recorder. @@ -211,22 +247,39 @@ async fn scan_skips_telemetry_in_airgap_mode() { &[("SOCKET_OFFLINE", "1")], ); - // Guard against a vacuous pass: prove scan actually ran its body (it - // crawled node_modules and reported the one package) rather than - // crashing before the telemetry-suppression point, which would also - // yield zero POSTs. - assert_eq!(code, 0, "offline scan must still succeed; stderr={stderr}"); + // Strict airgap (CLI_CONTRACT.md `--offline`: "never contact the + // network — operations that need remote data fail loudly"): scan's + // patch discovery IS remote data, so offline scan must refuse loudly + // up front instead of POSTing the crawled package inventory to the + // batch endpoint. The error envelope doubles as the anti-vacuous + // guard — a crash before the offline gate would exit nonzero too, + // but couldn't emit the refusal envelope. + assert_eq!( + code, 1, + "offline scan must fail loudly; stdout={stdout} stderr={stderr}" + ); let v: serde_json::Value = serde_json::from_str(&stdout) .unwrap_or_else(|e| panic!("scan stdout not JSON: {e}\n{stdout}")); assert_eq!( - v["status"], "success", - "offline scan status; stdout={stdout}" + v["status"], "error", + "offline scan must report an error envelope; stdout={stdout}" ); - assert_eq!( - v["scannedPackages"], 1, - "offline scan must crawl the one package; stdout={stdout}" + assert!( + v["error"].as_str().unwrap_or_default().contains("offline"), + "offline scan's error must name the offline gate; stdout={stdout}" ); + // The airgap core: ZERO requests of any kind — no batch POST (which + // would exfiltrate the crawled package inventory), no telemetry. + let received = mock.received_requests().await.expect("recording enabled"); + assert!( + received.is_empty(), + "SOCKET_OFFLINE=1 must suppress every network request during scan; saw: {:?}", + received + .iter() + .map(|r| format!("{} {}", r.method, r.url.path())) + .collect::>() + ); let count = telemetry_post_count(&mock, None).await; assert_eq!( count, 0, @@ -329,7 +382,7 @@ async fn get_skips_telemetry_in_airgap_mode() { write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "lodash", "4.17.20"); - let (_code, stdout, _stderr) = run_cmd( + let (code, stdout, stderr) = run_cmd( tmp.path(), &mock.uri(), "get", @@ -337,29 +390,38 @@ async fn get_skips_telemetry_in_airgap_mode() { &[("SOCKET_OFFLINE", "1")], ); - // Anti-vacuous guard: get must have reached the fetch step (it queries - // the view endpoint regardless of airgap) — proving it ran far enough - // to hit the telemetry-suppression point. A crash before that would - // also produce zero telemetry POSTs and falsely "pass". + // Strict airgap (CLI_CONTRACT.md `--offline`: "never contact the + // network — operations that need remote data fail loudly"): every + // `get` mode fetches remote patch data, so offline get must refuse + // loudly before touching the view endpoint. The error envelope is + // the anti-vacuous guard — a crash before the offline gate exits + // nonzero too, but couldn't emit the refusal envelope. + assert_eq!( + code, 1, + "offline get must fail loudly; stdout={stdout} stderr={stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("get stdout not JSON: {e}\n{stdout}")); + assert_eq!( + v["status"], "error", + "offline get must report an error envelope; stdout={stdout}" + ); + assert!( + v["error"].as_str().unwrap_or_default().contains("offline"), + "offline get's error must name the offline gate; stdout={stdout}" + ); + + // The airgap core: ZERO requests of any kind — no view GET, no + // telemetry. let received = mock.received_requests().await.expect("recording enabled"); - let view_hits = received - .iter() - .filter(|r| { - r.method == wiremock::http::Method::GET - && r.url - .path() - .contains(&format!("/v0/orgs/{ORG_SLUG}/patches/view/")) - }) - .count(); assert!( - view_hits >= 1, - "offline get must still query the view endpoint; saw paths: {:?}; stdout={stdout}", + received.is_empty(), + "SOCKET_OFFLINE=1 must suppress every network request during get; saw: {:?}", received .iter() - .map(|r| r.url.path().to_string()) + .map(|r| format!("{} {}", r.method, r.url.path())) .collect::>() ); - let count = telemetry_post_count(&mock, None).await; assert_eq!( count, 0, diff --git a/crates/socket-patch-core/Cargo.toml b/crates/socket-patch-core/Cargo.toml index 9d6f0bdb..72e0abbe 100644 --- a/crates/socket-patch-core/Cargo.toml +++ b/crates/socket-patch-core/Cargo.toml @@ -32,26 +32,11 @@ base64 = { workspace = true } [target.'cfg(unix)'.dependencies] libc = { workspace = true } -[features] -# `cargo`, `golang`, `nuget`, and `maven` are default features (npm + PyPI + -# Ruby gems are unconditional). Mirror the CLI's defaults so a plain `cargo -# build` of the workspace ships the supported ecosystems (npm, PyPI, Ruby gems, -# Go, Cargo, NuGet, Maven); the still-unsupported ecosystems (composer, deno) -# stay opt-in. Maven `apply` stays runtime-gated behind -# `SOCKET_EXPERIMENTAL_MAVEN=1` (in-place jar patching corrupts sidecars); -# committable `vendor` is safe (it never touches ~/.m2). -default = ["cargo", "golang", "nuget", "maven"] -cargo = [] -golang = [] -maven = [] -composer = [] -nuget = [] -# Deno covers two surfaces: (1) Deno 2.0's npm-install layouts that -# produce a standard node_modules/ (handled by NpmCrawler today, -# triggered here by deno.json / deno.lock project markers) and -# (2) JSR-registry packages cached at $DENO_DIR/npm/jsr.io/* with -# `pkg:jsr//@` PURLs handled by DenoCrawler. -deno = [] +# All ecosystems (npm, PyPI, Ruby gems, Go, Cargo, NuGet, Maven, Composer, +# Deno) are unconditionally compiled in — there are no ecosystem feature +# gates. Maven `apply` stays runtime-gated behind `SOCKET_EXPERIMENTAL_MAVEN=1` +# (in-place jar patching corrupts sidecars); committable `vendor` is safe (it +# never touches ~/.m2). [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/socket-patch-core/README.md b/crates/socket-patch-core/README.md index a365fb0e..ebb97ad4 100644 --- a/crates/socket-patch-core/README.md +++ b/crates/socket-patch-core/README.md @@ -1,12 +1,12 @@ # socket-patch-core -Core library for [socket-patch](https://github.com/SocketDev/socket-patch) — a CLI tool that applies security patches to npm and Python dependencies (plus Cargo, Go, Maven, Ruby, Composer, and NuGet via feature flags) without waiting for upstream fixes. +Core library for [socket-patch](https://github.com/SocketDev/socket-patch) — a CLI tool that applies security patches to npm, Python, Ruby, Cargo, Go, Maven, Composer, NuGet, and Deno dependencies without waiting for upstream fixes. ## What this crate provides - **Manifest management** — read, write, and validate `.socket/manifest.json` patch manifests - **Patch engine** — apply and rollback file-level patches using git SHA-256 content hashes -- **Crawlers** — discover installed packages across npm, PyPI, and Ruby gems (default), plus Cargo, Go, Maven, Composer, and NuGet (via feature flags) +- **Crawlers** — discover installed packages across npm, PyPI, Ruby gems, Cargo, Go, Maven, Composer, NuGet, and Deno - **API client** — fetch patches from the Socket API - **Utilities** — PURL parsing, blob storage, hash verification, fuzzy matching diff --git a/crates/socket-patch-core/src/api/blob_fetcher.rs b/crates/socket-patch-core/src/api/blob_fetcher.rs index 544ddda5..b9849532 100644 --- a/crates/socket-patch-core/src/api/blob_fetcher.rs +++ b/crates/socket-patch-core/src/api/blob_fetcher.rs @@ -1,5 +1,5 @@ use std::collections::HashSet; -use std::path::{Path, PathBuf}; +use std::path::Path; use crate::api::client::ApiClient; use crate::manifest::operations::get_after_hash_blobs; @@ -116,9 +116,10 @@ pub async fn fetch_missing_blobs( // Ensure blobs directory exists if let Err(e) = tokio::fs::create_dir_all(blobs_path).await { - return all_failed_result(missing.iter(), |h| { - (h.clone(), format!("Cannot create blobs directory: {}", e)) - }); + return all_failed_result( + missing.iter(), + &format!("Cannot create blobs directory: {}", e), + ); } let hashes: Vec = missing.into_iter().collect(); @@ -128,20 +129,16 @@ pub async fn fetch_missing_blobs( /// Build a [`FetchMissingBlobsResult`] whose entries are all failures /// for the same reason. Used by the early-return branches that hit a /// blocker (e.g. cannot create blobs dir) before any download attempt. -fn all_failed_result<'a, I, F>(items: I, mut into_pair: F) -> FetchMissingBlobsResult -where - I: IntoIterator, - F: FnMut(&'a String) -> (String, String), -{ +fn all_failed_result<'a>( + items: impl IntoIterator, + error: &str, +) -> FetchMissingBlobsResult { let results: Vec = items .into_iter() - .map(|item| { - let (hash, error) = into_pair(item); - BlobFetchResult { - hash, - success: false, - error: Some(error), - } + .map(|hash| BlobFetchResult { + hash: hash.clone(), + success: false, + error: Some(error.to_string()), }) .collect(); let failed = results.len(); @@ -171,9 +168,10 @@ pub async fn fetch_blobs_by_hash( // Ensure blobs directory exists if let Err(e) = tokio::fs::create_dir_all(blobs_path).await { - return all_failed_result(hashes.iter(), |h| { - (h.clone(), format!("Cannot create blobs directory: {}", e)) - }); + return all_failed_result( + hashes.iter(), + &format!("Cannot create blobs directory: {}", e), + ); } // Filter out hashes that already exist on disk @@ -206,17 +204,14 @@ pub async fn fetch_blobs_by_hash( } let download_result = download_hashes(&to_download, blobs_path, client, on_progress).await; + results.extend(download_result.results); FetchMissingBlobsResult { total: hashes.len(), downloaded: download_result.downloaded, failed: download_result.failed, skipped, - results: { - let mut combined = results; - combined.extend(download_result.results); - combined - }, + results, } } @@ -257,30 +252,16 @@ pub async fn fetch_missing_sources( client: &ApiClient, on_progress: Option<&OnProgress>, ) -> FetchMissingBlobsResult { - match mode { + let (dir, kind) = match mode { DownloadMode::File => { - fetch_missing_blobs(manifest, sources.blobs_path, client, on_progress).await + return fetch_missing_blobs(manifest, sources.blobs_path, client, on_progress).await } - DownloadMode::Diff => match sources.diffs_path { - Some(dir) => { - fetch_missing_archives_inner(manifest, dir, ArchiveKind::Diff, client, on_progress) - .await - } - None => FetchMissingBlobsResult::default(), - }, - DownloadMode::Package => match sources.packages_path { - Some(dir) => { - fetch_missing_archives_inner( - manifest, - dir, - ArchiveKind::Package, - client, - on_progress, - ) - .await - } - None => FetchMissingBlobsResult::default(), - }, + DownloadMode::Diff => (sources.diffs_path, ArchiveKind::Diff), + DownloadMode::Package => (sources.packages_path, ArchiveKind::Package), + }; + match dir { + Some(dir) => fetch_missing_archives_inner(manifest, dir, kind, client, on_progress).await, + None => FetchMissingBlobsResult::default(), } } @@ -303,12 +284,10 @@ async fn fetch_missing_archives_inner( } if let Err(e) = tokio::fs::create_dir_all(archives_dir).await { - return all_failed_result(missing.iter(), |u| { - ( - u.clone(), - format!("Cannot create archives directory: {}", e), - ) - }); + return all_failed_result( + missing.iter(), + &format!("Cannot create archives directory: {}", e), + ); } let uuids: Vec = missing.into_iter().collect(); @@ -329,7 +308,7 @@ async fn fetch_missing_archives_inner( match fetch_result { Ok(Some(data)) => { - let archive_path: PathBuf = archives_dir.join(format!("{}.tar.gz", uuid)); + let archive_path = archives_dir.join(format!("{}.tar.gz", uuid)); match write_cache_entry_atomic(&archive_path, &data).await { Ok(()) => { results.push(BlobFetchResult { @@ -447,6 +426,12 @@ pub fn format_fetch_result(result: &FetchMissingBlobsResult) -> String { /// makes the final path always either the complete bytes or absent, never a /// torn intermediate, matching the stage+rename discipline used by the /// patch-apply and copy-on-write write paths. +/// +/// Deliberately LIGHTER than [`crate::utils::fs::atomic_write_bytes`] (no +/// file fsync, no dir fsync, `.socket-dl-` prefix): these are re-downloadable +/// content-addressed cache entries, not user-owned files — post-crash loss +/// of a cache entry is harmless, so the extra durability isn't worth the +/// I/O. Do not "consolidate" this into the hardened writer. async fn write_cache_entry_atomic(dest: &Path, bytes: &[u8]) -> std::io::Result<()> { let parent = dest.parent().ok_or_else(|| { std::io::Error::new( @@ -460,7 +445,7 @@ async fn write_cache_entry_atomic(dest: &Path, bytes: &[u8]) -> std::io::Result< .unwrap_or_else(|| "blob".to_string()); // Leading dot keeps the stage out of editor/glob views; the uuid suffix // keeps concurrent writers of the same entry from colliding. - let stage: PathBuf = parent.join(format!(".socket-dl-{}-{}", stem, uuid::Uuid::new_v4())); + let stage = parent.join(format!(".socket-dl-{}-{}", stem, uuid::Uuid::new_v4())); if let Err(e) = tokio::fs::write(&stage, bytes).await { // A partial stage would otherwise leak as a `.socket-dl-*` turd. @@ -524,7 +509,7 @@ async fn download_hashes( continue; } - let blob_path: PathBuf = blobs_path.join(hash); + let blob_path = blobs_path.join(hash); match write_cache_entry_atomic(&blob_path, &data).await { Ok(()) => { results.push(BlobFetchResult { diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index b162b8a0..abffa7a4 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -2,22 +2,11 @@ use std::collections::HashSet; use reqwest::header::{self, HeaderMap, HeaderValue}; use reqwest::StatusCode; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use crate::api::types::*; -use crate::constants::{ - DEFAULT_PATCH_API_PROXY_URL, DEFAULT_SOCKET_API_URL, USER_AGENT as USER_AGENT_VALUE, -}; -use crate::utils::env_compat::read_env_with_legacy; - -/// Check if debug mode is enabled via SOCKET_DEBUG env (falling back to the -/// legacy SOCKET_PATCH_DEBUG name with a one-shot deprecation warning). -fn is_debug_enabled() -> bool { - match read_env_with_legacy("SOCKET_DEBUG", "SOCKET_PATCH_DEBUG") { - Some(val) => val == "1" || val == "true", - None => false, - } -} +use crate::constants::{DEFAULT_SOCKET_API_URL, USER_AGENT as USER_AGENT_VALUE}; +use crate::utils::env_compat::{is_debug_enabled, proxy_url_from_env}; /// Log debug messages when debug mode is enabled. fn debug_log(message: &str) { @@ -71,49 +60,20 @@ struct BatchSearchBody { components: Vec, } -#[derive(Serialize)] -struct BatchComponent { - purl: String, +impl BatchSearchBody { + fn new(purls: &[String]) -> Self { + Self { + components: purls + .iter() + .map(|p| BatchComponent { purl: p.clone() }) + .collect(), + } + } } -/// Body for the patch-package reference POST endpoint (`scan --redirect`). #[derive(Serialize)] -struct RegistryReferenceBody { - uuids: Vec, -} - -/// One downloadable artifact from the reference endpoint. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ReferenceArtifact { - pub kind: String, - #[serde(default)] - pub url: Option, - #[serde(default)] - pub integrity: crate::patch::redirect::Integrity, -} - -/// One patch's resolved hosted-patch reference — mirrors the api-v0 -/// `POST /v0/orgs/{org}/patches/package` (and proxy `/patch/package`) result: -/// the grant-tokenized artifact URL(s) + integrity + per-ecosystem registry -/// override that `scan --redirect` turns into a `DepOverride`. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RegistryReference { - pub status: String, - #[serde(default)] - pub url: Option, - #[serde(default)] - pub purl: Option, - #[serde(default)] - pub artifacts: Vec, - #[serde(default)] - pub registry_override: Option, -} - -#[derive(Deserialize)] -struct RegistryReferenceResponse { - results: std::collections::HashMap, +struct BatchComponent { + purl: String, } impl ApiClient { @@ -322,12 +282,7 @@ impl ApiClient { if !self.use_public_proxy { let slug = org_slug.or(self.org_slug.as_deref()).unwrap_or("default"); let path = format!("/v0/orgs/{}/patches/batch", slug); - let body = BatchSearchBody { - components: purls - .iter() - .map(|p| BatchComponent { purl: p.clone() }) - .collect(), - }; + let body = BatchSearchBody::new(purls); let result = self .post_json::(&path, &body) .await?; @@ -358,7 +313,7 @@ impl ApiClient { pub async fn fetch_registry_references( &self, uuids: &[String], - ) -> Result, ApiError> { + ) -> Result, ApiError> { if uuids.is_empty() { return Ok(std::collections::HashMap::new()); } @@ -368,11 +323,12 @@ impl ApiClient { let slug = self.org_slug.as_deref().unwrap_or("default"); format!("/v0/orgs/{}/patches/package", slug) }; - let body = RegistryReferenceBody { + let body = PackageVendorRequest { uuids: uuids.to_vec(), + free_only: None, }; let resp = self - .post_json::(&path, &body) + .post_json::(&path, &body) .await?; Ok(resp.map(|r| r.results).unwrap_or_default()) } @@ -405,12 +361,7 @@ impl ApiClient { let url = format!("{}/patch/batch", self.api_url); debug_log(&format!("POST {}", url)); - let body = BatchSearchBody { - components: purls - .iter() - .map(|p| BatchComponent { purl: p.clone() }) - .collect(), - }; + let body = BatchSearchBody::new(purls); let resp = self .client @@ -510,27 +461,19 @@ impl ApiClient { Ok(assemble_batch_from_individual(all_results)) } - /// Fetch organizations accessible to the current API token. - pub async fn fetch_organizations( - &self, - ) -> Result, ApiError> { - let path = "/v0/organizations"; - match self - .get_json::(path) - .await? - { - Some(resp) => Ok(resp.organizations.into_values().collect()), - None => Ok(Vec::new()), - } - } - /// Resolve the org slug from the API token by querying `/v0/organizations`. /// /// If there is exactly one org, returns its slug. /// If there are multiple, picks the first and prints a warning. /// If there are none, returns an error. - pub async fn resolve_org_slug(&self) -> Result { - let orgs = self.fetch_organizations().await?; + async fn resolve_org_slug(&self) -> Result { + let orgs = match self + .get_json::("/v0/organizations") + .await? + { + Some(resp) => resp.organizations.into_values().collect(), + None => Vec::new(), + }; select_org_slug(orgs) } @@ -547,7 +490,7 @@ impl ApiClient { hash ))); } - self.fetch_binary("blob", "blob", hash).await + self.fetch_binary("blob", hash).await } /// Fetch a per-file diff archive (tar.gz of bsdiff deltas) by patch UUID. @@ -562,7 +505,7 @@ impl ApiClient { uuid ))); } - self.fetch_binary("diff", "diff", uuid).await + self.fetch_binary("diff", uuid).await } /// Fetch a per-package patch archive (tar.gz of patched files) by patch UUID. @@ -575,7 +518,7 @@ impl ApiClient { uuid ))); } - self.fetch_binary("package", "package", uuid).await + self.fetch_binary("package", uuid).await } /// Build the URL (and an `is_authenticated` flag) for a binary fetch of @@ -603,8 +546,7 @@ impl ApiClient { let base = if self.use_public_proxy { self.api_url.clone() } else { - read_env_with_legacy("SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL") - .unwrap_or_else(|| DEFAULT_PATCH_API_PROXY_URL.to_string()) + proxy_url_from_env() }; let u = format!( "{}/patch/{}/{}", @@ -618,53 +560,37 @@ impl ApiClient { /// Shared implementation for `fetch_blob` / `fetch_diff` / `fetch_package`. /// - /// `kind` is the URL segment (`blob` / `diff` / `package`). `label` is the - /// human-readable noun used in log + error messages. `identifier` is the - /// hash or UUID interpolated into the URL. + /// `kind` is the URL segment (`blob` / `diff` / `package`), doubling as the + /// noun in log + error messages. `identifier` is the hash or UUID + /// interpolated into the URL. async fn fetch_binary( &self, kind: &str, - label: &str, identifier: &str, ) -> Result>, ApiError> { let (url, use_auth) = self.binary_url(kind, identifier); - debug_log(&format!("GET {} {}", label, url)); + debug_log(&format!("GET {} {}", kind, url)); - // Build the request. When fetching from the public proxy (different - // base URL than self.api_url), we use a plain client without auth - // headers to avoid leaking credentials to the proxy. - let resp = if use_auth { - self.client - .get(&url) - .header(header::ACCEPT, "application/octet-stream") - .send() - .await + // When fetching from the public proxy (different base URL than + // self.api_url), use a plain client without auth headers to avoid + // leaking credentials to the proxy. + let client = if use_auth { + self.client.clone() } else { - let mut headers = HeaderMap::new(); - headers.insert( - header::USER_AGENT, - HeaderValue::from_static(USER_AGENT_VALUE), - ); - headers.insert( - header::ACCEPT, - HeaderValue::from_static("application/octet-stream"), - ); - - let plain_client = reqwest::Client::builder() - .default_headers(headers) - .build() - .expect("failed to build plain reqwest client"); - - plain_client.get(&url).send().await + plain_client() }; - - let resp = resp.map_err(|e| { - ApiError::Network(format!( - "Network error fetching {} {}: {}", - label, identifier, e - )) - })?; + let resp = client + .get(&url) + .header(header::ACCEPT, "application/octet-stream") + .send() + .await + .map_err(|e| { + ApiError::Network(format!( + "Network error fetching {} {}: {}", + kind, identifier, e + )) + })?; let status = resp.status(); @@ -672,7 +598,7 @@ impl ApiClient { let bytes = resp.bytes().await.map_err(|e| { ApiError::Network(format!( "Error reading {} body for {}: {}", - label, identifier, e + kind, identifier, e )) })?; return Ok(Some(bytes.to_vec())); @@ -693,7 +619,7 @@ impl ApiClient { let text = resp.text().await.unwrap_or_default(); Err(ApiError::Other(format!( "Failed to fetch {} {}: status {} - {}", - label, + kind, identifier, status.as_u16(), text, @@ -713,7 +639,7 @@ impl ApiClient { /// the step-2 download host (both for staging / local-dev / testing). The /// returned [`FetchedVendorPackage`] carries the *unverified* bytes plus the /// service-reported integrity — the caller verifies before use. - pub async fn fetch_vendor_package( + pub(crate) async fn fetch_vendor_package( &self, uuid: &str, free_only: bool, @@ -811,10 +737,7 @@ impl ApiClient { ServeDownload::Ok(bytes) => VendorServiceOutcome::Ready(FetchedVendorPackage { tarball: bytes, integrity_sri, - sha1_hex: artifact.integrity.sha1.clone(), dirhash_h1: artifact.integrity.dirhash_h1.clone(), - size_bytes: artifact.size_bytes, - content_type: artifact.content_type.clone(), source_url: download_url, secondary_artifacts, }), @@ -826,6 +749,35 @@ impl ApiClient { } } + /// Build the URL (and an `is_authenticated` flag) for the vendor + /// package-reference POST of [`Self::request_vendor_package`]. + /// + /// Authenticated `/v0/orgs//patches/package` when a token + org + /// slug are configured and we're not pinned to the public proxy — + /// mirrors [`Self::binary_url`]'s decision so a bearer is never sent to + /// the proxy. Otherwise it targets the proxy's `/patch/package`. + /// `vendor_url` (staging / local-dev) overrides the base in every case. + /// + /// The base mirrors [`Self::binary_url`] too: in public-proxy mode the + /// client's own `api_url` IS the proxy, but an authenticated client that + /// lacks an org slug must re-derive the proxy base from the environment + /// — its `api_url` is the auth host, which has no `/patch/*` routes. + fn vendor_package_url(&self, vendor_url: Option<&str>) -> (String, bool) { + let use_auth = + self.api_token.is_some() && self.org_slug.is_some() && !self.use_public_proxy; + let base = match vendor_url { + Some(v) => v.trim_end_matches('/').to_string(), + None if use_auth || self.use_public_proxy => self.api_url.clone(), + None => proxy_url_from_env().trim_end_matches('/').to_string(), + }; + if use_auth { + let slug = self.org_slug.as_deref().unwrap(); + (format!("{base}/v0/orgs/{slug}/patches/package"), true) + } else { + (format!("{base}/patch/package"), false) + } + } + /// Step 1 of [`Self::fetch_vendor_package`]: POST the package-reference /// endpoint and return the single requested UUID's result. async fn request_vendor_package( @@ -840,20 +792,10 @@ impl ApiClient { // the authenticated endpoint defaults to false. free_only: free_only.then_some(true), }; - // Authenticated when a token + org slug are configured and we're not - // pinned to the public proxy — mirrors `binary_url`'s decision so a - // bearer is never sent to the proxy. - let use_auth = - self.api_token.is_some() && self.org_slug.is_some() && !self.use_public_proxy; - let base = vendor_url - .unwrap_or(&self.api_url) - .trim_end_matches('/') - .to_string(); + let (url, use_auth) = self.vendor_package_url(vendor_url); + debug_log(&format!("POST {url}")); let resp = if use_auth { - let slug = self.org_slug.as_deref().unwrap(); - let url = format!("{base}/v0/orgs/{slug}/patches/package"); - debug_log(&format!("POST {url}")); self.client .post(&url) .header(header::CONTENT_TYPE, "application/json") @@ -861,8 +803,6 @@ impl ApiClient { .send() .await } else { - let url = format!("{base}/patch/package"); - debug_log(&format!("POST {url}")); // Plain (no-auth) client: never leak the bearer to the proxy. plain_client() .post(&url) @@ -947,7 +887,7 @@ impl ApiClient { /// tarball download; the caller verifies the bytes against the artifact's /// integrity. A 404/410/408 surfaces as an error (a secondary the /// reference promised should be present). - pub async fn download_artifact(&self, url: &str) -> Result, ApiError> { + pub(crate) async fn download_artifact(&self, url: &str) -> Result, ApiError> { match self.download_vendor_archive(url).await { ServeDownload::Ok(bytes) => Ok(bytes), ServeDownload::NotFound => Err(ApiError::Other(format!("artifact not found: {url}"))), @@ -970,16 +910,12 @@ const MAX_VENDOR_PACKAGE_BYTES: u64 = 256 * 1024 * 1024; /// here — callers must verify against `integrity_sri` (and, for golang, the /// `h1:` dirhash) before writing/extracting. #[derive(Debug, Clone)] -pub struct FetchedVendorPackage { +pub(crate) struct FetchedVendorPackage { pub tarball: Vec, /// Normalized Subresource-Integrity string, always `sha512-`. pub integrity_sri: String, - /// Hex sha1 of the archive, when the service reported one. - pub sha1_hex: Option, /// golang module-zip dirhash (`h1:`), when present. pub dirhash_h1: Option, - pub size_bytes: Option, - pub content_type: Option, /// The (possibly host-rewritten) URL the bytes were fetched from. pub source_url: String, /// The OTHER served artifacts (e.g. the gem path-source stub gemspec), @@ -991,7 +927,7 @@ pub struct FetchedVendorPackage { /// A non-tarball served artifact reference (e.g. `gem-stub-gemspec`): its kind, /// final download URL, and sha512 SRI. Bytes are fetched + verified on demand. #[derive(Debug, Clone)] -pub struct SecondaryArtifact { +pub(crate) struct SecondaryArtifact { pub kind: String, pub url: String, /// Normalized `sha512-` of the artifact bytes. @@ -1004,7 +940,7 @@ pub struct SecondaryArtifact { /// `Ready` → use the service archive; `Pending`/`Unavailable`/`Failed` → fall /// back to a local build under `auto`, or hard-fail under `service`. #[derive(Debug)] -pub enum VendorServiceOutcome { +pub(crate) enum VendorServiceOutcome { /// Archive downloaded; integrity carried for the caller to verify. Ready(FetchedVendorPackage), /// The archive is still building (`pending_build` status or serve 408) — @@ -1154,10 +1090,7 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> .filter(|s| !s.is_empty()); if api_token.is_none() { - let proxy_url = overrides.proxy_url.unwrap_or_else(|| { - read_env_with_legacy("SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL") - .unwrap_or_else(|| DEFAULT_PATCH_API_PROXY_URL.to_string()) - }); + let proxy_url = overrides.proxy_url.unwrap_or_else(proxy_url_from_env); eprintln!("No SOCKET_API_TOKEN set. Using public patch API proxy (free patches only)."); let client = ApiClient::new(ApiClientOptions { api_url: proxy_url, @@ -1184,6 +1117,16 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> // Auto-resolve org slug if not provided let final_org_slug = if resolved_org_slug.is_some() { resolved_org_slug + } else if matches!( + std::env::var("SOCKET_OFFLINE").unwrap_or_default().as_str(), + "1" | "true" + ) { + // Strict airgap: `--offline` (mirrored into `SOCKET_OFFLINE` by the + // CLI before any client is built — same vocabulary the telemetry + // kill-switch matches) means zero network contact, so the org-slug + // auto-resolution round-trip must not fire. The slug only labels + // org-scoped fetches and telemetry, both already gated off offline. + None } else { let temp_client = ApiClient::new(ApiClientOptions { api_url: api_url.clone(), @@ -1229,10 +1172,10 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> /// shouldn't block access to free patches. The auth header is /// deliberately dropped (`api_token: None`). pub fn build_proxy_fallback_client(overrides: &ApiClientEnvOverrides) -> ApiClient { - let proxy_url = overrides.proxy_url.clone().unwrap_or_else(|| { - read_env_with_legacy("SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL") - .unwrap_or_else(|| DEFAULT_PATCH_API_PROXY_URL.to_string()) - }); + let proxy_url = overrides + .proxy_url + .clone() + .unwrap_or_else(proxy_url_from_env); ApiClient::new(ApiClientOptions { api_url: proxy_url, api_token: None, @@ -1247,7 +1190,7 @@ pub fn build_proxy_fallback_client(overrides: &ApiClientEnvOverrides) -> ApiClie /// gets configured with the storage representation by mistake (users /// copy what they see in the dashboard). Surfacing this as a hint /// short-circuits a confusing 401 round-trip. -pub fn looks_like_token_hash(token: &str) -> bool { +fn looks_like_token_hash(token: &str) -> bool { matches!( token.split_once('-'), Some(("sha256" | "sha384" | "sha512", _)) @@ -1269,7 +1212,7 @@ pub fn looks_like_token_hash(token: &str) -> bool { /// The returned message redacts the middle of the token (first 8 + /// last 4 chars) so a real token doesn't leak into stderr if a user /// pastes one with a wrong suffix. -pub fn validate_token_shape(token: &str) -> Option { +fn validate_token_shape(token: &str) -> Option { let has_prefix = token.starts_with("sktsec_"); let has_suffix = token.ends_with("_api") || token.ends_with("_agent"); // Measure in characters, not bytes: the preview/length reporting below @@ -1378,7 +1321,7 @@ fn is_batch_unsupported(status: StatusCode, body: &str) -> bool { /// Returns an error when the list is empty, the sole slug when there is /// exactly one, and the first slug (with a warning) when there are several. /// -/// `fetch_organizations` collects from a `HashMap`, so the upstream order is +/// `resolve_org_slug` collects from a `HashMap`, so the upstream order is /// not stable across runs. We sort by slug first so the chosen org *and* the /// warning text are deterministic — otherwise a token with multiple orgs /// could silently operate against a different org on each invocation. @@ -2301,6 +2244,89 @@ mod tests { ); } + // ── vendor_package_url: package-reference POST target ─────────────── + // + // Regression: an authenticated client *without* an org slug (auto- + // resolution failed, or `SOCKET_OFFLINE` skipped it) built the proxy + // route on its own `api_url` — `https://api.socket.dev/patch/package` — + // a path the auth host does not serve, so every vendor-service fetch + // failed with a 404-shaped `Other` error. `binary_url` re-derives the + // proxy base from the environment for exactly this client state; the + // vendor POST must do the same. + + #[test] + fn vendor_package_url_auth_without_org_slug_targets_proxy_host() { + let client = ApiClient::new(ApiClientOptions { + api_url: "https://api.socket.dev".into(), + api_token: Some("sktsec_x_api".into()), + use_public_proxy: false, + org_slug: None, + }); + let (url, use_auth) = client.vendor_package_url(None); + assert!(!use_auth, "no org slug → unauthenticated proxy request"); + assert!( + !url.starts_with("https://api.socket.dev"), + "must not target the auth host (it has no /patch/* routes); got: {url}" + ); + assert_eq!( + url, + format!( + "{}/patch/package", + proxy_url_from_env().trim_end_matches('/') + ), + "base must be the env-derived proxy host, like binary_url" + ); + } + + #[test] + fn vendor_package_url_proxy_client_uses_configured_api_url() { + // A public-proxy client's api_url IS the proxy — an explicit + // `--proxy-url` override must be honored, not re-read from env. + let client = proxy_client("https://custom.proxy.example"); + let (url, use_auth) = client.vendor_package_url(None); + assert!(!use_auth); + assert_eq!(url, "https://custom.proxy.example/patch/package"); + } + + #[test] + fn vendor_package_url_authenticated_uses_org_path() { + let client = ApiClient::new(ApiClientOptions { + api_url: "https://api.socket.dev".into(), + api_token: Some("sktsec_x_api".into()), + use_public_proxy: false, + org_slug: Some("my-org".into()), + }); + let (url, use_auth) = client.vendor_package_url(None); + assert!(use_auth); + assert_eq!(url, "https://api.socket.dev/v0/orgs/my-org/patches/package"); + } + + #[test] + fn vendor_package_url_vendor_url_overrides_base() { + // The staging override wins for every client state, including the + // no-org-slug fallback (it must not be clobbered by the env proxy). + let auth = ApiClient::new(ApiClientOptions { + api_url: "https://api.socket.dev".into(), + api_token: Some("sktsec_x_api".into()), + use_public_proxy: false, + org_slug: Some("my-org".into()), + }); + assert_eq!( + auth.vendor_package_url(Some("http://localhost:9099/")).0, + "http://localhost:9099/v0/orgs/my-org/patches/package" + ); + let no_org = ApiClient::new(ApiClientOptions { + api_url: "https://api.socket.dev".into(), + api_token: Some("sktsec_x_api".into()), + use_public_proxy: false, + org_slug: None, + }); + assert_eq!( + no_org.vendor_package_url(Some("http://localhost:9099")).0, + "http://localhost:9099/patch/package" + ); + } + // ── select_org_slug: deterministic org selection ──────────────────── fn org(slug: &str) -> crate::api::types::OrganizationInfo { @@ -2567,7 +2593,6 @@ mod vendor_package_tests { VendorServiceOutcome::Ready(pkg) => { assert_eq!(pkg.tarball, TARBALL); assert_eq!(pkg.integrity_sri, "sha512-ABC123=="); - assert_eq!(pkg.sha1_hex.as_deref(), Some("deadbeef")); assert_eq!(pkg.source_url, serve_url); } other => panic!("expected Ready, got {other:?}"), diff --git a/crates/socket-patch-core/src/api/mod.rs b/crates/socket-patch-core/src/api/mod.rs index a0a9feb6..f81f6c4e 100644 --- a/crates/socket-patch-core/src/api/mod.rs +++ b/crates/socket-patch-core/src/api/mod.rs @@ -1,6 +1,3 @@ pub mod blob_fetcher; pub mod client; pub mod types; - -pub use client::ApiClient; -pub use types::*; diff --git a/crates/socket-patch-core/src/api/types.rs b/crates/socket-patch-core/src/api/types.rs index 2ecf2e23..38fc0aa0 100644 --- a/crates/socket-patch-core/src/api/types.rs +++ b/crates/socket-patch-core/src/api/types.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use crate::patch::redirect::{Integrity, RegistryOverride}; + /// Organization info returned by the `/v0/organizations` endpoint. #[derive(Debug, Clone, Deserialize)] pub struct OrganizationInfo { @@ -101,7 +103,7 @@ pub struct BatchSearchResponse { /// download URLs + integrity. The public proxy forces `free_only`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct PackageVendorRequest { +pub(crate) struct PackageVendorRequest { pub uuids: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub free_only: Option, @@ -110,12 +112,13 @@ pub struct PackageVendorRequest { /// Response from the package-vendor endpoint: one result per requested UUID, /// keyed by the UUID string. #[derive(Debug, Clone, Deserialize)] -pub struct PackageVendorResponse { +pub(crate) struct PackageVendorResponse { pub results: HashMap, } /// One package-vendor result. `status` is the discriminator; `url` / `purl` / -/// `artifacts` are populated only for `granted` / `reused`. +/// `artifacts` / `registry_override` are populated only for `granted` / +/// `reused`. /// /// `status` values: `granted` | `reused` | `pending_build` | `build_failed` /// | `withdrawn` | `forbidden` | `not_found`. @@ -129,6 +132,10 @@ pub struct PackageVendorResult { pub purl: Option, #[serde(default)] pub artifacts: Option>, + /// Per-ecosystem registry override that `scan --redirect` turns into a + /// `DepOverride`. + #[serde(default)] + pub registry_override: Option, } /// One served artifact: the native tarball (`kind: "tarball"`), or a @@ -141,31 +148,12 @@ pub struct PackageVendorArtifact { pub kind: String, #[serde(default)] pub url: Option, + /// Every ecosystem's tarball populates `sha512` (npm SRI form + /// `sha512-`) + `sha1` + `md5`; golang additionally `dirhash_h1` + /// (`h1:`); the npm yarn-berry zip carries only `yarn_berry10c0` + /// (`10c0/`). No ecosystem exposes a plain sha256. #[serde(default)] - pub content_type: Option, - #[serde(default)] - pub size_bytes: Option, - #[serde(default)] - pub integrity: PackageVendorIntegrity, -} - -/// Per-artifact integrity hashes. Every ecosystem's tarball populates `sha512` -/// (npm SRI form `sha512-`) + `sha1` + `md5`; golang additionally -/// `dirhash_h1` (`h1:`); the npm yarn-berry zip carries only -/// `yarn_berry10c0` (`10c0/`). No ecosystem exposes a plain sha256. -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PackageVendorIntegrity { - #[serde(default)] - pub sha512: Option, - #[serde(default)] - pub sha1: Option, - #[serde(default)] - pub md5: Option, - #[serde(default)] - pub dirhash_h1: Option, - #[serde(default)] - pub yarn_berry10c0: Option, + pub integrity: Integrity, } #[cfg(test)] diff --git a/crates/socket-patch-core/src/composer_setup/mod.rs b/crates/socket-patch-core/src/composer_setup/mod.rs index 9445ffa8..739913c4 100644 --- a/crates/socket-patch-core/src/composer_setup/mod.rs +++ b/crates/socket-patch-core/src/composer_setup/mod.rs @@ -25,7 +25,7 @@ use tokio::fs; /// CLI is invoked from `PATH` (composer has no `npx`-style fetch), offline (the /// patches are committed under `.socket/`) and silent (so it doesn't clutter /// composer's own output). -pub const APPLY_COMMAND: &str = "socket-patch apply --offline --silent --ecosystems composer"; +const APPLY_COMMAND: &str = "socket-patch apply --offline --silent --ecosystems composer"; /// Composer script events `setup` wires: post-install-cmd fires after /// `composer install`, post-update-cmd after `composer update`. Covering both @@ -44,7 +44,7 @@ pub enum ComposerSetupStatus { Error, } -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct ComposerEditResult { /// Envelope `files[].kind` — always `composer`. pub kind: &'static str, @@ -53,50 +53,15 @@ pub struct ComposerEditResult { pub error: Option, } -impl ComposerEditResult { - fn from_result(path: String, result: Result) -> Self { - match result { - Ok(true) => Self { - kind: "composer", - path, - status: ComposerSetupStatus::Updated, - error: None, - }, - Ok(false) => Self { - kind: "composer", - path, - status: ComposerSetupStatus::AlreadyConfigured, - error: None, - }, - Err(e) => Self { - kind: "composer", - path, - status: ComposerSetupStatus::Error, - error: Some(e), - }, - } - } -} - -/// A discovered composer project (the dir holding `composer.json`). -#[derive(Debug, Clone)] -pub struct ComposerProject { - pub root: PathBuf, - pub composer_json: PathBuf, -} - -/// Find the composer project rooted at `cwd` (a `composer.json` in `cwd`). -/// cwd-only, matching the other single-project backends (gem/pypi/go). -pub async fn discover_composer_project(cwd: &Path) -> Option { +/// Find the composer project rooted at `cwd`: the path to a `composer.json` +/// directly in `cwd`. cwd-only, matching the other single-project backends +/// (gem/pypi/go). +pub async fn discover_composer_project(cwd: &Path) -> Option { let composer_json = cwd.join("composer.json"); - if fs::metadata(&composer_json).await.is_ok() { - Some(ComposerProject { - root: cwd.to_path_buf(), - composer_json, - }) - } else { - None - } + fs::metadata(&composer_json) + .await + .is_ok() + .then_some(composer_json) } /// Static check: does this `composer.json` already wire our re-apply hook into @@ -130,27 +95,39 @@ fn event_contains_marker(value: Option<&Value>) -> bool { // ── pure transforms ────────────────────────────────────────────────────────── -/// Append [`APPLY_COMMAND`] to both hook events, normalising each to an array. -/// `None` if already present in every event (idempotent no-op). -fn composer_add(content: &str) -> Result, String> { - let mut doc: Value = +/// Parse `composer.json` for editing, rejecting malformed input: the root must +/// be a JSON object, and a present `scripts` must be an object (or `null`) — +/// add refuses to clobber it, remove refuses to silently swallow it as a +/// "nothing to remove" no-op. +fn parse_checked(content: &str) -> Result { + let doc: Value = serde_json::from_str(content).map_err(|e| format!("Invalid composer.json: {e}"))?; if !doc.is_object() { return Err("Invalid composer.json: root is not a JSON object".to_string()); } - // Refuse to clobber a present-but-non-object `scripts`. if let Some(scripts) = doc.get("scripts") { if !scripts.is_null() && !scripts.is_object() { return Err("Invalid composer.json: \"scripts\" is not a JSON object".to_string()); } } + Ok(doc) +} +/// Append [`APPLY_COMMAND`] to both hook events, normalising each to an array. +/// `None` if already present in every event (idempotent no-op). +fn composer_add(content: &str) -> Result, String> { + let mut doc = parse_checked(content)?; let root = doc.as_object_mut().unwrap(); - let scripts = ensure_scripts_object(root); + + // Get-or-create the `scripts` object (replacing a `null`). + if !root.get("scripts").map(Value::is_object).unwrap_or(false) { + root.insert("scripts".to_string(), Value::Object(Map::new())); + } + let scripts = root.get_mut("scripts").unwrap().as_object_mut().unwrap(); let mut changed = false; for event in HOOK_EVENTS { - changed |= add_command_to_event(scripts, event); + changed |= add_command_to_event(scripts, event)?; } if !changed { // We created an empty `scripts` object above only if it was absent; @@ -170,18 +147,7 @@ fn composer_add(content: &str) -> Result, String> { /// Strip [`APPLY_COMMAND`] from both hook events, pruning emptied events and an /// emptied `scripts` object. `None` if our command is absent everywhere. fn composer_remove(content: &str) -> Result, String> { - let mut doc: Value = - serde_json::from_str(content).map_err(|e| format!("Invalid composer.json: {e}"))?; - if !doc.is_object() { - return Err("Invalid composer.json: root is not a JSON object".to_string()); - } - // Mirror `composer_add`: a present-but-non-object `scripts` is malformed and - // must error, not be silently swallowed as a "nothing to remove" no-op. - if let Some(scripts) = doc.get("scripts") { - if !scripts.is_null() && !scripts.is_object() { - return Err("Invalid composer.json: \"scripts\" is not a JSON object".to_string()); - } - } + let mut doc = parse_checked(content)?; let root = doc.as_object_mut().unwrap(); // An absent (or `null`) `scripts` is a legitimate no-op: nothing of ours. let scripts = match root.get_mut("scripts").and_then(Value::as_object_mut) { @@ -197,54 +163,55 @@ fn composer_remove(content: &str) -> Result, String> { return Ok(None); } if scripts.is_empty() { - root.remove("scripts"); + // shift_remove: with preserve_order, plain `remove` is swap_remove and + // would teleport the last root key into this slot. + root.shift_remove("scripts"); } Ok(Some(serde_json::to_string_pretty(&doc).unwrap() + "\n")) } -/// Get-or-create the `scripts` object (replacing a `null`). -fn ensure_scripts_object(root: &mut Map) -> &mut Map { - let needs_init = !root.get("scripts").map(Value::is_object).unwrap_or(false); - if needs_init { - root.insert("scripts".to_string(), Value::Object(Map::new())); - } - root.get_mut("scripts").unwrap().as_object_mut().unwrap() -} - /// Add [`APPLY_COMMAND`] to one event, normalising string → array. Returns -/// whether the event changed. Any command already carrying [`HOOK_MARKER`] +/// whether the event changed; errors on a non-string/array event value it +/// refuses to clobber. Any command already carrying [`HOOK_MARKER`] /// counts as present — the same predicate as [`is_hook_present`] / `--check`, /// so a user-customized flag set is left alone rather than duplicated. -fn add_command_to_event(scripts: &mut Map, event: &str) -> bool { +fn add_command_to_event(scripts: &mut Map, event: &str) -> Result { if event_contains_marker(scripts.get(event)) { - return false; + return Ok(false); } let cmd = Value::String(APPLY_COMMAND.to_string()); match scripts.get_mut(event) { None => { scripts.insert(event.to_string(), Value::Array(vec![cmd])); - true + Ok(true) } Some(Value::String(s)) => { let existing = Value::String(s.clone()); scripts.insert(event.to_string(), Value::Array(vec![existing, cmd])); - true + Ok(true) } Some(Value::Array(arr)) => { arr.push(cmd); - true + Ok(true) } - // A non-string/array script value is user data we won't clobber. - Some(_) => false, + // A non-string/array script value is user data we won't clobber — and + // can't wire into, so treating it as "no change" would surface as + // AlreadyConfigured while `--check` says not configured. Refuse loudly, + // like the non-object-`scripts` guard. + Some(_) => Err(format!( + "Invalid composer.json: \"{event}\" script is not a string or array" + )), } } /// Remove [`APPLY_COMMAND`] from one event, pruning an emptied event key. /// Returns whether the event changed. fn remove_command_from_event(scripts: &mut Map, event: &str) -> bool { + // shift_remove throughout: with preserve_order, plain `remove` is + // swap_remove and would shuffle the user's other scripts. match scripts.get_mut(event) { Some(Value::String(s)) if s == APPLY_COMMAND => { - scripts.remove(event); + scripts.shift_remove(event); true } Some(Value::Array(arr)) => { @@ -254,7 +221,7 @@ fn remove_command_from_event(scripts: &mut Map, event: &str) -> b return false; } if arr.is_empty() { - scripts.remove(event); + scripts.shift_remove(event); } true } @@ -264,65 +231,14 @@ fn remove_command_from_event(scripts: &mut Map, event: &str) -> b // ── async wrappers ─────────────────────────────────────────────────────────── -/// Atomically write `content` to `path`. -/// -/// A bare `fs::write` truncates the target before writing, so a crash, power -/// loss, or interrupted process mid-write would leave the user's committed -/// `composer.json` truncated or empty — destroying the file we only meant to -/// append two script events to. Instead we write to a sibling stage file, -/// fsync it, then rename over the target (rename is atomic on the same -/// filesystem) so a reader ever sees either the old bytes or the complete new -/// bytes. Mirrors the hardened writer in `package_json/update.rs`. -async fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let stem = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "composer.json".to_string()); - let stage = parent.join(format!(".socket-stage-{}-{}", stem, uuid::Uuid::new_v4())); - - let mut file = tokio::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&stage) - .await?; - - use tokio::io::AsyncWriteExt; - if let Err(e) = file.write_all(content.as_bytes()).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - if let Err(e) = file.sync_all().await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - drop(file); - - if let Err(e) = tokio::fs::rename(&stage, path).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - - // The rename only updated the parent directory entry; fsync the directory - // so the rename itself survives a crash. Best-effort, Unix only. - #[cfg(unix)] - { - if let Ok(dir) = tokio::fs::File::open(parent).await { - let _ = dir.sync_all().await; - } - } - - Ok(()) -} - /// Wire the project: append our command to the composer script events. -pub async fn add_hook(project: &ComposerProject, dry_run: bool) -> ComposerEditResult { - edit(&project.composer_json, dry_run, composer_add).await +pub async fn add_hook(composer_json: &Path, dry_run: bool) -> ComposerEditResult { + edit(composer_json, dry_run, composer_add).await } /// Unwire the project: strip our command, pruning emptied keys. -pub async fn remove_hook(project: &ComposerProject, dry_run: bool) -> ComposerEditResult { - edit(&project.composer_json, dry_run, composer_remove).await +pub async fn remove_hook(composer_json: &Path, dry_run: bool) -> ComposerEditResult { + edit(composer_json, dry_run, composer_remove).await } async fn edit( @@ -341,7 +257,10 @@ async fn edit( None => Ok(false), Some(new) => { if !dry_run { - atomic_write(composer_json, &new) + // The crate-wide atomic writer (stage+fsync+rename): the + // user's committed composer.json must never be left torn + // by a crash mid-write. + crate::utils::fs::atomic_write_bytes(composer_json, new.as_bytes()) .await .map_err(|e| e.to_string())?; } @@ -350,7 +269,17 @@ async fn edit( } } .await; - ComposerEditResult::from_result(composer_json.display().to_string(), result) + let (status, error) = match result { + Ok(true) => (ComposerSetupStatus::Updated, None), + Ok(false) => (ComposerSetupStatus::AlreadyConfigured, None), + Err(e) => (ComposerSetupStatus::Error, Some(e)), + }; + ComposerEditResult { + kind: "composer", + path: composer_json.display().to_string(), + status, + error, + } } #[cfg(test)] @@ -487,19 +416,19 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let cj = dir.path().join("composer.json"); fs::write(&cj, BASIC).await.unwrap(); - let project = discover_composer_project(dir.path()).await.unwrap(); + let found = discover_composer_project(dir.path()).await.unwrap(); - let added = add_hook(&project, false).await; + let added = add_hook(&found, false).await; assert_eq!(added.status, ComposerSetupStatus::Updated); assert!(is_hook_present(&fs::read_to_string(&cj).await.unwrap())); // Idempotent. assert_eq!( - add_hook(&project, false).await.status, + add_hook(&found, false).await.status, ComposerSetupStatus::AlreadyConfigured ); - let removed = remove_hook(&project, false).await; + let removed = remove_hook(&found, false).await; assert_eq!(removed.status, ComposerSetupStatus::Updated); assert_eq!( fs::read_to_string(&cj).await.unwrap(), @@ -513,8 +442,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let cj = dir.path().join("composer.json"); fs::write(&cj, BASIC).await.unwrap(); - let project = discover_composer_project(dir.path()).await.unwrap(); - let res = add_hook(&project, true).await; + let found = discover_composer_project(dir.path()).await.unwrap(); + let res = add_hook(&found, true).await; assert_eq!(res.status, ComposerSetupStatus::Updated); assert_eq!( fs::read_to_string(&cj).await.unwrap(), @@ -664,8 +593,8 @@ mod tests { fs::write(&cj, BASIC).await.unwrap(); std::fs::set_permissions(&cj, std::fs::Permissions::from_mode(0o444)).unwrap(); - let project = discover_composer_project(dir.path()).await.unwrap(); - let res = add_hook(&project, false).await; + let found = discover_composer_project(dir.path()).await.unwrap(); + let res = add_hook(&found, false).await; assert_eq!( res.status, ComposerSetupStatus::Updated, @@ -680,14 +609,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let cj = dir.path().join("composer.json"); fs::write(&cj, BASIC).await.unwrap(); - let project = discover_composer_project(dir.path()).await.unwrap(); + let found = discover_composer_project(dir.path()).await.unwrap(); assert_eq!( - add_hook(&project, false).await.status, + add_hook(&found, false).await.status, ComposerSetupStatus::Updated ); assert_eq!( - remove_hook(&project, false).await.status, + remove_hook(&found, false).await.status, ComposerSetupStatus::Updated ); assert_eq!(fs::read_to_string(&cj).await.unwrap(), BASIC); @@ -700,6 +629,62 @@ mod tests { } } + #[test] + fn test_remove_event_prune_preserves_sibling_script_order() { + // Regression: with preserve_order, serde_json's `Map::remove` is + // swap_remove — pruning an emptied event key teleported the *last* + // script into its slot, shuffling the user's own scripts. Scenario: + // setup wired the events first, the user appended scripts later. + let inp = format!( + "{{\"scripts\":{{\"post-install-cmd\":[\"{APPLY_COMMAND}\"],\"post-update-cmd\":[\"{APPLY_COMMAND}\"],\"test\":\"phpunit\",\"lint\":\"phpcs\"}}}}" + ); + let removed = composer_remove(&inp).unwrap().unwrap(); + assert!(!is_hook_present(&removed)); + let pos_test = removed.find("\"test\"").unwrap(); + let pos_lint = removed.find("\"lint\"").unwrap(); + assert!( + pos_test < pos_lint, + "sibling script order must survive event pruning:\n{removed}" + ); + } + + #[test] + fn test_remove_scripts_prune_preserves_root_key_order() { + // Regression: same swap_remove hazard at the root — pruning an emptied + // `scripts` object teleported the last root key into its slot. + let inp = format!( + "{{\"name\":\"acme/app\",\"scripts\":{{\"post-install-cmd\":[\"{APPLY_COMMAND}\"]}},\"require\":{{\"php\":\">=8.1\"}},\"autoload\":{{}}}}" + ); + let removed = composer_remove(&inp).unwrap().unwrap(); + assert!(parse(&removed).get("scripts").is_none(), "scripts pruned"); + let pos_name = removed.find("\"name\"").unwrap(); + let pos_require = removed.find("\"require\"").unwrap(); + let pos_autoload = removed.find("\"autoload\"").unwrap(); + assert!( + pos_name < pos_require && pos_require < pos_autoload, + "root key order must survive scripts pruning:\n{removed}" + ); + } + + #[test] + fn test_add_malformed_event_value_is_error_not_silent_success() { + // Regression: an event value that is neither string nor array can't be + // wired (we won't clobber it), but reporting "no change" surfaced as + // AlreadyConfigured — exit-0 success — while `--check` + // (is_hook_present) says not configured on the very same file. Refusal + // must be a loud error, matching the non-object-`scripts` guard. + let malformed = "{\"scripts\":{\"post-install-cmd\":42,\"post-update-cmd\":{\"a\":\"b\"}}}"; + assert!(!is_hook_present(malformed), "nothing configured here"); + let err = composer_add(malformed).unwrap_err(); + assert!( + err.contains("not a string or array"), + "refusal must be an error, got: {err}" + ); + // remove stays a no-op: a non-string/array value can't hold our + // command, so there is honestly nothing to strip. + assert!(composer_remove(malformed).unwrap().is_none()); + } + #[test] fn test_add_then_check_consistency() { // For every input where add reports a change, is_hook_present must be true. diff --git a/crates/socket-patch-core/src/constants.rs b/crates/socket-patch-core/src/constants.rs index d46ac77a..7809be2b 100644 --- a/crates/socket-patch-core/src/constants.rs +++ b/crates/socket-patch-core/src/constants.rs @@ -13,7 +13,7 @@ pub const DEFAULT_SOCKET_API_URL: &str = "https://api.socket.dev"; /// tracks the published release (currently `3.x`) instead of drifting from a /// hardcoded literal. Server-side analytics and any minimum-version gating rely /// on this reporting the real version. -pub const USER_AGENT: &str = concat!("SocketPatchCLI/", env!("CARGO_PKG_VERSION")); +pub(crate) const USER_AGENT: &str = concat!("SocketPatchCLI/", env!("CARGO_PKG_VERSION")); #[cfg(test)] mod tests { diff --git a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs index 8bd03059..13ef51ed 100644 --- a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs @@ -2,6 +2,8 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use super::types::{CrawledPackage, CrawlerOptions}; +use crate::patch::path_safety; +use crate::utils::fs::is_dir; // --------------------------------------------------------------------------- // Cargo.toml minimal parser @@ -209,38 +211,34 @@ impl CargoCrawler { for purl in purls { if let Some((name, version)) = crate::utils::purl::parse_cargo_purl(purl) { - // Try registry layout: -/ - let registry_dir = src_path.join(format!("{name}-{version}")); - if self - .verify_crate_at_path(®istry_dir, name, version) - .await - { - result.insert( - purl.clone(), - CrawledPackage { - name: name.to_string(), - version: version.to_string(), - namespace: None, - purl: purl.clone(), - path: registry_dir, - }, - ); + // Both coordinates are joined onto the scanned source root + // below and the resolved crate dir is patched IN PLACE, so a + // tampered PURL must not be able to traverse out of the + // root. Reject before touching the filesystem — + // `verify_crate_at_path` is no defense, since it compares + // against the escaped directory's own Cargo.toml. + if !is_safe_cargo_coordinate(name, version) { continue; } - // Try vendor layout: / - let vendor_dir = src_path.join(name); - if self.verify_crate_at_path(&vendor_dir, name, version).await { - result.insert( - purl.clone(), - CrawledPackage { - name: name.to_string(), - version: version.to_string(), - namespace: None, - purl: purl.clone(), - path: vendor_dir, - }, - ); + // Registry layout first (-/), then vendor (/). + for dir in [ + src_path.join(format!("{name}-{version}")), + src_path.join(name), + ] { + if self.verify_crate_at_path(&dir, name, version).await { + result.insert( + purl.clone(), + CrawledPackage { + name: name.to_string(), + version: version.to_string(), + namespace: None, + purl: purl.clone(), + path: dir, + }, + ); + break; + } } } } @@ -315,20 +313,14 @@ impl CargoCrawler { let cargo_toml_path = crate_path.join("Cargo.toml"); let content = tokio::fs::read_to_string(&cargo_toml_path).await.ok()?; - let (name, version) = match parse_cargo_toml_name_version(&content) { - Some(nv) => nv, - None => { - // Fallback: parse directory name as - - Self::parse_dir_name_version(dir_name)? - } - }; + // Fallback: parse directory name as - + let (name, version) = parse_cargo_toml_name_version(&content) + .or_else(|| Self::parse_dir_name_version(dir_name))?; let purl = crate::utils::purl::build_cargo_purl(&name, &version); - - if seen.contains(&purl) { + if !seen.insert(purl.clone()) { return None; } - seen.insert(purl.clone()); Some(CrawledPackage { name, @@ -350,19 +342,11 @@ impl CargoCrawler { match parse_cargo_toml_name_version(&content) { Some((n, v)) => n == name && v == version, - None => { - // Fallback: check directory name - let dir_name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - if let Some((parsed_name, parsed_version)) = Self::parse_dir_name_version(&dir_name) - { - parsed_name == name && parsed_version == version - } else { - false - } - } + // Fallback: check directory name + None => path + .file_name() + .and_then(|n| Self::parse_dir_name_version(&n.to_string_lossy())) + .is_some_and(|(n, v)| n == name && v == version), } } @@ -386,7 +370,7 @@ impl CargoCrawler { /// /// This is only a fallback for when `Cargo.toml` itself cannot be /// parsed; for registry crates the manifest is authoritative. - pub(crate) fn parse_dir_name_version(dir_name: &str) -> Option<(String, String)> { + fn parse_dir_name_version(dir_name: &str) -> Option<(String, String)> { let mut first_dotted: Option = None; let mut last_any: Option = None; for (i, _) in dir_name.match_indices('-') { @@ -416,27 +400,30 @@ impl CargoCrawler { if let Ok(cargo_home) = std::env::var("CARGO_HOME") { return PathBuf::from(cargo_home); } - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .unwrap_or_else(|_| "~".to_string()); - PathBuf::from(home).join(".cargo") + crate::utils::fs::home_dir().join(".cargo") } } +/// SECURITY: `find_by_purls` formats name/version into a `-` +/// registry dir (and the bare `` vendor dir) joined onto the scanned +/// source root, after which the resolved directory is patched in place — so +/// a tampered PURL must not be able to traverse out of the root. A real +/// crates.io name/version never contains a separator, a `.`/`..` segment, a +/// backslash, a colon, or a NUL. Delegates to +/// [`path_safety::is_safe_single_segment`], which also rejects `:` — a +/// Windows drive-relative coordinate (`C:evil`) joins as an absolute path. +/// Fails closed. Mirrors the nuget/maven/go/deno/npm/ruby crawler +/// coordinate guards. +fn is_safe_cargo_coordinate(name: &str, version: &str) -> bool { + path_safety::is_safe_single_segment(name) && path_safety::is_safe_single_segment(version) +} + impl Default for CargoCrawler { fn default() -> Self { Self::new() } } -/// Check whether a path is a directory. -async fn is_dir(path: &Path) -> bool { - tokio::fs::metadata(path) - .await - .map(|m| m.is_dir()) - .unwrap_or(false) -} - #[cfg(test)] mod tests { use super::*; @@ -598,7 +585,6 @@ version = "fake" cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -629,7 +615,6 @@ version = "fake" cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -656,7 +641,6 @@ version = "fake" cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -694,7 +678,6 @@ version = "fake" cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_crate_source_paths(&options).await.unwrap(); @@ -723,7 +706,6 @@ version = "fake" cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_crate_source_paths(&options).await.unwrap(); @@ -749,7 +731,6 @@ version = "fake" cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_crate_source_paths(&options).await.unwrap(); @@ -770,7 +751,6 @@ version = "fake" cwd: dir.path().to_path_buf(), // no Cargo.toml/Cargo.lock here global: false, global_prefix: Some(prefix.clone()), - batch_size: 100, }; let paths = crawler.get_crate_source_paths(&options).await.unwrap(); @@ -936,7 +916,6 @@ version = "fake" cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -1026,6 +1005,79 @@ version = "fake" assert!(result.contains_key("pkg:cargo/serde@1.0.200")); } + #[test] + fn test_is_safe_cargo_coordinate() { + // Real coordinates pass, including hyphen/underscore names, + // prerelease tags, and build metadata. + assert!(is_safe_cargo_coordinate("serde", "1.0.200")); + assert!(is_safe_cargo_coordinate("serde_json", "1.0.120")); + assert!(is_safe_cargo_coordinate("sha-1", "0.10.0")); + assert!(is_safe_cargo_coordinate("crate", "1.0.0-rc.1")); + assert!(is_safe_cargo_coordinate( + "wasi", + "0.11.0+wasi-snapshot-preview1" + )); + + // Traversal / separator smuggling fails closed. + assert!(!is_safe_cargo_coordinate("..", "1.0.0")); + assert!(!is_safe_cargo_coordinate("../escaped", "1.0.0")); + assert!(!is_safe_cargo_coordinate("a/b", "1.0.0")); + assert!(!is_safe_cargo_coordinate("a\\b", "1.0.0")); + assert!(!is_safe_cargo_coordinate("a\0b", "1.0.0")); + assert!(!is_safe_cargo_coordinate("a", "..")); + assert!(!is_safe_cargo_coordinate("a", "../../escaped")); + assert!(!is_safe_cargo_coordinate("a", "1/0")); + assert!(!is_safe_cargo_coordinate("a", ".")); + assert!(!is_safe_cargo_coordinate("", "1.0.0")); + assert!(!is_safe_cargo_coordinate("a", "")); + // Windows drive-relative escape: a `:` (e.g. `C:evil`) makes the + // joined path absolute under `Path::join`. + assert!(!is_safe_cargo_coordinate("C:evil", "1.0.0")); + assert!(!is_safe_cargo_coordinate("a", "C:1.0.0")); + } + + /// SECURITY regression: a tampered manifest PURL whose name or version + /// carries a `..`/separator must NOT resolve to a directory outside the + /// scanned crate source root. `find_by_purls` joins the PURL-derived + /// name/version onto `src_path` (`-` registry dirs, + /// bare `` vendor dirs) and the resolved directory is patched IN + /// PLACE — so an escape means an arbitrary out-of-tree write. + /// `verify_crate_at_path` is no defense: it compares against the + /// escaped directory's own `Cargo.toml`, which the attacker controls. + /// Twin of the nuget/maven/go/deno/npm/ruby crawler coordinate guards. + #[tokio::test] + async fn test_find_by_purls_rejects_traversal_coordinate() { + let root = tempfile::tempdir().unwrap(); + let src = root.path().join("registry"); + tokio::fs::create_dir_all(&src).await.unwrap(); + + // An out-of-tree crate dir whose Cargo.toml matches the traversal + // PURL's coordinates, so the only thing standing between the + // attacker and a match is the coordinate guard. + let escaped = root.path().join("evil"); + tokio::fs::create_dir_all(&escaped).await.unwrap(); + tokio::fs::write( + escaped.join("Cargo.toml"), + "[package]\nname = \"../evil\"\nversion = \"1.0.0\"\n", + ) + .await + .unwrap(); + + let purls = vec![ + // name traversal, vendor-layout probe: registry/../evil + "pkg:cargo/../evil@1.0.0".to_string(), + // version traversal (joined into the registry-layout dir name) + "pkg:cargo/pwn@../../evil".to_string(), + ]; + + let crawler = CargoCrawler::new(); + let result = crawler.find_by_purls(&src, &purls).await.unwrap(); + assert!( + result.is_empty(), + "traversal PURL must not resolve to an out-of-tree directory, got {result:?}" + ); + } + #[tokio::test] async fn test_crawl_all_registry_header_comment() { let dir = tempfile::tempdir().unwrap(); @@ -1043,7 +1095,6 @@ version = "fake" cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler.rs b/crates/socket-patch-core/src/crawlers/composer_crawler.rs index 3cb673a5..d6932ed5 100644 --- a/crates/socket-patch-core/src/crawlers/composer_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/composer_crawler.rs @@ -2,6 +2,9 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use super::types::{CrawledPackage, CrawlerOptions}; +use crate::patch::path_safety; +use crate::utils::fs::{is_dir, is_file}; +use crate::utils::process::{CommandRunner, SystemCommandRunner}; /// PHP/Composer ecosystem crawler for discovering packages in Composer /// vendor directories. @@ -21,10 +24,6 @@ impl ComposerCrawler { Self } - // ------------------------------------------------------------------ - // Public API - // ------------------------------------------------------------------ - /// Get vendor paths based on options. /// /// In global mode, checks `$COMPOSER_HOME/vendor/` (env var, command @@ -41,7 +40,14 @@ impl ComposerCrawler { if let Some(ref custom) = options.global_prefix { return Ok(vec![custom.clone()]); } - return Ok(Self::get_global_vendor_paths().await); + let mut paths = Vec::new(); + if let Some(composer_home) = get_composer_home().await { + let vendor_dir = composer_home.join("vendor"); + if is_dir(&vendor_dir).await { + paths.push(vendor_dir); + } + } + return Ok(paths); } // Local mode @@ -187,24 +193,6 @@ impl ComposerCrawler { Ok(result) } - - // ------------------------------------------------------------------ - // Private helpers - // ------------------------------------------------------------------ - - /// Get global Composer vendor paths. - async fn get_global_vendor_paths() -> Vec { - let mut paths = Vec::new(); - - if let Some(composer_home) = get_composer_home().await { - let vendor_dir = composer_home.join("vendor"); - if is_dir(&vendor_dir).await { - paths.push(vendor_dir); - } - } - - paths - } } impl Default for ComposerCrawler { @@ -240,24 +228,22 @@ async fn get_composer_home() -> Option { } // Try `composer global config home` - if let Ok(output) = std::process::Command::new("composer") - .args(["global", "config", "home"]) - .output() - { - if output.status.success() { - if let Some(path) = parse_composer_home_output(&String::from_utf8_lossy(&output.stdout)) - { - if is_dir(&path).await { - return Some(path); - } + if let Some(stdout) = SystemCommandRunner.run("composer", &["global", "config", "home"]) { + if let Some(path) = parse_composer_home_output(&stdout) { + if is_dir(&path).await { + return Some(path); } } } - // Platform defaults + // Platform defaults. A set-but-empty HOME counts as unset: honoring + // `""` would turn the `.composer`/`.config/composer` probes below into + // CWD-relative paths inside the user's project (same rule as + // `utils::fs::home_dir`). let home_dir = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .ok()?; + .ok() + .filter(|h| !h.is_empty()) + .or_else(|| std::env::var("USERPROFILE").ok().filter(|h| !h.is_empty()))?; let home = PathBuf::from(home_dir); let candidates = [ @@ -282,7 +268,11 @@ async fn get_composer_home() -> Option { /// version (`6.4.1`), so strip a single leading `v`/`V` when it /// directly precedes a digit. Versions that don't fit that shape (e.g. /// `dev-main`, `1.0.x-dev`) are returned untouched. -fn normalize_version(version: &str) -> &str { +/// +/// Also used by the composer vendor backend +/// (`patch::vendor::composer_lock`) to match lock versions against PURL +/// versions through the same normalization. +pub(crate) fn normalize_version(version: &str) -> &str { let mut chars = version.chars(); if matches!(chars.next(), Some('v') | Some('V')) && chars.next().map(|c| c.is_ascii_digit()).unwrap_or(false) @@ -292,6 +282,20 @@ fn normalize_version(version: &str) -> &str { version } +/// Whether an installed.json package name is safe to join onto the +/// vendor root. Both `crawl_all` and `find_by_purls` split the recorded +/// name at `/` and join the pieces onto the vendor directory, and the +/// resolved directory is later patched in place — so a tampered +/// installed.json name like `../evil` would otherwise read (and later +/// write) out of tree. Every `/`-separated segment must be a safe single +/// segment ([`path_safety::is_safe_multi_segment`]), which also rejects +/// `.`/`..`, backslashes, colons (a Windows drive-relative `C:evil` +/// joins as an absolute path), NULs, and empty segments. Fails closed. +/// Twin of the npm/deno/go/cargo/maven/nuget coordinate gates. +fn is_safe_composer_name(name: &str) -> bool { + path_safety::is_safe_multi_segment(name) +} + /// Read and parse `vendor/composer/installed.json`. /// /// Supports both Composer 1 (flat JSON array) and Composer 2 @@ -328,7 +332,7 @@ async fn read_installed_json(vendor_path: &Path) -> Vec { .filter_map(|entry| { let name = entry.get("name")?.as_str()?; let version = entry.get("version")?.as_str()?; - if name.is_empty() || version.is_empty() { + if name.is_empty() || version.is_empty() || !is_safe_composer_name(name) { return None; } Some(ComposerPackageEntry { @@ -339,22 +343,6 @@ async fn read_installed_json(vendor_path: &Path) -> Vec { .collect() } -/// Check whether a path is a directory. -async fn is_dir(path: &Path) -> bool { - tokio::fs::metadata(path) - .await - .map(|m| m.is_dir()) - .unwrap_or(false) -} - -/// Check whether a path is a file. -async fn is_file(path: &Path) -> bool { - tokio::fs::metadata(path) - .await - .map(|m| m.is_file()) - .unwrap_or(false) -} - #[cfg(test)] mod tests { use super::*; @@ -395,7 +383,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -520,7 +507,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -568,7 +554,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -667,7 +652,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -710,7 +694,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -820,7 +803,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -860,7 +842,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -907,6 +888,105 @@ mod tests { assert_eq!(pkg.name, "bar"); } + #[tokio::test] + async fn test_crawl_all_rejects_traversal_name_from_installed_json() { + // installed.json is part of the (untrusted) project being scanned. + // A tampered name like `../evil` joins onto the vendor root and + // resolves to a directory OUTSIDE it; apply would later write patch + // content there. The crawler must drop such entries — twin of the + // npm/cargo/maven/nuget/deno/go coordinate gates. + let dir = tempfile::tempdir().unwrap(); + let vendor_dir = dir.path().join("vendor"); + + let composer_dir = vendor_dir.join("composer"); + tokio::fs::create_dir_all(&composer_dir).await.unwrap(); + tokio::fs::write( + composer_dir.join("installed.json"), + r#"{"packages": [ + {"name": "monolog/monolog", "version": "3.5.0"}, + {"name": "../evil", "version": "1.0.0"} + ]}"#, + ) + .await + .unwrap(); + tokio::fs::create_dir_all(vendor_dir.join("monolog").join("monolog")) + .await + .unwrap(); + // The traversal target exists OUTSIDE the vendor root, so the + // on-disk `is_dir` corroboration alone does not stop it. + tokio::fs::create_dir_all(dir.path().join("evil")) + .await + .unwrap(); + tokio::fs::write(dir.path().join("composer.json"), "{}") + .await + .unwrap(); + + let crawler = ComposerCrawler::new(); + let options = CrawlerOptions { + cwd: dir.path().to_path_buf(), + global: false, + global_prefix: None, + }; + + let packages = crawler.crawl_all(&options).await; + assert_eq!( + packages.len(), + 1, + "traversal entry must be dropped, got: {:?}", + packages.iter().map(|p| &p.path).collect::>() + ); + assert_eq!(packages[0].name, "monolog"); + } + + #[tokio::test] + async fn test_find_by_purls_rejects_traversal_name_from_installed_json() { + // Same threat via the lookup path: a manifest purl whose + // namespace/name mirror a tampered installed.json entry would + // resolve a package directory outside the vendor root and hand it + // to apply as a patch target. + let dir = tempfile::tempdir().unwrap(); + let vendor_dir = dir.path().join("vendor"); + + let composer_dir = vendor_dir.join("composer"); + tokio::fs::create_dir_all(&composer_dir).await.unwrap(); + tokio::fs::write( + composer_dir.join("installed.json"), + r#"{"packages": [{"name": "../evil", "version": "1.0.0"}]}"#, + ) + .await + .unwrap(); + tokio::fs::create_dir_all(dir.path().join("evil")) + .await + .unwrap(); + + let crawler = ComposerCrawler::new(); + let purls = vec!["pkg:composer/../evil@1.0.0".to_string()]; + let result = crawler.find_by_purls(&vendor_dir, &purls).await.unwrap(); + assert!( + result.is_empty(), + "traversal name escaped the vendor root: {:?}", + result.values().map(|p| &p.path).collect::>() + ); + } + + #[test] + fn test_is_safe_composer_name() { + // Real composer names (vendor/name, case-preserved, dots/dashes). + assert!(is_safe_composer_name("monolog/monolog")); + assert!(is_safe_composer_name("Foo/Bar")); + assert!(is_safe_composer_name("symfony/polyfill-php80")); + assert!(is_safe_composer_name("phpunit/php-code-coverage")); + // Traversal, separators, absolute/drive forms, empties. + assert!(!is_safe_composer_name("../evil")); + assert!(!is_safe_composer_name("evil/..")); + assert!(!is_safe_composer_name("./evil")); + assert!(!is_safe_composer_name("/abs/path")); + assert!(!is_safe_composer_name("a//b")); + assert!(!is_safe_composer_name("a\\b/c")); + assert!(!is_safe_composer_name("C:evil/x")); + assert!(!is_safe_composer_name("")); + } + #[tokio::test] async fn test_find_by_purls_version_mismatch() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/deno_crawler.rs b/crates/socket-patch-core/src/crawlers/deno_crawler.rs index 9a7d7849..33d367e2 100644 --- a/crates/socket-patch-core/src/crawlers/deno_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/deno_crawler.rs @@ -42,6 +42,8 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use super::types::{CrawledPackage, CrawlerOptions}; +use crate::patch::path_safety; +use crate::utils::fs::{home_dir, is_dir}; /// Deno (JSR) ecosystem crawler. pub struct DenoCrawler; @@ -54,8 +56,8 @@ impl DenoCrawler { /// Get the JSR cache root paths to scan. /// - /// In global mode (or with `--global-prefix`), returns - /// `$DENO_DIR/npm/jsr.io/` directly. + /// With `--global-prefix`, returns the prefix directly; otherwise + /// resolves `$DENO_DIR/npm/jsr.io/`. /// /// In local mode, only returns paths when the cwd looks like a /// Deno project (`deno.json`, `deno.jsonc`, or `deno.lock` @@ -64,21 +66,12 @@ impl DenoCrawler { &self, options: &CrawlerOptions, ) -> Result, std::io::Error> { - if options.global || options.global_prefix.is_some() { - if let Some(ref custom) = options.global_prefix { - return Ok(vec![custom.clone()]); - } - let cache = deno_dir().join("npm").join("jsr.io"); - if is_dir(&cache).await { - return Ok(vec![cache]); - } - return Ok(Vec::new()); + if let Some(ref custom) = options.global_prefix { + return Ok(vec![custom.clone()]); } - - if !is_deno_project(&options.cwd).await { + if !options.global && !is_deno_project(&options.cwd).await { return Ok(Vec::new()); } - let cache = deno_dir().join("npm").join("jsr.io"); if is_dir(&cache).await { Ok(vec![cache]) @@ -205,16 +198,14 @@ async fn scan_jsr_cache(root: &Path, seen: &mut HashSet, out: &mut Vec bool { - !component.is_empty() - && component != "." - && component != ".." - && !component.contains('/') - && !component.contains('\\') - && !component.contains('\0') + path_safety::is_safe_single_segment(component) } /// Returns true if `cwd` looks like a Deno project. @@ -281,23 +272,6 @@ fn default_cache_root() -> PathBuf { home_dir().join(".cache") } -/// Resolve the user's home directory, mirroring the `HOME` -> -/// `USERPROFILE` -> `~` fallback chain used by the other crawlers. -fn home_dir() -> PathBuf { - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .unwrap_or_else(|_| "~".to_string()); - PathBuf::from(home) -} - -/// Check whether a path is a directory. -async fn is_dir(path: &Path) -> bool { - tokio::fs::metadata(path) - .await - .map(|m| m.is_dir()) - .unwrap_or(false) -} - #[cfg(test)] mod tests { use super::*; @@ -357,7 +331,6 @@ mod tests { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(cache), - batch_size: 100, }; assert!(crawler.crawl_all(&opts).await.is_empty()); } @@ -523,11 +496,22 @@ mod tests { cwd: project.path().to_path_buf(), // no deno.json/.jsonc/.lock global: false, global_prefix: None, - batch_size: 100, }; assert!(crawler.crawl_all(&opts).await.is_empty()); } + /// Unit contract for the coordinate gate: real scope/name/version + /// components pass; a `:` is rejected because a Windows drive-relative + /// component (`C:evil`) joins as an absolute path under `Path::join`. + #[test] + fn is_safe_jsr_component_rejects_colon() { + assert!(is_safe_jsr_component("@std")); + assert!(is_safe_jsr_component("path")); + assert!(is_safe_jsr_component("0.220.0")); + assert!(!is_safe_jsr_component("C:evil")); + assert!(!is_safe_jsr_component("c:")); + } + #[tokio::test] async fn find_by_purls_rejects_traversal_in_version() { // SECURITY: `find_by_purls` joins the PURL's scope/name/version diff --git a/crates/socket-patch-core/src/crawlers/go_crawler.rs b/crates/socket-patch-core/src/crawlers/go_crawler.rs index a8183742..9d30695b 100644 --- a/crates/socket-patch-core/src/crawlers/go_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/go_crawler.rs @@ -2,6 +2,8 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use super::types::{CrawledPackage, CrawlerOptions}; +use crate::patch::path_safety; +use crate::utils::fs::is_dir; // --------------------------------------------------------------------------- // Case-encoding helpers @@ -162,8 +164,8 @@ impl GoCrawler { .unwrap_or_default(); for cache_path in &cache_paths { - let found = self.scan_module_cache(cache_path, &mut seen).await; - packages.extend(found); + self.scan_dir_recursive(cache_path, cache_path, &mut seen, &mut packages) + .await; } packages @@ -201,6 +203,9 @@ impl GoCrawler { let module_dir = cache_path.join(format!("{encoded}@{encoded_version}")); if is_dir(&module_dir).await { + if is_partially_extracted(cache_path, &encoded, &encoded_version).await { + continue; + } // Split module_path into namespace and name let (namespace, name) = split_module_path(module_path); @@ -243,9 +248,14 @@ impl GoCrawler { return Some(first.join("pkg").join("mod")); } } + // A set-but-empty HOME/USERPROFILE counts as unset, matching the + // GOMODCACHE and GOPATH guards above: honoring `""` would yield the + // RELATIVE path `go/pkg/mod`, pointing the crawl at a directory + // inside the user's project instead of a real module cache. let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .ok()?; + .ok() + .filter(|h| !h.is_empty()) + .or_else(|| std::env::var("USERPROFILE").ok().filter(|h| !h.is_empty()))?; Some(PathBuf::from(home).join("go").join("pkg").join("mod")) } @@ -256,17 +266,6 @@ impl GoCrawler { /// /// We walk the tree looking for directories whose name contains `@` /// (the version separator), which marks a versioned module. - async fn scan_module_cache( - &self, - cache_path: &Path, - seen: &mut HashSet, - ) -> Vec { - let mut results = Vec::new(); - self.scan_dir_recursive(cache_path, cache_path, seen, &mut results) - .await; - results - } - fn scan_dir_recursive<'a>( &'a self, base_path: &'a Path, @@ -298,13 +297,11 @@ impl GoCrawler { // Build the child path from the raw `OsStr` rather than the // lossy UTF-8 rendering, so non-UTF-8 directory names still // resolve to the correct on-disk path. - let full_path = current_path.join(entry.file_name()); + let full_path = current_path.join(&dir_name); // Check if this directory has `@` in its name (versioned module) if dir_name_str.contains('@') { - if let Some(pkg) = - self.parse_versioned_dir(base_path, &full_path, &dir_name_str, seen) - { + if let Some(pkg) = self.parse_versioned_dir(base_path, &full_path, seen).await { results.push(pkg); } } else { @@ -317,11 +314,10 @@ impl GoCrawler { } /// Parse a versioned directory (containing `@`) into a `CrawledPackage`. - fn parse_versioned_dir( + async fn parse_versioned_dir( &self, base_path: &Path, dir_path: &Path, - _dir_name: &str, seen: &mut HashSet, ) -> Option { // Get the relative path from the cache root. @@ -338,6 +334,12 @@ impl GoCrawler { return None; } + // `version` is still the ENCODED on-disk form here, which is what + // the marker path is keyed by. + if is_partially_extracted(base_path, encoded_module_path, version).await { + return None; + } + // Decode case-encoding. Go escapes uppercase letters in BOTH the // module path and the version, so a pre-release tag such as // `v1.0.0-RC1` lands on disk as `v1.0.0-!r!c1`. Decoding only the @@ -385,35 +387,44 @@ fn split_module_path(module_path: &str) -> (&str, &str) { /// safe to join onto the module-cache root in [`GoCrawler::find_by_purls`]. /// /// A Go module path legitimately contains `/` separators -/// (`github.com/foo/bar`), so the path is validated **per segment** rather -/// than rejecting all separators — but a real path never has an empty, `.`, -/// or `..` segment (a leading `/` yields an empty first segment, so absolute -/// paths are rejected here too). A version is a single segment with no -/// separator. Backslashes and NULs are rejected outright. This mirrors the +/// (`github.com/foo/bar`), so it is validated per segment via +/// [`path_safety::is_safe_multi_segment`] — a real path never has an empty, +/// `.`, or `..` segment, and absolute paths are rejected too. A version is a +/// single segment ([`path_safety::is_safe_single_segment`]). Both helpers +/// reject backslashes, NULs, and `:` — a Windows drive-relative coordinate +/// (`C:evil`, `C:/evil`) joins as an absolute path. This mirrors the /// `go_redirect` coordinate guard and fails closed so a tampered manifest PURL /// cannot traverse out of the cache. fn is_safe_module_coordinate(module_path: &str, version: &str) -> bool { - let module_ok = !module_path.is_empty() - && !module_path.contains('\\') - && !module_path.contains('\0') - && module_path - .split('/') - .all(|seg| !seg.is_empty() && seg != "." && seg != ".."); - let version_ok = !version.is_empty() - && version != "." - && version != ".." - && !version.contains('/') - && !version.contains('\\') - && !version.contains('\0'); - module_ok && version_ok + path_safety::is_safe_multi_segment(module_path) && path_safety::is_safe_single_segment(version) } -/// Check whether a path is a directory. -async fn is_dir(path: &Path) -> bool { - tokio::fs::metadata(path) - .await - .map(|m| m.is_dir()) - .unwrap_or(false) +/// Whether Go's partial-extraction marker exists for an (encoded) module +/// coordinate under `cache_path`. +/// +/// Go (≥1.14.2) extracts a module zip in place at its final +/// `@` location, creating +/// `cache/download//@v/.partial` first and removing it only +/// after extraction succeeds (`cmd/go/internal/modfetch/fetch.go` — the +/// marker exists "to prevent other processes from reading the directory if +/// we crash"). A dir whose marker survives is incomplete: Go treats it as +/// not downloaded (`DownloadDirPartialError`) and deletes + re-extracts it +/// on next use, destroying anything patched into it. Both the scan and the +/// PURL lookup must therefore skip it. Mirrors Go's `os.Stat(partialPath)` +/// succeeded check in `DownloadDir`; both halves of the coordinate are the +/// case-ENCODED on-disk forms, matching Go's `CachePath(mod, "partial")`. +async fn is_partially_extracted( + cache_path: &Path, + encoded_module: &str, + encoded_version: &str, +) -> bool { + let marker = cache_path + .join("cache") + .join("download") + .join(encoded_module) + .join("@v") + .join(format!("{encoded_version}.partial")); + tokio::fs::metadata(&marker).await.is_ok() } #[cfg(test)] @@ -626,7 +637,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -654,7 +664,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -686,7 +695,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -703,7 +711,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_module_cache_paths(&options).await.unwrap(); @@ -727,7 +734,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -745,13 +751,13 @@ mod tests { /// `encoded_module_path = ""`. The empty-prefix guard in /// parse_versioned_dir must return None rather than emit a /// `("", "v1.0.0")` ghost package with an empty module path. - #[test] - fn test_parse_versioned_dir_empty_module_path_guard() { + #[tokio::test] + async fn test_parse_versioned_dir_empty_module_path_guard() { let base = std::path::Path::new("/cache"); let dir = std::path::Path::new("/cache/@v1.0.0"); let mut seen = HashSet::new(); let crawler = GoCrawler; - let result = crawler.parse_versioned_dir(base, dir, "@v1.0.0", &mut seen); + let result = crawler.parse_versioned_dir(base, dir, &mut seen).await; assert!( result.is_none(), "empty encoded module path must yield None" @@ -801,7 +807,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -829,7 +834,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -886,7 +890,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -913,7 +916,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -923,8 +925,8 @@ mod tests { assert!(purls.contains("pkg:golang/github.com/gin-gonic/gin@v1.9.1")); } - #[test] - fn test_parse_versioned_dir_empty_version_guard() { + #[tokio::test] + async fn test_parse_versioned_dir_empty_version_guard() { // A dir name with a trailing `@` and no version (`foo@`) is // malformed metadata: the empty-version guard must yield None // rather than emit a package with an empty version that would @@ -933,7 +935,7 @@ mod tests { let dir = std::path::Path::new("/cache/github.com/foo/bar@"); let mut seen = HashSet::new(); let crawler = GoCrawler; - let result = crawler.parse_versioned_dir(base, dir, "bar@", &mut seen); + let result = crawler.parse_versioned_dir(base, dir, &mut seen).await; assert!(result.is_none(), "empty version must yield None"); } @@ -991,6 +993,112 @@ mod tests { ); } + /// Unit contract for the coordinate gate: real module paths/versions + /// pass; a `:` is rejected because a Windows drive-relative coordinate + /// (`C:evil`, `C:/evil`) joins as an absolute path under `Path::join`. + #[test] + fn test_is_safe_module_coordinate_rejects_colon() { + assert!(is_safe_module_coordinate("github.com/foo/bar", "v1.2.3")); + assert!(!is_safe_module_coordinate("C:/evil", "v1.0.0")); + assert!(!is_safe_module_coordinate( + "github.com/C:evil/bar", + "v1.0.0" + )); + assert!(!is_safe_module_coordinate("github.com/foo/bar", "C:v1.0.0")); + } + + #[tokio::test] + async fn test_crawl_skips_partially_extracted_module() { + // Go (≥1.14.2) extracts a module zip IN PLACE at its final + // `@` location, creating a + // `cache/download//@v/.partial` marker first and + // removing it only after extraction succeeds. Per + // `cmd/go/internal/modfetch/fetch.go`, the marker exists "to prevent + // other processes from reading the directory if we crash" — a dir + // whose marker survives is incomplete, and Go deletes + re-extracts + // it on next use, destroying anything patched into it. The crawler + // must treat it like Go does: not installed. + let dir = tempfile::tempdir().unwrap(); + + let complete = dir.path().join("github.com").join("foo").join("ok@v1.0.0"); + tokio::fs::create_dir_all(&complete).await.unwrap(); + + let partial = dir.path().join("github.com").join("foo").join("bad@v2.0.0"); + tokio::fs::create_dir_all(&partial).await.unwrap(); + let marker_dir = dir + .path() + .join("cache") + .join("download") + .join("github.com") + .join("foo") + .join("bad") + .join("@v"); + tokio::fs::create_dir_all(&marker_dir).await.unwrap(); + tokio::fs::write(marker_dir.join("v2.0.0.partial"), b"") + .await + .unwrap(); + + let crawler = GoCrawler::new(); + let options = CrawlerOptions { + cwd: dir.path().to_path_buf(), + global: false, + global_prefix: Some(dir.path().to_path_buf()), + }; + + let packages = crawler.crawl_all(&options).await; + let purls: HashSet<_> = packages.iter().map(|p| p.purl.as_str()).collect(); + assert!( + purls.contains("pkg:golang/github.com/foo/ok@v1.0.0"), + "the completely extracted module must still be found" + ); + assert!( + !purls.contains("pkg:golang/github.com/foo/bad@v2.0.0"), + "a module dir with a surviving .partial marker is incomplete \ + and must be skipped" + ); + assert_eq!(packages.len(), 1); + } + + #[tokio::test] + async fn test_find_by_purls_skips_partially_extracted_module() { + // Same marker protocol as the scan test, exercised through the + // lookup path — and with case-escaped coordinates, pinning that the + // marker is probed at the ENCODED path and version + // (`.../!azure/bar/@v/v1.0.0-!r!c1.partial`), exactly where Go's + // `CachePath(mod, "partial")` writes it. + let dir = tempfile::tempdir().unwrap(); + + let module_dir = dir + .path() + .join("github.com") + .join("!azure") + .join("bar@v1.0.0-!r!c1"); + tokio::fs::create_dir_all(&module_dir).await.unwrap(); + let marker_dir = dir + .path() + .join("cache") + .join("download") + .join("github.com") + .join("!azure") + .join("bar") + .join("@v"); + tokio::fs::create_dir_all(&marker_dir).await.unwrap(); + tokio::fs::write(marker_dir.join("v1.0.0-!r!c1.partial"), b"") + .await + .unwrap(); + + let crawler = GoCrawler::new(); + let purls = vec!["pkg:golang/github.com/Azure/bar@v1.0.0-RC1".to_string()]; + let result = crawler.find_by_purls(dir.path(), &purls).await.unwrap(); + + assert!( + result.is_empty(), + "a half-extracted module (surviving .partial marker) must not \ + be returned as a patch target — Go will delete and re-extract \ + the dir, silently destroying any patch applied there" + ); + } + #[tokio::test] async fn test_find_by_purls_absent_returns_empty_ok() { // No matching directory on disk → Ok(empty map), never an Err. @@ -1028,7 +1136,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler.rs b/crates/socket-patch-core/src/crawlers/maven_crawler.rs index 2afcd356..0696a90a 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler.rs @@ -2,6 +2,8 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use super::types::{CrawledPackage, CrawlerOptions}; +use crate::patch::path_safety; +use crate::utils::fs::is_dir; // --------------------------------------------------------------------------- // POM XML minimal parser @@ -152,10 +154,20 @@ pub fn parse_pom_group_artifact_version(content: &str) -> Option<(String, String // the same line (``) or self-closes // (``) leaves the depth unchanged; only a lone open // increments and a lone close decrements. + // + // Any line carrying a close tag still holds that section's content up + // to the close (`9.9`, or a whole + // compact `...` block), so it must not + // reach extraction even once the depth is back to 0 — otherwise a + // dependency's coordinates leak as the project's. A coordinate that + // legitimately follows a close on the same line is sacrificed to + // `None`, which scan rescues via the directory-path fallback. + let mut saw_section_close = false; for section in &skip_sections { let open = opening_tag(trimmed, section); let has_open = open.is_some(); let has_close = contains_closing_tag(trimmed, section); + saw_section_close |= has_close; if has_open && !has_close && open != Some(true) { skip_depth += 1; } else if has_close && !has_open { @@ -163,7 +175,7 @@ pub fn parse_pom_group_artifact_version(content: &str) -> Option<(String, String } } - if skip_depth > 0 { + if skip_depth > 0 || saw_section_close { continue; } @@ -279,33 +291,26 @@ fn parse_path_coordinates( /// The coordinates come straight from the (untrusted) manifest PURL and are /// joined onto the repo root, after which the resolved directory is patched IN /// PLACE (Maven has no `replace`-redirect backend). A tampered PURL must not be -/// able to traverse out of the repository. `verify_maven_at_path` only checks -/// for a `.pom` file, so it is no defense — this gate is. Fails closed. +/// able to traverse out of the repository. `has_pom_file` only checks for a +/// `.pom` file, so it is no defense — this gate is. Fails closed. /// /// - `artifact_id` and `version` are each a single path segment, so a real one -/// never contains a separator, a `.`/`..` segment, a backslash, or a NUL. +/// never contains a separator, a `.`/`..` segment, a backslash, a colon, or +/// a NUL — [`path_safety::is_safe_single_segment`]. /// - `group_id` is dot-separated and run through [`group_id_to_path`] (each -/// `.` becomes `/`). Requiring every dot-split segment to be non-empty -/// rejects the forms that would convert to an absolute or `..`-bearing path -/// (`.` -> `/`, `.a` -> `/a`, `a..b` -> `a//b`). +/// `.` becomes `/`), so every dot-split segment must independently satisfy +/// [`path_safety::is_safe_single_segment`]. That rejects the forms that +/// would convert to an absolute or `..`-bearing path (`.` -> `/`, `.a` -> +/// `/a`, `a..b` -> `a//b`) and — unlike the previous local check — a `/` +/// smuggled inside a dot-split segment (`/etc`, `com/evil`). /// -/// Mirrors the `go_crawler` / `deno_crawler` coordinate guards. +/// The delegation also rejects `:` everywhere — a Windows drive-relative +/// coordinate (`C:evil`) joins as an absolute path. Mirrors the `go_crawler` +/// / `deno_crawler` coordinate guards. fn is_safe_maven_coordinate(group_id: &str, artifact_id: &str, version: &str) -> bool { - let safe_segment = |s: &str| { - !s.is_empty() - && s != "." - && s != ".." - && !s.contains('/') - && !s.contains('\\') - && !s.contains('\0') - }; - let group_ok = !group_id.is_empty() - && !group_id.contains('\\') - && !group_id.contains('\0') - && group_id - .split('.') - .all(|seg| !seg.is_empty() && seg != "." && seg != ".."); - group_ok && safe_segment(artifact_id) && safe_segment(version) + group_id.split('.').all(path_safety::is_safe_single_segment) + && path_safety::is_safe_single_segment(artifact_id) + && path_safety::is_safe_single_segment(version) } // --------------------------------------------------------------------------- @@ -423,10 +428,10 @@ impl MavenCrawler { .join(artifact_id) .join(version); - if self - .verify_maven_at_path(&expected_path, group_id, artifact_id, version) - .await - { + // The path already encodes the coordinates + // (groupId/artifactId/version), so verifying the package is + // just checking a `.pom` file exists there. + if self.has_pom_file(&expected_path).await { result.insert( purl.clone(), CrawledPackage { @@ -458,10 +463,7 @@ impl MavenCrawler { if let Ok(m2_home) = std::env::var("M2_HOME") { return PathBuf::from(m2_home).join("repository"); } - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .unwrap_or_else(|_| "~".to_string()); - PathBuf::from(home).join(".m2").join("repository") + crate::utils::fs::home_dir().join(".m2").join("repository") } /// Scan a Maven repository directory and return all valid packages found. @@ -512,20 +514,6 @@ impl MavenCrawler { results } - /// Verify that a Maven package directory contains a `.pom` file - /// with the expected coordinates. - async fn verify_maven_at_path( - &self, - path: &Path, - _group_id: &str, - _artifact_id: &str, - _version: &str, - ) -> bool { - // The path already encodes the coordinates (groupId/artifactId/version), - // so we just need to verify a .pom file exists here. - self.has_pom_file(path).await - } - /// Check if a directory contains at least one `.pom` file. async fn has_pom_file(&self, path: &Path) -> bool { if !is_dir(path).await { @@ -550,14 +538,6 @@ impl Default for MavenCrawler { } } -/// Check whether a path is a directory. -async fn is_dir(path: &Path) -> bool { - tokio::fs::metadata(path) - .await - .map(|m| m.is_dir()) - .unwrap_or(false) -} - #[cfg(test)] mod tests { use super::*; @@ -980,6 +960,46 @@ mod tests { assert_eq!(v, "1.0.0"); } + #[test] + fn test_parse_pom_compact_single_line_skip_block_does_not_leak() { + // REGRESSION: a skip section that opens AND closes on one physical + // line (compact/minified formatting) leaves skip_depth unchanged, and + // extraction then ran over that same line — leaking the dependency's + // as the project's (the project's own comes + // later, but first-match wins). Parse must not return 9.9.9 here. + let content = r#" + com.example + my-app + org.leakleak9.9.9 + 1.0.0 +"#; + let (g, a, v) = parse_pom_group_artifact_version(content).unwrap(); + assert_eq!(g, "com.example"); + assert_eq!(a, "my-app"); + assert_eq!(v, "1.0.0"); + } + + #[test] + fn test_parse_pom_value_on_skip_section_close_line_does_not_leak() { + // REGRESSION: on the line that CLOSES a skip section, the depth is + // decremented before the skip check, so a coordinate sharing that + // physical line (still inside the section) was extracted — leaking + // the plugin's as the project's. + let content = r#" + com.example + my-app + + + org.leak + 9.9.9 + 1.0.0 +"#; + let (g, a, v) = parse_pom_group_artifact_version(content).unwrap(); + assert_eq!(g, "com.example"); + assert_eq!(a, "my-app"); + assert_eq!(v, "1.0.0"); + } + #[test] fn test_parse_pom_parent_block_with_foreign_prefixed_child() { // A `` decoy must not be mistaken for opening the real @@ -1126,7 +1146,7 @@ mod tests { // directory outside the Maven repo root. Maven patches are applied IN // PLACE at the directory the crawler returns (no redirect backend // stands between resolution and disk), so an escape means an - // arbitrary out-of-tree write. `verify_maven_at_path` only checks for + // arbitrary out-of-tree write. `has_pom_file` only checks for // a `.pom` file, which does nothing to stop traversal — hence the // fail-closed coordinate guard. Twin of the go/deno crawler guards. let root = tempfile::tempdir().unwrap(); @@ -1188,6 +1208,17 @@ mod tests { assert!(!is_safe_maven_coordinate("", "a", "1.0.0")); assert!(!is_safe_maven_coordinate("g", "", "1.0.0")); assert!(!is_safe_maven_coordinate("g", "a", "")); + // Windows drive-relative escape: a `:` (e.g. `C:evil`) makes the + // joined path absolute under `Path::join`; rejected in every + // coordinate, including inside a dot-split groupId segment. + assert!(!is_safe_maven_coordinate("C:evil.org", "a", "1.0.0")); + assert!(!is_safe_maven_coordinate("g", "C:evil", "1.0.0")); + assert!(!is_safe_maven_coordinate("g", "a", "C:1.0.0")); + // A `/` smuggled inside a dot-split groupId segment never hits the + // per-dot-segment checks (`/etc` has no dots at all) but converts to + // an absolute or deeper path via `group_id_to_path`. + assert!(!is_safe_maven_coordinate("/etc", "a", "1.0.0")); + assert!(!is_safe_maven_coordinate("com/evil", "a", "1.0.0")); } // ---- crawl_all tests ---- @@ -1240,7 +1271,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -1279,7 +1309,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -1315,7 +1344,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -1334,7 +1362,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_maven_repo_paths(&options).await.unwrap(); @@ -1349,7 +1376,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let paths = crawler.get_maven_repo_paths(&options).await.unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/mod.rs b/crates/socket-patch-core/src/crawlers/mod.rs index 7506b455..8cdebf90 100644 --- a/crates/socket-patch-core/src/crawlers/mod.rs +++ b/crates/socket-patch-core/src/crawlers/mod.rs @@ -1,33 +1,21 @@ -#[cfg(feature = "cargo")] pub mod cargo_crawler; -#[cfg(feature = "composer")] pub mod composer_crawler; -#[cfg(feature = "deno")] pub mod deno_crawler; -#[cfg(feature = "golang")] pub mod go_crawler; -#[cfg(feature = "maven")] pub mod maven_crawler; pub mod npm_crawler; -#[cfg(feature = "nuget")] pub mod nuget_crawler; pub mod pkg_managers; pub mod python_crawler; pub mod ruby_crawler; pub mod types; -#[cfg(feature = "cargo")] pub use cargo_crawler::CargoCrawler; -#[cfg(feature = "composer")] pub use composer_crawler::ComposerCrawler; -#[cfg(feature = "deno")] pub use deno_crawler::DenoCrawler; -#[cfg(feature = "golang")] pub use go_crawler::GoCrawler; -#[cfg(feature = "maven")] pub use maven_crawler::MavenCrawler; pub use npm_crawler::NpmCrawler; -#[cfg(feature = "nuget")] pub use nuget_crawler::NuGetCrawler; pub use pkg_managers::{detect_npm_pkg_manager, NpmPkgManager}; pub use python_crawler::PythonCrawler; diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 2d9f8d3b..af4e74e1 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1,15 +1,13 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use serde::Deserialize; use super::types::{CrawledPackage, CrawlerOptions}; +use crate::patch::path_safety; +use crate::utils::fs::is_dir; use crate::utils::purl::{percent_decode_purl_component, strip_purl_qualifiers}; -/// Default batch size for crawling. -#[cfg(test)] -const DEFAULT_BATCH_SIZE: usize = 100; - /// Directories to skip when searching for workspace node_modules. const SKIP_DIRS: &[&str] = &[ "dist", @@ -34,8 +32,23 @@ struct PackageJsonPartial { /// Read and parse a `package.json` file, returning `(name, version)` if valid. pub async fn read_package_json(pkg_json_path: &Path) -> Option<(String, String)> { - let content = tokio::fs::read_to_string(pkg_json_path).await.ok()?; - let pkg: PackageJsonPartial = serde_json::from_str(&content).ok()?; + use tokio::io::AsyncReadExt; + + // The path lives inside the (untrusted) package tree: a planted FIFO + // would make a plain `read_to_string` open block forever waiting for a + // writer, wedging scan (crawl_all) and apply (find_by_purls). Open via + // `open_regular_file` — non-blocking on Unix, rejecting + // FIFOs/devices/directories (see its docs). + let (mut file, metadata) = crate::utils::fs::open_regular_file(pkg_json_path) + .await + .ok()?; + let mut content = String::with_capacity(metadata.len() as usize); + file.read_to_string(&mut content).await.ok()?; + // npm and Node both tolerate a leading UTF-8 BOM in package.json + // (Windows-authored packages ship them), but serde_json rejects it — + // a BOM'd install would be invisible to scan and unpatchable. + let pkg: PackageJsonPartial = + serde_json::from_str(crate::package_json::detect::strip_bom(&content)).ok()?; let name = pkg.name?; let version = pkg.version?; if name.is_empty() || version.is_empty() { @@ -303,7 +316,7 @@ impl NpmCrawler { .unwrap_or_default(); for nm_path in &nm_paths { - let found = self.scan_node_modules(nm_path, &mut seen).await; + let found = Self::scan_node_modules(nm_path, &mut seen).await; packages.extend(found); } @@ -322,8 +335,6 @@ impl NpmCrawler { ) -> Result, std::io::Error> { let mut result: HashMap = HashMap::new(); - // Parse each PURL to extract the directory key and expected version. - // // `purl` is the *verbatim* caller-supplied PURL, including any // `?qualifiers`. The result map is keyed by this exact string: the // dispatcher drives npm with `passthrough_purls` + `merge_first_wins`, @@ -335,60 +346,143 @@ impl NpmCrawler { name: String, version: String, purl: String, + /// Install dir relative to a `node_modules` root + /// (`@scope/name` or `name`) — which is also exactly what the + /// package.json `name` field must say for this dir to BE that + /// package. dir_key: String, } - let mut targets: Vec = Vec::new(); - + let mut pending: Vec = Vec::new(); for purl in purls { - if let Some((ns, name, version)) = Self::parse_purl_components(purl) { - // SECURITY: `ns`/`name` come straight from the (untrusted) - // manifest PURL and are joined onto `node_modules_path` below, - // then patched in place. A real npm scope/name is a single - // path segment, so reject any that could traverse out of the - // tree (`pkg:npm/../../evil@1.0.0`). Fail closed — twin of the - // deno/go/maven coordinate gates. - let ns_safe = ns.as_deref().map(is_safe_npm_component).unwrap_or(true); - if !ns_safe || !is_safe_npm_component(&name) { - continue; - } - let dir_key = match &ns { - Some(ns_str) => format!("{ns_str}/{name}"), - None => name.clone(), - }; - targets.push(Target { - namespace: ns, - name, - version, - purl: purl.clone(), - dir_key, - }); + let Some((namespace, name, version)) = Self::parse_purl_components(purl) else { + continue; + }; + + // SECURITY: `namespace`/`name` come straight from the (untrusted) + // manifest PURL and are joined onto `node_modules_path` below, + // then patched in place. A real npm scope/name is a single + // path segment, so reject any that could traverse out of the + // tree (`pkg:npm/../../evil@1.0.0`). Fail closed — twin of the + // deno/go/maven coordinate gates. + let ns_safe = namespace + .as_deref() + .map(is_safe_npm_component) + .unwrap_or(true); + if !ns_safe || !is_safe_npm_component(&name) { + continue; } + + let dir_key = match &namespace { + Some(ns) => format!("{ns}/{name}"), + None => name.clone(), + }; + pending.push(Target { + namespace, + name, + version, + purl: purl.clone(), + dir_key, + }); } - for target in &targets { - let pkg_path = node_modules_path.join(&target.dir_key); - let pkg_json_path = pkg_path.join("package.json"); - - if let Some((_, version)) = read_package_json(&pkg_json_path).await { - if version == target.version { - result.insert( - target.purl.clone(), - CrawledPackage { - name: target.name.clone(), - version, - namespace: target.namespace.clone(), - purl: target.purl.clone(), - path: pkg_path.clone(), - }, - ); + // Probe trees breadth-first: the root `node_modules` first (so a + // root-level install always wins), then — only while targets remain + // unresolved — each nested `node_modules`. npm nests a conflicting + // version under the dependent package, so a patched version can + // exist *only* nested; CLI_CONTRACT ("Deeply nested transitive + // dependencies are fully supported") promises those are patched + // identically to direct deps, and `crawl_all` (scan) already + // discovers them at unbounded depth. + let mut queue: VecDeque = VecDeque::from([node_modules_path.to_path_buf()]); + while let Some(nm_path) = queue.pop_front() { + if pending.is_empty() { + break; + } + let mut unresolved = Vec::with_capacity(pending.len()); + for target in pending { + let pkg_path = nm_path.join(&target.dir_key); + let pkg_json_path = pkg_path.join("package.json"); + + match read_package_json(&pkg_json_path).await { + // The on-disk *name* must match too: an alias install + // (`npm i foo@npm:bar@1.0.0`) puts a different package + // in `node_modules/foo`, so matching on version alone + // would misidentify it and patch the wrong package's + // files. + Some((found_name, found_version)) + if found_name == target.dir_key && found_version == target.version => + { + result.insert( + target.purl.clone(), + CrawledPackage { + name: target.name, + version: found_version, + namespace: target.namespace, + purl: target.purl, + path: pkg_path, + }, + ); + } + _ => unresolved.push(target), } } + pending = unresolved; + if !pending.is_empty() { + Self::collect_nested_node_modules(&nm_path, &mut queue).await; + } } Ok(result) } + /// Append the `node_modules` dirs living one level below `nm_path` + /// (inside each of its package dirs, scoped or not) to `queue`. + /// Mirrors `scan_node_modules`' traversal policy: hidden entries are + /// skipped and symlinked packages are never traversed — a symlink here + /// points into pnpm's content-addressed store or an `npm link` target + /// outside the project. + async fn collect_nested_node_modules(nm_path: &Path, queue: &mut VecDeque) { + for entry in crate::utils::fs::list_dir_entries(nm_path).await { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with('.') || name_str == "node_modules" { + continue; + } + let Some(file_type) = crate::utils::fs::entry_file_type(&entry).await else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let entry_path = nm_path.join(&name); + + if name_str.starts_with('@') { + for scoped in crate::utils::fs::list_dir_entries(&entry_path).await { + let scoped_name = scoped.file_name(); + if scoped_name.to_string_lossy().starts_with('.') { + continue; + } + let Some(scoped_type) = crate::utils::fs::entry_file_type(&scoped).await else { + continue; + }; + if !scoped_type.is_dir() { + continue; + } + let nested = entry_path.join(&scoped_name).join("node_modules"); + if is_dir(&nested).await { + queue.push_back(nested); + } + } + } else { + let nested = entry_path.join("node_modules"); + if is_dir(&nested).await { + queue.push_back(nested); + } + } + } + } + // ------------------------------------------------------------------ // Private helpers – global paths // ------------------------------------------------------------------ @@ -522,66 +616,20 @@ impl NpmCrawler { // ------------------------------------------------------------------ /// Scan a `node_modules` directory, returning all valid packages found. - async fn scan_node_modules( - &self, - node_modules_path: &Path, - seen: &mut HashSet, - ) -> Vec { - let mut results = Vec::new(); - - for entry in crate::utils::fs::list_dir_entries(node_modules_path).await { - let name = entry.file_name(); - let name_str = name.to_string_lossy().to_string(); - - // Skip hidden files and node_modules - if name_str.starts_with('.') || name_str == "node_modules" { - continue; - } - - let Some(file_type) = crate::utils::fs::entry_file_type(&entry).await else { - continue; - }; - - // Allow both directories and symlinks (pnpm uses symlinks) - if !file_type.is_dir() && !file_type.is_symlink() { - continue; - } - - let entry_path = node_modules_path.join(&name_str); - - if name_str.starts_with('@') { - // Scoped packages - let scoped = Self::scan_scoped_packages(&entry_path, seen).await; - results.extend(scoped); - } else { - // Regular package - if let Some(pkg) = Self::check_package(&entry_path, seen).await { - results.push(pkg); - } - // Nested node_modules only for real directories (not symlinks) - if file_type.is_dir() { - let nested = Self::scan_nested_node_modules(&entry_path, seen).await; - results.extend(nested); - } - } - } - - results - } - - /// Scan a scoped packages directory (`@scope/`). - fn scan_scoped_packages<'a>( - scope_path: &'a Path, + /// Recurses into each package's own nested `node_modules`. + fn scan_node_modules<'a>( + node_modules_path: &'a Path, seen: &'a mut HashSet, ) -> std::pin::Pin> + 'a>> { Box::pin(async move { let mut results = Vec::new(); - for entry in crate::utils::fs::list_dir_entries(scope_path).await { + for entry in crate::utils::fs::list_dir_entries(node_modules_path).await { let name = entry.file_name(); let name_str = name.to_string_lossy().to_string(); - if name_str.starts_with('.') { + // Skip hidden files and node_modules + if name_str.starts_with('.') || name_str == "node_modules" { continue; } @@ -589,19 +637,31 @@ impl NpmCrawler { continue; }; + // Allow both directories and symlinks (pnpm uses symlinks) if !file_type.is_dir() && !file_type.is_symlink() { continue; } - let pkg_path = scope_path.join(&name_str); - if let Some(pkg) = Self::check_package(&pkg_path, seen).await { - results.push(pkg); - } + let entry_path = node_modules_path.join(&name_str); - // Nested node_modules only for real directories - if file_type.is_dir() { - let nested = Self::scan_nested_node_modules(&pkg_path, seen).await; - results.extend(nested); + if name_str.starts_with('@') { + // Scoped packages + let scoped = Self::scan_scoped_packages(&entry_path, seen).await; + results.extend(scoped); + } else { + // Regular package + if let Some(pkg) = Self::check_package(&entry_path, seen).await { + results.push(pkg); + } + // Recurse into nested node_modules only for real + // directories (not symlinks). Following a symlink here + // would walk into pnpm's content-addressed store (or an + // `npm link` target outside the project). + if file_type.is_dir() { + let nested = + Self::scan_node_modules(&entry_path.join("node_modules"), seen).await; + results.extend(nested); + } } } @@ -609,20 +669,19 @@ impl NpmCrawler { }) } - /// Scan nested `node_modules` inside a package (if it exists). - fn scan_nested_node_modules<'a>( - pkg_path: &'a Path, + /// Scan a scoped packages directory (`@scope/`). + fn scan_scoped_packages<'a>( + scope_path: &'a Path, seen: &'a mut HashSet, ) -> std::pin::Pin> + 'a>> { Box::pin(async move { - let nested_nm = pkg_path.join("node_modules"); let mut results = Vec::new(); - for entry in crate::utils::fs::list_dir_entries(&nested_nm).await { + for entry in crate::utils::fs::list_dir_entries(scope_path).await { let name = entry.file_name(); let name_str = name.to_string_lossy().to_string(); - if name_str.starts_with('.') || name_str == "node_modules" { + if name_str.starts_with('.') { continue; } @@ -634,24 +693,16 @@ impl NpmCrawler { continue; } - let entry_path = nested_nm.join(&name_str); + let pkg_path = scope_path.join(&name_str); + if let Some(pkg) = Self::check_package(&pkg_path, seen).await { + results.push(pkg); + } - if name_str.starts_with('@') { - let scoped = Self::scan_scoped_packages(&entry_path, seen).await; - results.extend(scoped); - } else { - if let Some(pkg) = Self::check_package(&entry_path, seen).await { - results.push(pkg); - } - // Recurse into deeper nested node_modules only for real - // directories (not symlinks) — matching the invariant in - // `scan_node_modules`/`scan_scoped_packages`. Following a - // symlink here would walk into pnpm's content-addressed - // store (or an `npm link` target outside the project). - if file_type.is_dir() { - let deeper = Self::scan_nested_node_modules(&entry_path, seen).await; - results.extend(deeper); - } + // Nested node_modules only for real directories + if file_type.is_dir() { + let nested = + Self::scan_node_modules(&pkg_path.join("node_modules"), seen).await; + results.extend(nested); } } @@ -739,30 +790,20 @@ impl Default for NpmCrawler { // Utility // --------------------------------------------------------------------------- -/// Check whether a path is a directory (follows symlinks). -async fn is_dir(path: &Path) -> bool { - tokio::fs::metadata(path) - .await - .map(|m| m.is_dir()) - .unwrap_or(false) -} - /// Whether a PURL-derived path component is safe to join onto the /// `node_modules` root. An npm package's scope (`@types`) and bare name /// (`node`) are each a single path segment, so a real one never contains a -/// separator, a `.`/`..` segment, a backslash, or a NUL. `find_by_purls` -/// joins these straight from the (untrusted) manifest PURL onto the -/// `node_modules` root and then patches the resolved package in place, so a -/// tampered PURL like `pkg:npm/../../evil@1.0.0` would otherwise read (and -/// later write) out of tree. Reject those fail-closed. Twin of the deno -/// (`is_safe_jsr_component`), go, and maven coordinate gates. +/// separator, a `.`/`..` segment, a backslash, a colon, or a NUL. +/// `find_by_purls` joins these straight from the (untrusted) manifest PURL +/// onto the `node_modules` root and then patches the resolved package in +/// place, so a tampered PURL like `pkg:npm/../../evil@1.0.0` would otherwise +/// read (and later write) out of tree. Delegates to +/// [`path_safety::is_safe_single_segment`], which also rejects `:` — a +/// Windows drive-relative component (`C:evil`) joins as an absolute path. +/// Fails closed. Twin of the deno (`is_safe_jsr_component`), go, and maven +/// coordinate gates. fn is_safe_npm_component(component: &str) -> bool { - !component.is_empty() - && component != "." - && component != ".." - && !component.contains('/') - && !component.contains('\\') - && !component.contains('\0') + path_safety::is_safe_single_segment(component) } #[cfg(test)] @@ -893,7 +934,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: DEFAULT_BATCH_SIZE, }; let packages = crawler.crawl_all(&options).await; @@ -922,7 +962,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: DEFAULT_BATCH_SIZE, }; let packages = crawler.crawl_all(&options).await; @@ -1300,6 +1339,10 @@ mod tests { assert!(!is_safe_npm_component("a/b")); assert!(!is_safe_npm_component("a\\b")); assert!(!is_safe_npm_component("a\0b")); + // Windows drive-relative escape: a `:` (e.g. `C:evil`) makes the + // joined path absolute under `Path::join`. + assert!(!is_safe_npm_component("C:evil")); + assert!(!is_safe_npm_component("c:")); } /// A PURL whose version is not the one on disk must be skipped, while a diff --git a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs index 31a88bfe..7c49f04f 100644 --- a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs @@ -2,6 +2,8 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use super::types::{CrawledPackage, CrawlerOptions}; +use crate::patch::path_safety; +use crate::utils::fs::is_dir; /// NuGet/.NET ecosystem crawler for discovering packages in global cache, /// legacy `packages/` folders, and `obj/` restore layouts. @@ -109,71 +111,51 @@ impl NuGetCrawler { let mut result: HashMap = HashMap::new(); for purl in purls { - if let Some((name, version)) = crate::utils::purl::parse_nuget_purl(purl) { - // SECURITY: the coordinates are untrusted manifest input - // joined onto the package root and then patched IN PLACE - // (NuGet has no redirect backend). Reject anything that - // could traverse out of the root before touching the - // filesystem — `verify_nuget_package` only checks for - // `lib/` or a `.nuspec`, so it is no defense. - if !is_safe_nuget_coordinate(name, version) { - continue; - } - - // Try global cache layout: //. - // NuGet lowercases BOTH the id and the version when it lays - // out the global packages folder, so a prerelease tag like - // `2.0.0-RC1` lives on disk as `2.0.0-rc1`. Lowercasing only - // the name (but not the version) would miss those packages. - let global_dir = pkg_path - .join(name.to_lowercase()) - .join(version.to_lowercase()); - if self.verify_nuget_package(&global_dir).await { - result.insert( - purl.clone(), - CrawledPackage { - name: name.to_string(), - version: version.to_string(), - namespace: None, - purl: purl.clone(), - path: global_dir, - }, - ); - continue; - } - - // Try legacy layout: ./ - let legacy_dir = pkg_path.join(format!("{name}.{version}")); - if self.verify_nuget_package(&legacy_dir).await { - result.insert( - purl.clone(), - CrawledPackage { - name: name.to_string(), - version: version.to_string(), - namespace: None, - purl: purl.clone(), - path: legacy_dir, - }, - ); - continue; - } + let Some((name, version)) = crate::utils::purl::parse_nuget_purl(purl) else { + continue; + }; + // SECURITY: the coordinates are untrusted manifest input + // joined onto the package root and then patched IN PLACE + // (NuGet has no redirect backend). Reject anything that + // could traverse out of the root before touching the + // filesystem — `verify_nuget_package` only checks for + // `lib/` or a `.nuspec`, so it is no defense. + if !is_safe_nuget_coordinate(name, version) { + continue; + } - // Try case-insensitive legacy scan (NuGet names are case-insensitive) - if let Some(found_dir) = self - .find_legacy_dir_case_insensitive(pkg_path, name, version) + // Global cache layout: //. + // NuGet lowercases BOTH the id and the version when it lays + // out the global packages folder, so a prerelease tag like + // `2.0.0-RC1` lives on disk as `2.0.0-rc1`. Lowercasing only + // the name (but not the version) would miss those packages. + let global_dir = pkg_path + .join(name.to_lowercase()) + .join(version.to_lowercase()); + // Legacy layout: ./, tried exact-case first, then + // case-insensitively (NuGet names are case-insensitive). + let legacy_dir = pkg_path.join(format!("{name}.{version}")); + + let found = if self.verify_nuget_package(&global_dir).await { + Some(global_dir) + } else if self.verify_nuget_package(&legacy_dir).await { + Some(legacy_dir) + } else { + self.find_legacy_dir_case_insensitive(pkg_path, name, version) .await - { - result.insert( - purl.clone(), - CrawledPackage { - name: name.to_string(), - version: version.to_string(), - namespace: None, - purl: purl.clone(), - path: found_dir, - }, - ); - } + }; + + if let Some(path) = found { + result.insert( + purl.clone(), + CrawledPackage { + name: name.to_string(), + version: version.to_string(), + namespace: None, + purl: purl.clone(), + path, + }, + ); } } @@ -314,7 +296,15 @@ impl NuGetCrawler { } // Check for any .nuspec file - find_nuspec_in_dir(path).await.is_some() + for entry in crate::utils::fs::list_dir_entries(path).await { + if let Some(name) = entry.file_name().to_str() { + if name.ends_with(".nuspec") { + return true; + } + } + } + + false } /// Find a legacy package directory with case-insensitive matching. @@ -356,18 +346,12 @@ impl Default for NuGetCrawler { /// which the resolved directory is patched IN PLACE (NuGet has no redirect /// backend) — so a tampered PURL must not be able to traverse out of the /// root. A real NuGet id/version never contains a separator, a `.`/`..` -/// segment, a backslash, or a NUL. Fails closed. Mirrors the -/// maven/go/deno/npm crawler coordinate guards. +/// segment, a backslash, a colon, or a NUL. Delegates to +/// [`path_safety::is_safe_single_segment`], which also rejects `:` — a +/// Windows drive-relative coordinate (`C:evil`) joins as an absolute path. +/// Fails closed. Mirrors the maven/go/deno/npm crawler coordinate guards. fn is_safe_nuget_coordinate(name: &str, version: &str) -> bool { - let safe_segment = |s: &str| { - !s.is_empty() - && s != "." - && s != ".." - && !s.contains('/') - && !s.contains('\\') - && !s.contains('\0') - }; - safe_segment(name) && safe_segment(version) + path_safety::is_safe_single_segment(name) && path_safety::is_safe_single_segment(version) } /// Get the NuGet global packages folder. @@ -383,15 +367,15 @@ fn nuget_home() -> PathBuf { } } - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .unwrap_or_else(|_| "~".to_string()); - PathBuf::from(home).join(".nuget").join("packages") + crate::utils::fs::home_dir().join(".nuget").join("packages") } /// Check if the cwd contains any .NET project indicators. async fn is_dotnet_project(cwd: &Path) -> bool { - let extensions = [".csproj", ".fsproj", ".vbproj", ".sln"]; + // `.slnx` is the XML solution format (GA since VS 2022 17.13 / + // dotnet 9.0.200); migrating deletes the old `.sln`, and a solution + // root often has no other root-level marker. + let extensions = [".csproj", ".fsproj", ".vbproj", ".sln", ".slnx"]; for entry in crate::utils::fs::list_dir_entries(cwd).await { if let Some(name) = entry.file_name().to_str() { @@ -439,29 +423,17 @@ fn parse_legacy_dir_name(dir_name: &str) -> Option<(String, String)> { Some((name.to_string(), version.to_string())) } -/// Find a `.nuspec` file in a directory. -async fn find_nuspec_in_dir(dir: &Path) -> Option { - for entry in crate::utils::fs::list_dir_entries(dir).await { - if let Some(name) = entry.file_name().to_str() { - if name.ends_with(".nuspec") { - return Some(dir.join(name)); - } - } - } - None -} - /// Discover additional package paths from `obj/project.assets.json` files. async fn discover_paths_from_assets(cwd: &Path) -> Vec { let mut paths = Vec::new(); // Look for obj/project.assets.json in cwd let assets_path = cwd.join("obj").join("project.assets.json"); - if let Some(pkg_folder) = parse_project_assets_package_folders(&assets_path).await { - for folder in pkg_folder { - paths.push(folder); - } - } + paths.extend( + parse_project_assets_package_folders(&assets_path) + .await + .unwrap_or_default(), + ); // Also check subdirectories one level deep for multi-project solutions for entry in crate::utils::fs::list_dir_entries(cwd).await { @@ -472,11 +444,11 @@ async fn discover_paths_from_assets(cwd: &Path) -> Vec { .join(entry.file_name()) .join("obj") .join("project.assets.json"); - if let Some(pkg_folders) = parse_project_assets_package_folders(&sub_assets).await { - for folder in pkg_folders { - paths.push(folder); - } - } + paths.extend( + parse_project_assets_package_folders(&sub_assets) + .await + .unwrap_or_default(), + ); } paths } @@ -489,22 +461,7 @@ async fn parse_project_assets_package_folders(path: &Path) -> Option = folders.keys().map(PathBuf::from).collect(); - - if result.is_empty() { - None - } else { - Some(result) - } -} - -/// Check whether a path is a directory. -async fn is_dir(path: &Path) -> bool { - tokio::fs::metadata(path) - .await - .map(|m| m.is_dir()) - .unwrap_or(false) + Some(folders.keys().map(PathBuf::from).collect()) } #[cfg(test)] @@ -602,7 +559,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -635,7 +591,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -669,6 +624,50 @@ mod tests { assert!(super::is_dotnet_project(dir.path()).await); } + /// Regression: `.slnx` (the XML solution format, GA since VS 2022 + /// 17.13 / dotnet 9.0.200) replaces `.sln` when a repo migrates — the + /// old file is deleted. A solution root keeps its projects in + /// subdirectories, so `.slnx` is often the ONLY root-level .NET + /// marker; without it the local-mode gate fails and + /// `get_nuget_package_paths` returns no paths at all (not even the + /// global cache), silently disabling NuGet patching for that repo. + #[tokio::test] + async fn test_is_dotnet_project_slnx() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("MySolution.slnx"), "") + .await + .unwrap(); + assert!(super::is_dotnet_project(dir.path()).await); + + let crawler = NuGetCrawler::new(); + let options = CrawlerOptions { + cwd: dir.path().to_path_buf(), + global: false, + global_prefix: None, + }; + // Path discovery must also flow through: an assets-file path in a + // sub-project of the .slnx solution is found once the gate passes. + let pkg_folder = dir.path().join("nuget-cache"); + tokio::fs::create_dir_all(&pkg_folder).await.unwrap(); + let obj_dir = dir.path().join("MyApp").join("obj"); + tokio::fs::create_dir_all(&obj_dir).await.unwrap(); + tokio::fs::write( + obj_dir.join("project.assets.json"), + serde_json::to_string(&serde_json::json!({ + "packageFolders": { pkg_folder.to_string_lossy().to_string(): {} } + })) + .unwrap(), + ) + .await + .unwrap(); + + let paths = crawler.get_nuget_package_paths(&options).await.unwrap(); + assert!( + paths.contains(&pkg_folder), + "a .slnx solution root must be gated in and its sub-project assets discovered, got {paths:?}" + ); + } + #[tokio::test] async fn test_verify_nuget_package_with_nuspec() { let dir = tempfile::tempdir().unwrap(); @@ -719,7 +718,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -785,7 +783,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_nuget_package_paths(&options).await.unwrap(); @@ -813,7 +810,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_nuget_package_paths(&options).await.unwrap(); @@ -867,7 +863,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let pkgs = crawler.crawl_all(&options).await; @@ -896,7 +891,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let pkgs = crawler.crawl_all(&options).await; @@ -928,7 +922,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let mut purls: Vec = crawler @@ -999,6 +992,10 @@ mod tests { assert!(!is_safe_nuget_coordinate("a", ".")); assert!(!is_safe_nuget_coordinate("", "1.0.0")); assert!(!is_safe_nuget_coordinate("a", "")); + // Windows drive-relative escape: a `:` (e.g. `C:evil`) makes the + // joined path absolute under `Path::join`. + assert!(!is_safe_nuget_coordinate("C:evil", "1.0.0")); + assert!(!is_safe_nuget_coordinate("a", "C:1.0.0")); } /// SECURITY regression: a tampered manifest PURL whose name or version diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index e400a13d..fd06350d 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -12,7 +12,7 @@ use crate::utils::process::{CommandRunner, SystemCommandRunner}; /// /// Tries `python3`, `python`, and `py` (Windows launcher) in order, /// returning the first one that responds to `--version`. -pub fn find_python_command() -> Option<&'static str> { +fn find_python_command() -> Option<&'static str> { find_python_command_with(&SystemCommandRunner) } @@ -26,9 +26,6 @@ pub fn find_python_command_with(runner: &dyn CommandRunner) -> Option<&'static s .find(|cmd| runner.run(cmd, &["--version"]).is_some()) } -/// Default batch size for crawling. -const _DEFAULT_BATCH_SIZE: usize = 100; - // --------------------------------------------------------------------------- // PEP 503 name canonicalization // --------------------------------------------------------------------------- @@ -36,7 +33,7 @@ const _DEFAULT_BATCH_SIZE: usize = 100; /// Canonicalize a Python package name per PEP 503. /// /// Lowercases, trims, and replaces runs of `[-_.]` with a single `-`. -pub fn canonicalize_pypi_name(name: &str) -> String { +pub(crate) fn canonicalize_pypi_name(name: &str) -> String { let trimmed = name.trim().to_lowercase(); let mut result = String::with_capacity(trimmed.len()); let mut in_separator_run = false; @@ -216,7 +213,7 @@ pub async fn find_python_dirs(base_path: &Path, segments: &[&str]) -> Vec Vec { @@ -267,7 +264,7 @@ pub async fn get_global_python_site_packages() -> Vec { let mut results = Vec::new(); let mut seen = HashSet::new(); - let add_path = |p: PathBuf, seen: &mut HashSet, results: &mut Vec| { + fn add_path(p: PathBuf, seen: &mut HashSet, results: &mut Vec) { let resolved = if p.is_absolute() { p } else { @@ -276,7 +273,7 @@ pub async fn get_global_python_site_packages() -> Vec { if seen.insert(resolved.clone()) { results.push(resolved); } - }; + } // 1. Ask Python for site-packages if let Some(python_cmd) = find_python_command() { @@ -295,9 +292,7 @@ pub async fn get_global_python_site_packages() -> Vec { } // 2. Well-known system paths - let home_dir = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .unwrap_or_else(|_| "~".to_string()); + let home_dir = crate::utils::fs::home_dir(); // Helper closure to scan base/{lib,lib64}/python3.*/[dist|site]-packages. // `lib64` is the multilib dir on RHEL/Fedora/SUSE where compiled @@ -313,14 +308,7 @@ pub async fn get_global_python_site_packages() -> Vec { let mut matches = find_python_dirs(base, &["lib", "python3.*", pkg_type]).await; matches.extend(find_python_dirs(base, &["lib64", "python3.*", pkg_type]).await); for m in matches { - let resolved = if m.is_absolute() { - m - } else { - std::path::absolute(&m).unwrap_or(m) - }; - if seen.insert(resolved.clone()) { - results.push(resolved); - } + add_path(m, seen, results); } } @@ -345,7 +333,7 @@ pub async fn get_global_python_site_packages() -> Vec { ) .await; // pip --user on Unix - let user_local = PathBuf::from(&home_dir).join(".local"); + let user_local = home_dir.join(".local"); scan_well_known(&user_local, "site-packages", &mut seen, &mut results).await; } @@ -418,7 +406,7 @@ pub async fn get_global_python_site_packages() -> Vec { { let pyenv_root = std::env::var("PYENV_ROOT") .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(&home_dir).join(".pyenv")); + .unwrap_or_else(|_| home_dir.join(".pyenv")); let pyenv_versions = pyenv_root.join("versions"); let pyenv_matches = find_python_dirs(&pyenv_versions, &["*", "lib", "python3.*", "site-packages"]).await; @@ -428,15 +416,19 @@ pub async fn get_global_python_site_packages() -> Vec { } // Conda - let anaconda = PathBuf::from(&home_dir).join("anaconda3"); + let anaconda = home_dir.join("anaconda3"); scan_well_known(&anaconda, "site-packages", &mut seen, &mut results).await; - let miniconda = PathBuf::from(&home_dir).join("miniconda3"); + let miniconda = home_dir.join("miniconda3"); scan_well_known(&miniconda, "site-packages", &mut seen, &mut results).await; // uv tools — platform-specific install root. #[cfg(target_os = "macos")] { - let uv_base = PathBuf::from(&home_dir) + // Legacy/secondary location only: uv follows XDG conventions on + // macOS (`uv tool dir` → ~/.local/share/uv/tools, covered by the + // not(windows) scan below), but older layouts used the platform + // data dir, so keep scanning it too. + let uv_base = home_dir .join("Library") .join("Application Support") .join("uv") @@ -458,9 +450,12 @@ pub async fn get_global_python_site_packages() -> Vec { } } } - #[cfg(all(not(target_os = "macos"), not(windows)))] + #[cfg(not(windows))] { - let uv_base = PathBuf::from(&home_dir) + // uv uses XDG paths on BOTH Linux and macOS (`uv tool dir` → + // ~/.local/share/uv/tools; verified against a real uv install — + // macOS does NOT get an Application Support tool dir). + let uv_base = home_dir .join(".local") .join("share") .join("uv") @@ -482,7 +477,7 @@ pub async fn get_global_python_site_packages() -> Vec { // should surface those. #[cfg(not(windows))] { - let uv_python = PathBuf::from(&home_dir) + let uv_python = home_dir .join(".local") .join("share") .join("uv") @@ -521,6 +516,9 @@ pub async fn get_global_python_site_packages() -> Vec { /// * `requirements.txt` — pip-compile / bare requirements /// * `uv.lock` — uv-managed projects (PEP 751 export sibling is /// `pylock.toml` but in practice `uv.lock` is what ships) +/// * `Pipfile` / `Pipfile.lock` — pipenv projects, which commonly +/// ship NEITHER pyproject.toml nor requirements.txt and keep +/// their venvs out-of-tree (`~/.local/share/virtualenvs`) pub async fn is_python_project(cwd: &Path) -> bool { let markers = [ "pyproject.toml", @@ -528,6 +526,8 @@ pub async fn is_python_project(cwd: &Path) -> bool { "setup.cfg", "requirements.txt", "uv.lock", + "Pipfile", + "Pipfile.lock", ]; for m in &markers { if tokio::fs::metadata(cwd.join(m)).await.is_ok() { @@ -597,8 +597,19 @@ impl PythonCrawler { .unwrap_or_default(); for sp_path in &sp_paths { - let found = self.scan_site_packages(sp_path, &mut seen).await; - packages.extend(found); + for (name, version) in list_dist_info_packages(sp_path).await { + let purl = format!("pkg:pypi/{name}@{version}"); + if !seen.insert(purl.clone()) { + continue; + } + packages.push(CrawledPackage { + name, + version, + namespace: None, + purl, + path: sp_path.clone(), + }); + } } packages @@ -615,10 +626,15 @@ impl PythonCrawler { ) -> Result, std::io::Error> { let mut result = HashMap::new(); - // Build lookup: canonicalized-name@version -> purl + // Build lookup: canonicalized-name@version -> purl. The API serves + // purls percent-encoded (a PEP 440 local/epoch version carries + // `+`/`!`, arriving as `%2B`/`%21`), so decode the coordinates + // before keying or the installed package never matches. let mut purl_lookup: HashMap = HashMap::new(); for purl in purls { - if let Some((name, version)) = Self::parse_pypi_purl(purl) { + if let Some((name, version)) = crate::utils::purl::parse_pypi_purl(purl) { + let name = crate::utils::purl::percent_decode_purl_component(name); + let version = crate::utils::purl::percent_decode_purl_component(version); let key = format!("{}@{}", canonicalize_pypi_name(&name), version); purl_lookup.insert(key, purl.as_str()); } @@ -628,98 +644,42 @@ impl PythonCrawler { return Ok(result); } - // Scan all .dist-info dirs - for entry in crate::utils::fs::list_dir_entries(site_packages_path).await { - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - if !name_str.ends_with(".dist-info") { - continue; - } - - let dist_info_path = site_packages_path.join(&*name_str); - if let Some((raw_name, version)) = read_python_metadata(&dist_info_path).await { - let canon_name = canonicalize_pypi_name(&raw_name); - let key = format!("{canon_name}@{version}"); - - if let Some(&matched_purl) = purl_lookup.get(&key) { - result.insert( - matched_purl.to_string(), - CrawledPackage { - name: canon_name, - version, - namespace: None, - purl: matched_purl.to_string(), - path: site_packages_path.to_path_buf(), - }, - ); - } + for (name, version) in list_dist_info_packages(site_packages_path).await { + let key = format!("{name}@{version}"); + if let Some(&matched_purl) = purl_lookup.get(&key) { + result.insert( + matched_purl.to_string(), + CrawledPackage { + name, + version, + namespace: None, + purl: matched_purl.to_string(), + path: site_packages_path.to_path_buf(), + }, + ); } } Ok(result) } +} - // ------------------------------------------------------------------ - // Private helpers - // ------------------------------------------------------------------ - - /// Scan a `site-packages` directory for `.dist-info` directories. - async fn scan_site_packages( - &self, - site_packages_path: &Path, - seen: &mut HashSet, - ) -> Vec { - let mut results = Vec::new(); - - for entry in crate::utils::fs::list_dir_entries(site_packages_path).await { - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - if !name_str.ends_with(".dist-info") { - continue; - } - - let dist_info_path = site_packages_path.join(&*name_str); - if let Some((raw_name, version)) = read_python_metadata(&dist_info_path).await { - let canon_name = canonicalize_pypi_name(&raw_name); - let purl = format!("pkg:pypi/{canon_name}@{version}"); - - if seen.contains(&purl) { - continue; - } - seen.insert(purl.clone()); - - results.push(CrawledPackage { - name: canon_name, - version, - namespace: None, - purl, - path: site_packages_path.to_path_buf(), - }); - } +/// Scan a `site-packages` directory for `.dist-info` entries, returning +/// `(canonicalized name, version)` for each package that yields metadata. +async fn list_dist_info_packages(site_packages_path: &Path) -> Vec<(String, String)> { + let mut out = Vec::new(); + for entry in crate::utils::fs::list_dir_entries(site_packages_path).await { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str.ends_with(".dist-info") { + continue; } - - results - } - - /// Parse a PyPI PURL string to extract name and version. - /// Strips qualifiers and subpath before parsing. - fn parse_pypi_purl(purl: &str) -> Option<(String, String)> { - // A `#subpath` can appear without a preceding `?qualifier`, so the - // shared helper cuts at whichever comes first — a `?`-only cut would - // leak the subpath into the version and miss the installed package. - let base = crate::utils::purl::strip_purl_qualifiers(purl); - - let rest = base.strip_prefix("pkg:pypi/")?; - let at_idx = rest.rfind('@')?; - let name = &rest[..at_idx]; - let version = &rest[at_idx + 1..]; - - if name.is_empty() || version.is_empty() { - return None; + let dist_info_path = site_packages_path.join(&*name_str); + if let Some((raw_name, version)) = read_python_metadata(&dist_info_path).await { + out.push((canonicalize_pypi_name(&raw_name), version)); } - - Some((name.to_string(), version.to_string())) } + out } impl Default for PythonCrawler { @@ -745,6 +705,7 @@ pub fn parse_python_site_packages_output(stdout: &str) -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::utils::purl::parse_pypi_purl; #[test] fn test_canonicalize_pypi_name_basic() { @@ -767,17 +728,20 @@ mod tests { assert_eq!(canonicalize_pypi_name(" requests "), "requests"); } + // `find_by_purls` delegates purl parsing to the shared + // `crate::utils::purl::parse_pypi_purl`; these pin the behaviors the + // crawler depends on (qualifier/subpath stripping, non-pypi rejection). + #[test] fn test_parse_pypi_purl() { - let (name, ver) = PythonCrawler::parse_pypi_purl("pkg:pypi/requests@2.28.0").unwrap(); + let (name, ver) = parse_pypi_purl("pkg:pypi/requests@2.28.0").unwrap(); assert_eq!(name, "requests"); assert_eq!(ver, "2.28.0"); } #[test] fn test_parse_pypi_purl_with_qualifiers() { - let (name, ver) = - PythonCrawler::parse_pypi_purl("pkg:pypi/requests@2.28.0?artifact_id=abc").unwrap(); + let (name, ver) = parse_pypi_purl("pkg:pypi/requests@2.28.0?artifact_id=abc").unwrap(); assert_eq!(name, "requests"); assert_eq!(ver, "2.28.0"); } @@ -788,22 +752,20 @@ mod tests { /// silently failing the installed-package match. #[test] fn test_parse_pypi_purl_with_subpath() { - let (name, ver) = - PythonCrawler::parse_pypi_purl("pkg:pypi/requests@2.28.0#src/requests").unwrap(); + let (name, ver) = parse_pypi_purl("pkg:pypi/requests@2.28.0#src/requests").unwrap(); assert_eq!(name, "requests"); assert_eq!(ver, "2.28.0"); // Qualifier + subpath together (subpath follows qualifiers). - let (name, ver) = - PythonCrawler::parse_pypi_purl("pkg:pypi/requests@2.28.0?artifact_id=abc#src").unwrap(); + let (name, ver) = parse_pypi_purl("pkg:pypi/requests@2.28.0?artifact_id=abc#src").unwrap(); assert_eq!(name, "requests"); assert_eq!(ver, "2.28.0"); } #[test] fn test_parse_pypi_purl_invalid() { - assert!(PythonCrawler::parse_pypi_purl("pkg:npm/lodash@4.17.21").is_none()); - assert!(PythonCrawler::parse_pypi_purl("not-a-purl").is_none()); + assert!(parse_pypi_purl("pkg:npm/lodash@4.17.21").is_none()); + assert!(parse_pypi_purl("not-a-purl").is_none()); } #[tokio::test] @@ -938,7 +900,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; assert_eq!(packages.len(), 1); @@ -978,7 +939,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; assert_eq!(packages.len(), 1); @@ -1201,7 +1161,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -1229,13 +1188,11 @@ mod tests { #[test] fn test_home_dir_detection() { - // Verify the fallback chain works: HOME -> USERPROFILE -> "~" - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .unwrap_or_else(|_| "~".to_string()); - // On any CI or dev machine, we should get a real path, not "~" - assert_ne!(home, "~", "expected a real home directory"); - assert!(!home.is_empty()); + // Verify the shared fallback chain (HOME -> USERPROFILE -> "~") + // yields a real path, not the "~" sentinel, on any CI or dev machine. + let home = crate::utils::fs::home_dir(); + assert_ne!(home, PathBuf::from("~"), "expected a real home directory"); + assert!(!home.as_os_str().is_empty()); } #[tokio::test] diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index e8526db3..c3c5b4cd 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -2,6 +2,9 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use super::types::{CrawledPackage, CrawlerOptions}; +use crate::patch::path_safety; +use crate::utils::fs::{entry_is_dir, home_dir, is_dir, list_dir_entries}; +use crate::utils::process::{CommandRunner, SystemCommandRunner}; /// Ruby/RubyGems ecosystem crawler for discovering gems in Bundler vendor /// directories or global gem installation paths. @@ -52,16 +55,12 @@ impl RubyCrawler { if has_gemfile || has_gemfile_lock { // Try gem env gemdir - let mut paths = Vec::new(); if let Some(gemdir) = Self::run_gem_env("gemdir").await { let gems_path = PathBuf::from(gemdir).join("gems"); if is_dir(&gems_path).await { - paths.push(gems_path); + return Ok(vec![gems_path]); } } - if !paths.is_empty() { - return Ok(paths); - } } // Not a Ruby project — return empty @@ -136,8 +135,8 @@ impl RubyCrawler { let vendor_ruby = cwd.join("vendor").join("bundle").join("ruby"); let mut paths = Vec::new(); - for entry in crate::utils::fs::list_dir_entries(&vendor_ruby).await { - if !crate::utils::fs::entry_is_dir(&entry).await { + for entry in list_dir_entries(&vendor_ruby).await { + if !entry_is_dir(&entry).await { continue; } let gems_dir = vendor_ruby.join(entry.file_name()).join("gems"); @@ -175,10 +174,7 @@ impl RubyCrawler { } // Fallback well-known paths - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .unwrap_or_else(|_| "~".to_string()); - let home = PathBuf::from(home); + let home = home_dir(); let fallback_globs = [ home.join(".gem").join("ruby"), @@ -187,8 +183,8 @@ impl RubyCrawler { ]; for base in &fallback_globs { - for entry in crate::utils::fs::list_dir_entries(base).await { - if !crate::utils::fs::entry_is_dir(&entry).await { + for entry in list_dir_entries(base).await { + if !entry_is_dir(&entry).await { continue; } @@ -203,7 +199,7 @@ impl RubyCrawler { // ~/.rbenv/versions/*/lib/ruby/gems/*/gems/ let lib_ruby_gems = entry_path.join("lib").join("ruby").join("gems"); - for sub_entry in crate::utils::fs::list_dir_entries(&lib_ruby_gems).await { + for sub_entry in list_dir_entries(&lib_ruby_gems).await { let gems_dir = lib_ruby_gems.join(sub_entry.file_name()).join("gems"); if is_dir(&gems_dir).await && seen.insert(gems_dir.clone()) { paths.push(gems_dir); @@ -220,7 +216,7 @@ impl RubyCrawler { ]; for base in &system_bases { - for entry in crate::utils::fs::list_dir_entries(base).await { + for entry in list_dir_entries(base).await { let gems_dir = base.join(entry.file_name()).join("gems"); if is_dir(&gems_dir).await && seen.insert(gems_dir.clone()) { paths.push(gems_dir); @@ -233,18 +229,8 @@ impl RubyCrawler { /// Run `gem env ` and return the trimmed stdout. async fn run_gem_env(key: &str) -> Option { - Self::run_gem_env_with(&crate::utils::process::SystemCommandRunner, key) - } - - /// Version of `run_gem_env` that accepts an injected - /// `CommandRunner`. Tests use this with a `MockCommandRunner` to - /// exercise the success arm (gem binary present, stdout parsed) - /// without requiring ruby on the host's PATH. - fn run_gem_env_with( - runner: &dyn crate::utils::process::CommandRunner, - key: &str, - ) -> Option { - parse_gem_env_output(runner.run("gem", &["env", key]).as_deref().unwrap_or("")) + let stdout = SystemCommandRunner.run("gem", &["env", key]); + parse_gem_env_output(stdout.as_deref().unwrap_or("")) } /// Scan a gem directory and return all valid gem packages found. @@ -255,8 +241,8 @@ impl RubyCrawler { ) -> Vec { let mut results = Vec::new(); - for entry in crate::utils::fs::list_dir_entries(gem_path).await { - if !crate::utils::fs::entry_is_dir(&entry).await { + for entry in list_dir_entries(gem_path).await { + if !entry_is_dir(&entry).await { continue; } @@ -279,10 +265,9 @@ impl RubyCrawler { let purl = crate::utils::purl::build_gem_purl(&name, &version); - if seen.contains(&purl) { + if !seen.insert(purl.clone()) { continue; } - seen.insert(purl.clone()); results.push(CrawledPackage { name, @@ -310,7 +295,7 @@ impl RubyCrawler { } // Check for any .gemspec file - for entry in crate::utils::fs::list_dir_entries(path).await { + for entry in list_dir_entries(path).await { if let Some(name) = entry.file_name().to_str() { if name.ends_with(".gemspec") { return true; @@ -360,7 +345,7 @@ impl RubyCrawler { return Some(exact); } let prefix = format!("{name}-{version}-"); - for entry in crate::utils::fs::list_dir_entries(gem_path).await { + for entry in list_dir_entries(gem_path).await { let file_name = entry.file_name(); let dir_name = file_name.to_string_lossy(); if dir_name.starts_with(&prefix) { @@ -407,29 +392,16 @@ fn gem_homes_to_gems_dirs(gempath: &str) -> Vec { /// Whether a PURL-derived gem coordinate is safe to join onto the gem root. /// SECURITY: `find_by_purls` formats name/version into a `-` /// directory name joined onto `gem_path`, and a real gem name/version is -/// dash/dot/word characters only — never a separator, NUL, or bare dot -/// segment. `verify_gem_at_path` only checks for `lib/`/`.gemspec` and gems -/// are patched in place, so a tampered manifest PURL (`pkg:gem/../x@1.0`, +/// dash/dot/word characters only — never a separator, colon, NUL, or bare +/// dot segment. `verify_gem_at_path` only checks for `lib/`/`.gemspec` and +/// gems are patched in place, so a tampered manifest PURL (`pkg:gem/../x@1.0`, /// an absolute name, a `/`-bearing version) must be rejected here, fail -/// closed. Mirrors the deno/go/maven/npm/nuget crawler coordinate guards. +/// closed. Delegates to [`path_safety::is_safe_single_segment`], which also +/// rejects `:` — a Windows drive-relative coordinate (`C:evil`) joins as an +/// absolute path. Mirrors the deno/go/maven/npm/nuget crawler coordinate +/// guards. fn is_safe_gem_coordinate(name: &str, version: &str) -> bool { - let safe = |s: &str| { - !s.is_empty() - && s != "." - && s != ".." - && !s.contains('/') - && !s.contains('\\') - && !s.contains('\0') - }; - safe(name) && safe(version) -} - -/// Check whether a path is a directory. -async fn is_dir(path: &Path) -> bool { - tokio::fs::metadata(path) - .await - .map(|m| m.is_dir()) - .unwrap_or(false) + path_safety::is_safe_single_segment(name) && path_safety::is_safe_single_segment(version) } #[cfg(test)] @@ -529,7 +501,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -572,7 +543,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; @@ -671,7 +641,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; assert_eq!(packages.len(), 1); @@ -789,7 +758,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: Some(dir.path().to_path_buf()), - batch_size: 100, }; let packages = crawler.crawl_all(&options).await; let purls: HashSet<_> = packages.iter().map(|p| p.purl.as_str()).collect(); @@ -877,7 +845,6 @@ mod tests { cwd: dir.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_gem_paths(&options).await.unwrap(); assert!( @@ -979,6 +946,10 @@ mod tests { assert!(!is_safe_gem_coordinate("a\\b", "1.0.0")); assert!(!is_safe_gem_coordinate("a\0b", "1.0.0")); assert!(!is_safe_gem_coordinate("/abs/evil", "1.0.0")); + // Windows drive-relative escape: a `:` (e.g. `C:evil`) makes the + // joined path absolute under `Path::join`. + assert!(!is_safe_gem_coordinate("C:evil", "1.0.0")); + assert!(!is_safe_gem_coordinate("rails", "C:1.0.0")); } /// Gem names with embedded underscores/digits and multi-dash names diff --git a/crates/socket-patch-core/src/crawlers/types.rs b/crates/socket-patch-core/src/crawlers/types.rs index acf35722..283fb5e9 100644 --- a/crates/socket-patch-core/src/crawlers/types.rs +++ b/crates/socket-patch-core/src/crawlers/types.rs @@ -5,16 +5,11 @@ use std::path::PathBuf; pub enum Ecosystem { Npm, Pypi, - #[cfg(feature = "cargo")] Cargo, Gem, - #[cfg(feature = "golang")] Golang, - #[cfg(feature = "maven")] Maven, - #[cfg(feature = "composer")] Composer, - #[cfg(feature = "nuget")] Nuget, /// Deno's JSR registry. PURL form /// `pkg:jsr//@`. Note: Deno's `deno install` @@ -22,7 +17,6 @@ pub enum Ecosystem { /// `pkg:npm/...` packages — those route through `Ecosystem::Npm` /// unchanged. Only JSR (the deno-native registry) gets its own /// variant. - #[cfg(feature = "deno")] Deno, } @@ -32,48 +26,36 @@ impl Ecosystem { &[ Ecosystem::Npm, Ecosystem::Pypi, - #[cfg(feature = "cargo")] Ecosystem::Cargo, Ecosystem::Gem, - #[cfg(feature = "golang")] Ecosystem::Golang, - #[cfg(feature = "maven")] Ecosystem::Maven, - #[cfg(feature = "composer")] Ecosystem::Composer, - #[cfg(feature = "nuget")] Ecosystem::Nuget, - #[cfg(feature = "deno")] Ecosystem::Deno, ] } /// Match a PURL string to its ecosystem. pub fn from_purl(purl: &str) -> Option { - #[cfg(feature = "cargo")] if purl.starts_with("pkg:cargo/") { return Some(Ecosystem::Cargo); } if purl.starts_with("pkg:gem/") { return Some(Ecosystem::Gem); } - #[cfg(feature = "golang")] if purl.starts_with("pkg:golang/") { return Some(Ecosystem::Golang); } - #[cfg(feature = "maven")] if purl.starts_with("pkg:maven/") { return Some(Ecosystem::Maven); } - #[cfg(feature = "composer")] if purl.starts_with("pkg:composer/") { return Some(Ecosystem::Composer); } - #[cfg(feature = "nuget")] if purl.starts_with("pkg:nuget/") { return Some(Ecosystem::Nuget); } - #[cfg(feature = "deno")] if purl.starts_with("pkg:jsr/") { return Some(Ecosystem::Deno); } @@ -91,18 +73,12 @@ impl Ecosystem { match self { Ecosystem::Npm => "npm", Ecosystem::Pypi => "pypi", - #[cfg(feature = "cargo")] Ecosystem::Cargo => "cargo", Ecosystem::Gem => "gem", - #[cfg(feature = "golang")] Ecosystem::Golang => "golang", - #[cfg(feature = "maven")] Ecosystem::Maven => "maven", - #[cfg(feature = "composer")] Ecosystem::Composer => "composer", - #[cfg(feature = "nuget")] Ecosystem::Nuget => "nuget", - #[cfg(feature = "deno")] Ecosystem::Deno => "deno", } } @@ -123,12 +99,7 @@ impl Ecosystem { /// out to every variant (release-variant ecosystems) or to match /// PURLs 1:1 (everything else). pub fn supports_release_variants(&self) -> bool { - match self { - Ecosystem::Pypi | Ecosystem::Gem => true, - #[cfg(feature = "maven")] - Ecosystem::Maven => true, - _ => false, - } + matches!(self, Ecosystem::Pypi | Ecosystem::Gem | Ecosystem::Maven) } /// Human-readable name for user-facing messages. @@ -136,18 +107,12 @@ impl Ecosystem { match self { Ecosystem::Npm => "npm", Ecosystem::Pypi => "python", - #[cfg(feature = "cargo")] Ecosystem::Cargo => "cargo", Ecosystem::Gem => "ruby", - #[cfg(feature = "golang")] Ecosystem::Golang => "go", - #[cfg(feature = "maven")] Ecosystem::Maven => "maven", - #[cfg(feature = "composer")] Ecosystem::Composer => "php", - #[cfg(feature = "nuget")] Ecosystem::Nuget => "nuget", - #[cfg(feature = "deno")] Ecosystem::Deno => "deno", } } @@ -177,8 +142,6 @@ pub struct CrawlerOptions { pub global: bool, /// Custom path to global package directory (overrides auto-detection). pub global_prefix: Option, - /// Batch size for yielding packages (default: 100). - pub batch_size: usize, } impl Default for CrawlerOptions { @@ -187,7 +150,6 @@ impl Default for CrawlerOptions { cwd: std::env::current_dir().unwrap_or_default(), global: false, global_prefix: None, - batch_size: 100, } } } @@ -270,12 +232,10 @@ mod tests { assert_eq!(Ecosystem::Pypi.display_name(), "python"); assert_eq!(Ecosystem::Gem.cli_name(), "gem"); assert_eq!(Ecosystem::Gem.display_name(), "ruby"); - #[cfg(feature = "golang")] { assert_eq!(Ecosystem::Golang.cli_name(), "golang"); assert_eq!(Ecosystem::Golang.display_name(), "go"); } - #[cfg(feature = "composer")] { assert_eq!(Ecosystem::Composer.cli_name(), "composer"); assert_eq!(Ecosystem::Composer.display_name(), "php"); @@ -293,7 +253,6 @@ mod tests { // A synthetic PURL built from the type re-classifies to itself. // Deno is the one type whose PURL token (`jsr`) differs from its // cli_name (`deno`), so it is exercised separately below. - #[cfg(feature = "deno")] if *eco == Ecosystem::Deno { continue; } @@ -307,7 +266,6 @@ mod tests { } } - #[cfg(feature = "cargo")] #[test] fn test_from_purl_cargo() { assert_eq!( @@ -321,27 +279,21 @@ mod tests { let all = Ecosystem::all(); #[allow(unused_mut)] let mut expected = 3; - #[cfg(feature = "cargo")] { expected += 1; } - #[cfg(feature = "golang")] { expected += 1; } - #[cfg(feature = "maven")] { expected += 1; } - #[cfg(feature = "composer")] { expected += 1; } - #[cfg(feature = "nuget")] { expected += 1; } - #[cfg(feature = "deno")] { expected += 1; } @@ -360,7 +312,6 @@ mod tests { assert_eq!(Ecosystem::Pypi.display_name(), "python"); } - #[cfg(feature = "cargo")] #[test] fn test_cargo_properties() { assert_eq!(Ecosystem::Cargo.cli_name(), "cargo"); @@ -372,23 +323,16 @@ mod tests { // Multi-artifact ecosystems. assert!(Ecosystem::Pypi.supports_release_variants()); assert!(Ecosystem::Gem.supports_release_variants()); - #[cfg(feature = "maven")] assert!(Ecosystem::Maven.supports_release_variants()); // Single-artifact ecosystems. assert!(!Ecosystem::Npm.supports_release_variants()); - #[cfg(feature = "cargo")] assert!(!Ecosystem::Cargo.supports_release_variants()); - #[cfg(feature = "nuget")] assert!(!Ecosystem::Nuget.supports_release_variants()); - #[cfg(feature = "golang")] assert!(!Ecosystem::Golang.supports_release_variants()); - #[cfg(feature = "composer")] assert!(!Ecosystem::Composer.supports_release_variants()); - #[cfg(feature = "deno")] assert!(!Ecosystem::Deno.supports_release_variants()); } - #[cfg(feature = "deno")] #[test] fn test_from_purl_deno_jsr() { // JSR packages use the `pkg:jsr/` type but route to Ecosystem::Deno. @@ -403,7 +347,6 @@ mod tests { ); } - #[cfg(feature = "deno")] #[test] fn test_deno_properties() { assert_eq!(Ecosystem::Deno.cli_name(), "deno"); @@ -424,7 +367,6 @@ mod tests { assert_eq!(Ecosystem::Gem.display_name(), "ruby"); } - #[cfg(feature = "maven")] #[test] fn test_from_purl_maven() { assert_eq!( @@ -433,14 +375,12 @@ mod tests { ); } - #[cfg(feature = "maven")] #[test] fn test_maven_properties() { assert_eq!(Ecosystem::Maven.cli_name(), "maven"); assert_eq!(Ecosystem::Maven.display_name(), "maven"); } - #[cfg(feature = "golang")] #[test] fn test_from_purl_golang() { assert_eq!( @@ -449,14 +389,12 @@ mod tests { ); } - #[cfg(feature = "golang")] #[test] fn test_golang_properties() { assert_eq!(Ecosystem::Golang.cli_name(), "golang"); assert_eq!(Ecosystem::Golang.display_name(), "go"); } - #[cfg(feature = "composer")] #[test] fn test_from_purl_composer() { assert_eq!( @@ -465,14 +403,12 @@ mod tests { ); } - #[cfg(feature = "composer")] #[test] fn test_composer_properties() { assert_eq!(Ecosystem::Composer.cli_name(), "composer"); assert_eq!(Ecosystem::Composer.display_name(), "php"); } - #[cfg(feature = "nuget")] #[test] fn test_from_purl_nuget() { assert_eq!( @@ -481,7 +417,6 @@ mod tests { ); } - #[cfg(feature = "nuget")] #[test] fn test_nuget_properties() { assert_eq!(Ecosystem::Nuget.cli_name(), "nuget"); @@ -494,7 +429,6 @@ mod tests { /// classify→cli_name chain must still land on `"deno"` or /// `--ecosystems deno` would silently drop every JSR package. The /// existing tests pin the two halves separately; this pins the join. - #[cfg(feature = "deno")] #[test] fn test_jsr_purl_classifies_to_deno_cli_token() { assert_eq!( @@ -504,40 +438,34 @@ mod tests { } /// `test_from_purl_ignores_qualifiers` only exercises npm/pypi/gem. - /// The feature-gated ecosystems carry qualifiers in the wild too + /// The remaining ecosystems carry qualifiers in the wild too /// (`?repository_url=` for jsr/maven, `?classifier=&ext=` for maven, /// version-suffixed module paths for go), and classification must /// still key off the type prefix alone. #[test] - fn test_from_purl_ignores_qualifiers_feature_gated() { - #[cfg(feature = "cargo")] + fn test_from_purl_ignores_qualifiers_other_ecosystems() { assert_eq!( Ecosystem::from_purl("pkg:cargo/serde@1.0.200?foo=bar"), Some(Ecosystem::Cargo) ); - #[cfg(feature = "maven")] assert_eq!( Ecosystem::from_purl( "pkg:maven/org.apache.commons/commons-lang3@3.12.0?classifier=sources&ext=jar" ), Some(Ecosystem::Maven) ); - #[cfg(feature = "golang")] assert_eq!( Ecosystem::from_purl("pkg:golang/github.com/go-redis/cache/v9@v9.0.0?foo=bar"), Some(Ecosystem::Golang) ); - #[cfg(feature = "composer")] assert_eq!( Ecosystem::from_purl("pkg:composer/monolog/monolog@3.5.0?dev=true"), Some(Ecosystem::Composer) ); - #[cfg(feature = "nuget")] assert_eq!( Ecosystem::from_purl("pkg:nuget/Newtonsoft.Json@13.0.3?foo=bar"), Some(Ecosystem::Nuget) ); - #[cfg(feature = "deno")] assert_eq!( Ecosystem::from_purl("pkg:jsr/@std/path@0.220.0?repository_url=https://jsr.io"), Some(Ecosystem::Deno) @@ -571,13 +499,12 @@ mod tests { } } - /// The documented default batch size is 100. A regression to 0 would - /// reintroduce the batch-size-0 division/panic class of bug seen in - /// the scan path, so pin the contract here at the source of truth. + /// Defaults must describe a local (non-global) crawl with no prefix + /// override, so a caller that forgets to set the flags gets the safe + /// project-local behavior. #[test] - fn test_crawler_options_default_batch_size() { + fn test_crawler_options_defaults() { let opts = CrawlerOptions::default(); - assert_eq!(opts.batch_size, 100); assert!(!opts.global); assert!(opts.global_prefix.is_none()); } diff --git a/crates/socket-patch-core/src/gem_setup/mod.rs b/crates/socket-patch-core/src/gem_setup/mod.rs index 44dc933a..3926071c 100644 --- a/crates/socket-patch-core/src/gem_setup/mod.rs +++ b/crates/socket-patch-core/src/gem_setup/mod.rs @@ -11,15 +11,18 @@ //! on every `bundle install` (load-time digest gate + `after-install-all` //! hook), failing the build loudly on a patch failure; //! * a managed block appended to the `Gemfile` that references the plugin via -//! `plugin "socket-patch", git: File.expand_path(".socket/bundler-plugin", -//! __dir__)`. Bundler only loads *committed* git plugins, so the generated -//! directory must be committed. +//! `plugin "socket-patch", path: File.expand_path(".socket/bundler-plugin", +//! __dir__)`. The source must be `path:` — Bundler fetches `git:` plugin +//! sources with `git clone`, and the generated dir is a plain directory +//! (committing it to the parent repo does not give it a `.git`), so `git:` +//! fails every `bundle install`. The directory still must be committed so +//! clones and CI have the plugin on disk. //! //! The actual gem patching is done by `apply` (unchanged); this module only //! manages the setup wiring. Phase 2 (follow-up) replaces the in-tree plugin //! with a published `socket-patch-bundler` gem. -pub mod update; +mod update; use std::path::{Path, PathBuf}; @@ -31,15 +34,15 @@ pub use update::{ }; /// The in-tree plugin directory, relative to the project root. -pub const PLUGIN_DIR: &str = ".socket/bundler-plugin"; +const PLUGIN_DIR: &str = ".socket/bundler-plugin"; /// First line of every generated plugin file — the ownership signal for removal /// (we never delete a file that lacks it). -pub const GENERATED_MARKER: &str = "# Code generated by `socket-patch setup`. DO NOT EDIT."; +const GENERATED_MARKER: &str = "# Code generated by `socket-patch setup`. DO NOT EDIT."; /// The generated `plugins.rb` body (the two-trigger idempotent applier). -pub const PLUGINS_RB: &str = include_str!("templates/plugins.rb.tmpl"); +const PLUGINS_RB: &str = include_str!("templates/plugins.rb.tmpl"); /// The generated plugin gemspec. -pub const GEMSPEC: &str = include_str!("templates/gemspec.tmpl"); +const GEMSPEC: &str = include_str!("templates/gemspec.tmpl"); /// A discovered Bundler project. #[derive(Debug, Clone)] @@ -70,7 +73,34 @@ pub async fn discover_bundler_project(cwd: &Path) -> Option { }); } } - dir = dir.parent()?.to_path_buf(); + dir = if matches!( + dir.components().next_back(), + Some(std::path::Component::ParentDir) + ) { + // `Path::parent` is lexical: it strips a trailing `..`, stepping + // the walk back DOWN into the directory the `..` just escaped + // (parent of `a/b/..` is `a/b`) and probing descendants outside + // the real ancestry. Resolve the position against the filesystem — + // the same way the kernel resolved this iteration's metadata + // probes — and continue from its real parent. + fs::canonicalize(&dir).await.ok()?.parent()?.to_path_buf() + } else { + match dir.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(), + // A relative `cwd` (e.g. the CLI's default `--cwd .`) exhausts its + // lexical components here (`Path::parent` of `.` is `Some("")`, + // then `None`) without ever reaching the real parent directories. + // Re-root the walk on the process cwd so the true ancestry is + // still probed — `bundle` resolves the Gemfile from the invocation + // dir's real ancestors, wherever it is run from. + _ if dir.is_relative() => std::env::current_dir() + .ok()? + .join(&dir) + .parent()? + .to_path_buf(), + _ => return None, + } + }; } } @@ -116,7 +146,7 @@ async fn write_file(path: &Path, body: &str) -> Result<(), String> { /// Generate `.socket/bundler-plugin/{plugins.rb, socket-patch.gemspec}`. /// Idempotent: `AlreadyConfigured` when both already match the templates byte /// for byte. `kind = "gem_plugin"`. -pub async fn add_plugin_files(root: &Path, dry_run: bool) -> GemEditResult { +async fn add_plugin_files(root: &Path, dry_run: bool) -> GemEditResult { let dir = plugin_dir(root); let result = async { let rb_changed = needs_write(&plugins_rb_path(root), PLUGINS_RB).await; @@ -161,7 +191,7 @@ async fn remove_generated(path: &Path) -> Result<(), String> { /// [`GENERATED_MARKER`], so a user-authored file at either path is never /// deleted (and an orphaned generated file is still cleaned up). Idempotent: /// `AlreadyConfigured` when nothing of ours is there. -pub async fn remove_plugin_files(root: &Path, dry_run: bool) -> GemEditResult { +async fn remove_plugin_files(root: &Path, dry_run: bool) -> GemEditResult { let dir = plugin_dir(root); let result = async { let rb_ours = is_generated(&plugins_rb_path(root)).await; @@ -292,6 +322,37 @@ mod tests { assert_eq!(fs::read_to_string(&proj.gemfile).await.unwrap(), "inner\n"); } + #[tokio::test] + async fn test_discover_dot_dot_cwd_walks_real_ancestry_not_back_down() { + // `--cwd`/SOCKET_CWD may carry `..` components (`--cwd ..`, `$PWD/..`). + // The metadata probes resolve them against the real filesystem, but + // `Path::parent` strips components lexically — the parent of + // `up/mid/b/..` is `up/mid/b`, the very directory the `..` just + // escaped. The walk must continue UP the real ancestry + // (`up/mid` → `up`), never back down into a descendant that `bundle` + // itself would not consult. + let dir = tempfile::tempdir().unwrap(); + // Canonicalize so the expected root compares path-exactly (macOS + // tempdirs live behind the /var → /private/var symlink). + let base = std::fs::canonicalize(dir.path()).unwrap(); + let up = base.join("up"); + let b = up.join("mid").join("b"); + fs::create_dir_all(&b).await.unwrap(); + write(&up.join("Gemfile"), "real ancestor\n").await; + write(&b.join("Gemfile"), "descendant, not in the ancestry\n").await; + + let proj = discover_bundler_project(&b.join("..")).await.unwrap(); + assert_eq!( + proj.root, up, + "walk from up/mid (= up/mid/b/..) must reach the ancestor `up`, \ + not fall back down into up/mid/b" + ); + assert_eq!( + fs::read_to_string(&proj.gemfile).await.unwrap(), + "real ancestor\n" + ); + } + #[test] fn test_templates_are_well_formed() { // The plugin must carry the ownership marker and both triggers. @@ -313,6 +374,10 @@ mod tests { assert!(GEMSPEC.starts_with(GENERATED_MARKER)); assert!(GEMSPEC.contains("\"socket-patch\"")); assert!(GEMSPEC.contains("plugins.rb")); + // The flat plugin dir has no lib/; without this override Bundler + // refuses to load the plugin ("plugin paths don't exist: .../lib") + // and silently continues without it. + assert!(GEMSPEC.contains("s.require_paths = [\".\"]")); } #[tokio::test] diff --git a/crates/socket-patch-core/src/gem_setup/templates/gemspec.tmpl b/crates/socket-patch-core/src/gem_setup/templates/gemspec.tmpl index 68a73bed..cd3b054f 100644 --- a/crates/socket-patch-core/src/gem_setup/templates/gemspec.tmpl +++ b/crates/socket-patch-core/src/gem_setup/templates/gemspec.tmpl @@ -1,8 +1,8 @@ # Code generated by `socket-patch setup`. DO NOT EDIT. # # Minimal gemspec for the in-tree socket-patch Bundler plugin. `setup` references -# it from the Gemfile via `plugin "socket-patch", git: File.expand_path(...)`; -# Bundler only loads committed git plugins, so this directory must be committed. +# it from the Gemfile via `plugin "socket-patch", path: File.expand_path(...)`; +# the directory must be committed so clones and CI have the plugin on disk. # (Phase 2 replaces this in-tree plugin with a published `socket-patch-bundler` # gem.) Gem::Specification.new do |s| @@ -13,5 +13,10 @@ Gem::Specification.new do |s| s.authors = ["Socket"] s.license = "MIT" s.files = ["plugins.rb"] + # The plugin dir is flat (plugins.rb at the root, no lib/). Bundler refuses + # to load a plugin whose require paths are missing on disk ("The following + # plugin paths don't exist: .../lib ... Continuing without installing + # plugin"), so the default `lib` must be overridden. + s.require_paths = ["."] s.required_ruby_version = ">= 2.6.0" end diff --git a/crates/socket-patch-core/src/gem_setup/update.rs b/crates/socket-patch-core/src/gem_setup/update.rs index 9fa9e539..e2b46982 100644 --- a/crates/socket-patch-core/src/gem_setup/update.rs +++ b/crates/socket-patch-core/src/gem_setup/update.rs @@ -11,6 +11,7 @@ use std::path::Path; use tokio::fs; use super::{add_plugin_files, remove_plugin_files, BundlerProject}; +use crate::utils::fs::atomic_write_bytes; /// Outcome of one setup edit. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -20,7 +21,7 @@ pub enum GemSetupStatus { Error, } -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct GemEditResult { /// Envelope `files[].kind` (`gemfile` | `gem_plugin`). pub kind: &'static str, @@ -31,7 +32,7 @@ pub struct GemEditResult { impl GemEditResult { /// Build a result from an `Ok(changed)` / `Err(message)` outcome. - pub(crate) fn from_result( + pub(super) fn from_result( kind: &'static str, path: String, result: Result, @@ -59,68 +60,22 @@ impl GemEditResult { } } -/// Atomically write `content` to `path`. -/// -/// A bare `fs::write` truncates the target before writing, so a crash, power -/// loss, or interrupted process mid-write would leave the user's committed -/// `Gemfile` truncated or empty — destroying the file we only meant to -/// append a three-line block to. Instead we write to a sibling stage file, -/// fsync it, then rename over the target (rename is atomic on the same -/// filesystem) so a reader ever sees either the old bytes or the complete new -/// bytes. Mirrors the hardened writer in `composer_setup` / `package_json`. -async fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let stem = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "Gemfile".to_string()); - let stage = parent.join(format!(".socket-stage-{}-{}", stem, uuid::Uuid::new_v4())); - - let mut file = tokio::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&stage) - .await?; - - use tokio::io::AsyncWriteExt; - if let Err(e) = file.write_all(content.as_bytes()).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - if let Err(e) = file.sync_all().await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - drop(file); - - if let Err(e) = tokio::fs::rename(&stage, path).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - - // The rename only updated the parent directory entry; fsync the directory - // so the rename itself survives a crash. Best-effort, Unix only. - #[cfg(unix)] - { - if let Ok(dir) = tokio::fs::File::open(parent).await { - let _ = dir.sync_all().await; - } - } - - Ok(()) -} - /// Stable substring identifying our managed block — `setup --check` and the /// add/remove edits all key on it, so a user-authored `plugin` line is never /// mistaken for ours. -pub const MANAGED_MARKER: &str = "# >>> socket-patch:managed"; +const MANAGED_MARKER: &str = "# >>> socket-patch:managed"; /// The exact block `setup` appends to the Gemfile (trailing newline included). /// `File.expand_path(..., __dir__)` resolves relative to the Gemfile's own dir, /// so the reference is correct regardless of where `bundle` is invoked from. +/// The source MUST be `path:`, not `git:`: Bundler fetches a `git:` plugin via +/// `git clone `, and the generated dir is a plain directory (committing it +/// to the parent repo does not give it a `.git`), so a `git:` source fails +/// every `bundle install` with "repository ... does not exist". A `path:` +/// source loads the directory in place. const MANAGED_BLOCK: &str = "\ # >>> socket-patch:managed (added by `socket-patch setup`; do not edit) >>>\n\ -plugin 'socket-patch', git: File.expand_path('.socket/bundler-plugin', __dir__)\n\ +plugin 'socket-patch', path: File.expand_path('.socket/bundler-plugin', __dir__)\n\ # <<< socket-patch:managed <<<\n"; /// What we append after the user's content: a blank-line separator + the block. @@ -154,8 +109,19 @@ fn gemfile_remove(content: &str) -> Option { // block if the leading separator was edited away. let appended = appended(); if let Some(idx) = content.find(&appended) { + let end = idx + appended.len(); + // The separator "\n" doubles as the terminator of a final unterminated + // pre-setup line. Stripping it is only safe when the block sits at EOF + // (the byte-exact restore) or the separator is a pure blank line + // (preceded by a newline, or at the start of the file); otherwise the + // user's lines on either side of the block would glue into one. + let start = if end == content.len() || idx == 0 || content[..idx].ends_with('\n') { + idx + } else { + idx + 1 + }; let mut out = content.to_string(); - out.replace_range(idx..idx + appended.len(), ""); + out.replace_range(start..end, ""); Some(out) } else { // Separator edited away: strip just the block. If the block body was @@ -179,7 +145,10 @@ async fn edit_gemfile_add(gemfile: &Path, dry_run: bool) -> GemEditResult { None => Ok(false), Some(new) => { if !dry_run { - atomic_write(gemfile, &new) + // Stage+fsync+rename via the crate-wide hardened writer: + // the user's committed Gemfile must never be left torn by + // a crash mid-write. + atomic_write_bytes(gemfile, new.as_bytes()) .await .map_err(|e| e.to_string())?; } @@ -204,7 +173,7 @@ async fn edit_gemfile_remove(gemfile: &Path, dry_run: bool) -> GemEditResult { None => Ok(false), Some(new) => { if !dry_run { - atomic_write(gemfile, &new) + atomic_write_bytes(gemfile, new.as_bytes()) .await .map_err(|e| e.to_string())?; } @@ -251,7 +220,10 @@ mod tests { "original bytes preserved as a prefix" ); assert!(is_plugin_directive_present(&out)); - assert!(out.contains("plugin 'socket-patch'")); + // `path:`-sourced, never `git:`: Bundler git-clones a `git:` plugin + // source, and the plain generated dir is uncloneable, breaking every + // `bundle install` on the wired project. + assert!(out.contains("plugin 'socket-patch', path:")); assert!(out.contains("File.expand_path('.socket/bundler-plugin', __dir__)")); // Idempotent. assert!(gemfile_add(&out).is_none()); @@ -340,6 +312,23 @@ mod tests { ); } + #[test] + fn test_remove_does_not_glue_lines_when_original_lacked_trailing_newline() { + // Original Gemfile has no final newline; setup's "\n" separator becomes + // the terminator of that last line. The user then adds gems AFTER our + // block. remove must not strip that separator along with the block — + // doing so glues `gem 'colorize', '1.1.0'` onto `gem 'extra', '2.0'` + // (one invalid Ruby line). + let no_nl = "source 'https://rubygems.org'\ngem 'colorize', '1.1.0'"; + let added = gemfile_add(no_nl).unwrap(); + let user_edited = format!("{added}gem 'extra', '2.0'\n"); + assert_eq!( + gemfile_remove(&user_edited).unwrap(), + format!("{no_nl}\ngem 'extra', '2.0'\n"), + "the separator newline must survive as the last line's terminator" + ); + } + #[test] fn test_round_trips_crlf_content_byte_for_byte() { // A Windows-authored Gemfile uses CRLF line endings. add appends an diff --git a/crates/socket-patch-core/src/hash/git_sha256.rs b/crates/socket-patch-core/src/hash/git_sha256.rs index 06048200..27f7d3ac 100644 --- a/crates/socket-patch-core/src/hash/git_sha256.rs +++ b/crates/socket-patch-core/src/hash/git_sha256.rs @@ -28,7 +28,7 @@ pub fn compute_git_sha256_from_bytes(data: &[u8]) -> String { /// To avoid draining an arbitrarily large (or slow/unbounded) stream once the /// hash is already known to be invalid, the loop bails out as soon as the bytes /// read exceed `size`; it does not keep reading just to report a larger total. -pub async fn compute_git_sha256_from_reader( +pub(crate) async fn compute_git_sha256_from_reader( size: u64, mut reader: R, ) -> io::Result { diff --git a/crates/socket-patch-core/src/hash/mod.rs b/crates/socket-patch-core/src/hash/mod.rs index 45732e4e..cfe16716 100644 --- a/crates/socket-patch-core/src/hash/mod.rs +++ b/crates/socket-patch-core/src/hash/mod.rs @@ -1,3 +1 @@ pub mod git_sha256; - -pub use git_sha256::*; diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index af5ff755..1d10dbfa 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -1,5 +1,4 @@ pub mod api; -#[cfg(feature = "composer")] pub mod composer_setup; pub mod constants; pub mod crawlers; diff --git a/crates/socket-patch-core/src/manifest/mod.rs b/crates/socket-patch-core/src/manifest/mod.rs index 38b32c42..93413870 100644 --- a/crates/socket-patch-core/src/manifest/mod.rs +++ b/crates/socket-patch-core/src/manifest/mod.rs @@ -1,4 +1,2 @@ pub mod operations; pub mod schema; - -pub use schema::*; diff --git a/crates/socket-patch-core/src/manifest/operations.rs b/crates/socket-patch-core/src/manifest/operations.rs index 8d6b04cf..561c3606 100644 --- a/crates/socket-patch-core/src/manifest/operations.rs +++ b/crates/socket-patch-core/src/manifest/operations.rs @@ -1,19 +1,8 @@ use std::collections::HashSet; -use std::path::{Path, PathBuf}; +use std::path::Path; use crate::manifest::schema::PatchManifest; -/// Resolve a manifest path: absolute paths are returned as-is, relative paths -/// are joined to `cwd`. Centralizes the duplicate block previously inlined in -/// apply/rollback/list/remove/repair commands. -pub fn resolve_manifest_path(cwd: &Path, manifest_path: &str) -> PathBuf { - if Path::new(manifest_path).is_absolute() { - PathBuf::from(manifest_path) - } else { - cwd.join(manifest_path) - } -} - /// Get only afterHash blobs referenced by a manifest. /// Used for apply operations -- we only need the patched file content, not the original. /// This saves disk space since beforeHash blobs are not needed for applying patches. @@ -51,7 +40,7 @@ pub fn get_before_hash_blobs(manifest: &PatchManifest) -> HashSet { /// Validate a parsed JSON value as a PatchManifest. /// Returns Ok(manifest) if valid, or Err(message) if invalid. -pub fn validate_manifest(value: &serde_json::Value) -> Result { +fn validate_manifest(value: &serde_json::Value) -> Result { serde_json::from_value::(value.clone()) .map_err(|e| format!("Invalid manifest: {}", e)) } @@ -67,7 +56,7 @@ pub async fn read_manifest( let content = match tokio::fs::read_to_string(path).await { Ok(c) => c, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => return Err(e), // FIX: propagate actual I/O error + Err(e) => return Err(e), }; let parsed: serde_json::Value = match serde_json::from_str(&content) { @@ -104,48 +93,7 @@ pub async fn write_manifest( let path = path.as_ref(); let content = serde_json::to_string_pretty(manifest) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let stem = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "manifest.json".to_string()); - let stage = parent.join(format!(".socket-stage-{}-{}", stem, uuid::Uuid::new_v4())); - - let mut file = tokio::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&stage) - .await?; - - use tokio::io::AsyncWriteExt; - if let Err(e) = file.write_all(content.as_bytes()).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - if let Err(e) = file.sync_all().await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - drop(file); - - if let Err(e) = tokio::fs::rename(&stage, path).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - - // Durability: `sync_all` flushed the file's data, but the rename only - // updated the parent directory entry. fsync the directory so the rename - // itself survives a crash. Unix only; best-effort, since a directory we - // can't open for fsync must not fail an otherwise-successful write. - #[cfg(unix)] - { - if let Ok(dir) = tokio::fs::File::open(parent).await { - let _ = dir.sync_all().await; - } - } - - Ok(()) + crate::utils::fs::atomic_write_bytes(path, content.as_bytes()).await } #[cfg(test)] @@ -529,30 +477,4 @@ mod tests { ); } } - - #[test] - fn test_resolve_manifest_path_relative_joins_cwd() { - let cwd = Path::new("/tmp/proj"); - let resolved = resolve_manifest_path(cwd, ".socket/manifest.json"); - assert_eq!(resolved, PathBuf::from("/tmp/proj/.socket/manifest.json")); - } - - #[test] - fn test_resolve_manifest_path_absolute_unchanged() { - let cwd = Path::new("/tmp/proj"); - let absolute = if cfg!(windows) { - r"C:\custom\manifest.json" - } else { - "/etc/custom/manifest.json" - }; - let resolved = resolve_manifest_path(cwd, absolute); - assert_eq!(resolved, PathBuf::from(absolute)); - } - - #[test] - fn test_resolve_manifest_path_relative_dotted() { - let cwd = Path::new("/tmp/proj"); - let resolved = resolve_manifest_path(cwd, "../manifest.json"); - assert_eq!(resolved, PathBuf::from("/tmp/proj/../manifest.json")); - } } diff --git a/crates/socket-patch-core/src/manifest/schema.rs b/crates/socket-patch-core/src/manifest/schema.rs index aac2ce29..d7d54b81 100644 --- a/crates/socket-patch-core/src/manifest/schema.rs +++ b/crates/socket-patch-core/src/manifest/schema.rs @@ -1,21 +1,6 @@ -use serde::{Deserialize, Serialize, Serializer}; -use std::collections::{BTreeMap, HashMap}; - -/// Serialize a `HashMap` with its keys in sorted order so the emitted JSON is -/// deterministic across runs. The manifest is persisted as `.socket/manifest.json` -/// and committed to git; `HashMap`'s randomized iteration order would otherwise -/// re-shuffle the keys on every write, producing spurious diffs and merge -/// conflicts. This mirrors the `BTreeMap` choice in `vex::schema`, which the -/// project made for the same "easier diffing across runs" reason. The public -/// field type stays `HashMap` (so callers and deserialization are unaffected); -/// only the on-the-wire ordering is pinned. -fn serialize_sorted(map: &HashMap, serializer: S) -> Result -where - S: Serializer, - V: Serialize, -{ - map.iter().collect::>().serialize(serializer) -} +use crate::utils::serde::serialize_sorted; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; /// Information about a vulnerability fixed by a patch. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -71,7 +56,7 @@ pub struct SetupConfig { impl SetupConfig { /// Whether this carries no setup state (so the manifest can omit the key). - pub fn is_empty(&self) -> bool { + fn is_empty(&self) -> bool { self.exclude.is_empty() && self.manual.is_empty() } } @@ -88,7 +73,7 @@ fn setup_is_absent(setup: &Option) -> bool { /// The top-level patch manifest structure. /// Stored as `.socket/manifest.json`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct PatchManifest { /// Maps package PURL (e.g., "pkg:npm/lodash@4.17.21") -> patch record. #[serde(serialize_with = "serialize_sorted")] @@ -104,16 +89,7 @@ pub struct PatchManifest { impl PatchManifest { /// Create an empty manifest. pub fn new() -> Self { - Self { - patches: HashMap::new(), - setup: None, - } - } -} - -impl Default for PatchManifest { - fn default() -> Self { - Self::new() + Self::default() } } diff --git a/crates/socket-patch-core/src/package_json/detect.rs b/crates/socket-patch-core/src/package_json/detect.rs index 569f118b..ab7de389 100644 --- a/crates/socket-patch-core/src/package_json/detect.rs +++ b/crates/socket-patch-core/src/package_json/detect.rs @@ -39,23 +39,23 @@ pub struct ScriptSetupStatus { pub needs_update: bool, } -/// Check if package.json scripts are properly configured for socket-patch. -/// Checks both the postinstall and dependencies lifecycle scripts. -pub fn is_setup_configured(package_json: &serde_json::Value) -> ScriptSetupStatus { - let scripts = package_json.get("scripts"); - - let postinstall_script = scripts - .and_then(|s| s.get("postinstall")) +/// Read `scripts.` as a string, treating absent or non-string as empty. +fn read_script(package_json: &serde_json::Value, key: &str) -> String { + package_json + .get("scripts") + .and_then(|s| s.get(key)) .and_then(|v| v.as_str()) .unwrap_or("") - .to_string(); + .to_string() +} + +/// Check if package.json scripts are properly configured for socket-patch. +/// Checks both the postinstall and dependencies lifecycle scripts. +fn is_setup_configured(package_json: &serde_json::Value) -> ScriptSetupStatus { + let postinstall_script = read_script(package_json, "postinstall"); let postinstall_configured = script_is_configured(&postinstall_script); - let dependencies_script = scripts - .and_then(|s| s.get("dependencies")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); + let dependencies_script = read_script(package_json, "dependencies"); let dependencies_configured = script_is_configured(&dependencies_script); ScriptSetupStatus { @@ -68,10 +68,12 @@ pub fn is_setup_configured(package_json: &serde_json::Value) -> ScriptSetupStatu } /// Strip a leading UTF-8 BOM. npm and Node tolerate (and strip) a BOM in -/// package.json — files saved by Windows editors commonly carry one — but -/// serde_json rejects it, so every parse of user-supplied package.json content -/// must go through this first or npm-valid manifests error out. -fn strip_bom(content: &str) -> &str { +/// package.json, and cargo accepts one in Cargo.toml — files saved by Windows +/// editors commonly carry one — but serde_json (and vex's TOML line scanner) +/// reject it, so every parse of user-supplied manifest content must go through +/// this first or toolchain-valid manifests error out. Also used by +/// `vex::product`. +pub(crate) fn strip_bom(content: &str) -> &str { content.strip_prefix('\u{feff}').unwrap_or(content) } @@ -91,7 +93,7 @@ pub fn is_setup_configured_str(content: &str) -> ScriptSetupStatus { /// Generate an updated script that includes the socket-patch apply command. /// If already configured, returns unchanged. Otherwise prepends the command. -pub fn generate_updated_script(current_script: &str, pm: PackageManager) -> String { +fn generate_updated_script(current_script: &str, pm: PackageManager) -> String { let command = socket_patch_command(pm); let trimmed = current_script.trim(); @@ -112,7 +114,7 @@ pub fn generate_updated_script(current_script: &str, pm: PackageManager) -> Stri /// Update a package.json Value with socket-patch in both postinstall and /// dependencies scripts. /// Returns (modified, new_postinstall, new_dependencies). -pub fn update_package_json_object( +fn update_package_json_object( package_json: &mut serde_json::Value, pm: PackageManager, ) -> (bool, String, String) { @@ -175,7 +177,7 @@ pub fn update_package_json_object( /// - `(true, Some(rest))` — patch segment(s) removed, other commands survive. /// - `(true, None)` — the script was *only* socket-patch; the key should be /// deleted entirely. -pub fn remove_socket_patch_from_script(script: &str) -> (bool, Option) { +fn remove_socket_patch_from_script(script: &str) -> (bool, Option) { let trimmed = script.trim(); if trimmed.is_empty() { return (false, None); @@ -211,7 +213,7 @@ pub fn remove_socket_patch_from_script(script: &str) -> (bool, Option) { /// Status of a remove operation on a single package.json object. #[derive(Debug, Clone)] -pub struct ScriptRemoveStatus { +pub(crate) struct ScriptRemoveStatus { pub modified: bool, pub old_postinstall: String, pub new_postinstall: Option, @@ -225,25 +227,15 @@ pub struct ScriptRemoveStatus { /// `scripts` ends up empty the whole `scripts` key is dropped too — undoing /// exactly what [`update_package_json_object`] added. Returns a /// [`ScriptRemoveStatus`] describing what changed. -pub fn remove_package_json_object(package_json: &mut serde_json::Value) -> ScriptRemoveStatus { - let read_script = |pj: &serde_json::Value, key: &str| -> String { - pj.get("scripts") - .and_then(|s| s.get(key)) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string() - }; - +fn remove_package_json_object(package_json: &mut serde_json::Value) -> ScriptRemoveStatus { let old_postinstall = read_script(package_json, "postinstall"); let old_dependencies = read_script(package_json, "dependencies"); + // `remove_socket_patch_from_script` reports `changed` only when a + // socket-patch segment was actually present and removed. let (pi_changed, new_postinstall) = remove_socket_patch_from_script(&old_postinstall); let (dep_changed, new_dependencies) = remove_socket_patch_from_script(&old_dependencies); - - // Only treat as modified when a socket-patch segment was actually present. - let pi_had_patch = pi_changed && script_is_configured(&old_postinstall); - let dep_had_patch = dep_changed && script_is_configured(&old_dependencies); - let modified = pi_had_patch || dep_had_patch; + let modified = pi_changed || dep_changed; if !modified { return ScriptRemoveStatus { @@ -262,7 +254,7 @@ pub fn remove_package_json_object(package_json: &mut serde_json::Value) -> Scrip .get_mut("scripts") .and_then(|s| s.as_object_mut()) { - if pi_had_patch { + if pi_changed { match &new_postinstall { Some(s) => { scripts.insert( @@ -275,7 +267,7 @@ pub fn remove_package_json_object(package_json: &mut serde_json::Value) -> Scrip } } } - if dep_had_patch { + if dep_changed { match &new_dependencies { Some(s) => { scripts.insert( @@ -308,7 +300,7 @@ pub fn remove_package_json_object(package_json: &mut serde_json::Value) -> Scrip /// Parse package.json content and remove socket-patch lifecycle scripts. /// Returns `(modified, new_content, status)`. -pub fn remove_package_json_content( +pub(crate) fn remove_package_json_content( content: &str, ) -> Result<(bool, String, ScriptRemoveStatus), String> { let mut package_json: serde_json::Value = serde_json::from_str(strip_bom(content)) @@ -338,7 +330,7 @@ pub fn remove_package_json_content( /// Parse package.json content and update it with socket-patch scripts. /// Returns (modified, new_content, old_postinstall, new_postinstall, /// old_dependencies, new_dependencies). -pub fn update_package_json_content( +pub(crate) fn update_package_json_content( content: &str, pm: PackageManager, ) -> Result<(bool, String, String, String, String, String), String> { diff --git a/crates/socket-patch-core/src/package_json/find.rs b/crates/socket-patch-core/src/package_json/find.rs index dd7c9447..ed534d17 100644 --- a/crates/socket-patch-core/src/package_json/find.rs +++ b/crates/socket-patch-core/src/package_json/find.rs @@ -1,7 +1,8 @@ use std::path::{Path, PathBuf}; use tokio::fs; -use super::detect::PackageManager; +use super::detect::{strip_bom, PackageManager}; +use crate::utils::fs::{entry_file_type, is_dir, list_dir_entries}; /// Detect the package manager based on lockfiles in the project root. /// Checks for pnpm-lock.yaml, pnpm-lock.yml, and pnpm-workspace.yaml. @@ -24,9 +25,9 @@ pub enum WorkspaceType { /// Workspace configuration. #[derive(Debug, Clone)] -pub struct WorkspaceConfig { - pub ws_type: WorkspaceType, - pub patterns: Vec, +struct WorkspaceConfig { + ws_type: WorkspaceType, + patterns: Vec, } /// Location of a discovered package.json file. @@ -35,7 +36,6 @@ pub struct PackageJsonLocation { pub path: PathBuf, pub is_root: bool, pub is_workspace: bool, - pub workspace_pattern: Option, } /// Result of finding package.json files. @@ -60,23 +60,34 @@ pub async fn find_package_json_files(start_path: &Path) -> PackageJsonFindResult root_exists = true; workspace_config = detect_workspaces(&root_package_json).await; results.push(PackageJsonLocation { - path: root_package_json, + path: root_package_json.clone(), is_root: true, is_workspace: false, - workspace_pattern: None, }); } match workspace_config.ws_type { WorkspaceType::None => { + // No workspace config: pick up nested manifests with a bounded + // walk (the root entry is already in `results`). if root_exists { - let nested = find_nested_package_json_files(start_path).await; - results.extend(nested); + let mut nested = Vec::new(); + search_recursive(start_path, 0, 5, &mut nested).await; + results.extend(nested.into_iter().filter(|p| *p != root_package_json).map( + |path| PackageJsonLocation { + path, + is_root: false, + is_workspace: false, + }, + )); } } _ => { - let ws_packages = find_workspace_packages(start_path, &workspace_config).await; - results.extend(ws_packages); + // Members are collected into their own vec so a `!`-negation + // pattern can only remove members, never the root entry. + let mut members = Vec::new(); + collect_workspace_members(start_path, &workspace_config, 0, &mut members).await; + results.extend(members); } } @@ -94,7 +105,7 @@ pub async fn find_package_json_files(start_path: &Path) -> PackageJsonFindResult } /// Detect workspace configuration from package.json. -pub async fn detect_workspaces(package_json_path: &Path) -> WorkspaceConfig { +async fn detect_workspaces(package_json_path: &Path) -> WorkspaceConfig { let default = WorkspaceConfig { ws_type: WorkspaceType::None, patterns: Vec::new(), @@ -122,7 +133,7 @@ pub async fn detect_workspaces(package_json_path: &Path) -> WorkspaceConfig { Err(_) => return default, }; - let pkg: serde_json::Value = match serde_json::from_str(&content) { + let pkg: serde_json::Value = match serde_json::from_str(strip_bom(&content)) { Ok(v) => v, Err(_) => return default, }; @@ -160,7 +171,9 @@ fn parse_pnpm_workspace_patterns(yaml_content: &str) -> Vec { let mut patterns = Vec::new(); let mut in_packages = false; - for line in yaml_content.lines() { + // A BOM is not Unicode whitespace, so `trim` would leave it glued to a + // first-line `packages:` header and the whole section would be missed. + for line in strip_bom(yaml_content).lines() { let trimmed = line.trim(); // The header may carry an inline comment (`packages: # globs`); a `#` @@ -229,23 +242,14 @@ fn parse_yaml_list_value(raw: &str) -> String { value.trim().to_string() } -/// Find workspace packages based on workspace patterns, recursing into any -/// member that is **itself** a workspace root (property 9's nested-workspace -/// rule). A member's own `workspaces` patterns are resolved relative to that -/// member's directory. -async fn find_workspace_packages( - root_path: &Path, - config: &WorkspaceConfig, -) -> Vec { - let mut results = Vec::new(); - collect_workspace_members(root_path, config, 0, &mut results).await; - results -} - /// Bounded-depth recursion limit for nested workspaces — deep enough for any /// real monorepo, a hard stop against a pattern that loops back on itself. const MAX_WORKSPACE_DEPTH: usize = 10; +/// Collect workspace members matching the config's patterns, recursing into +/// any member that is **itself** a workspace root (property 9's +/// nested-workspace rule). A member's own `workspaces` patterns are resolved +/// relative to that member's directory. async fn collect_workspace_members( root_path: &Path, config: &WorkspaceConfig, @@ -272,7 +276,6 @@ async fn collect_workspace_members( path: p, is_root: false, is_workspace: true, - workspace_pattern: Some(pattern.clone()), }); // If this member declares its own workspaces, configure ITS members // too (one repo-root `setup` covers the whole nested tree). The @@ -324,7 +327,7 @@ async fn find_packages_matching_pattern(root_path: &Path, pattern: &str) -> Vec< if fs::metadata(&own_pkg).await.is_ok() { results.push(own_pkg); } - search_recursive(&search_path, &mut results).await; + search_recursive(&search_path, 0, usize::MAX, &mut results).await; } } _ => { @@ -346,26 +349,17 @@ fn is_ignored_dir(name: &str) -> bool { /// Search one level deep for package.json files. async fn search_one_level(dir: &Path, results: &mut Vec) { - let mut entries = match fs::read_dir(dir).await { - Ok(e) => e, - Err(_) => return, - }; - - while let Ok(Some(entry)) = entries.next_entry().await { + for entry in list_dir_entries(dir).await { let path = entry.path(); // A single-level `dir/*` glob follows a symlinked direct member, the // way npm/pnpm (and our cargo `glob_dir`) resolve a workspace member // that is itself a symlink. `entry.file_type()` reports the *link's* // own type — `is_dir() == false` — so it would silently drop such a // member; stat the path instead so the link is followed. (The - // recursive `**` searchers below deliberately do NOT follow symlinks, + // recursive searcher below deliberately does NOT follow symlinks, // to avoid loops/escapes — there a symlink's `is_dir() == false` is the // desired skip.) - if !fs::metadata(&path) - .await - .map(|m| m.is_dir()) - .unwrap_or(false) - { + if !is_dir(&path).await { continue; } // A `dir/*` pattern must not pick up node_modules/hidden/output dirs as @@ -380,27 +374,24 @@ async fn search_one_level(dir: &Path, results: &mut Vec) { } } -/// Search recursively for package.json files. -async fn search_recursive(dir: &Path, results: &mut Vec) { - let mut entries = match fs::read_dir(dir).await { - Ok(e) => e, - Err(_) => return, - }; +/// Search recursively for package.json files, descending at most `max_depth` +/// directory levels below `dir` (pass `usize::MAX` for an unbounded walk). +/// Symlinks are deliberately not followed — see `search_one_level`. +async fn search_recursive(dir: &Path, depth: usize, max_depth: usize, results: &mut Vec) { + if depth > max_depth { + return; + } - while let Ok(Some(entry)) = entries.next_entry().await { - let ft = match entry.file_type().await { - Ok(ft) => ft, - Err(_) => continue, + for entry in list_dir_entries(dir).await { + let Some(ft) = entry_file_type(&entry).await else { + continue; }; if !ft.is_dir() { continue; } - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - // Skip hidden directories, node_modules, dist, build - if is_ignored_dir(&name_str) { + if is_ignored_dir(&entry.file_name().to_string_lossy()) { continue; } @@ -410,61 +401,7 @@ async fn search_recursive(dir: &Path, results: &mut Vec) { results.push(pkg_json); } - Box::pin(search_recursive(&full_path, results)).await; - } -} - -/// Find nested package.json files without workspace configuration. -async fn find_nested_package_json_files(start_path: &Path) -> Vec { - let mut results = Vec::new(); - let root_pkg = start_path.join("package.json"); - search_nested(start_path, &root_pkg, 0, &mut results).await; - results -} - -async fn search_nested( - dir: &Path, - root_pkg: &Path, - depth: usize, - results: &mut Vec, -) { - if depth > 5 { - return; - } - - let mut entries = match fs::read_dir(dir).await { - Ok(e) => e, - Err(_) => return, - }; - - while let Ok(Some(entry)) = entries.next_entry().await { - let ft = match entry.file_type().await { - Ok(ft) => ft, - Err(_) => continue, - }; - if !ft.is_dir() { - continue; - } - - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - - if is_ignored_dir(&name_str) { - continue; - } - - let full_path = entry.path(); - let pkg_json = full_path.join("package.json"); - if fs::metadata(&pkg_json).await.is_ok() && pkg_json != root_pkg { - results.push(PackageJsonLocation { - path: pkg_json, - is_root: false, - is_workspace: false, - workspace_pattern: None, - }); - } - - Box::pin(search_nested(&full_path, root_pkg, depth + 1, results)).await; + Box::pin(search_recursive(&full_path, depth + 1, max_depth, results)).await; } } @@ -544,6 +481,17 @@ mod tests { assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/**"]); } + #[test] + fn test_parse_pnpm_bom_first_line() { + // A UTF-8 BOM (Windows editors commonly write one) is NOT Unicode + // whitespace, so `trim` leaves it in place and a `packages:` header on + // the first line never matches — every pattern is silently lost, and + // because pnpm-workspace.yaml still marks the project as a pnpm + // workspace, no fallback walk runs: zero members discovered. + let yaml = "\u{feff}packages:\n - packages/*"; + assert_eq!(parse_pnpm_workspace_patterns(yaml), vec!["packages/*"]); + } + // ── Group 2: workspace detection + file discovery ──────────────── #[tokio::test] @@ -622,6 +570,53 @@ mod tests { assert_eq!(config.patterns, vec!["packages/*"]); } + #[tokio::test] + async fn test_detect_workspaces_npm_with_bom() { + // npm strips a leading UTF-8 BOM before parsing package.json, so a + // BOM'd manifest is npm-valid; its workspaces must not be silently + // dropped (which would demote the project to "no workspace"). + let dir = tempfile::tempdir().unwrap(); + let pkg = dir.path().join("package.json"); + fs::write(&pkg, "\u{feff}{\"workspaces\": [\"packages/*\"]}") + .await + .unwrap(); + let config = detect_workspaces(&pkg).await; + assert!(matches!(config.ws_type, WorkspaceType::Npm)); + assert_eq!(config.patterns, vec!["packages/*"]); + } + + #[tokio::test] + async fn test_find_bom_root_workspace_negation_honored() { + // End-to-end symptom of the BOM gap: with a BOM'd root manifest the + // workspace config silently degraded to None, so members were found + // only by the fallback walk — mislabeled as non-workspace and with + // `!`-negations ignored, letting setup edit an excluded package. + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("package.json"), + "\u{feff}{\"workspaces\": [\"packages/*\", \"!packages/private\"]}", + ) + .await + .unwrap(); + for member in ["a", "private"] { + let m = dir.path().join("packages").join(member); + fs::create_dir_all(&m).await.unwrap(); + fs::write(m.join("package.json"), r#"{"name":"m"}"#) + .await + .unwrap(); + } + let result = find_package_json_files(dir.path()).await; + assert!(matches!(result.workspace_type, WorkspaceType::Npm)); + let members: Vec<_> = result.files.iter().filter(|f| f.is_workspace).collect(); + assert_eq!( + members.len(), + 1, + "negated member must stay excluded under a BOM'd root: {:?}", + result.files.iter().map(|f| &f.path).collect::>() + ); + assert!(members[0].path.ends_with("packages/a/package.json")); + } + #[tokio::test] async fn test_detect_workspaces_none() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/package_json/update.rs b/crates/socket-patch-core/src/package_json/update.rs index 74f917c7..f4f5ae7d 100644 --- a/crates/socket-patch-core/src/package_json/update.rs +++ b/crates/socket-patch-core/src/package_json/update.rs @@ -1,71 +1,18 @@ use std::path::Path; use tokio::fs; -use super::detect::{ - is_setup_configured_str, remove_package_json_content, update_package_json_content, - PackageManager, -}; - -/// Atomically write `content` to `path`. -/// -/// A bare `fs::write` truncates the target before writing, so a crash, power -/// loss, or interrupted process mid-write would leave the user's -/// `package.json` truncated or empty — destroying the file we only meant to -/// append two scripts to. Instead we write to a sibling stage file, fsync it, -/// then rename over the target (rename is atomic on the same filesystem) so the -/// reader ever sees either the old bytes or the complete new bytes. Mirrors the -/// hardened writer in `manifest/operations.rs`. -async fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let stem = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "package.json".to_string()); - let stage = parent.join(format!(".socket-stage-{}-{}", stem, uuid::Uuid::new_v4())); - - let mut file = tokio::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&stage) - .await?; - - use tokio::io::AsyncWriteExt; - if let Err(e) = file.write_all(content.as_bytes()).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - if let Err(e) = file.sync_all().await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - drop(file); - - if let Err(e) = tokio::fs::rename(&stage, path).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - - // The rename only updated the parent directory entry; fsync the directory - // so the rename itself survives a crash. Best-effort, Unix only. - #[cfg(unix)] - { - if let Ok(dir) = tokio::fs::File::open(parent).await { - let _ = dir.sync_all().await; - } - } - - Ok(()) -} +use super::detect::{remove_package_json_content, update_package_json_content, PackageManager}; +use crate::utils::fs::atomic_write_bytes_preserving_mode; /// Result of updating a single package.json. #[derive(Debug, Clone)] pub struct UpdateResult { pub path: String, pub status: UpdateStatus, + /// Previous `postinstall` script (empty if absent). pub old_script: String, + /// New `postinstall` script. pub new_script: String, - pub old_dependencies_script: String, - pub new_dependencies_script: String, pub error: Option, } @@ -92,49 +39,23 @@ pub async fn update_package_json( status: UpdateStatus::Error, old_script: String::new(), new_script: String::new(), - old_dependencies_script: String::new(), - new_dependencies_script: String::new(), error: Some(e.to_string()), }; } }; - let status = is_setup_configured_str(&content); - if !status.needs_update { - return UpdateResult { - path: path_str, - status: UpdateStatus::AlreadyConfigured, - old_script: status.postinstall_script.clone(), - new_script: status.postinstall_script, - old_dependencies_script: status.dependencies_script.clone(), - new_dependencies_script: status.dependencies_script, - error: None, - }; - } - match update_package_json_content(&content, pm) { - Ok((modified, new_content, old_pi, new_pi, old_dep, new_dep)) => { - if !modified { - return UpdateResult { - path: path_str, - status: UpdateStatus::AlreadyConfigured, - old_script: old_pi, - new_script: new_pi, - old_dependencies_script: old_dep, - new_dependencies_script: new_dep, - error: None, - }; - } - - if !dry_run { - if let Err(e) = atomic_write(package_json_path, &new_content).await { + Ok((modified, new_content, old_pi, new_pi, _, _)) => { + if modified && !dry_run { + if let Err(e) = + atomic_write_bytes_preserving_mode(package_json_path, new_content.as_bytes()) + .await + { return UpdateResult { path: path_str, status: UpdateStatus::Error, old_script: old_pi, new_script: new_pi, - old_dependencies_script: old_dep, - new_dependencies_script: new_dep, error: Some(e.to_string()), }; } @@ -142,11 +63,13 @@ pub async fn update_package_json( UpdateResult { path: path_str, - status: UpdateStatus::Updated, + status: if modified { + UpdateStatus::Updated + } else { + UpdateStatus::AlreadyConfigured + }, old_script: old_pi, new_script: new_pi, - old_dependencies_script: old_dep, - new_dependencies_script: new_dep, error: None, } } @@ -155,8 +78,6 @@ pub async fn update_package_json( status: UpdateStatus::Error, old_script: String::new(), new_script: String::new(), - old_dependencies_script: String::new(), - new_dependencies_script: String::new(), error: Some(e), }, } @@ -209,20 +130,11 @@ pub async fn remove_package_json(package_json_path: &Path, dry_run: bool) -> Rem match remove_package_json_content(&content) { Ok((modified, new_content, status)) => { - if !modified { - return RemoveResult { - path: path_str, - status: RemoveStatus::NotConfigured, - old_script: status.old_postinstall, - new_script: status.new_postinstall, - old_dependencies_script: status.old_dependencies, - new_dependencies_script: status.new_dependencies, - error: None, - }; - } - - if !dry_run { - if let Err(e) = atomic_write(package_json_path, &new_content).await { + if modified && !dry_run { + if let Err(e) = + atomic_write_bytes_preserving_mode(package_json_path, new_content.as_bytes()) + .await + { return RemoveResult { path: path_str, status: RemoveStatus::Error, @@ -237,7 +149,11 @@ pub async fn remove_package_json(package_json_path: &Path, dry_run: bool) -> Rem RemoveResult { path: path_str, - status: RemoveStatus::Removed, + status: if modified { + RemoveStatus::Removed + } else { + RemoveStatus::NotConfigured + }, old_script: status.old_postinstall, new_script: status.new_postinstall, old_dependencies_script: status.old_dependencies, @@ -591,6 +507,59 @@ mod tests { assert!(!content.contains("socket-patch")); } + /// The stage+rename write swaps in a fresh inode, so unless the writer + /// re-applies the destination's permission bits, an edit resets the + /// user's package.json mode to umask defaults (typically 0644): a 0600 + /// user-private manifest silently becomes world-readable, and a 0664 + /// group-writable one locks the group out. npm's own write-file-atomic + /// preserves mode on package.json edits; so must we. (The 0744 file + /// makes this red under any umask — a 0666-based create can never + /// produce an exec bit.) + #[cfg(unix)] + #[tokio::test] + async fn test_update_preserves_file_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + for mode in [0o600u32, 0o744] { + let pkg = dir.path().join(format!("pkg-{mode:o}.json")); + fs::write(&pkg, r#"{"name":"x","scripts":{"build":"tsc"}}"#) + .await + .unwrap(); + std::fs::set_permissions(&pkg, std::fs::Permissions::from_mode(mode)).unwrap(); + + let result = update_package_json(&pkg, false, PackageManager::Npm).await; + assert_eq!(result.status, UpdateStatus::Updated, "mode {mode:o}"); + let got = std::fs::metadata(&pkg).unwrap().permissions().mode() & 0o777; + assert_eq!( + got, mode, + "update must preserve the package.json mode, got {got:o} for {mode:o}" + ); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn test_remove_preserves_file_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + for mode in [0o600u32, 0o744] { + let pkg = dir.path().join(format!("pkg-{mode:o}.json")); + fs::write(&pkg, r#"{"name":"x","scripts":{"build":"tsc"}}"#) + .await + .unwrap(); + update_package_json(&pkg, false, PackageManager::Npm).await; + std::fs::set_permissions(&pkg, std::fs::Permissions::from_mode(mode)).unwrap(); + + let result = remove_package_json(&pkg, false).await; + assert_eq!(result.status, RemoveStatus::Removed, "mode {mode:o}"); + let got = std::fs::metadata(&pkg).unwrap().permissions().mode() & 0o777; + assert_eq!( + got, mode, + "remove must preserve the package.json mode, got {got:o} for {mode:o}" + ); + } + } + /// A dry-run must never create a stage file either — it does no I/O at all. #[tokio::test] async fn test_update_dry_run_leaves_no_stage_litter() { diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs index da1ce6dc..e30eb176 100644 --- a/crates/socket-patch-core/src/patch/apply.rs +++ b/crates/socket-patch-core/src/patch/apply.rs @@ -45,14 +45,13 @@ pub struct VerifyResult { /// content or fails, never silently corrupted. What tolerance can do is /// discard local modifications to the dependency file, which is why /// `Strict` exists. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MismatchPolicy { /// DEFAULT: a beforeHash mismatch is overwritten with the verified /// patched content and surfaced as a warning (the promoted /// [`VerifyResult`] keeps `expected_hash`/`current_hash`, which is /// how callers detect and report it). A MISSING pre-existing file is /// still a hard error. - #[default] Warn, /// A beforeHash mismatch is a hard error (`--strict`). Strict, @@ -103,9 +102,10 @@ pub struct PatchSources<'a> { impl<'a> PatchSources<'a> { /// Construct a `PatchSources` that only knows about the legacy - /// per-file blob directory. Convenient for tests and existing call - /// sites that have not been upgraded. - pub fn blobs_only(blobs_path: &'a Path) -> Self { + /// per-file blob directory. All remaining callers are same-crate + /// tests, hence the `cfg(test)` gate. + #[cfg(test)] + pub(crate) fn blobs_only(blobs_path: &'a Path) -> Self { Self { blobs_path, packages_path: None, @@ -141,7 +141,7 @@ pub struct ApplyResult { /// Normalize file path by removing the "package/" prefix if present. /// Patch files come from the API with paths like "package/lib/file.js" /// but we need relative paths like "lib/file.js" for the actual package directory. -pub fn normalize_file_path(file_name: &str) -> &str { +pub(crate) fn normalize_file_path(file_name: &str) -> &str { const PACKAGE_PREFIX: &str = "package/"; if let Some(stripped) = file_name.strip_prefix(PACKAGE_PREFIX) { stripped @@ -160,7 +160,7 @@ pub fn normalize_file_path(file_name: &str) -> &str { /// execution) via `pkg_path.join(key)` — `Path::join` discards the base on an /// absolute key, and `..` components walk out. We reject anything that isn't a /// plain relative path (no absolute/root/prefix components, no `..`, no NUL). -pub fn is_safe_relative_subpath(normalized: &str) -> bool { +pub(crate) fn is_safe_relative_subpath(normalized: &str) -> bool { use std::path::Component; if normalized.is_empty() || normalized.contains('\0') { return false; @@ -373,7 +373,7 @@ pub async fn select_installed_variants( /// set on new files to honor the read-only-by-default policy. /// /// Writes the patched content and verifies the resulting hash. -pub async fn apply_file_patch( +pub(crate) async fn apply_file_patch( pkg_path: &Path, file_name: &str, patched_content: &[u8], @@ -447,9 +447,10 @@ pub async fn apply_file_patch( // before we mutate. No-op on regular private files (single // syscall). See `patch::cow`. // - // Atomic write: stage in the parent directory, fsync, rename onto - // the target. POSIX `rename(2)` is atomic — observers see either - // the old bytes or the new bytes, never a truncated half-write. + // Atomic write (`utils::fs::atomic_write_bytes`): stage in the + // parent directory, fsync, rename onto the target. POSIX + // `rename(2)` is atomic — observers see either the old bytes or + // the new bytes, never a truncated half-write. // // The stage file is created with the user's umask defaults // (typically 0o644) — that's how we sidestep the "existing file @@ -462,7 +463,7 @@ pub async fn apply_file_patch( // restored — even if a step errors — before the failure propagates. let write_result = async { break_hardlink_if_needed(&filepath).await?; - write_atomic(&filepath, patched_content).await + crate::utils::fs::atomic_write_bytes(&filepath, patched_content).await } .await; dir_guard.restore().await; @@ -553,63 +554,6 @@ async fn nearest_existing_ancestor(path: &Path) -> Option<&Path> { None } -/// Write `content` to `target` atomically via stage + rename. -/// -/// Two-phase commit: -/// 1. Create `/.socket-stage--` (leading dot -/// so editor globs ignore it; uuid suffix so concurrent callers -/// never collide — defense in depth on top of the apply lock). -/// 2. `write_all` the content, then `sync_all()` so the bytes are -/// durably on disk before the rename. -/// 3. `rename(stage, target)` — atomic on POSIX, best-effort on -/// Windows. On failure unlink the stage so we don't leave a -/// dotfile behind in the package directory. -async fn write_atomic(target: &Path, content: &[u8]) -> std::io::Result<()> { - let parent = target.parent().unwrap_or_else(|| Path::new(".")); - let stem = target - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "anon".to_string()); - let stage = parent.join(format!(".socket-stage-{}-{}", stem, uuid::Uuid::new_v4())); - - let mut file = tokio::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&stage) - .await?; - - use tokio::io::AsyncWriteExt; - if let Err(e) = file.write_all(content).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - if let Err(e) = file.sync_all().await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - drop(file); - - if let Err(e) = tokio::fs::rename(&stage, target).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - - // Durability: `sync_all` above flushed the file's *data*, but the - // rename only updated the parent directory entry. fsync the - // directory so the rename itself survives a crash — otherwise a - // post-crash filesystem could surface the old name (or neither). - // Unix only; best-effort, since a directory we can't open for fsync - // must not fail an otherwise-successful write. - #[cfg(unix)] - { - if let Ok(dir) = tokio::fs::File::open(parent).await { - let _ = dir.sync_all().await; - } - } - - Ok(()) -} - /// Restore the post-write permission state on `filepath`. /// /// * `pre_patch` = `Some(meta)` → the file existed before the patch; @@ -735,7 +679,6 @@ pub async fn apply_package_patch( // disk write — NOT skippable by `--force`, since a path escape is never // a legitimate patch target. if !is_safe_relative_subpath(normalize_file_path(file_name)) { - result.success = false; result.error = Some(format!( "Refusing patch with unsafe file path (escapes package directory): {file_name}" )); @@ -928,9 +871,7 @@ pub async fn apply_package_patch( // consumers see a uniform shape regardless of whether the // fixup succeeded, was advisory-only, or raised an error. if !result.files_patched.is_empty() { - use crate::patch::sidecars::{ - dispatch_fixup, SidecarAdvisory, SidecarAdvisoryCode, SidecarRecord, SidecarSeverity, - }; + use crate::patch::sidecars::{dispatch_fixup, fixup_failed_record}; // Include files verified `AlreadyPatched` alongside the ones // written this run: a previous apply that failed partway left // them patched on disk but returned before this boundary, so @@ -950,23 +891,14 @@ pub async fn apply_package_patch( .map(|v| v.file.clone()), ) .collect(); - match dispatch_fixup(package_key, pkg_path, &fixup_files, files).await { + match dispatch_fixup(package_key, pkg_path, &fixup_files).await { Ok(Some(record)) => result.sidecar = Some(record), Ok(None) => {} Err(e) => { - let ecosystem = crate::crawlers::Ecosystem::from_purl(package_key) - .map(|eco| eco.cli_name().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - result.sidecar = Some(SidecarRecord { - purl: package_key.to_string(), - ecosystem, - files: Vec::new(), - advisory: Some(SidecarAdvisory { - code: SidecarAdvisoryCode::SidecarFixupFailed, - severity: SidecarSeverity::Error, - message: format!("sidecar fixup failed (patch still applied): {}", e), - }), - }); + result.sidecar = Some(fixup_failed_record( + package_key, + format!("sidecar fixup failed (patch still applied): {}", e), + )); } } } @@ -2578,7 +2510,6 @@ mod tests { /// sidecar fixup, or its checksum entry stays stale forever and /// `cargo build` refuses the crate even though the retry reported /// success. - #[cfg(feature = "cargo")] #[tokio::test] async fn test_apply_retry_resyncs_already_patched_checksum_entries() { fn plain_sha256(b: &[u8]) -> String { diff --git a/crates/socket-patch-core/src/patch/bun_lock_text.rs b/crates/socket-patch-core/src/patch/bun_lock_text.rs index cd0c9e2a..ea076828 100644 --- a/crates/socket-patch-core/src/patch/bun_lock_text.rs +++ b/crates/socket-patch-core/src/patch/bun_lock_text.rs @@ -11,7 +11,7 @@ /// The only text-lockfile version the surgery has byte-exact fixtures for /// (bun 1.3.x; spike pinned 1.3.14). -pub(crate) const SUPPORTED_LOCK_VERSION: u64 = 1; +const SUPPORTED_LOCK_VERSION: u64 = 1; /// One parsed single-line packages entry. pub(crate) struct BunEntry { @@ -27,9 +27,15 @@ pub(crate) struct BunEntry { pub(crate) trailing_comma: bool, } -/// `name@spec` split at the LAST `@` (scoped names keep their leading `@`). +/// `name@spec` split at the FIRST `@` past the leading character: a name's +/// only `@` is a scope marker at index 0, while the spec itself may contain +/// `@` (a vendored path keeps the scope dir in its leaf — +/// `@scope/pkg@.socket/vendor/npm//@scope/pkg-1.0.0.tgz`), so the +/// last `@` is not a safe split point. pub(crate) fn split_name_spec(s: &str) -> Option<(&str, &str)> { - let at = s.rfind('@').filter(|&i| i > 0)?; + let at = s + .char_indices() + .find_map(|(i, c)| (c == '@' && i > 0).then_some(i))?; Some((&s[..at], &s[at + 1..])) } @@ -134,7 +140,7 @@ pub(crate) fn parse_entry_line(line: &str) -> Result { /// Byte index one past the closing quote of the JSON string at the start of /// `s` (escape-aware). -pub(crate) fn scan_json_string(s: &str) -> Result { +fn scan_json_string(s: &str) -> Result { let bytes = s.as_bytes(); if bytes.first() != Some(&b'"') { return Err("expected a quoted key".to_string()); @@ -151,18 +157,21 @@ pub(crate) fn scan_json_string(s: &str) -> Result { } /// Byte index one past the `]` matching the `[` at the start of `s` -/// (string- and nesting-aware). -pub(crate) fn scan_balanced_array(s: &str) -> Result { +/// (string- and nesting-aware; closer type must match its opener). +fn scan_balanced_array(s: &str) -> Result { let bytes = s.as_bytes(); - let mut depth = 0usize; + let mut stack: Vec = Vec::new(); let mut i = 0; while i < bytes.len() { match bytes[i] { - b'"' => i += scan_json_string(&s[i..]).map_err(|e| e.to_string())? - 1, - b'[' | b'{' => depth += 1, + b'"' => i += scan_json_string(&s[i..])? - 1, + b'[' => stack.push(b']'), + b'{' => stack.push(b'}'), b']' | b'}' => { - depth -= 1; - if depth == 0 { + if stack.pop() != Some(bytes[i]) { + return Err("mismatched brackets".to_string()); + } + if stack.is_empty() { return Ok(i + 1); } } @@ -175,20 +184,23 @@ pub(crate) fn scan_balanced_array(s: &str) -> Result { /// Split the tuple interior at top-level commas into verbatim trimmed /// element substrings. -pub(crate) fn split_top_level(interior: &str) -> Result, String> { +fn split_top_level(interior: &str) -> Result, String> { let bytes = interior.as_bytes(); let mut elems = Vec::new(); - let mut depth = 0usize; + let mut stack: Vec = Vec::new(); let mut elem_start = 0usize; let mut i = 0; while i < bytes.len() { match bytes[i] { b'"' => i += scan_json_string(&interior[i..])? - 1, - b'[' | b'{' => depth += 1, + b'[' => stack.push(b']'), + b'{' => stack.push(b'}'), b']' | b'}' => { - depth = depth.checked_sub(1).ok_or("unbalanced brackets")?; + if stack.pop() != Some(bytes[i]) { + return Err("unbalanced brackets".to_string()); + } } - b',' if depth == 0 => { + b',' if stack.is_empty() => { elems.push(interior[elem_start..i].trim().to_string()); elem_start = i + 1; } @@ -260,12 +272,29 @@ mod tests { None, "a scope @ alone is not a version sep" ); + assert_eq!( + split_name_spec("@scope/pkg@.socket/vendor/npm/u/@scope/pkg-1.0.0.tgz"), + Some(("@scope/pkg", ".socket/vendor/npm/u/@scope/pkg-1.0.0.tgz")), + "an @ inside the spec (scoped vendored leaf) must not shift the split" + ); // Fail-closed grammar. assert!( parse_entry_line(" \"k\": [\"a\", ").is_err(), "unterminated" ); + assert!( + parse_entry_line(r#" "k": ["a"},"#).is_err(), + "array closed by `}}` must not parse" + ); + assert!( + parse_entry_line(r#" "k": ["a", {"x": 1]],"#).is_err(), + "object closed by `]` must not parse" + ); + assert!( + parse_entry_line(r#" "k": ["a", [1}]"#).is_err(), + "nested array closed by `}}` must not parse" + ); assert!(parse_entry_line(" k: [\"a\"]").is_err(), "unquoted key"); assert!(parse_entry_line(" \"k\": \"not an array\"").is_err()); assert!( diff --git a/crates/socket-patch-core/src/patch/copy_tree.rs b/crates/socket-patch-core/src/patch/copy_tree.rs index 26eed36b..8e34933b 100644 --- a/crates/socket-patch-core/src/patch/copy_tree.rs +++ b/crates/socket-patch-core/src/patch/copy_tree.rs @@ -1,10 +1,8 @@ -//! Shared tree-copy helpers for the project-local Go `replace`-redirect backend -//! ([`crate::patch::go_redirect`]). It materialises a project-local **patched -//! copy** of a module by copying its pristine source out of the read-only, -//! checksum-verified module cache into a writable dir under `.socket/`, then -//! patching the copy in place. -//! -//! Only compiled when the Go redirect backend is enabled (gated in `mod.rs`). +//! Shared tree-copy helpers used by the Go `replace`-redirect backend +//! ([`crate::patch::go_redirect`]) and the vendor backends. They materialise a +//! project-local **patched copy** of a package by copying its pristine source +//! out of a read-only registry/module cache into a writable dir under +//! `.socket/`, then patching the copy in place. use std::path::Path; @@ -60,12 +58,13 @@ pub(crate) async fn fresh_copy( Ok(()) }) .await - .map_err(|e| std::io::Error::other(e.to_string()))? + .map_err(to_io)? } -/// Recursively remove a tree, retrying once after relaxing perms (a previously -/// patched copy may carry read-only file modes copied from the registry/cache). -pub(crate) fn force_remove_dir_all(dir: &Path) -> std::io::Result<()> { +/// Recursively remove a tree, retrying once after relaxing *directory* perms +/// (a previously patched copy may carry read-only dir modes copied from the +/// registry/cache; on unix file modes never gate unlinking). +fn force_remove_dir_all(dir: &Path) -> std::io::Result<()> { match std::fs::remove_dir_all(dir) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -82,18 +81,20 @@ pub(crate) fn force_remove_dir_all(dir: &Path) -> std::io::Result<()> { .into_iter() .flatten() { - let ft = entry.file_type(); - // Never chmod a symlink: `set_permissions` follows the link - // and would mutate its *target's* mode — which may live - // outside the tree. A symlink is unlinked via the write bit - // on its (relaxed) parent dir; its own mode is irrelevant. - if ft.is_symlink() { + // Only directory modes gate removal on unix: unlinking an + // entry needs write+execute on its (relaxed) parent dir, + // never a mode on the entry itself. Never chmod anything + // else: `set_permissions` follows a symlink and would + // mutate its *target's* mode, and a regular file may be a + // hard link to an inode outside the tree — chmod'ing it + // mutates that shared inode. (Links aren't followed, so a + // symlinked dir reports !is_dir and is skipped too.) + if !entry.file_type().is_dir() { continue; } - let mode = if ft.is_dir() { 0o755 } else { 0o644 }; let _ = std::fs::set_permissions( entry.path(), - std::fs::Permissions::from_mode(mode), + std::fs::Permissions::from_mode(0o755), ); } } @@ -107,7 +108,7 @@ pub async fn remove_tree(dir: &Path) -> std::io::Result<()> { let dir = dir.to_path_buf(); tokio::task::spawn_blocking(move || force_remove_dir_all(&dir)) .await - .map_err(|e| std::io::Error::other(e.to_string()))? + .map_err(to_io)? } #[cfg(test)] @@ -273,8 +274,8 @@ mod tests { /// Regression: the perm-relax retry in [`force_remove_dir_all`] must not /// chmod *through* a symlink. `set_permissions` follows links, so a symlink /// entry would silently mutate its target's mode — which can live outside - /// the tree. (Copy trees are symlink-free today, but this is a general - /// pub(crate) helper and the safety property must hold regardless.) + /// the tree. (Copy trees are symlink-free today, but [`remove_tree`] is a + /// general pub helper and the safety property must hold regardless.) #[cfg(unix)] #[tokio::test] async fn relax_loop_must_not_chmod_external_symlink_target() { @@ -305,6 +306,41 @@ mod tests { assert!(outside.exists()); } + /// Regression: the perm-relax retry must not chmod regular files at all. + /// On unix, unlinking needs write on the *parent dir*, never a mode on the + /// file itself — so the file chmod had no benefit, and a file inside the + /// tree may be a *hard link* to an inode outside it (dedupe tools, + /// store-linked installs; vendored copies live in the user's project + /// indefinitely). chmod'ing it mutates the shared inode's mode + /// (0o600 secret → 0o644 world-readable). + #[cfg(unix)] + #[tokio::test] + async fn relax_loop_must_not_chmod_hardlinked_external_inode() { + let base = tempfile::tempdir().unwrap(); + // An external precious file with restrictive perms. + let outside = base.path().join("secret.txt"); + fs::write(&outside, b"secret").unwrap(); + fs::set_permissions(&outside, fs::Permissions::from_mode(0o600)).unwrap(); + + // A tree whose FIRST remove_dir_all will FAIL (read-only dir) so the + // perm-relax retry runs, containing a HARD link to `outside`. + let root = base.path().join("tree"); + fs::create_dir_all(&root).unwrap(); + fs::hard_link(&outside, root.join("link.txt")).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o555)).unwrap(); + + remove_tree(&root).await.unwrap(); + + assert!(!root.exists(), "tree should still be removed"); + let mode = fs::metadata(&outside).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "external hardlinked inode mode was changed to {:o}", + mode + ); + assert_eq!(fs::read(&outside).unwrap(), b"secret"); + } + /// Regression: the perm-relax retry must not traverse *through* a /// symlinked root either. walkdir follows root symlinks by default /// (`follow_root_links`), so if the tree path itself is a symlink and the diff --git a/crates/socket-patch-core/src/patch/cow.rs b/crates/socket-patch-core/src/patch/cow.rs index 605683ff..a9b9c43e 100644 --- a/crates/socket-patch-core/src/patch/cow.rs +++ b/crates/socket-patch-core/src/patch/cow.rs @@ -25,7 +25,7 @@ //! `GetFileInformationByHandle` via `windows-sys` for full Windows //! parity. -use std::path::{Path, PathBuf}; +use std::path::Path; /// Outcome of [`break_hardlink_if_needed`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -61,6 +61,19 @@ pub async fn break_hardlink_if_needed(path: &Path) -> std::io::Result }; if lstat.file_type().is_symlink() { + // Gate on the *target's* type before reading through the link: + // `read()` on a symlink to a FIFO blocks forever at `open(2)` + // waiting for a writer (the same hazard the hardlink branch + // guards against below), and a device target reads unbounded + // bytes. Non-regular targets are not cow's problem — leave the + // link untouched, matching the hardlink branch's treatment of + // non-regular inodes. `metadata` follows the link, so a + // dangling symlink still surfaces as the NotFound error the + // read-through used to produce. + let target_meta = tokio::fs::metadata(path).await?; + if !target_meta.is_file() { + return Ok(CowAction::AlreadyPrivate); + } // Read through the symlink (this DOES follow it) to grab the // current target content. We need it on disk as a regular // file at `path` so the patch write lands on our copy. @@ -79,7 +92,8 @@ pub async fn break_hardlink_if_needed(path: &Path) -> std::io::Result // target, a crash), the original would be gone with nothing to // roll back to. The rename-over-symlink is a single atomic // step — on any failure `path` still holds the original link. - // This mirrors the hardlink branch below and `write_atomic`. + // This mirrors the hardlink branch below and the apply path's + // `utils::fs::atomic_write_bytes`. write_via_stage_rename(path, &target_bytes).await?; return Ok(CowAction::BrokeSymlink); } @@ -110,14 +124,11 @@ pub async fn break_hardlink_if_needed(path: &Path) -> std::io::Result /// `path`. Cross-FS-safe because the stage lives in the same /// directory as the target, so `rename(2)` is intra-filesystem. async fn write_via_stage_rename(path: &Path, bytes: &[u8]) -> std::io::Result<()> { - // Preconditions: cow callers always pass a real file path - // inside a package directory, so `path.parent()` and - // `path.file_name()` are guaranteed `Some`. The previous - // `unwrap_or_else` defaults only fired on `path == "/"`, - // which cow can never reach (lstat on "/" returns a directory, - // and the hardlink branch's `read("/")` errors out long - // before we get here). Using `.expect()` documents the - // invariant and eliminates the dead defensive default. + // Cow callers always pass a real file path inside a package + // directory, so `path.parent()` and `path.file_name()` are + // guaranteed `Some`: the only counterexample, `path == "/"`, + // is unreachable (lstat on "/" reports a directory, and the + // hardlink branch's `read("/")` errors long before we get here). let parent = path .parent() .expect("cow stage path always has a parent — callers pass package-internal files"); @@ -127,13 +138,13 @@ async fn write_via_stage_rename(path: &Path, bytes: &[u8]) -> std::io::Result<() // but defense in depth.) let stem = path .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .expect("cow stage path always has a file_name — callers pass package-internal files"); - let stage: PathBuf = parent.join(format!(".socket-cow-{}-{}", stem, uuid::Uuid::new_v4())); + .expect("cow stage path always has a file_name — callers pass package-internal files") + .to_string_lossy(); + let stage = parent.join(format!(".socket-cow-{}-{}", stem, uuid::Uuid::new_v4())); // Stage write. If this fails *after* creating the file (e.g. a // mid-write ENOSPC), the partial stage would otherwise leak as a // `.socket-cow-*` turd, so clean it up before propagating — same - // discipline as `apply::write_atomic`'s write arm. + // discipline as `utils::fs::atomic_write_bytes`'s write arm. if let Err(e) = tokio::fs::write(&stage, bytes).await { let _ = tokio::fs::remove_file(&stage).await; return Err(e); @@ -384,6 +395,52 @@ mod tests { assert_eq!(leftover_stage_count(dir.path()), 0); } + /// The symlink branch has the same FIFO hazard the hardlink branch + /// guards against: `read()` through a symlink whose target is a + /// FIFO blocks forever at `open(2)` waiting for a writer, hanging + /// the whole apply. A symlink to a non-regular inode is not cow's + /// problem — it must come back promptly as `AlreadyPrivate` with + /// the link untouched. + #[cfg(unix)] + #[tokio::test] + async fn symlink_to_fifo_is_not_routed_into_symlink_break() { + let dir = tempfile::tempdir().unwrap(); + let fifo = dir.path().join("pipe"); + let status = std::process::Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap(); + assert!(status.success()); + let link = dir.path().join("pipe-link"); + tokio::fs::symlink(&fifo, &link).await.unwrap(); + + let result = tokio::time::timeout( + std::time::Duration::from_secs(2), + break_hardlink_if_needed(&link), + ) + .await; + // Rescue: if the code under test wrongly opened the FIFO for + // read, give it a writer + immediate EOF so the blocked pool + // thread can exit — otherwise a regression wedges the test + // binary at runtime shutdown instead of failing the asserts + // below. (O_RDWR on a FIFO never blocks; no-op when the code + // behaved.) + drop( + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&fifo), + ); + let action = result + .expect("must not block opening the FIFO through the symlink") + .unwrap(); + assert_eq!(action, CowAction::AlreadyPrivate); + // The symlink itself must be left untouched. + let meta = tokio::fs::symlink_metadata(&link).await.unwrap(); + assert!(meta.file_type().is_symlink()); + assert_eq!(leftover_stage_count(dir.path()), 0); + } + /// A directory always has `nlink >= 2` on Unix, which a bare /// `nlink > 1` check misreads as a hardlinked file — `read()` then /// fails EISDIR instead of the documented no-op. Directories are diff --git a/crates/socket-patch-core/src/patch/file_hash.rs b/crates/socket-patch-core/src/patch/file_hash.rs index ac563ce8..ed976b03 100644 --- a/crates/socket-patch-core/src/patch/file_hash.rs +++ b/crates/socket-patch-core/src/patch/file_hash.rs @@ -1,12 +1,14 @@ use std::path::Path; use crate::hash::git_sha256::compute_git_sha256_from_reader; +use crate::utils::fs::open_regular_file; /// Compute Git-compatible SHA256 hash of file contents using streaming. /// -/// Opens the file *once* and derives the size from that open handle (an -/// `fstat`), then streams the same handle through the hasher without loading -/// the entire file into memory. +/// Opens the file *once* via [`open_regular_file`] (non-blocking on Unix, +/// regular files only — see its docs for the FIFO/special-file rationale) and +/// derives the size from that open handle (an `fstat`), then streams the same +/// handle through the hasher without loading the entire file into memory. /// /// Deriving the size from the open file descriptor — rather than `stat`-ing the /// path separately and then re-opening it — is what makes this safe under @@ -17,41 +19,13 @@ use crate::hash::git_sha256::compute_git_sha256_from_reader; /// [`compute_git_sha256_from_reader`] and produce a hash whose Git header (the /// size) and body came from different inodes. Reading both from the same `fd` /// makes that impossible. -/// -/// Only regular files are accepted. Following a path to a directory or a -/// special file (FIFO, device, …) and hashing it is never meaningful here, and -/// on some platforms a directory can read as zero bytes — which would otherwise -/// be silently reported as the empty-blob hash. -/// -/// On Unix the open itself is non-blocking (`O_NONBLOCK`): a plain `open(2)` -/// of a FIFO with `O_RDONLY` waits for a writer that may never come, which -/// would hang the patch engine forever *before* the regular-file guard below -/// ever runs. With `O_NONBLOCK` the open returns immediately (it has no effect -/// on reads of regular files) and the guard rejects the FIFO with an error. -pub async fn compute_file_git_sha256(filepath: impl AsRef) -> Result { - let filepath = filepath.as_ref(); - - // Open the file once; everything below operates on this single descriptor. - #[cfg(unix)] - let file = tokio::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NONBLOCK) - .open(filepath) - .await?; - #[cfg(not(unix))] - let file = tokio::fs::File::open(filepath).await?; +pub(crate) async fn compute_file_git_sha256( + filepath: impl AsRef, +) -> Result { + let (file, metadata) = open_regular_file(filepath.as_ref()).await?; // Size comes from the open handle (fstat), so it and the bytes we hash are // guaranteed to refer to the same inode even if the path is replaced. - let metadata = file.metadata().await?; - - if !metadata.is_file() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("git sha256: {} is not a regular file", filepath.display()), - )); - } - let file_size = metadata.len(); let reader = tokio::io::BufReader::new(file); diff --git a/crates/socket-patch-core/src/patch/go_mod_edit.rs b/crates/socket-patch-core/src/patch/go_mod_edit.rs index 45ccb1ee..181569de 100644 --- a/crates/socket-patch-core/src/patch/go_mod_edit.rs +++ b/crates/socket-patch-core/src/patch/go_mod_edit.rs @@ -43,7 +43,7 @@ pub const GO_PATCHES_DIR: &str = ".socket/go-patches"; /// Project-relative directory holding `vendor`'s committed module copies /// (`//@`). A `replace` whose /// target path is under this prefix is owned by [`ReplaceOwner::Vendor`]. -pub const GO_VENDOR_DIR: &str = ".socket/vendor/golang"; +const GO_VENDOR_DIR: &str = ".socket/vendor/golang"; /// Which socket-managed backend owns a `replace` directive, classified by the /// directive's target-path prefix. @@ -55,25 +55,18 @@ pub enum ReplaceOwner { Vendor, } -impl ReplaceOwner { - /// The classifying path prefix (no trailing slash). - pub fn prefix(self) -> &'static str { - match self { - Self::GoPatches => GO_PATCHES_DIR, - Self::Vendor => GO_VENDOR_DIR, - } - } -} - /// Classify a `replace` target path: which socket backend owns it, or `None` /// for a user-authored path. The two prefixes don't overlap, but `Vendor` is /// tested first to keep the intent explicit (`.socket/vendor/golang/` is more /// specific than a hypothetical future `.socket/` catch-all). -pub fn detect_owner(path: &str) -> Option { - for owner in [ReplaceOwner::Vendor, ReplaceOwner::GoPatches] { - let norm = path.replace('\\', "/"); - let norm = norm.strip_prefix("./").unwrap_or(&norm); - let prefix = format!("{}/", owner.prefix()); +pub(crate) fn detect_owner(path: &str) -> Option { + let norm = path.replace('\\', "/"); + let norm = norm.strip_prefix("./").unwrap_or(&norm); + for (owner, dir) in [ + (ReplaceOwner::Vendor, GO_VENDOR_DIR), + (ReplaceOwner::GoPatches, GO_PATCHES_DIR), + ] { + let prefix = format!("{dir}/"); if norm.starts_with(&prefix) || norm.contains(&format!("/{prefix}")) { return Some(owner); } @@ -90,11 +83,6 @@ pub fn replace_target_path(base_rel: &str, module: &str, version: &str) -> Strin format!("./{base_rel}/{module}@{version}") } -/// The expected `replace` target for an `apply` (go-patches) redirect copy. -pub fn expected_replace_path(module: &str, version: &str) -> String { - replace_target_path(GO_PATCHES_DIR, module, version) -} - /// One parsed `replace` directive. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReplaceEntry { @@ -196,7 +184,11 @@ async fn edit_go_mod( None => Ok(false), Some(new) => { if !dry_run { - atomic_write(&path, new.as_bytes()) + // go.mod is user-owned (their own require/replace directives and + // comments live alongside our socket `replace`) — a torn write + // would corrupt a manifest that no longer builds, and the swap + // must keep the file's permission bits. + crate::utils::fs::atomic_write_bytes_preserving_mode(&path, new.as_bytes()) .await .map_err(|e| format!("write {}: {e}", path.display()))?; } @@ -205,17 +197,6 @@ async fn edit_go_mod( } } -/// Atomically commit `content` to `path` via stage + fsync + rename. -/// -/// A `go.mod` is a *user-owned* file that **defines the module** and carries -/// the user's own `require`/`exclude`/`retract`/`replace` directives and -/// comments alongside our socket `replace` — a torn write would corrupt a -/// manifest that no longer builds, when we only meant to add or refresh one -/// line. Delegates to the crate-wide hardened writer. -async fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> { - crate::utils::fs::atomic_write_bytes(path, content).await -} - // ── parsing ──────────────────────────────────────────────────────────────── /// Strip a trailing `// …` line comment. Module paths and our `./…` targets @@ -227,6 +208,45 @@ fn strip_comment(line: &str) -> &str { } } +/// Walk every directive body for `keyword` — the single-line form +/// (`keyword `) and the members of a `keyword ( … )` block — calling +/// `f(line_index, body)` with the comment stripped and whitespace trimmed. +fn for_each_directive_body( + content: &str, + keyword: &str, + mut f: impl FnMut(usize, &str) -> Result<(), String>, +) -> Result<(), String> { + let mut in_block = false; + for (i, raw) in content.lines().enumerate() { + let line = strip_comment(raw).trim(); + if line.is_empty() { + continue; + } + if in_block { + if line == ")" { + in_block = false; + } else { + f(i, line)?; + } + } else if let Some(after) = line.strip_prefix(keyword) { + let rest = after.trim_start(); + match rest { + "(" => in_block = true, + "()" => {} // empty inline block — nothing inside + _ => { + // Go's lexer separates tokens on ANY whitespace, so + // `replace\tmod …` is as valid as `replace mod …` (and + // `replaceX` is a different word entirely). + if after.starts_with(char::is_whitespace) && !rest.is_empty() { + f(i, rest)?; + } + } + } + } + } + Ok(()) +} + /// True if a replacement RHS token is a filesystem path (vs a module path). /// Go's rule: a path begins with `./`, `../`, `/`, or a Windows drive/`\`. fn rhs_is_path(tok: &str) -> bool { @@ -264,95 +284,29 @@ fn parse_replace_body(body: &str) -> Option { } /// Parse every `replace` directive (single-line and block forms). -pub fn parse_replace_entries(content: &str) -> Vec { +fn parse_replace_entries(content: &str) -> Vec { let mut out = Vec::new(); - let mut in_block = false; - for raw in content.lines() { - let line = strip_comment(raw).trim(); - if line.is_empty() { - continue; - } - if in_block { - if line == ")" { - in_block = false; - continue; - } - if let Some(e) = parse_replace_body(line) { - out.push(e); - } - continue; - } - if let Some(rest) = directive_block_open(line, "replace") { - if rest { - in_block = true; - } - continue; - } - if let Some(body) = line.strip_prefix("replace ") { - if let Some(e) = parse_replace_body(body) { - out.push(e); - } - } - } + let _ = for_each_directive_body(content, "replace", |_, body| { + out.extend(parse_replace_body(body)); + Ok(()) + }); out } /// Parse `require` directives into `module -> version` (last wins; the module /// graph selects one version per module path). -pub fn parse_required_versions(content: &str) -> HashMap { +fn parse_required_versions(content: &str) -> HashMap { let mut out = HashMap::new(); - let mut in_block = false; - for raw in content.lines() { - let line = strip_comment(raw).trim(); - if line.is_empty() { - continue; - } - if in_block { - if line == ")" { - in_block = false; - continue; - } - insert_require(&mut out, line); - continue; - } - if let Some(rest) = directive_block_open(line, "require") { - if rest { - in_block = true; - } - continue; + let _ = for_each_directive_body(content, "require", |_, body| { + let mut toks = body.split_whitespace(); + if let (Some(m), Some(v)) = (toks.next(), toks.next()) { + out.insert(m.to_string(), v.to_string()); } - if let Some(body) = line.strip_prefix("require ") { - insert_require(&mut out, body); - } - } + Ok(()) + }); out } -fn insert_require(out: &mut HashMap, body: &str) { - let toks: Vec<&str> = body.split_whitespace().collect(); - if let (Some(m), Some(v)) = (toks.first(), toks.get(1)) { - out.insert((*m).to_string(), (*v).to_string()); - } -} - -/// For a directive keyword (`replace`/`require`), classify a line: -/// * `Some(true)` — opens a `keyword (` block, -/// * `Some(false)` — a bare `keyword (` … `)` is not this (e.g. `keyword (` only -/// matches the open form); returns `Some(false)` for `keyword ()` empties, -/// * `None` — the line is not a block opener for this keyword. -fn directive_block_open(line: &str, keyword: &str) -> Option { - // `replace (` / `replace(` - let rest = line.strip_prefix(keyword)?; - let rest = rest.trim_start(); - if rest == "(" { - return Some(true); - } - if rest == "()" { - return Some(false); // empty inline block — nothing inside - } - None -} - // ── pure transforms ────────────────────────────────────────────────────────── /// Upsert a socket-owned `replace module version => .//…@version`. @@ -365,40 +319,23 @@ fn upsert_replace_entry( let want_path = replace_target_path(base_rel, module, version); let want_line = format!("replace {module} {version} => {want_path}"); - let mut lines: Vec = content.lines().map(str::to_string).collect(); - // Locate an existing socket-owned replace line for `module`, and detect a // conflicting user-authored replace pinning the same module+version. let mut socket_line: Option = None; - let mut in_block = false; - for (i, raw) in lines.iter().enumerate() { - let line = strip_comment(raw).trim(); - if line.is_empty() { - continue; - } - if in_block { - if line == ")" { - in_block = false; - continue; - } - inspect_existing(line, module, version, &want_path, i, &mut socket_line)?; - continue; - } - if let Some(opened) = directive_block_open(line, "replace") { - in_block = opened; - continue; - } - if let Some(body) = line.strip_prefix("replace ") { - inspect_existing(body, module, version, &want_path, i, &mut socket_line)?; - } - } + for_each_directive_body(content, "replace", |i, body| { + inspect_existing(body, module, version, &want_path, i, &mut socket_line) + })?; if let Some(idx) = socket_line { // Rewrite the existing socket-owned line in place, preserving whether it // was a block member (`\tmodule … => …`) or a single-line `replace …`. + let mut lines: Vec = content.lines().map(str::to_string).collect(); let raw = &lines[idx]; let indent: String = raw.chars().take_while(|c| c.is_whitespace()).collect(); - let is_block_member = !strip_comment(raw).trim_start().starts_with("replace "); + let is_block_member = !strip_comment(raw) + .trim_start() + .strip_prefix("replace") + .is_some_and(|rest| rest.starts_with(char::is_whitespace)); let new = if is_block_member { format!("{indent}{module} {version} => {want_path}") } else { @@ -480,7 +417,7 @@ fn remove_replace_entry( let mut changed = false; while i < lines.len() { let line = strip_comment(lines[i]).trim(); - if directive_block_open(line, "replace") == Some(true) { + if line.strip_prefix("replace").map(str::trim_start) == Some("(") { // Block spans [i, close]; mark socket-owned members for removal. let open = i; let mut close = i; @@ -516,11 +453,13 @@ fn remove_replace_entry( i = close + 1; continue; } - if let Some(body) = line.strip_prefix("replace ") { - if let Some(e) = parse_replace_body(body) { - if e.module == module && e.owner == Some(owner) { - keep[i] = false; - changed = true; + if let Some(after) = line.strip_prefix("replace") { + if after.starts_with(char::is_whitespace) { + if let Some(e) = parse_replace_body(after) { + if e.module == module && e.owner == Some(owner) { + keep[i] = false; + changed = true; + } } } } @@ -591,9 +530,9 @@ mod tests { } #[test] - fn test_expected_path() { + fn test_replace_target_path() { assert_eq!( - expected_replace_path("github.com/foo/bar", "v1.4.2"), + replace_target_path(GO_PATCHES_DIR, "github.com/foo/bar", "v1.4.2"), "./.socket/go-patches/github.com/foo/bar@v1.4.2" ); } @@ -1038,6 +977,75 @@ replace ( ); } + /// `go.mod` is user-owned: editing it must not reset its permission bits + /// (a 0600 private go.mod silently becoming umask-default 0644 is the + /// `package_json/update.rs` mode-reset bug, same class). + #[cfg(unix)] + #[tokio::test] + async fn test_ensure_preserves_go_mod_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("go.mod"); + fs::write(&path, "module m\n\ngo 1.21\n").await.unwrap(); + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(&path, perms).unwrap(); + + assert!(ensure_replace_entry( + dir.path(), + "github.com/foo/bar", + "v1.4.2", + GO_PATCHES_DIR, + false + ) + .await + .unwrap()); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "edit must preserve go.mod permission bits"); + } + + // ── tab-separated directives (Go's lexer: any whitespace separates) ────── + /// A hand-formatted `replace\tmodule … => …` (tab after the keyword) is + /// valid go.mod. Upsert must recognize it as the existing socket-owned + /// entry — appending a second directive for the same module+version makes + /// go.mod invalid ("duplicate replacement"). + #[test] + fn test_upsert_recognizes_tab_separated_socket_replace() { + let gomod = "module m\n\nreplace\tgithub.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n"; + let out = + upsert_replace_entry(gomod, "github.com/foo/bar", "v1.4.2", GO_PATCHES_DIR).unwrap(); + // Either a no-op or an in-place normalization is fine; a duplicate is not. + // Count raw text occurrences — parse_replace_entries can't be the + // oracle for a form the parser itself might be blind to. + let content = out.as_deref().unwrap_or(gomod); + assert_eq!( + content.matches("github.com/foo/bar v1.4.2 =>").count(), + 1, + "must not append a duplicate replace for the module: {content:?}" + ); + // Still a well-formed single-line directive (keyword kept). + assert!( + content.contains("replace") + && content.contains( + "github.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2" + ), + ); + } + + /// Same input: drop must remove the tab-separated socket-owned directive — + /// leaving it behind strands a `replace` pointing at a copy dir the caller + /// is about to delete. + #[test] + fn test_remove_tab_separated_socket_replace() { + let gomod = "module m\n\nreplace\tgithub.com/foo/bar v1.4.2 => ./.socket/go-patches/github.com/foo/bar@v1.4.2\n"; + let out = remove_replace_entry(gomod, "github.com/foo/bar", ReplaceOwner::GoPatches) + .unwrap() + .expect("tab-separated socket replace must be removable"); + assert!(!out.contains("go-patches")); + assert!(out.contains("module m")); + } + #[tokio::test] async fn test_ensure_missing_go_mod_errors() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/patch/go_redirect.rs b/crates/socket-patch-core/src/patch/go_redirect.rs index 976defcb..69a1c313 100644 --- a/crates/socket-patch-core/src/patch/go_redirect.rs +++ b/crates/socket-patch-core/src/patch/go_redirect.rs @@ -27,16 +27,19 @@ use std::path::{Path, PathBuf}; use crate::manifest::schema::{PatchFileInfo, PatchManifest}; use crate::patch::apply::{ - apply_package_patch, normalize_file_path, ApplyResult, MismatchPolicy, PatchSources, - VerifyResult, VerifyStatus, + apply_package_patch, is_safe_relative_subpath, normalize_file_path, ApplyResult, + MismatchPolicy, PatchSources, }; use crate::patch::file_hash::compute_file_git_sha256; +use crate::patch::vendor::common::{ + already_patched_result, copy_matches_after_hashes, synthesized_result, +}; use crate::utils::purl::{build_golang_purl, parse_golang_purl, strip_purl_qualifiers}; use super::copy_tree::{fresh_copy, remove_tree}; use super::go_mod_edit::{ - self, expected_replace_path, read_replace_entries, read_required_versions, replace_target_path, - ReplaceOwner, GO_PATCHES_DIR, + self, read_replace_entries, read_required_versions, replace_target_path, ReplaceOwner, + GO_PATCHES_DIR, }; use super::path_safety; @@ -124,7 +127,11 @@ impl std::fmt::Display for Drift { /// [`GO_PATCHES_DIR`] for apply, `/` for vendor). /// `module` carries the real (decoded) module path with `/`-separators, so the /// on-disk layout mirrors the module cache (`github.com/foo/bar@v1.4.2`). -fn copy_dir_for(project_root: &Path, base_rel: &str, module: &str, version: &str) -> PathBuf { +/// +/// `pub` (not `pub(crate)`): the single home of this on-disk layout — the +/// vendor backend and the CLI's VEX go-patches synthesis key the same copy +/// dir and must stay in lockstep rather than carry drift-prone mirrors. +pub fn copy_dir_for(project_root: &Path, base_rel: &str, module: &str, version: &str) -> PathBuf { project_root .join(base_rel) .join(format!("{module}@{version}")) @@ -141,7 +148,11 @@ fn copy_dir_for(project_root: &Path, base_rel: &str, module: &str, version: &str /// separators (`github.com/foo/bar`), so it is validated **per segment** /// (see [`path_safety::is_safe_multi_segment`]); a version is a single /// segment. Reject fail-closed before any disk access. -pub(crate) fn are_safe_redirect_coords(module: &str, version: &str) -> bool { +/// +/// `pub` (not `pub(crate)`): the CLI's VEX go-patches synthesis keys the +/// same copy-dir path from the same untrusted `go.mod` coordinates and must +/// apply this exact guard rather than a drift-prone mirror. +pub fn are_safe_redirect_coords(module: &str, version: &str) -> bool { path_safety::is_safe_multi_segment(module) && path_safety::is_safe_single_segment(version) } @@ -206,12 +217,12 @@ pub async fn apply_go_redirect( // fingerprint stays stable across repeated applies (the guard re-runs apply // on most "deps changed" builds). if redirect_in_sync(©_dir, files, project_root, module, version, base_rel).await { - let verified = files.keys().map(|f| already_patched_verify(f)).collect(); - return synthesized_result(purl, ©_dir, verified, true, None); + return already_patched_result(purl, ©_dir, files); } // Fresh copy pristine → copy_dir. if let Err(e) = fresh_copy(pristine_src, ©_dir, None).await { + teardown_failed_redirect(project_root, ©_dir, module, version, base_rel).await; return synthesized_result( purl, ©_dir, @@ -225,7 +236,7 @@ pub async fn apply_go_redirect( // module path. Pre-modules packages have none in their extracted cache dir // (validated: `gopkg.in/inf.v0`), so synthesize Go's own minimal form. if let Err(e) = ensure_module_go_mod(©_dir, module).await { - let _ = remove_tree(©_dir).await; + teardown_failed_redirect(project_root, ©_dir, module, version, base_rel).await; return synthesized_result( purl, ©_dir, @@ -246,7 +257,7 @@ pub async fn apply_go_redirect( if !result.success { // Don't leave a half-built copy that verify/reconcile would misjudge. - let _ = remove_tree(©_dir).await; + teardown_failed_redirect(project_root, ©_dir, module, version, base_rel).await; return result; } @@ -431,21 +442,27 @@ pub async fn verify_go_redirect_state( drifts.push(Drift::MissingCopy { purl: purl.clone() }); } else { for (file_name, info) in &record.files { - let path = copy_dir.join(normalize_file_path(file_name)); - match compute_file_git_sha256(&path).await { - Ok(h) if h == info.after_hash => {} - Ok(h) => drifts.push(Drift::StaleCopy { - purl: purl.clone(), - file: file_name.clone(), - expected: info.after_hash.clone(), - found: Some(h), - }), - Err(_) => drifts.push(Drift::StaleCopy { + let normalized = normalize_file_path(file_name); + // SECURITY: never hash through a manifest key that escapes + // the copy dir — a poisoned manifest (`../../../home/...`) + // would otherwise leak the existence + content hash of an + // arbitrary user-readable file into the audit output. + // Fail closed as drift (`found: None`, no read), same as the + // guarded `copy_matches_after_hashes` this audit mirrors. + let found = if is_safe_relative_subpath(normalized) { + compute_file_git_sha256(©_dir.join(normalized)) + .await + .ok() + } else { + None + }; + if found.as_deref() != Some(info.after_hash.as_str()) { + drifts.push(Drift::StaleCopy { purl: purl.clone(), file: file_name.clone(), expected: info.after_hash.clone(), - found: None, - }), + found, + }); } } } @@ -454,7 +471,7 @@ pub async fn verify_go_redirect_state( // keys `replace` by module + version, so a socket directive pinned to // another version (an aborted/partial apply, a bad merge, a hand-edit) // is silently ignored while the copy-hash checks above pass. - let expected = expected_replace_path(module, version); + let expected = replace_target_path(GO_PATCHES_DIR, module, version); let socket = entries .iter() .find(|e| e.module == module && e.owner == Some(ReplaceOwner::GoPatches)); @@ -490,6 +507,30 @@ pub async fn verify_go_redirect_state( // ── helpers ────────────────────────────────────────────────────────────────── +/// Failure cleanup for the rebuild legs of [`apply_go_redirect`]. By the time +/// a rebuild leg fails, any pre-existing copy is already gone (`fresh_copy` +/// removes the destination first), so a pre-existing socket-owned `replace` +/// directive — a re-apply healing drift, or a version bump — would dangle at a +/// deleted/husk directory and every `go build` fails with "replacement +/// directory does not exist". Tear down to the same end state a failed FIRST +/// apply leaves: no copy, no directive (the build falls back to the unpatched +/// module until a later apply succeeds). The drop is owner-filtered, keyed by +/// the owner `base_rel` implies, so the other backend's and the user's +/// directives are never touched — and on a first apply it is a no-op. +async fn teardown_failed_redirect( + project_root: &Path, + copy_dir: &Path, + module: &str, + version: &str, + base_rel: &str, +) { + let _ = remove_tree(copy_dir).await; + if let Some(owner) = go_mod_edit::detect_owner(&replace_target_path(base_rel, module, version)) + { + let _ = go_mod_edit::drop_replace_entry(project_root, module, owner, false).await; + } +} + /// True if the copy exists, every patched file in it already hashes to its /// `afterHash`, and a socket-owned `replace` pins this version's copy. async fn redirect_in_sync( @@ -500,16 +541,9 @@ async fn redirect_in_sync( version: &str, base_rel: &str, ) -> bool { - if tokio::fs::metadata(copy_dir).await.is_err() { + if !copy_matches_after_hashes(copy_dir, files).await { return false; } - for (file_name, info) in files { - let path = copy_dir.join(normalize_file_path(file_name)); - match compute_file_git_sha256(&path).await { - Ok(h) if h == info.after_hash => {} - _ => return false, - } - } let expected = replace_target_path(base_rel, module, version); read_replace_entries(project_root).await.iter().any(|e| { e.module == module @@ -533,104 +567,20 @@ pub(crate) async fn ensure_module_go_mod(copy_dir: &Path, module: &str) -> std:: if tokio::fs::metadata(&go_mod).await.is_ok() { return Ok(()); } - atomic_write(&go_mod, format!("module {module}\n").as_bytes()).await -} - -/// Atomically commit `content` to `path` via stage + fsync + rename. Mirrors the -/// hardened writer in [`crate::patch::go_mod_edit`]: a reader/recovering process -/// only ever sees the complete old or complete new bytes, never a truncated -/// intermediate. -async fn atomic_write(path: &Path, content: &[u8]) -> std::io::Result<()> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let stem = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "go.mod".to_string()); - let stage = parent.join(format!(".socket-stage-{}-{}", stem, uuid::Uuid::new_v4())); - - let mut file = tokio::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&stage) - .await?; - - use tokio::io::AsyncWriteExt; - if let Err(e) = file.write_all(content).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - if let Err(e) = file.sync_all().await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - drop(file); - - if let Err(e) = tokio::fs::rename(&stage, path).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - - // The rename only updated the parent directory entry; fsync the directory so - // the rename itself survives a crash. Best-effort, Unix only. - #[cfg(unix)] - { - if let Ok(dir) = tokio::fs::File::open(parent).await { - let _ = dir.sync_all().await; - } - } - - Ok(()) -} - -fn synthesized_result( - package_key: &str, - copy_dir: &Path, - files_verified: Vec, - success: bool, - error: Option, -) -> ApplyResult { - ApplyResult { - package_key: package_key.to_string(), - package_path: copy_dir.display().to_string(), - success, - files_verified, - files_patched: Vec::new(), - applied_via: HashMap::new(), - error, - sidecar: None, - } -} - -fn already_patched_verify(file: &str) -> VerifyResult { - VerifyResult { - file: file.to_string(), - status: VerifyStatus::AlreadyPatched, - message: None, - current_hash: None, - expected_hash: None, - target_hash: None, - } + crate::utils::fs::atomic_write_bytes(&go_mod, format!("module {module}\n").as_bytes()).await } /// Recursively find every patched-copy module dir under `go_patches_root`, /// returning `(purl, dir)`. A module dir is identified by an `@` in its final -/// path component (`github.com/foo/bar@v1.4.2`); recursion stops there (the +/// path component (`github.com/foo/bar@v1.4.2`); descent stops there (the /// module's own contents are not scanned). Returns empty if the root is absent. async fn collect_copy_modules(go_patches_root: &Path) -> Vec<(String, PathBuf)> { let mut out = Vec::new(); - collect_copy_modules_inner(go_patches_root, String::new(), &mut out).await; - out -} - -fn collect_copy_modules_inner<'a>( - dir: &'a Path, - prefix: String, - out: &'a mut Vec<(String, PathBuf)>, -) -> std::pin::Pin + 'a>> { - Box::pin(async move { - let mut rd = match tokio::fs::read_dir(dir).await { + let mut pending = vec![(go_patches_root.to_path_buf(), String::new())]; + while let Some((dir, prefix)) = pending.pop() { + let mut rd = match tokio::fs::read_dir(&dir).await { Ok(rd) => rd, - Err(_) => return, + Err(_) => continue, }; while let Ok(Some(entry)) = rd.next_entry().await { if !entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) { @@ -639,8 +589,7 @@ fn collect_copy_modules_inner<'a>( let name = entry.file_name().to_string_lossy().to_string(); if let Some(at) = name.rfind('@') { // `` is the module's final path segment. - let leaf = &name[..at]; - let version = &name[at + 1..]; + let (leaf, version) = (&name[..at], &name[at + 1..]); let module = if prefix.is_empty() { leaf.to_string() } else { @@ -649,17 +598,18 @@ fn collect_copy_modules_inner<'a>( if !module.is_empty() && !version.is_empty() { out.push((build_golang_purl(&module, version), entry.path())); } - // Do not recurse into a module dir. + // Do not descend into a module dir. } else { let child_prefix = if prefix.is_empty() { - name.clone() + name } else { format!("{prefix}/{name}") }; - collect_copy_modules_inner(&entry.path(), child_prefix, out).await; + pending.push((entry.path(), child_prefix)); } } - }) + } + out } #[cfg(test)] @@ -961,6 +911,139 @@ mod tests { assert!(read_replace_entries(root).await.is_empty()); } + /// A failed RE-apply (drift heal / patch-content update) must not leave + /// `go.mod` pointing at a copy dir the failure path just deleted. The + /// rebuild leg destroys the existing copy (`fresh_copy`) and, on pipeline + /// failure, removes the half-built one — so the pre-existing socket-owned + /// `replace` directive would reference a nonexistent directory and every + /// `go build` fails with "replacement directory does not exist". Failure + /// must fall back to the unpatched module: directive dropped alongside the + /// copy, the same end state as a first-apply failure. + #[tokio::test] + async fn test_failed_reapply_drops_directive_with_copy() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let sources = PatchSources::blobs_only(&blobs); + let result = apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + MismatchPolicy::Warn, + ) + .await; + assert!(result.success, "setup apply failed: {:?}", result.error); + + // Drift the copy so the in-sync hot path is skipped and the rebuild + // leg runs. + let copy_dir = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2"); + tokio::fs::write(copy_dir.join("bar.go"), b"corrupted") + .await + .unwrap(); + + // Re-apply with EMPTY blob sources → the apply pipeline fails after + // the copy was already rebuilt from pristine. + let empty = root.join(".socket/empty-blobs"); + tokio::fs::create_dir_all(&empty).await.unwrap(); + let empty_sources = PatchSources::blobs_only(&empty); + let result = apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &empty_sources, + None, + false, + MismatchPolicy::Warn, + ) + .await; + assert!(!result.success, "re-apply with no blobs must fail"); + + assert!(!copy_dir.exists(), "failed copy must be rolled back"); + let entries = read_replace_entries(root).await; + assert!( + !entries + .iter() + .any(|e| e.module == MODULE && e.socket_owned()), + "a socket-owned replace must not survive pointing at a deleted \ + copy (go.mod would brick the build): {entries:?}" + ); + } + + /// Same invariant when the pristine source itself is gone: `fresh_copy` + /// force-removes the stale copy FIRST and then fails walking the missing + /// source, so without cleanup the directive dangles at a deleted dir. + #[tokio::test] + async fn test_failed_recopy_missing_pristine_drops_directive() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let sources = PatchSources::blobs_only(&blobs); + let result = apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + MismatchPolicy::Warn, + ) + .await; + assert!(result.success, "setup apply failed: {:?}", result.error); + + // Drift the copy, then make the pristine source vanish (evicted + // module cache). + let copy_dir = root.join(".socket/go-patches/github.com/foo/bar@v1.4.2"); + tokio::fs::write(copy_dir.join("bar.go"), b"corrupted") + .await + .unwrap(); + tokio::fs::remove_dir_all(&pristine).await.unwrap(); + + let result = apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + MismatchPolicy::Warn, + ) + .await; + assert!( + !result.success, + "re-apply with no pristine source must fail" + ); + + assert!( + !copy_dir.exists(), + "fresh_copy removed the stale copy before failing" + ); + let entries = read_replace_entries(root).await; + assert!( + !entries + .iter() + .any(|e| e.module == MODULE && e.socket_owned()), + "a socket-owned replace must not survive pointing at a deleted \ + copy (go.mod would brick the build): {entries:?}" + ); + } + #[tokio::test] async fn test_synthesizes_go_mod_for_pre_modules_package() { let (dir, blobs, pristine, files, _after) = fixture().await; @@ -1345,6 +1428,64 @@ mod tests { .any(|d| matches!(d, Drift::OrphanReplace { .. }))); } + /// SECURITY: the audit must not hash through a manifest file key that + /// escapes the copy dir — a poisoned committed manifest would otherwise + /// leak the existence + content hash of an arbitrary user-readable file + /// into the drift report. The unsafe key fails closed as + /// `StaleCopy { found: None }` with no out-of-tree read. + #[tokio::test] + async fn test_verify_rejects_escaping_manifest_key_without_reading() { + let (dir, blobs, pristine, files, _after) = fixture().await; + let root = dir.path(); + let sources = PatchSources::blobs_only(&blobs); + apply_go_redirect( + PURL, + MODULE, + VERSION, + &pristine, + root, + GO_PATCHES_DIR, + &files, + &sources, + None, + false, + MismatchPolicy::Warn, + ) + .await; + + // The out-of-tree file a poisoned key points at (5 `..`s climb from + // `.socket/go-patches/github.com/foo/bar@v1.4.2/` back to root). + tokio::fs::write(root.join("secret.txt"), b"out-of-tree") + .await + .unwrap(); + let mut manifest = manifest_with(&files); + manifest.patches.get_mut(PURL).unwrap().files.insert( + "../../../../../secret.txt".to_string(), + PatchFileInfo { + before_hash: "0".repeat(64), + after_hash: "1".repeat(64), + }, + ); + + let desired: HashSet = [PURL.to_string()].into_iter().collect(); + let drifts = verify_go_redirect_state(root, &manifest, &desired) + .await + .unwrap_err(); + let found = drifts + .iter() + .find_map(|d| match d { + Drift::StaleCopy { file, found, .. } if file.contains("secret") => { + Some(found.clone()) + } + _ => None, + }) + .expect("unsafe key must surface as drift"); + assert_eq!( + found, None, + "must fail closed WITHOUT hashing the out-of-tree file" + ); + } + #[tokio::test] async fn test_empty_files_is_noop() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/patch/mod.rs b/crates/socket-patch-core/src/patch/mod.rs index cfe7337f..28d4d66c 100644 --- a/crates/socket-patch-core/src/patch/mod.rs +++ b/crates/socket-patch-core/src/patch/mod.rs @@ -6,10 +6,8 @@ pub(crate) mod bun_lock_text; pub mod copy_tree; pub mod cow; pub mod diff; -pub mod file_hash; -#[cfg(feature = "golang")] +pub(crate) mod file_hash; pub mod go_mod_edit; -#[cfg(feature = "golang")] pub mod go_redirect; pub mod package; pub(crate) mod path_safety; diff --git a/crates/socket-patch-core/src/patch/package.rs b/crates/socket-patch-core/src/patch/package.rs index 53803fe4..66709183 100644 --- a/crates/socket-patch-core/src/patch/package.rs +++ b/crates/socket-patch-core/src/patch/package.rs @@ -18,6 +18,7 @@ use flate2::read::GzDecoder; use tar::Archive; use crate::manifest::schema::PatchFileInfo; +use crate::patch::apply::{is_safe_relative_subpath, normalize_file_path}; /// Maximum cumulative *decompressed* bytes we accept from a single /// archive. Real socket-patch archives are tiny (kilobytes); 64 MiB is a @@ -47,12 +48,6 @@ pub enum ArchiveError { TooManyEntries(usize), } -/// Strip the leading `package/` prefix from an entry path, matching the -/// convention used by `normalize_file_path` in `apply.rs`. -fn normalize_entry_path(path: &str) -> &str { - path.strip_prefix("package/").unwrap_or(path) -} - /// Read a `.tar.gz` archive into a map of `normalized_path -> bytes`. /// /// Returns an error if any entry path is absolute or contains `..` @@ -124,59 +119,39 @@ pub fn read_archive_to_map(archive_path: &Path) -> Result { - saw_normal_component = true; - true - } - Component::CurDir => true, - _ => false, - }); - if normalized.is_empty() - || normalized.as_bytes().contains(&0) - || leading_separator - || normalized_path.is_absolute() - || !all_components_safe - || !saw_normal_component - { + let has_real_segment = normalized_path + .components() + .any(|c| matches!(c, Component::Normal(_))); + if leading_separator || !has_real_segment || !is_safe_relative_subpath(&normalized) { return Err(ArchiveError::UnsafePath(path_str)); } @@ -212,7 +187,7 @@ pub fn read_archive_filtered( ) -> Result>, ArchiveError> { let allowed: std::collections::HashSet = expected_files .keys() - .map(|k| normalize_entry_path(k).to_string()) + .map(|k| normalize_file_path(k).to_string()) .collect(); let all = read_archive_to_map(archive_path)?; @@ -300,43 +275,7 @@ mod tests { /// rejects absolute paths and `..`. This lets us exercise the /// defense-in-depth check inside [`read_archive_to_map`]. fn write_raw_archive(path: &Path, name: &[u8], data: &[u8]) { - let mut block = [0u8; 512]; - // Name (first 100 bytes). - let copy_len = name.len().min(100); - block[..copy_len].copy_from_slice(&name[..copy_len]); - // Mode "0000644\0". - block[100..108].copy_from_slice(b"0000644\0"); - // Size as octal in 11 chars + NUL. - let size_str = format!("{:011o}", data.len()); - block[124..135].copy_from_slice(size_str.as_bytes()); - block[135] = 0; - // mtime - block[136..147].copy_from_slice(b"00000000000"); - block[147] = 0; - // typeflag '0' = normal file - block[156] = b'0'; - // ustar magic - block[257..263].copy_from_slice(b"ustar\0"); - block[263..265].copy_from_slice(b"00"); - // Checksum: spaces during compute. - block[148..156].fill(b' '); - let sum: u32 = block.iter().map(|&b| b as u32).sum(); - let sum_str = format!("{:06o}\0 ", sum); - block[148..156].copy_from_slice(sum_str.as_bytes()); - - let mut tar_bytes = Vec::new(); - tar_bytes.extend_from_slice(&block); - tar_bytes.extend_from_slice(data); - // Pad data to 512-byte boundary. - let pad = (512 - (data.len() % 512)) % 512; - tar_bytes.extend(std::iter::repeat_n(0u8, pad)); - // Two zero blocks mark end of archive. - tar_bytes.extend([0u8; 1024]); - - let file = std::fs::File::create(path).unwrap(); - let mut gz = GzEncoder::new(file, Compression::default()); - gz.write_all(&tar_bytes).unwrap(); - gz.finish().unwrap(); + write_raw_tar_gz(path, &[raw_entry(name, data.len() as u64, data)]); } #[test] @@ -482,31 +421,19 @@ mod tests { /// way to smuggle bytes a plain ustar name field can't hold — notably an /// embedded NUL (the ustar name field is NUL-terminated). fn write_gnu_longname_archive(path: &Path, long_name: &[u8], data: &[u8]) { - let mut tar_bytes = Vec::new(); // GNU long-name body = the name plus a single trailing NUL (the tar // reader trims exactly one trailing NUL, preserving any embedded ones). let mut lname = long_name.to_vec(); lname.push(0); - tar_bytes.extend_from_slice(&ustar_block(b"././@LongLink", b'L', lname.len() as u64)); - tar_bytes.extend_from_slice(&lname); + let mut long_link = Vec::new(); + long_link.extend_from_slice(&ustar_block(b"././@LongLink", b'L', lname.len() as u64)); + long_link.extend_from_slice(&lname); let pad = (512 - (lname.len() % 512)) % 512; - tar_bytes.extend(std::iter::repeat_n(0u8, pad)); + long_link.extend(std::iter::repeat_n(0u8, pad)); // The real entry. Its own name field is a harmless placeholder; the // preceding long-name entry overrides it. - tar_bytes.extend_from_slice(&ustar_block(b"placeholder", b'0', data.len() as u64)); - tar_bytes.extend_from_slice(data); - let pad = if data.is_empty() { - 0 - } else { - (512 - (data.len() % 512)) % 512 - }; - tar_bytes.extend(std::iter::repeat_n(0u8, pad)); - tar_bytes.extend([0u8; 1024]); - - let file = std::fs::File::create(path).unwrap(); - let mut gz = GzEncoder::new(file, Compression::default()); - gz.write_all(&tar_bytes).unwrap(); - gz.finish().unwrap(); + let real = raw_entry(b"placeholder", data.len() as u64, data); + write_raw_tar_gz(path, &[long_link, real]); } #[test] @@ -514,9 +441,9 @@ mod tests { // A plain ustar name field is NUL-terminated, so an embedded NUL can // only reach the validator through a GNU long-name entry. `safe\0evil` // is a single path component (no `/`, no `..`, not absolute) — so it - // is ONLY rejectable by the explicit NUL guard, which mirrors - // `is_safe_relative_subpath` in apply.rs. Refuse the OsStr/C-string - // truncation ambiguity outright. + // is ONLY rejectable by the explicit NUL guard inside + // `is_safe_relative_subpath`. Refuse the OsStr/C-string truncation + // ambiguity outright. let dir = tempfile::tempdir().unwrap(); let archive = dir.path().join("arc.tar.gz"); write_gnu_longname_archive(&archive, b"safe\0evil.txt", b"evil"); @@ -642,13 +569,6 @@ mod tests { assert!(result.is_err()); } - #[test] - fn test_normalize_entry_path() { - assert_eq!(normalize_entry_path("package/lib/x.js"), "lib/x.js"); - assert_eq!(normalize_entry_path("lib/x.js"), "lib/x.js"); - assert_eq!(normalize_entry_path("packagefoo/x.js"), "packagefoo/x.js"); - } - #[test] fn test_read_archive_corrupt_gzip() { let dir = tempfile::tempdir().unwrap(); @@ -677,43 +597,22 @@ mod tests { /// boundary. Used to forge size-mismatched entries the writer would /// normally refuse. fn raw_entry(name: &[u8], declared_size: u64, data: &[u8]) -> Vec { - let mut block = [0u8; 512]; - let copy_len = name.len().min(100); - block[..copy_len].copy_from_slice(&name[..copy_len]); - block[100..108].copy_from_slice(b"0000644\0"); - let size_str = format!("{:011o}", declared_size); - block[124..135].copy_from_slice(size_str.as_bytes()); - block[135] = 0; - block[136..147].copy_from_slice(b"00000000000"); - block[147] = 0; - block[156] = b'0'; // regular file - block[257..263].copy_from_slice(b"ustar\0"); - block[263..265].copy_from_slice(b"00"); - block[148..156].fill(b' '); - let sum: u32 = block.iter().map(|&b| b as u32).sum(); - let sum_str = format!("{:06o}\0 ", sum); - block[148..156].copy_from_slice(sum_str.as_bytes()); - let mut out = Vec::new(); - out.extend_from_slice(&block); + out.extend_from_slice(&ustar_block(name, b'0', declared_size)); out.extend_from_slice(data); - let pad = if data.is_empty() { - 0 - } else { - (512 - (data.len() % 512)) % 512 - }; + let pad = (512 - (data.len() % 512)) % 512; out.extend(std::iter::repeat_n(0u8, pad)); out } - fn write_raw_tar_gz(path: &Path, entries: &[Vec], trailer: bool) { + /// Gzip the concatenated raw `entries` plus the two zero blocks that + /// mark end-of-archive, and write the result to `path`. + fn write_raw_tar_gz(path: &Path, entries: &[Vec]) { let mut tar_bytes = Vec::new(); for e in entries { tar_bytes.extend_from_slice(e); } - if trailer { - tar_bytes.extend([0u8; 1024]); - } + tar_bytes.extend([0u8; 1024]); let file = std::fs::File::create(path).unwrap(); let mut gz = GzEncoder::new(file, Compression::default()); gz.write_all(&tar_bytes).unwrap(); @@ -729,7 +628,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let archive = dir.path().join("oversize.tar.gz"); let entry = raw_entry(b"big.bin", 1024 * 1024 * 1024, b"tiny"); - write_raw_tar_gz(&archive, &[entry], true); + write_raw_tar_gz(&archive, &[entry]); let err = read_archive_to_map(&archive).unwrap_err(); assert!( @@ -748,7 +647,7 @@ mod tests { let entries: Vec> = (0..(MAX_ENTRIES + 1)) .map(|i| raw_entry(format!("f{i}").as_bytes(), 0, b"")) .collect(); - write_raw_tar_gz(&archive, &entries, true); + write_raw_tar_gz(&archive, &entries); let err = read_archive_to_map(&archive).unwrap_err(); assert!( @@ -786,7 +685,7 @@ mod tests { // 4 * 15 MiB = 60 MiB declared, just under the 64 MiB cap. // Add a fifth to push us over. let entry5 = raw_entry(b"e.bin", chunk.len() as u64, &chunk); - write_raw_tar_gz(&archive, &[entry1, entry2, entry3, entry4, entry5], true); + write_raw_tar_gz(&archive, &[entry1, entry2, entry3, entry4, entry5]); let result = read_archive_to_map(&archive); // Either we get an Io error from truncation or the read diff --git a/crates/socket-patch-core/src/patch/path_safety.rs b/crates/socket-patch-core/src/patch/path_safety.rs index 56c0eff8..57243fbf 100644 --- a/crates/socket-patch-core/src/patch/path_safety.rs +++ b/crates/socket-patch-core/src/patch/path_safety.rs @@ -26,16 +26,11 @@ pub(crate) fn is_safe_single_segment(s: &str) -> bool { } /// A multi-segment relative path (Go module path `github.com/foo/bar`, npm -/// scoped name `@scope/name`, composer `vendor/name`): `/`-separated segments, -/// each non-empty and not `.`/`..`; no leading `/`, no backslash, no colon, -/// no NUL. +/// scoped name `@scope/name`, composer `vendor/name`): every `/`-separated +/// segment must be safe on its own, which also rejects the empty string, a +/// leading/trailing `/`, and `//` (each yields an empty segment). pub(crate) fn is_safe_multi_segment(s: &str) -> bool { - if s.is_empty() || s.starts_with('/') || s.contains('\\') || s.contains(':') || s.contains('\0') - { - return false; - } - s.split('/') - .all(|seg| !seg.is_empty() && seg != "." && seg != "..") + s.split('/').all(is_safe_single_segment) } /// The canonical lowercase hyphenated UUID grammar diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 2b2ba04c..24ff5363 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -20,7 +20,10 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -pub mod state; +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::patch::vendor::yarn_berry_lock::yarnrc_compression_level; + +mod state; pub use state::{load_redirect_state, RedirectState, REDIRECT_STATE_REL}; /// One ecosystem's integrity hashes (mirrors the TS `PatchArtifactIntegrity`). @@ -178,6 +181,12 @@ fn rewrite_npm_lock( return; }; let Ok(mut lock) = serde_json::from_str::(&files[lockfile]) else { + // A corrupt lockfile is strictly worse than a missing one (which + // warns above) — never skip the whole npm redirect silently. + result.warnings.push(RewriteWarning { + code: "redirect_npm_lock_unparseable".into(), + detail: format!("{lockfile} is not valid JSON; npm redirect skipped"), + }); return; }; let mut changed = false; @@ -281,23 +290,6 @@ fn rewrite_npm_v2_deps( } // ── pip requirements.txt ──────────────────────────────────────────────────── -fn normalize_py_name(name: &str) -> String { - let mut out = String::new(); - let mut prev_dash = false; - for ch in name.to_lowercase().chars() { - if ch == '-' || ch == '_' || ch == '.' { - if !prev_dash { - out.push('-'); - prev_dash = true; - } - } else { - out.push(ch); - prev_dash = false; - } - } - out -} - fn rewrite_pypi_requirements( files: &BTreeMap, overrides: &[DepOverride], @@ -321,7 +313,7 @@ fn rewrite_pypi_requirements( }); continue; }; - let target = normalize_py_name(&dep.name); + let target = canonicalize_pypi_name(&dep.name); for raw in lines.iter_mut() { let line = raw.trim(); if line.is_empty() || line.starts_with('#') || line.starts_with('-') { @@ -330,7 +322,7 @@ fn rewrite_pypi_requirements( let Some(caps) = name_re.captures(line) else { continue; }; - if normalize_py_name(&caps[1]) != target { + if canonicalize_pypi_name(&caps[1]) != target { continue; } // pip-compile --generate-hashes emits backslash continuations @@ -387,10 +379,6 @@ fn rewrite_pypi_requirements( } // ── cargo (Cargo.toml + .cargo/config.toml + Cargo.lock) ───────────────────── -fn cargo_registry_name(patch_uuid: &str) -> String { - format!("socket-patch-{patch_uuid}") -} - fn rewrite_cargo( files: &BTreeMap, overrides: &[DepOverride], @@ -431,7 +419,7 @@ fn rewrite_cargo( }); continue; }; - let reg = cargo_registry_name(&dep.patch_uuid); + let reg = format!("socket-patch-{}", dep.patch_uuid); let index_url = &ov.index_url; // 1. .cargo/config.toml registry definition (idempotent). @@ -457,14 +445,19 @@ fn rewrite_cargo( // 2. Cargo.toml dep → add `registry = ""`. if let Some(toml) = cargo_toml.as_mut() { - if let Some(edit) = add_cargo_toml_registry(toml, &dep.name, ®) { - result.edits.push(edit); - toml_changed = true; - } else { - result.warnings.push(RewriteWarning { - code: "redirect_cargo_toml_dep_not_found".into(), - detail: format!("no [dependencies] entry for {} in Cargo.toml", dep.name), - }); + match add_cargo_toml_registry(toml, &dep.name, ®) { + CargoTomlRewrite::Rewritten(edit) => { + result.edits.push(*edit); + toml_changed = true; + } + // Re-run over an already-redirected Cargo.toml: not missing. + CargoTomlRewrite::AlreadyRedirected => {} + CargoTomlRewrite::NotFound => { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_toml_dep_not_found".into(), + detail: format!("no [dependencies] entry for {} in Cargo.toml", dep.name), + }); + } } } @@ -507,14 +500,31 @@ fn rewrite_cargo( } } -fn add_cargo_toml_registry(content: &mut String, crate_name: &str, reg: &str) -> Option { +/// Outcome of the Cargo.toml dependency rewrite — a re-run over an entry that +/// already carries OUR `registry = "socket-patch-…"` is "already redirected" +/// (silent), not "dependency not found" (caller warns). +enum CargoTomlRewrite { + Rewritten(Box), + AlreadyRedirected, + NotFound, +} + +fn add_cargo_toml_registry(content: &mut String, crate_name: &str, reg: &str) -> CargoTomlRewrite { let c = regex::escape(crate_name); // Inline table: `crate = { version = "…", … }`. let table_re = Regex::new(&format!(r"(?m)^({c}\s*=\s*\{{)([^}}\n]*)(\}})")).unwrap(); if let Some(m) = table_re.captures(content) { let inner = m.get(2).unwrap().as_str(); + if Regex::new(&format!(r#"\bregistry\s*=\s*"{}""#, regex::escape(reg))) + .unwrap() + .is_match(inner) + { + return CargoTomlRewrite::AlreadyRedirected; + } if Regex::new(r"\bregistry\s*=").unwrap().is_match(inner) { - return None; + // Pinned to some OTHER registry — leave it alone; the caller's + // warning surfaces that the redirect did not land. + return CargoTomlRewrite::NotFound; } let whole = m.get(0).unwrap().as_str().to_string(); let inner_trim = inner.trim_end(); @@ -529,14 +539,14 @@ fn add_cargo_toml_registry(content: &mut String, crate_name: &str, reg: &str) -> m.get(3).unwrap().as_str() ); *content = content.replacen(&whole, &rebuilt, 1); - return Some(FileEdit { + return CargoTomlRewrite::Rewritten(Box::new(FileEdit { path: "Cargo.toml".into(), kind: "redirect_cargo_toml_dep".into(), action: "rewritten".into(), key: Some(crate_name.into()), original: Some(Value::String(whole)), new: Some(Value::String(rebuilt)), - }); + })); } // Plain version: `crate = "1.0"`. let ver_re = Regex::new(&format!(r#"(?m)^({c}\s*=\s*)"([^"]+)"\s*$"#)).unwrap(); @@ -548,16 +558,16 @@ fn add_cargo_toml_registry(content: &mut String, crate_name: &str, reg: &str) -> m.get(2).unwrap().as_str() ); *content = content.replacen(&whole, &rebuilt, 1); - return Some(FileEdit { + return CargoTomlRewrite::Rewritten(Box::new(FileEdit { path: "Cargo.toml".into(), kind: "redirect_cargo_toml_dep".into(), action: "rewritten".into(), key: Some(crate_name.into()), original: Some(Value::String(whole)), new: Some(Value::String(rebuilt)), - }); + })); } - None + CargoTomlRewrite::NotFound } fn set_cargo_lock_source( @@ -634,22 +644,20 @@ enum CargoLockRewrite { } // ── pnpm-lock.yaml ─────────────────────────────────────────────────────────── -/// A `pnpm-lock.yaml` is `pnpm-lock.yaml` at the project root or at any nested -/// path (e.g. Rush repos keep them under `common/config/rush/`). Every such -/// files-map key is rewritten under the same grammar. -fn is_pnpm_lock_key(key: &str) -> bool { - key == "pnpm-lock.yaml" || key.ends_with("/pnpm-lock.yaml") -} - fn rewrite_pnpm_lock( files: &BTreeMap, overrides: &[DepOverride], result: &mut RewriteResult, ) { let npm: Vec<&DepOverride> = overrides.iter().filter(|o| o.ecosystem == "npm").collect(); - // Deterministic order: BTreeMap iterates keys sorted, so goldens are - // stable across every pnpm lock in the set. - let lock_keys: Vec<&String> = files.keys().filter(|k| is_pnpm_lock_key(k)).collect(); + // A pnpm lock lives at the project root or at any nested path (e.g. Rush + // repos keep them under `common/config/rush/`); every such files-map key + // is rewritten under the same grammar. Deterministic order: BTreeMap + // iterates keys sorted, so goldens are stable across every lock in the set. + let lock_keys: Vec<&String> = files + .keys() + .filter(|k| k.as_str() == "pnpm-lock.yaml" || k.ends_with("/pnpm-lock.yaml")) + .collect(); if npm.is_empty() || lock_keys.is_empty() { return; } @@ -668,12 +676,16 @@ fn rewrite_pnpm_lock( }); continue; }; - // `(^ {2}/?@:\n(?: {4,}.*\n)*? {4,}resolution: )\{([^}\n]*)\}` - let pat = String::from(r"(?m)(^ {2}/?") - + ®ex::escape(&fname) - + "@" - + ®ex::escape(&dep.version) - + r":\n(?: {4,}.*\n)*? {4,}resolution: )\{([^}\n]*)\}"; + // `(^ {2}(?:''|/?):\n(?: {4,}.*\n)*? {4,}resolution: )\{([^}\n]*)\}` + // where `` is `@`. lockfileVersion 9 single-quotes keys + // that begin with `@` (`'@scope/name@1.0.0':` — YAML forbids a plain + // scalar starting with `@`); v6 keys start with `/` and are unquoted. + let key = regex::escape(&fname) + "@" + ®ex::escape(&dep.version); + let pat = String::from(r"(?m)(^ {2}(?:'") + + &key + + r"'|/?" + + &key + + r"):\n(?: {4,}.*\n)*? {4,}resolution: )\{([^}\n]*)\}"; let re = Regex::new(&pat).unwrap(); let mut matched_any = false; for (key, content, changed) in &mut contents { @@ -841,15 +853,6 @@ fn berry_cache_key(content: &str) -> Option { None } -/// `.yarnrc.yml` `compressionLevel:` scalar, when set (flat top-level line -/// scan — yarn writes it as a top-level scalar, spike B4). -fn yarnrc_compression_level(rc: &str) -> Option { - rc.lines().find_map(|line| { - let rest = line.strip_prefix("compressionLevel:")?; - Some(rest.trim().trim_matches(['\'', '"']).to_string()) - }) -} - /// Split `name@npm:...` at the `@` past a leading `@scope/` marker. fn split_berry_descriptor(pattern: &str) -> Option<(&str, &str)> { let from = usize::from(pattern.starts_with('@')); @@ -1046,7 +1049,7 @@ fn rewrite_bun_lock( result: &mut RewriteResult, ) { use crate::patch::bun_lock_text::{ - check_lock_version, decode_json_string, parse_packages_section, split_name_spec, + check_lock_version, decode_json_string, parse_packages_section, }; let npm: Vec<&DepOverride> = overrides.iter().filter(|o| o.ecosystem == "npm").collect(); @@ -1122,9 +1125,8 @@ fn rewrite_bun_lock( } deps_verbatim = entry.elems[1].clone(); } else { - // `split_name_spec` guards a same-name-but-unowned entry (user - // file:/URL dep, other version) — never touched. - let _ = split_name_spec(&spec); + // Same-name-but-unowned entry (user file:/URL dep, other + // version) — never touched. continue; } let original = lines[entry.line_idx].clone(); @@ -1181,7 +1183,7 @@ fn rewrite_uv_lock( }; // Find the [[package]] block for this name+version by string bounds // (no lookahead in Rust regex). Iterate over [[package]] starts. - let target = normalize_py_name(&dep.name); + let target = canonicalize_pypi_name(&dep.name); let mut matched = false; let marker = "[[package]]\n"; let mut search = 0usize; @@ -1196,7 +1198,7 @@ fn rewrite_uv_lock( search = block_end; let name_ok = name_re .captures(&block) - .map(|c| normalize_py_name(&c[1]) == target) + .map(|c| canonicalize_pypi_name(&c[1]) == target) .unwrap_or(false); let version_ok = block.contains(&format!("version = \"{}\"\n", dep.version)) || block.contains(&format!("version = \"{}\"", dep.version)); @@ -1225,8 +1227,12 @@ fn rewrite_uv_lock( if !wheel_re.is_match(&body) { continue; } + // Repoint EVERY url/hash entry in the block — sdist AND all + // wheels. uv prefers a wheel, so an upstream `wheels` entry left + // behind installs the unpatched artifact while the redirect is + // reported (and attested) as landed. let new_body = wheel_re - .replace( + .replace_all( &body, format!( "{{ url = \"{}\", hash = \"sha256:{sha256}\"${{1}} }}", @@ -1236,6 +1242,9 @@ fn rewrite_uv_lock( ) .to_string(); if new_body == body { + // Already redirected (re-run): the entry exists at the target + // values — not "entry not found". + matched = true; continue; } content = format!( @@ -1608,6 +1617,32 @@ fn rewrite_nuget( } // ── rubygems (Gemfile + Gemfile.lock) ──────────────────────────────────────── + +/// The argument tail of a `gem "name", …` line minus any leading quoted +/// version-constraint args (`"7.0.0"`, `'~> 7.0'`, `">= 1", "< 2"`) — i.e. the +/// options (`require: false`, `group: :test`, …) that must survive the move +/// into the source block. Empty when the line carries none; bails to empty on +/// an unparseable tail (unbalanced quote), matching the previous behavior. +/// Shared with the vendor backend's Gemfile rewrite (`patch::vendor::gem`), +/// which has the same drop-the-options failure mode. +pub(crate) fn gem_line_trailing_options(tail: &str) -> String { + let mut rest = tail.trim_start(); + loop { + let Some(after_comma) = rest.strip_prefix(',') else { + return String::new(); + }; + let arg = after_comma.trim_start(); + match arg.chars().next() { + Some(q @ ('"' | '\'')) => match arg[1..].find(q) { + Some(end) => rest = arg[1 + end + 1..].trim_start(), + None => return String::new(), + }, + Some(_) => return arg.trim_end().to_string(), + None => return String::new(), + } + } +} + fn rewrite_gem( files: &BTreeMap, overrides: &[DepOverride], @@ -1653,17 +1688,30 @@ fn rewrite_gem( let gem_line_re = Regex::new( &(String::from(r#"(?m)^\s*gem ["']"#) + ®ex::escape(&dep.name) - + r#"["'][^\n]*$"#), + + r#"["']([^\n]*)$"#), ) .unwrap(); let block = format!( "source \"{}\" do\n gem \"{}\", \"{}\"\nend", ov.index_url, dep.name, dep.version ); - if let Some(m) = gem_line_re.find(gf) { - let original = m.as_str().to_string(); - let new_gf = gem_line_re.replace(gf, block.as_str()).to_string(); - *gf = new_gf; + if let Some(m) = gem_line_re.captures(gf) { + let original = m.get(0).unwrap().as_str().to_string(); + // Trailing options (`require: false`, `group: …`) must + // survive the move into the source block — dropping + // `require: false` auto-requires the gem at boot. + let opts = gem_line_trailing_options(m.get(1).unwrap().as_str()); + let block = if opts.is_empty() { + block + } else { + format!( + "source \"{}\" do\n gem \"{}\", \"{}\", {opts}\nend", + ov.index_url, dep.name, dep.version + ) + }; + // Plain replacen: the block may carry user text (`opts`), + // which a regex replacement would `$`-expand. + *gf = gf.replacen(&original, &block, 1); gemfile_changed = true; result.edits.push(FileEdit { path: "Gemfile".into(), @@ -1812,11 +1860,6 @@ const MVN_CONFIG_ARGS: &[&str] = &[ const MVN_CONFIG: &str = ".mvn/maven.config"; const MVN_CHECKSUMS: &str = ".mvn/checksums/checksums.sha256"; -/// Unique-per-patch maven repository id (valid chars: alnum, `-`, `_`, `.`). -fn maven_repository_id(patch_uuid: &str) -> String { - format!("socket-patch-{patch_uuid}") -} - /// Strip any `sha256-`/`sha256:` SRI-style prefix off a stored hash, leaving the /// bare lowercase hex Maven's trusted-checksums summary file expects (twin of /// the TS `bareSha256Hex`). @@ -1954,7 +1997,8 @@ fn rewrite_maven_pom( if pom.is_none() { continue; } - let repo_id = maven_repository_id(&dep.patch_uuid); + // Unique-per-patch repository id (valid chars: alnum, `-`, `_`, `.`). + let repo_id = format!("socket-patch-{}", dep.patch_uuid); // LEGACY same-GAV fallback: no suffixed version means the patched jar is // served under its original GAV. Add the repository (transport checksum @@ -2248,24 +2292,18 @@ fn rewrite_maven_pom( } } -/// The `` block for the socket-patch source: releases enabled with +/// Insert the socket-patch `` block: releases enabled with /// `fail` (the transport-level check against /// the served `.jar.sha1`); snapshots disabled (patched artifacts are always -/// released versions). Indented for insertion under a `` element. -fn maven_repository_block(id: &str, url: &str) -> String { - format!( - " \n {id}\n {url}\n \n true\n fail\n \n \n false\n \n " - ) -} - -/// Insert the socket-patch repository block. Prefer an existing -/// `` element (single replace, inserted first so it's consulted -/// before the project's other repositories); otherwise author a full -/// `` section immediately before the closing ``. -/// `` is matched exactly so it never collides with -/// ``. +/// released versions). Prefer an existing `` element (single +/// replace, inserted first so it's consulted before the project's other +/// repositories); otherwise author a full `` section immediately +/// before the closing ``. `` is matched exactly so it +/// never collides with ``. fn insert_maven_repository(pom: &str, id: &str, url: &str) -> String { - let block = maven_repository_block(id, url); + let block = format!( + " \n {id}\n {url}\n \n true\n fail\n \n \n false\n \n " + ); if pom.contains("") { return pom.replacen("", &format!("\n{block}"), 1); } @@ -2273,13 +2311,6 @@ fn insert_maven_repository(pom: &str, id: &str, url: &str) -> String { pom.replacen("", &format!("{section}\n"), 1) } -/// A `` block for a `` pin (2-space step ×4). -fn maven_management_dependency_block(group_id: &str, artifact_id: &str, version: &str) -> String { - format!( - " \n {group_id}\n {artifact_id}\n {version}\n " - ) -} - /// Add a `` version pin. Prefer extending an existing /// `` element (insert right after the /// opening `` tag); otherwise author a full @@ -2291,7 +2322,9 @@ fn insert_maven_dependency_management( artifact_id: &str, version: &str, ) -> String { - let block = maven_management_dependency_block(group_id, artifact_id, version); + let block = format!( + " \n {group_id}\n {artifact_id}\n {version}\n " + ); let dm_re = Regex::new(r"(?s)\s*").unwrap(); if let Some(m) = dm_re.find(pom) { let matched = m.as_str(); @@ -3292,4 +3325,262 @@ mod tests { assert!(r.files.is_empty()); assert_eq!(r.warnings[0].code, "redirect_bun_missing_sha512"); } + + /// A realistic uv.lock block carries BOTH an `sdist` entry and a `wheels` + /// entry. Every `{ url, hash }` in the block must be repointed at the + /// hosted patch: uv PREFERS a wheel, so leaving `wheels` at the upstream + /// URL/hash makes the install silently use the UNPATCHED artifact while + /// the scan confirms the dep as redirected (the artifact URL landed in + /// the sdist slot). + #[test] + fn uv_lock_sdist_and_wheels_all_repointed() { + let lock = "version = 1\nrequires-python = \">=3.8\"\n\n[[package]]\nname = \"requests\"\nversion = \"2.28.1\"\nsource = { registry = \"https://pypi.org/simple\" }\nsdist = { url = \"https://files.pythonhosted.org/packages/aa/requests-2.28.1.tar.gz\", hash = \"sha256:aaaa\" }\nwheels = [\n { url = \"https://files.pythonhosted.org/packages/bb/requests-2.28.1-py3-none-any.whl\", hash = \"sha256:bbbb\" },\n]\n"; + let mut files = BTreeMap::new(); + files.insert("uv.lock".to_string(), lock.to_string()); + let url = "http://patch.test/requests-2.28.1-py3-none-any.whl"; + let overrides = vec![pypi_override("requests", "2.28.1", url, &"c".repeat(64))]; + let first = rewrite_registry_redirect(&files, &overrides); + let out = first.files.get("uv.lock").expect("uv.lock rewritten"); + assert!( + !out.contains("files.pythonhosted.org"), + "no upstream URL may survive for the redirected dep: {out}" + ); + assert_eq!( + out.matches(url).count(), + 2, + "sdist AND wheel repointed: {out}" + ); + assert_eq!( + out.matches(&format!("hash = \"sha256:{}\"", "c".repeat(64))) + .count(), + 2, + "both hashes pinned: {out}" + ); + + // Re-run over the rewritten output: a no-op, and NOT reported as + // entry-not-found (the entry exists — it is already redirected). + let mut again = files.clone(); + again.insert("uv.lock".to_string(), out.clone()); + let second = rewrite_registry_redirect(&again, &overrides); + assert!( + second.files.is_empty() && second.edits.is_empty(), + "re-run must be a no-op: files={:?} edits={:?}", + second.files.keys(), + second.edits + ); + assert!( + !second + .warnings + .iter() + .any(|w| w.code == "redirect_uv_entry_not_found"), + "already-redirected must not warn entry-not-found: {:?}", + second.warnings + ); + } + + fn cargo_sparse_override() -> DepOverride { + DepOverride { + ecosystem: "cargo".into(), + name: "serde".into(), + namespace: None, + version: "1.0.190".into(), + token: "tok".into(), + patch_uuid: "uuid".into(), + artifact_url: "https://patch.test/serde-1.0.190.crate".into(), + berry_zip_url: None, + registry_override: Some(RegistryOverride { + kind: "cargo-sparse".into(), + index_url: "sparse+https://patch.test/cargo/uuid/".into(), + identifiers: RegistryOverrideIdentifiers { + name: "serde".into(), + version: "1.0.190".into(), + cargo_cksum_sha256: Some("e".repeat(64)), + ..Default::default() + }, + }), + integrity: Integrity::default(), + } + } + + /// A re-run over already-redirected cargo output must be SILENT: the + /// Cargo.toml dep already carries `registry = "socket-patch-…"`, which is + /// "already redirected", not "dependency missing" — warning + /// `redirect_cargo_toml_dep_not_found` on every re-run is false and sends + /// the operator hunting for a [dependencies] entry that exists. + #[test] + fn cargo_rerun_over_redirected_output_is_silent() { + let mut files = BTreeMap::new(); + files.insert( + "Cargo.toml".to_string(), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n" + .to_string(), + ); + files.insert( + "Cargo.lock".to_string(), + "version = 3\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"91f70896d6720bc714a4a57d22fc91f1db634680e65c8efe13323f1fa38d53f5\"\n" + .to_string(), + ); + let overrides = vec![cargo_sparse_override()]; + let first = rewrite_registry_redirect(&files, &overrides); + assert!(!first.edits.is_empty(), "first pass records edits"); + assert!(first.warnings.is_empty(), "{:?}", first.warnings); + + let mut again = files.clone(); + for (name, content) in &first.files { + again.insert(name.clone(), content.clone()); + } + let second = rewrite_registry_redirect(&again, &overrides); + assert!( + second.files.is_empty() && second.edits.is_empty(), + "re-run must be a no-op: files={:?} edits={:?}", + second.files.keys(), + second.edits + ); + assert!( + second.warnings.is_empty(), + "re-run over redirected output must not warn: {:?}", + second.warnings + ); + } + + fn gem_override(name: &str, version: &str) -> DepOverride { + DepOverride { + ecosystem: "gem".into(), + name: name.into(), + namespace: None, + version: version.into(), + token: "tok".into(), + patch_uuid: "uuid".into(), + artifact_url: format!("https://patch.test/{name}-{version}.gem"), + berry_zip_url: None, + registry_override: Some(RegistryOverride { + kind: "rubygems-compact-index".into(), + index_url: "https://patch.test/gem/tok/uuid/".into(), + identifiers: RegistryOverrideIdentifiers { + name: name.into(), + version: version.into(), + gem_checksum_sha256: Some("f".repeat(64)), + ..Default::default() + }, + }), + integrity: Integrity::default(), + } + } + + /// Trailing options on the original `gem` line (`require: false`, + /// `group: …`) must survive the move into the source block — dropping + /// `require: false` auto-requires the gem at boot, changing app behavior + /// (e.g. rack-mini-profiler enables itself globally when required). + #[test] + fn gemfile_rewrite_preserves_trailing_options() { + let mut files = BTreeMap::new(); + files.insert( + "Gemfile".to_string(), + "source \"https://rubygems.org\"\n\ngem \"rack-mini-profiler\", \"3.1.0\", require: false\n" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[gem_override("rack-mini-profiler", "3.1.0")]); + let out = r.files.get("Gemfile").expect("Gemfile rewritten"); + assert!( + out.contains(" gem \"rack-mini-profiler\", \"3.1.0\", require: false\n"), + "options preserved inside the source block: {out}" + ); + } + + /// An unparseable package-lock.json must surface a warning, not silently + /// skip the npm redirect entirely (missing-lockfile already warns; a + /// corrupt lockfile is strictly worse and was silent). + #[test] + fn npm_unparseable_lockfile_warns() { + let mut files = BTreeMap::new(); + files.insert("package-lock.json".to_string(), "{ not json".to_string()); + let overrides = vec![npm_override( + "left-pad", + "1.3.0", + "http://patch.test/lp.tgz", + "sha512-PATCHED==", + )]; + let r = rewrite_registry_redirect(&files, &overrides); + assert!(r.files.is_empty() && r.edits.is_empty()); + assert!( + r.warnings + .iter() + .any(|w| w.code == "redirect_npm_lock_unparseable"), + "corrupt lockfile must warn: {:?}", + r.warnings + ); + } + + /// pnpm lockfileVersion 9 single-quotes `packages:` keys that begin with + /// `@` (`'@scope/name@1.0.0':` — YAML forbids a plain scalar starting + /// with `@`), so the rewriter must match the quoted form too. Without it, + /// every scoped npm package silently fails to redirect (entry_not_found + /// warning only) while unscoped deps in the same run succeed. + #[test] + fn pnpm_v9_quoted_scoped_key_is_rewritten() { + let lock = "lockfileVersion: '9.0' + +importers: + .: + dependencies: + '@socktest/pkg': + specifier: 1.0.0 + version: 1.0.0 + +packages: + + '@socktest/pkg@1.0.0': + resolution: {integrity: sha512-UPSTREAM==} + +snapshots: + + '@socktest/pkg@1.0.0': {} +"; + let mut files = BTreeMap::new(); + files.insert("pnpm-lock.yaml".to_string(), lock.to_string()); + let ovr = npm_override( + "@socktest/pkg", + "1.0.0", + "http://patch.test/socktest-pkg-1.0.0.tgz", + "sha512-PATCHED==", + ); + let first = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + let out = first.files.get("pnpm-lock.yaml").unwrap_or_else(|| { + panic!( + "the quoted scoped key must be rewritten; warnings={:?}", + first.warnings + ) + }); + assert!( + out.contains( + " '@socktest/pkg@1.0.0':\n resolution: {integrity: sha512-PATCHED==, \ + tarball: http://patch.test/socktest-pkg-1.0.0.tgz}" + ), + "resolution spliced under the QUOTED key (quotes preserved): {out}" + ); + assert!( + !out.contains("sha512-UPSTREAM=="), + "upstream integrity replaced: {out}" + ); + assert!( + first + .edits + .iter() + .any(|e| e.kind == "redirect_pnpm_resolution" + && e.key.as_deref() == Some("@socktest/pkg@1.0.0")), + "edit recorded under the unquoted name@version key: {:?}", + first.edits + ); + + // Re-run over the rewritten output: no edits, no file changes. + let mut again = files.clone(); + again.insert("pnpm-lock.yaml".to_string(), out.clone()); + let second = rewrite_registry_redirect(&again, std::slice::from_ref(&ovr)); + assert!( + second.files.is_empty() && second.edits.is_empty(), + "re-run over a redirected scoped entry must be a no-op: files={:?} edits={:?}", + second.files.keys(), + second.edits + ); + } } diff --git a/crates/socket-patch-core/src/patch/redirect/state.rs b/crates/socket-patch-core/src/patch/redirect/state.rs index 529fc7c4..a3c280b5 100644 --- a/crates/socket-patch-core/src/patch/redirect/state.rs +++ b/crates/socket-patch-core/src/patch/redirect/state.rs @@ -47,10 +47,6 @@ impl RedirectState { records: BTreeMap::new(), } } - - pub fn is_empty(&self) -> bool { - self.edits.is_empty() && self.records.is_empty() - } } impl Default for RedirectState { diff --git a/crates/socket-patch-core/src/patch/rollback.rs b/crates/socket-patch-core/src/patch/rollback.rs index 20ac9989..8f94dfb0 100644 --- a/crates/socket-patch-core/src/patch/rollback.rs +++ b/crates/socket-patch-core/src/patch/rollback.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::path::Path; use crate::manifest::schema::PatchFileInfo; +use crate::patch::apply::normalize_file_path; use crate::patch::file_hash::compute_file_git_sha256; /// Status of a file rollback verification. @@ -49,16 +50,6 @@ pub struct RollbackResult { pub sidecar: Option, } -/// Normalize file path by removing the "package/" prefix if present. -fn normalize_file_path(file_name: &str) -> &str { - const PACKAGE_PREFIX: &str = "package/"; - if let Some(stripped) = file_name.strip_prefix(PACKAGE_PREFIX) { - stripped - } else { - file_name - } -} - /// Verify a single file can be rolled back. /// /// A file is ready for rollback if: @@ -129,7 +120,25 @@ pub async fn verify_file_rollback( } Ok(_) => {} } - let current_hash = compute_file_git_sha256(&filepath).await.unwrap_or_default(); + // A hash failure (directory/FIFO planted at the path, unreadable + // file, dangling symlink target) is the same unverifiable state as a + // stat failure above and must fail closed with the real error — a + // swallowed error would misreport "modified after patching" with a + // fabricated empty hash, and would compare equal to an empty + // `after_hash`, wrongly clearing the entry for deletion. + let current_hash = match compute_file_git_sha256(&filepath).await { + Ok(h) => h, + Err(e) => { + return VerifyRollbackResult { + file: file_name.to_string(), + status: VerifyRollbackStatus::NotFound, + message: Some(format!("Failed to hash file: {}", e)), + current_hash: None, + expected_hash: None, + target_hash: None, + }; + } + }; if current_hash == file_info.after_hash { return VerifyRollbackResult { file: file_name.to_string(), @@ -257,46 +266,6 @@ pub async fn verify_file_rollback( } } -/// Rollback a single file to its original state by writing -/// `original_content` (whose Git SHA256 must equal `expected_hash`). -/// -/// This delegates to [`apply_file_patch`](crate::patch::apply::apply_file_patch), -/// the hardened write path shared with apply. Rolling a file back is the -/// exact same operation as patching it forward — "safely overwrite this -/// file with these hash-verified bytes" — so it must get the exact same -/// guarantees: -/// -/// * **Atomic** — the bytes are staged in the parent directory, fsync'd, -/// and `rename(2)`d over the target. A crash or `ENOSPC` mid-write -/// leaves either the old or the new content, never a truncated file. -/// * **Copy-on-write safe** — a symlink/hardlink into a shared content -/// store (pnpm, Nix, the Go module cache) is broken into a private -/// inode first, so a rollback never bleeds into a sibling project's -/// copy or the store entry. -/// * **Validate-before-write** — `original_content` is hash-checked in -/// memory *before* any disk write, so a corrupt blob is refused -/// instead of being committed over the file and only then flagged. -/// * **Permission-faithful** — the file's mode + uid/gid are restored -/// afterward. Because apply preserves a file's original permissions -/// when patching, the on-disk patched file already carries the -/// pre-patch mode (e.g. a read-only `0o444` Go-cache source), and -/// that exact mode is re-applied to the rolled-back inode. -/// -/// The previous implementation used a bare in-place `tokio::fs::write`, -/// which had none of these properties: it could corrupt a hardlinked -/// sibling, leave a half-written file on a crash, write a bad blob over -/// the file *before* discovering the hash mismatch, and leave a -/// read-only file writable. -pub async fn rollback_file_patch( - pkg_path: &Path, - file_name: &str, - original_content: &[u8], - expected_hash: &str, -) -> Result<(), std::io::Error> { - crate::patch::apply::apply_file_patch(pkg_path, file_name, original_content, expected_hash) - .await -} - /// Verify and rollback patches for a single package. /// /// For each file in `files`, this function: @@ -340,29 +309,25 @@ pub async fn rollback_package_patch( result.files_verified.push(verify_result); } - // Check if all files are already in original state + // Stop before touching disk when nothing needs restoring (all files + // already original) or on a dry run. let all_original = result .files_verified .iter() .all(|v| v.status == VerifyRollbackStatus::AlreadyOriginal); - if all_original { - result.success = true; - return result; - } - - // If dry run, stop here - if dry_run { + if all_original || dry_run { result.success = true; return result; } // Rollback files that need it for (file_name, file_info) in files { - let verify_result = result.files_verified.iter().find(|v| v.file == *file_name); - if let Some(vr) = verify_result { - if vr.status == VerifyRollbackStatus::AlreadyOriginal { - continue; - } + let already_original = result + .files_verified + .iter() + .any(|v| v.file == *file_name && v.status == VerifyRollbackStatus::AlreadyOriginal); + if already_original { + continue; } // New files (empty beforeHash): delete instead of restoring. @@ -425,8 +390,14 @@ pub async fn rollback_package_patch( } }; - // Rollback the file - if let Err(e) = rollback_file_patch( + // Restore via `apply_file_patch`, the hardened write path shared + // with apply — rolling a file back is the same operation as patching + // it forward ("safely overwrite this file with these hash-verified + // bytes") and must get the same guarantees: atomic stage+rename, + // hardlink/symlink broken into a private inode before writing (pnpm / + // Go-cache stores), blob hash-checked in memory before any disk + // write, and the file's original mode + uid/gid restored afterward. + if let Err(e) = crate::patch::apply::apply_file_patch( pkg_path, file_name, &original_content, @@ -450,10 +421,7 @@ pub async fn rollback_package_patch( // rollback — the restored bytes are already committed — it surfaces // as an `Error`-severity `sidecar_fixup_failed` advisory instead. if !result.files_rolled_back.is_empty() { - use crate::patch::sidecars::{ - dispatch_rollback_fixup, SidecarAdvisory, SidecarAdvisoryCode, SidecarRecord, - SidecarSeverity, - }; + use crate::patch::sidecars::{dispatch_rollback_fixup, fixup_failed_record}; // Include files verified `AlreadyOriginal` alongside the ones // restored this run: a previous rollback that failed partway // restored them but returned before this boundary, so their @@ -479,19 +447,10 @@ pub async fn rollback_package_patch( Ok(Some(record)) => result.sidecar = Some(record), Ok(None) => {} Err(e) => { - let ecosystem = crate::crawlers::Ecosystem::from_purl(package_key) - .map(|eco| eco.cli_name().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - result.sidecar = Some(SidecarRecord { - purl: package_key.to_string(), - ecosystem, - files: Vec::new(), - advisory: Some(SidecarAdvisory { - code: SidecarAdvisoryCode::SidecarFixupFailed, - severity: SidecarSeverity::Error, - message: format!("sidecar resync failed (files still rolled back): {}", e), - }), - }); + result.sidecar = Some(fixup_failed_record( + package_key, + format!("sidecar resync failed (files still rolled back): {}", e), + )); } } } @@ -504,6 +463,10 @@ pub async fn rollback_package_patch( mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; + // The rollback write path IS `apply_file_patch` (see the restore loop in + // `rollback_package_patch`); these tests pin the guarantees rollback + // relies on from it. + use crate::patch::apply::apply_file_patch; #[tokio::test] async fn test_verify_file_rollback_not_found() { @@ -645,7 +608,7 @@ mod tests { .await .unwrap(); - rollback_file_patch(dir.path(), "index.js", original, &original_hash) + apply_file_patch(dir.path(), "index.js", original, &original_hash) .await .unwrap(); @@ -661,7 +624,7 @@ mod tests { .unwrap(); let result = - rollback_file_patch(dir.path(), "index.js", b"original content", "wrong_hash").await; + apply_file_patch(dir.path(), "index.js", b"original content", "wrong_hash").await; assert!(result.is_err()); assert!(result .unwrap_err() @@ -684,7 +647,7 @@ mod tests { .unwrap(); let result = - rollback_file_patch(dir.path(), "index.js", b"original content", "wrong_hash").await; + apply_file_patch(dir.path(), "index.js", b"original content", "wrong_hash").await; assert!(result.is_err()); // The file must NOT have been overwritten with the bad blob. @@ -725,7 +688,7 @@ mod tests { let original = b"original bytes"; let original_hash = compute_git_sha256_from_bytes(original); - rollback_file_patch( + apply_file_patch( project.parent().unwrap(), "foo.js", original, @@ -761,7 +724,7 @@ mod tests { .await .unwrap(); - rollback_file_patch(dir.path(), "index.js", original, &original_hash) + apply_file_patch(dir.path(), "index.js", original, &original_hash) .await .unwrap(); @@ -1476,7 +1439,6 @@ mod tests { /// in the cargo-build e2e). Rollback must resync the sidecar: /// restored files get their original hash back, and the entry for a /// patch-added (now deleted) file is removed entirely. - #[cfg(feature = "cargo")] #[tokio::test] async fn test_rollback_package_patch_cargo_resyncs_checksum_sidecar() { use sha2::{Digest, Sha256}; @@ -1591,7 +1553,6 @@ mod tests { /// fail the rollback (the bytes are already restored) — it surfaces /// as an `Error`-severity `sidecar_fixup_failed` advisory, mirroring /// apply's boundary in `apply_package_patch`. - #[cfg(feature = "cargo")] #[tokio::test] async fn test_rollback_package_patch_cargo_sidecar_failure_is_best_effort() { use crate::patch::sidecars::{SidecarAdvisoryCode, SidecarSeverity}; @@ -1653,7 +1614,6 @@ mod tests { /// resync, or its entry stays patched-hash over original bytes and /// `cargo build` refuses the crate even though the retry reported /// success. - #[cfg(feature = "cargo")] #[tokio::test] async fn test_rollback_retry_resyncs_already_original_checksum_entries() { fn plain_sha256(b: &[u8]) -> String { diff --git a/crates/socket-patch-core/src/patch/sidecars/cargo.rs b/crates/socket-patch-core/src/patch/sidecars/cargo.rs index a1d96ac6..60a75e58 100644 --- a/crates/socket-patch-core/src/patch/sidecars/cargo.rs +++ b/crates/socket-patch-core/src/patch/sidecars/cargo.rs @@ -33,6 +33,7 @@ use sha2::{Digest, Sha256}; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::patch::apply::{apply_file_patch, is_safe_relative_subpath, normalize_file_path}; +use crate::utils::fs::open_regular_file; use super::{SidecarError, SidecarFile, SidecarFileAction, SidecarPayload}; @@ -209,8 +210,8 @@ async fn update_entries( } let on_disk = pkg_path.join(&normalized); - let hash = match sha256_file(&on_disk).await { - Ok(hash) => hash, + let bytes = match read_regular_file(&on_disk).await { + Ok(bytes) => bytes, Err(e) if remove_missing && e.kind() == std::io::ErrorKind::NotFound => { // Rollback deleted this patch-added file; drop the entry // apply's fixup inserted for it. Only NotFound qualifies — @@ -226,57 +227,33 @@ async fn update_entries( }); } }; - files.insert(normalized, Value::String(hash)); + // Cargo wants the plain lowercase-hex SHA256 of the raw bytes + // (not the Git "blob N\0" framing used elsewhere). + let mut hasher = Sha256::new(); + hasher.update(&bytes); + files.insert( + normalized, + Value::String(format!("{:x}", hasher.finalize())), + ); } Ok(()) } -/// Compute the lowercase-hex SHA256 of the file at `path`. -/// -/// Loads the whole file into memory and hashes in one go. -/// Cargo source files are bounded (the registry rejects crates -/// whose `.crate` tarball exceeds ~10MB unpacked), so a single -/// read is cheaper than the streaming-loop dance — and the open -/// error passes through untouched, which the -/// `dispatch_fixup_cargo_sha256_file_failure_arm` integration -/// test drives via a non-existent path. -async fn sha256_file(path: &Path) -> std::io::Result { - let bytes = read_regular_file(path).await?; - let mut hasher = Sha256::new(); - hasher.update(&bytes); - Ok(format!("{:x}", hasher.finalize())) -} - /// Read a whole file, refusing anything that isn't a regular file. /// -/// Both call sites read paths inside the (untrusted) package tree. A -/// plain `open(2)` with `O_RDONLY` on a FIFO planted at one of those -/// paths waits for a writer that may never come — hanging the patch -/// engine forever before any guard runs. As in -/// [`compute_file_git_sha256`](crate::patch::file_hash::compute_file_git_sha256), -/// the open is non-blocking on Unix (a no-op for regular files) and the -/// `is_file` check on the open handle rejects FIFOs/devices/directories -/// instead of reading them. +/// Both call sites read paths inside the (untrusted) package tree, so +/// the open goes through [`open_regular_file`] — non-blocking on Unix, +/// rejecting FIFOs/devices/directories — to keep a planted special +/// file from hanging the patch engine (see its docs). Loading the +/// whole file is fine: cargo source files are bounded (the registry +/// rejects crates whose `.crate` tarball exceeds ~10MB unpacked), and +/// the open error passes through untouched, which the +/// `dispatch_fixup_cargo_sha256_file_failure_arm` integration test +/// drives via a non-existent path. async fn read_regular_file(path: &Path) -> std::io::Result> { use tokio::io::AsyncReadExt; - #[cfg(unix)] - let mut file = tokio::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NONBLOCK) - .open(path) - .await?; - #[cfg(not(unix))] - let mut file = tokio::fs::File::open(path).await?; - - let metadata = file.metadata().await?; - if !metadata.is_file() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("{} is not a regular file", path.display()), - )); - } - + let (mut file, metadata) = open_regular_file(path).await?; let mut bytes = Vec::with_capacity(metadata.len() as usize); file.read_to_end(&mut bytes).await?; Ok(bytes) diff --git a/crates/socket-patch-core/src/patch/sidecars/mod.rs b/crates/socket-patch-core/src/patch/sidecars/mod.rs index 878c3ee8..fe34e573 100644 --- a/crates/socket-patch-core/src/patch/sidecars/mod.rs +++ b/crates/socket-patch-core/src/patch/sidecars/mod.rs @@ -23,19 +23,15 @@ //! //! All ecosystems return a [`SidecarRecord`] via [`dispatch_fixup`]. //! The record is the canonical JSON-envelope shape — see -//! [`types`] for field documentation and stability guarantees. +//! [`SidecarRecord`] for field documentation and stability guarantees. -use std::collections::HashMap; use std::path::Path; use crate::crawlers::Ecosystem; -use crate::manifest::schema::PatchFileInfo; -#[cfg(feature = "cargo")] pub(crate) mod cargo; -#[cfg(feature = "nuget")] pub(crate) mod nuget; -pub mod types; +mod types; pub use types::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarFile, SidecarFileAction, SidecarRecord, @@ -71,7 +67,7 @@ pub enum SidecarError { /// Helper for advisory-only ecosystems (PyPI / gem / Go) — builds a /// payload with no touched files and a single structured advisory. -pub(crate) fn advisory_only_payload( +fn advisory_only_payload( code: SidecarAdvisoryCode, severity: SidecarSeverity, message: &str, @@ -86,6 +82,26 @@ pub(crate) fn advisory_only_payload( } } +/// Uniform `Error`-severity record for a fixup/resync that raised. +/// Both apply's and rollback's best-effort boundaries convert a +/// [`SidecarError`] into this shape (empty `files`, advisory code +/// `sidecar_fixup_failed`) so consumers see the same JSON regardless +/// of direction; only the message differs. +pub(crate) fn fixup_failed_record(package_key: &str, message: String) -> SidecarRecord { + SidecarRecord { + purl: package_key.to_string(), + ecosystem: Ecosystem::from_purl(package_key) + .map(|eco| eco.cli_name().to_string()) + .unwrap_or_else(|| "unknown".to_string()), + files: Vec::new(), + advisory: Some(SidecarAdvisory { + code: SidecarAdvisoryCode::SidecarFixupFailed, + severity: SidecarSeverity::Error, + message, + }), + } +} + /// Run the post-apply integrity fixup for the package's ecosystem. /// /// Returns a fully-formed [`SidecarRecord`] (PURL + ecosystem + @@ -101,28 +117,22 @@ pub(crate) fn advisory_only_payload( /// files_patched`) plus any verified `AlreadyPatched` — an earlier /// apply that failed partway wrote those but never reached this /// boundary, so their sidecar entries are still stale and the retry -/// must resync them. `files` is reserved for future use (currently -/// unread). -#[allow(unused_variables)] // `pkg_path` is feature-gated below +/// must resync them. pub async fn dispatch_fixup( package_key: &str, pkg_path: &Path, patched: &[String], - _files: &HashMap, ) -> Result, SidecarError> { if patched.is_empty() { return Ok(None); } - let ecosystem = match Ecosystem::from_purl(package_key) { - Some(eco) => eco, - None => return Ok(None), + let Some(ecosystem) = Ecosystem::from_purl(package_key) else { + return Ok(None); }; let payload: Option = match ecosystem { - #[cfg(feature = "cargo")] Ecosystem::Cargo => cargo::fixup(pkg_path, patched).await?, - #[cfg(feature = "nuget")] Ecosystem::Nuget => nuget::fixup(pkg_path).await?, Ecosystem::Pypi => Some(advisory_only_payload( SidecarAdvisoryCode::PypiRecordStale, @@ -137,7 +147,6 @@ pub async fn dispatch_fixup( "Ruby gem: `bundle install --redownload` will revert these \ patches by reinstalling from the cached .gem.", )), - #[cfg(feature = "golang")] Ecosystem::Golang => Some(advisory_only_payload( SidecarAdvisoryCode::GoModVerifyFails, SidecarSeverity::Warning, @@ -170,8 +179,7 @@ pub async fn dispatch_fixup( /// `AlreadyOriginal` (restored by an earlier partial rollback that /// never reached this boundary). Same return contract as /// [`dispatch_fixup`]. -#[allow(unused_variables)] // `pkg_path` is feature-gated below -pub async fn dispatch_rollback_fixup( +pub(crate) async fn dispatch_rollback_fixup( package_key: &str, pkg_path: &Path, rolled_back: &[String], @@ -180,13 +188,11 @@ pub async fn dispatch_rollback_fixup( return Ok(None); } - let ecosystem = match Ecosystem::from_purl(package_key) { - Some(eco) => eco, - None => return Ok(None), + let Some(ecosystem) = Ecosystem::from_purl(package_key) else { + return Ok(None); }; let payload: Option = match ecosystem { - #[cfg(feature = "cargo")] Ecosystem::Cargo => cargo::resync_after_rollback(pkg_path, rolled_back).await?, _ => None, }; @@ -203,14 +209,10 @@ pub async fn dispatch_rollback_fixup( mod tests { use super::*; - fn empty_files() -> HashMap { - HashMap::new() - } - #[tokio::test] async fn empty_patched_returns_none() { let d = tempfile::tempdir().unwrap(); - let out = dispatch_fixup("pkg:npm/anything@1.0.0", d.path(), &[], &empty_files()) + let out = dispatch_fixup("pkg:npm/anything@1.0.0", d.path(), &[]) .await .unwrap(); assert!(out.is_none()); @@ -223,7 +225,6 @@ mod tests { "pkg:npm/anything@1.0.0", d.path(), &["package/x.js".to_string()], - &empty_files(), ) .await .unwrap(); @@ -237,7 +238,6 @@ mod tests { "pkg:pypi/requests@2.28.0", d.path(), &["package/foo.py".to_string()], - &empty_files(), ) .await .unwrap(); @@ -258,7 +258,6 @@ mod tests { "pkg:gem/rails@7.1.0", d.path(), &["lib/rails.rb".to_string()], - &empty_files(), ) .await .unwrap(); @@ -272,14 +271,9 @@ mod tests { async fn unknown_ecosystem_returns_none() { // PURL has no recognized prefix → dispatcher bails with None. let d = tempfile::tempdir().unwrap(); - let out = dispatch_fixup( - "pkg:weirdo/x@1", - d.path(), - &["x".to_string()], - &empty_files(), - ) - .await - .unwrap(); + let out = dispatch_fixup("pkg:weirdo/x@1", d.path(), &["x".to_string()]) + .await + .unwrap(); assert!(out.is_none()); } @@ -292,7 +286,7 @@ mod tests { #[tokio::test] async fn empty_patched_short_circuits_before_advisory() { let d = tempfile::tempdir().unwrap(); - let out = dispatch_fixup("pkg:pypi/requests@2.28.0", d.path(), &[], &empty_files()) + let out = dispatch_fixup("pkg:pypi/requests@2.28.0", d.path(), &[]) .await .unwrap(); assert!( @@ -312,7 +306,6 @@ mod tests { /// Cargo PURL routes through `dispatch_fixup` to the checksum /// rewriter and the resulting record denormalizes purl + ecosystem /// and carries the rewritten-file entry. - #[cfg(feature = "cargo")] #[tokio::test] async fn cargo_dispatch_rewrites_checksum_and_builds_record() { let d = tempfile::tempdir().unwrap(); @@ -332,14 +325,9 @@ mod tests { .await .unwrap(); - let out = dispatch_fixup( - "pkg:cargo/mycrate@1.0.0", - pkg, - &["src/lib.rs".to_string()], - &empty_files(), - ) - .await - .unwrap(); + let out = dispatch_fixup("pkg:cargo/mycrate@1.0.0", pkg, &["src/lib.rs".to_string()]) + .await + .unwrap(); let record = out.expect("cargo dispatch must produce a record"); assert_eq!(record.ecosystem, "cargo"); @@ -353,7 +341,6 @@ mod tests { /// Cargo crate with no `.cargo-checksum.json` → the sub-fixup /// returns `None`, so `dispatch_fixup` produces no record (not an /// empty-files record). - #[cfg(feature = "cargo")] #[tokio::test] async fn cargo_dispatch_without_checksum_returns_none() { let d = tempfile::tempdir().unwrap(); @@ -361,7 +348,6 @@ mod tests { "pkg:cargo/mycrate@1.0.0", d.path(), &["src/lib.rs".to_string()], - &empty_files(), ) .await .unwrap(); @@ -372,7 +358,6 @@ mod tests { /// `dispatch_fixup` must propagate the `SidecarError` (the apply /// boundary converts it to a `sidecar_fixup_failed` advisory) and /// must NOT swallow it into `Ok(None)`. - #[cfg(feature = "cargo")] #[tokio::test] async fn cargo_dispatch_propagates_malformed_error() { let d = tempfile::tempdir().unwrap(); @@ -383,7 +368,6 @@ mod tests { "pkg:cargo/mycrate@1.0.0", d.path(), &["src/lib.rs".to_string()], - &empty_files(), ) .await .unwrap_err(); @@ -393,7 +377,6 @@ mod tests { /// NuGet PURL routes through `dispatch_fixup` to the metadata /// neutralizer; the on-disk `.nupkg.metadata` is deleted and the /// record records it as `Deleted`. - #[cfg(feature = "nuget")] #[tokio::test] async fn nuget_dispatch_deletes_metadata_and_builds_record() { let d = tempfile::tempdir().unwrap(); @@ -405,7 +388,6 @@ mod tests { "pkg:nuget/Newtonsoft.Json@13.0.3", d.path(), &["lib/x.dll".to_string()], - &empty_files(), ) .await .unwrap(); @@ -422,7 +404,6 @@ mod tests { } /// NuGet package with neither metadata nor signature → no record. - #[cfg(feature = "nuget")] #[tokio::test] async fn nuget_dispatch_nothing_to_do_returns_none() { let d = tempfile::tempdir().unwrap(); @@ -430,7 +411,6 @@ mod tests { "pkg:nuget/Newtonsoft.Json@13.0.3", d.path(), &["lib/x.dll".to_string()], - &empty_files(), ) .await .unwrap(); @@ -439,7 +419,6 @@ mod tests { /// Go PURL routes through `dispatch_fixup` to the advisory-only /// path and denormalizes the ecosystem name to `golang`. - #[cfg(feature = "golang")] #[tokio::test] async fn golang_dispatch_returns_structured_advisory() { let d = tempfile::tempdir().unwrap(); @@ -447,7 +426,6 @@ mod tests { "pkg:golang/github.com/gin-gonic/gin@v1.9.1", d.path(), &["gin.go".to_string()], - &empty_files(), ) .await .unwrap(); @@ -462,7 +440,6 @@ mod tests { /// Rollback dispatcher: a cargo PURL routes to the checksum resync /// and the record carries the rewritten-file entry; a deleted /// (patch-added) file's entry is dropped from the map. - #[cfg(feature = "cargo")] #[tokio::test] async fn cargo_rollback_dispatch_resyncs_checksum() { let d = tempfile::tempdir().unwrap(); @@ -534,22 +511,4 @@ mod tests { .unwrap(); assert!(out.is_none()); } - - /// When the `cargo` feature is disabled, a `pkg:cargo/` PURL is - /// unrecognized by `Ecosystem::from_purl` and `dispatch_fixup` - /// returns `None` rather than attempting (or panicking on) a fixup. - #[cfg(not(feature = "cargo"))] - #[tokio::test] - async fn cargo_purl_without_feature_returns_none() { - let d = tempfile::tempdir().unwrap(); - let out = dispatch_fixup( - "pkg:cargo/mycrate@1.0.0", - d.path(), - &["src/lib.rs".to_string()], - &empty_files(), - ) - .await - .unwrap(); - assert!(out.is_none()); - } } diff --git a/crates/socket-patch-core/src/patch/sidecars/nuget.rs b/crates/socket-patch-core/src/patch/sidecars/nuget.rs index eeea1f00..ebd5e567 100644 --- a/crates/socket-patch-core/src/patch/sidecars/nuget.rs +++ b/crates/socket-patch-core/src/patch/sidecars/nuget.rs @@ -21,6 +21,7 @@ use std::path::Path; use crate::patch::apply::DirWriteGuard; +use crate::utils::fs::{is_file, list_dir_entries}; use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, @@ -116,23 +117,12 @@ pub(crate) async fn fixup(pkg_path: &Path) -> Result, Sid /// directory false-positive, not fail-open against a symlinked marker /// (the symlink-drop trap the npm/cargo crawlers were bitten by). async fn has_signed_marker(pkg_path: &Path) -> bool { - let mut entries = match tokio::fs::read_dir(pkg_path).await { - Ok(rd) => rd, - Err(_) => return false, - }; - while let Ok(Some(entry)) = entries.next_entry().await { - if !entry + for entry in list_dir_entries(pkg_path).await { + if entry .file_name() .as_encoded_bytes() .ends_with(b".nupkg.sha512") - { - continue; - } - // Name matches — confirm it's a regular file before believing it. - if tokio::fs::metadata(entry.path()) - .await - .map(|m| m.is_file()) - .unwrap_or(false) + && is_file(&entry.path()).await { return true; } diff --git a/crates/socket-patch-core/src/patch/vendor/berry_zip.rs b/crates/socket-patch-core/src/patch/vendor/berry_zip.rs index 800dc2d6..e9f10846 100644 --- a/crates/socket-patch-core/src/patch/vendor/berry_zip.rs +++ b/crates/socket-patch-core/src/patch/vendor/berry_zip.rs @@ -35,6 +35,7 @@ //! fields, no comments, one CDH per LFH in the same order; //! * **EOCD** — single disk, no zip64, no archive comment. +use std::collections::HashSet; use std::io::Read; use flate2::read::GzDecoder; @@ -60,7 +61,8 @@ const MODE_FILE_EXEC: u32 = 0o100755; /// `tgz_bytes` for `node_modules//`. /// /// Fail-closed: any tar shape the spiked recipe did not cover (symlinks, -/// hardlinks, non-ASCII names, single-component paths) is an `Err` — a wrong +/// hardlinks, non-ASCII names, single-component paths, non-canonical paths +/// — `./`, `..`, `//`, absolute — and duplicate paths) is an `Err` — a wrong /// checksum would brick the user's `yarn install` with a YN0018, so we never /// guess. pub(super) fn berry_cache_checksum_10c0( @@ -96,15 +98,12 @@ fn rebuild_cache_zip(tgz_bytes: &[u8], package_ident: &str) -> Result, S fn collect_entries(tgz_bytes: &[u8], package_ident: &str) -> Result, String> { let prefix = format!("node_modules/{package_ident}"); let mut entries: Vec = Vec::new(); - let mut seen_dirs: std::collections::HashSet = std::collections::HashSet::new(); + let mut seen_dirs: HashSet = HashSet::new(); + let mut seen_files: HashSet = HashSet::new(); // mkdirp: emit every missing ancestor of `dirpath` (no trailing slash), // shallowest first, exactly once. - fn mkdirp( - dirpath: &str, - seen: &mut std::collections::HashSet, - out: &mut Vec, - ) { + fn mkdirp(dirpath: &str, seen: &mut HashSet, out: &mut Vec) { let parts: Vec<&str> = dirpath.split('/').collect(); for i in 1..=parts.len() { let d = format!("{}/", parts[..i].join("/")); @@ -132,12 +131,28 @@ fn collect_entries(tgz_bytes: &[u8], package_ident: &str) -> Result>().join("/"); - let stripped = stripped.trim_end_matches('/'); + let stripped = raw_name + .split_once('/') + .map_or("", |(_, rest)| rest) + .trim_end_matches('/'); - let entry_type = entry.header().entry_type(); - match entry_type { + match entry.header().entry_type() { tar::EntryType::Directory => { let dir = if stripped.is_empty() { prefix.clone() @@ -153,6 +168,14 @@ fn collect_entries(tgz_bytes: &[u8], package_ident: &str) -> Result Result, String> { let mut blob: Vec = Vec::new(); let mut central: Vec = Vec::new(); - let mut offsets: Vec = Vec::with_capacity(entries.len()); for e in entries { - offsets.push(as_u32(blob.len(), "local header offset")?); + let offset = as_u32(blob.len(), "local header offset")?; let crc = if e.is_dir { 0 } else { @@ -220,6 +242,8 @@ fn write_zip(entries: &[ZipEntry]) -> Result, String> { crc.sum() }; let size = as_u32(e.data.len(), "entry size")?; + let name_len = + u16::try_from(e.name.len()).map_err(|_| "entry name too long".to_string())?; let vneed = if e.is_dir { VERSION_NEEDED_DIR } else { @@ -235,10 +259,7 @@ fn write_zip(entries: &[ZipEntry]) -> Result, String> { w32(&mut blob, crc); w32(&mut blob, size); // compressed == uncompressed (stored) w32(&mut blob, size); - w16( - &mut blob, - u16::try_from(e.name.len()).map_err(|_| "entry name too long".to_string())?, - ); + w16(&mut blob, name_len); w16(&mut blob, 0); // extra len blob.extend_from_slice(e.name.as_bytes()); blob.extend_from_slice(&e.data); @@ -253,13 +274,13 @@ fn write_zip(entries: &[ZipEntry]) -> Result, String> { w32(&mut central, crc); w32(&mut central, size); w32(&mut central, size); - w16(&mut central, e.name.len() as u16); + w16(&mut central, name_len); w16(&mut central, 0); // extra len w16(&mut central, 0); // comment len w16(&mut central, 0); // disk number start w16(&mut central, 0); // internal attrs w32(&mut central, e.mode << 16); // external attrs - w32(&mut central, *offsets.last().expect("just pushed")); + w32(&mut central, offset); central.extend_from_slice(e.name.as_bytes()); } @@ -462,6 +483,49 @@ mod tests { } } + /// Build a tgz of regular-file entries whose names are written straight + /// into the GNU header, bypassing tar-rs path validation — the only way + /// to reproduce the non-canonical names hand-rolled registry tarballs + /// can carry. + fn tgz_with_names(entries: &[(&str, &[u8])]) -> Vec { + let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::new(6)); + let mut tar = tar::Builder::new(gz); + for (path, data) in entries { + let mut h = tar::Header::new_gnu(); + h.set_entry_type(tar::EntryType::Regular); + h.set_size(data.len() as u64); + h.set_mode(0o644); + h.as_gnu_mut().unwrap().name[..path.len()].copy_from_slice(path.as_bytes()); + h.set_cksum(); + tar.append(&h, *data).unwrap(); + } + tar.into_inner().unwrap().finish().unwrap() + } + + /// Path shapes yarn's converter normalizes (`./`, `//`) or skips + /// outright (absolute, `..`) before stripping the first component, and + /// duplicate paths (yarn overwrites in place — one zip entry, not two): + /// none covered by the spike, so hashing our literal mapping would emit + /// a checksum yarn disagrees with. Must fail closed, never guess. + #[test] + fn non_canonical_and_duplicate_paths_fail_closed() { + for name in [ + "./package/index.js", + "package/./index.js", + "package/../index.js", + "/package/index.js", + "package//index.js", + ] { + let tgz = tgz_with_names(&[(name, b"x")]); + let err = berry_cache_checksum_10c0(&tgz, "x").unwrap_err(); + assert!(err.contains("not in canonical form"), "`{name}`: {err}"); + } + + let tgz = tgz_with_names(&[("package/a.txt", b"1"), ("package/a.txt", b"2")]); + let err = berry_cache_checksum_10c0(&tgz, "x").unwrap_err(); + assert!(err.contains("more than once"), "{err}"); + } + #[test] fn unsupported_inputs_fail_closed() { // Not a gzip stream at all. diff --git a/crates/socket-patch-core/src/patch/vendor/bun_lock.rs b/crates/socket-patch-core/src/patch/vendor/bun_lock.rs index 81ef1479..f5b5ba19 100644 --- a/crates/socket-patch-core/src/patch/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/bun_lock.rs @@ -27,13 +27,12 @@ //! grammar that fails CLOSED on anything unexpected; the file is never fed //! to a JSON parser. -use std::collections::HashMap; use std::path::Path; use serde_json::Value; use crate::manifest::schema::PatchRecord; -use crate::patch::apply::{ApplyResult, PatchSources, VerifyResult, VerifyStatus}; +use crate::patch::apply::PatchSources; use crate::patch::bun_lock_text::{ check_lock_version, decode_json_string, packages_bounds, parse_entry_line, parse_packages_section, split_name_spec, BunEntry, @@ -41,8 +40,9 @@ use crate::patch::bun_lock_text::{ use crate::patch::copy_tree::remove_tree; use crate::utils::fs::atomic_write_bytes; -use super::npm_common::{done_failure, guard_coordinates, refused, stage_patch_pack, tgz_rel_leaf}; -use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; +use super::common::{already_patched_result, refused}; +use super::npm_common::{done_failure, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack}; +use super::path::parse_vendor_path; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; @@ -54,18 +54,12 @@ const BUN_LOCK: &str = "bun.lock"; /// original/new = the verbatim entry LINE. const KIND_LOCK_PACKAGE: &str = "bun_lock_package"; -/// SECURITY: revert writes are restricted to the one file vendor edits — a -/// poisoned state.json must not be able to point the rewrite at an arbitrary -/// project file. Records naming anything else are skipped with a warning -/// (fail-closed). -const REVERT_ALLOWLIST: [&str; 1] = [BUN_LOCK]; - /// Vendor one installed npm package into a bun project (see the module doc). /// Same contract as `npm_lock::vendor_npm`: refuse-early / wire-last, /// `entry` present iff `result.success` and not a dry run, and an in-sync /// re-run synthesizes AlreadyPatched with no entry. #[allow(clippy::too_many_arguments)] -pub async fn vendor_bun( +pub(crate) async fn vendor_bun( purl: &str, installed_dir: &Path, project_root: &Path, @@ -84,8 +78,6 @@ pub async fn vendor_bun( Err(outcome) => return *outcome, }; let (name, version) = (coords.name.as_str(), coords.version.as_str()); - // BN3 spelling: BARE project-relative path, no `file:`/`./` prefix. - let rel_tgz = format!("{}/{}", coords.uuid_dir_rel, tgz_rel_leaf(name, version)); // ── 2. Read + strictly parse the lock (refuse before any write) ────── let lock_text = match tokio::fs::read_to_string(project_root.join(BUN_LOCK)).await { @@ -153,7 +145,8 @@ pub async fn vendor_bun( warnings, }; }; - debug_assert_eq!(staged.rel_tgz, rel_tgz); + // BN3 spelling: BARE project-relative path, no `file:`/`./` prefix. + let rel_tgz = staged.rel_tgz; let packed = staged.packed; if staged.staged_pkg_json.is_some() { // The tuple's deps object mirrors the package's own manifest; the @@ -181,7 +174,7 @@ pub async fn vendor_bun( TupleShape::Ours { path } => { // Idempotency: an instance already carrying this exact path // and integrity needs no edit and no wiring record. - if path == rel_tgz && entry.elems[2] == json_str(&packed.integrity) { + if path == rel_tgz && entry.elems[2] == format!("\"{}\"", packed.integrity) { continue; } (entry.elems[1].clone(), true) @@ -219,13 +212,8 @@ pub async fn vendor_bun( // Every instance already points at this uuid with the packed // integrity: in sync. The tarball re-pack above was byte-identical // by determinism; synthesize AlreadyPatched and record nothing. - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); return VendorOutcome::Done { - result: synthesized_result(purl, &project_root.join(&rel_tgz), verified, true, None), + result: already_patched_result(purl, &project_root.join(&rel_tgz), &record.files), entry: None, warnings, }; @@ -238,16 +226,7 @@ pub async fn vendor_bun( } // ── 6. Marker + ledger entry ────────────────────────────────────────── - let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); - vulnerabilities.sort(); - let marker = VendorMarker { - schema_version: 1, - purl: coords.base_purl.clone(), - patch_uuid: record.uuid.clone(), - ecosystem: "npm".to_string(), - vulnerabilities, - vendored_at: vendored_at.to_string(), - }; + let marker = VendorMarker::new("npm", &coords.base_purl, record, vendored_at); if let Err(e) = write_marker(&project_root.join(&coords.uuid_dir_rel), &marker).await { warnings.push(VendorWarning::new( "vendor_marker_write_failed", @@ -287,24 +266,30 @@ pub async fn vendor_bun( /// Undo one bun-vendored package: restore the recorded entry lines and /// remove the artifact dir. Reverse application order; per-record ownership /// is re-checked against the live line (drift ⇒ warning, left alone). -pub async fn revert_bun(entry: &VendorEntry, project_root: &Path, dry_run: bool) -> RevertOutcome { +pub(crate) async fn revert_bun( + entry: &VendorEntry, + project_root: &Path, + dry_run: bool, +) -> RevertOutcome { // SECURITY: `entry.uuid` comes from the committed, tamper-able // state.json and names the directory tree we are about to DELETE. // Validate through the same fail-closed grammar vendor used. - let Some(uuid_dir_rel) = vendor_uuid_dir_rel("npm", &entry.uuid) else { - return RevertOutcome::failed(format!( - "refusing revert: `{}` is not a canonical patch uuid (tampered state.json?)", - entry.uuid - )); + let uuid_dir_rel = match guard_revert_uuid_dir(&entry.uuid) { + Ok(d) => d, + Err(outcome) => return outcome, }; if dry_run { return RevertOutcome::ok(); } let mut outcome = RevertOutcome::ok(); + // SECURITY: revert writes are restricted to the one file vendor edits — a + // poisoned state.json must not be able to point the rewrite at an + // arbitrary project file. Records naming anything else are skipped with a + // warning (fail-closed). let mut touches_lock = false; for rec in &entry.wiring { - if !REVERT_ALLOWLIST.contains(&rec.file.as_str()) { + if rec.file != BUN_LOCK { outcome.warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", format!( @@ -462,52 +447,15 @@ fn classify(entry: &BunEntry, target_spec: &str, name: &str) -> Option String { - format!("\"{s}\"") -} - -// ───────────────────────── small shared helpers ─────────────────────────── -// (same shapes as npm_lock's; duplicated because that module's helpers are -// private and this file is the only allowed edit surface) - -fn synthesized_result( - package_key: &str, - path: &Path, - files_verified: Vec, - success: bool, - error: Option, -) -> ApplyResult { - ApplyResult { - package_key: package_key.to_string(), - package_path: path.display().to_string(), - success, - files_verified, - files_patched: Vec::new(), - applied_via: HashMap::new(), - error, - sidecar: None, - } -} - -fn already_patched_verify(file: &str) -> VerifyResult { - VerifyResult { - file: file.to_string(), - status: VerifyStatus::AlreadyPatched, - message: None, - current_hash: None, - expected_hash: None, - target_hash: None, - } -} - #[cfg(test)] mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; + use crate::patch::apply::{ApplyResult, VerifyStatus}; use base64::Engine as _; use sha2::{Digest, Sha512}; + use std::collections::HashMap; use std::path::PathBuf; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; @@ -610,6 +558,27 @@ mod tests { "haspad/left-pad": ["left-pad@.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", {}, "sha512-BeCz4t+xVlVhKgnBa2K5pAR1MKUgHxv3w9G4T/ADxBhxHNY1ByfS0zcyKi6WQYEM+W2MbTE5kpwwVpgkS//6lQ=="], } } +"#; + + // Scoped package: the vendored spec embeds an `@` inside the path + // (`@scope/pkg@.socket/vendor/npm//@scope/pkg-1.0.0.tgz` — the + // scope stays a real subdirectory in the tarball leaf), so name/path + // splitting must not key on the LAST `@`. + const SCOPED_BEFORE_LOCK: &str = r#"{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "scoped-fixture", + "dependencies": { + "@scope/pkg": "1.0.0", + }, + }, + }, + "packages": { + "@scope/pkg": ["@scope/pkg@1.0.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], + } +} "#; struct Fixture { @@ -962,6 +931,105 @@ mod tests { ); } + /// Build a scoped-package fixture and vendor it once (not dry). + async fn scoped_fixture() -> Fixture { + let fx = fixture_with(SCOPED_BEFORE_LOCK, "node_modules/@scope/pkg").await; + tokio::fs::write( + fx.installed.join("package.json"), + br#"{"name":"@scope/pkg","version":"1.0.0"}"#, + ) + .await + .unwrap(); + fx + } + + async fn vendor_scoped(fx: &Fixture) -> VendorOutcome { + let blobs = fx.root().join(".socket/blobs"); + let sources = PatchSources::blobs_only(&blobs); + vendor_bun( + "pkg:npm/@scope/pkg@1.0.0", + &fx.installed, + fx.root(), + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await + } + + #[tokio::test] + async fn scoped_package_rerun_is_in_sync_not_refused() { + let fx = scoped_fixture().await; + let (result, entry, _) = expect_done(vendor_scoped(&fx).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_some()); + let lock_first = fx.read_lock().await; + assert!( + lock_first.contains(&format!( + "\"@scope/pkg@.socket/vendor/npm/{UUID}/@scope/pkg-1.0.0.tgz\"" + )), + "vendored spec keeps the scope dir in the leaf: {lock_first}" + ); + + // The in-sync re-run must synthesize AlreadyPatched, not refuse. + let (result, entry, _) = expect_done(vendor_scoped(&fx).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none(), "in-sync re-run records nothing"); + assert!( + result + .files_verified + .iter() + .all(|v| v.status == VerifyStatus::AlreadyPatched), + "{:?}", + result.files_verified + ); + assert_eq!(fx.read_lock().await, lock_first, "lock byte-stable"); + } + + #[tokio::test] + async fn scoped_reserialized_entry_is_still_ours_on_revert() { + let fx = scoped_fixture().await; + let (_, entry, _) = expect_done(vendor_scoped(&fx).await); + let entry = entry.unwrap(); + + // Simulate bun re-serializing the line without moving the entry: + // same key, same tuple, trailing comma dropped. The uuid-ownership + // fallback (not the byte-exact compare) must still claim it. + let live = fx.read_lock().await; + let new_line = entry.wiring[0] + .new + .as_ref() + .and_then(Value::as_str) + .unwrap(); + let reserialized = new_line.strip_suffix(',').unwrap(); + tokio::fs::write( + fx.root().join(BUN_LOCK), + live.replacen(new_line, reserialized, 1), + ) + .await + .unwrap(); + + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome.warnings.is_empty(), + "an unmoved entry is ours, not drift: {:?}", + outcome.warnings + ); + assert_eq!( + fx.read_lock().await, + SCOPED_BEFORE_LOCK, + "registry tuple byte-restored" + ); + assert!(!fx + .root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists()); + } + #[tokio::test] async fn dry_run_writes_nothing() { let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; diff --git a/crates/socket-patch-core/src/patch/vendor/cargo.rs b/crates/socket-patch-core/src/patch/vendor/cargo.rs index 50c079d5..15d0ea07 100644 --- a/crates/socket-patch-core/src/patch/vendor/cargo.rs +++ b/crates/socket-patch-core/src/patch/vendor/cargo.rs @@ -13,26 +13,26 @@ //! [`apply_package_patch`] pipeline** pointed at the fresh copy, so all the //! verify → package/diff/blob → atomic-write machinery is reused unchanged. -use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::Path; -use crate::manifest::schema::{PatchFileInfo, PatchRecord}; -use crate::patch::apply::{ - normalize_file_path, ApplyResult, PatchSources, VerifyResult, VerifyStatus, -}; +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::{fresh_copy, remove_tree}; -use crate::patch::file_hash::compute_file_git_sha256; use crate::patch::path_safety::is_safe_single_segment; use crate::utils::purl::{parse_cargo_purl, strip_purl_qualifiers}; use super::cargo_config::{self, LEGACY_CARGO_PATCHES_DIR}; -use super::cargo_lock::{self, LockEditError, LockEntryOriginal}; +use super::cargo_lock::{self, LockEditError}; +use super::common::{ + already_patched_result, copy_matches_after_hashes, done, refused, service_offline_conflict, + synthesized_result, +}; use super::path::vendor_uuid_dir_rel; use super::registry_fetch::extract_tgz; use super::service_fetch::{fetch_verified_archive, ServiceArtifact}; use super::state::{ write_marker, CargoLockOriginal, VendorArtifact, VendorEntry, VendorMarker, WiringAction, - WiringRecord, + WiringRecord, VENDOR_MARKER_FILE, }; use super::{RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; @@ -64,86 +64,20 @@ fn is_legacy_redirect_path(path: &str) -> bool { norm.starts_with(&format!("{LEGACY_CARGO_PATCHES_DIR}/")) } -/// True when the lock entry for `name`+`version` no longer needs detaching: -/// either there is no lockfile (nothing to edit — the first build generates a -/// path-form lock), or the entry exists with no `source` (already detached). -/// Probed via a dry-run detach: `NotRegistry` *is* the detached shape. -async fn lock_entry_detached(project_root: &Path, name: &str, version: &str) -> bool { - matches!( - cargo_lock::detach_lock_entry(project_root, name, version, true).await, - Err(LockEditError::NotRegistry) | Err(LockEditError::NoLockfile) - ) -} - -/// True if the copy exists, every patched file in it already hashes to its -/// `afterHash`, the config entry points at this copy, and the lock entry is -/// already detached — i.e. a re-run has nothing to do. Touch nothing then, so -/// cargo's source fingerprint and the committed bytes stay stable. -/// The committed copy exists and every patched file matches its afterHash. -async fn copy_hashes_ok(copy_dir: &Path, files: &HashMap) -> bool { - if tokio::fs::metadata(copy_dir).await.is_err() { - return false; - } - for (file_name, info) in files { - let path = copy_dir.join(normalize_file_path(file_name)); - match compute_file_git_sha256(&path).await { - Ok(h) if h == info.after_hash => {} - _ => return false, - } - } - true -} - -/// The config `[patch]` entry points at THIS copy and the lock entry is -/// already detached — the wiring half of the in-sync test. +/// The config `[patch]` entry points at THIS copy and the lock entry no +/// longer needs detaching: either there is no lockfile (nothing to edit — the +/// first build generates a path-form lock), or the entry exists with no +/// `source` (already detached). The lock half is probed via a dry-run detach: +/// `NotRegistry` *is* the detached shape. async fn wiring_in_sync(project_root: &Path, name: &str, version: &str, copy_rel: &str) -> bool { let entries = cargo_config::read_patch_entries(project_root).await; if entries.get(name).and_then(|i| i.path.as_deref()) != Some(copy_rel) { return false; } - lock_entry_detached(project_root, name, version).await -} - -fn synthesized_result( - package_key: &str, - copy_dir: &Path, - files_verified: Vec, - success: bool, - error: Option, -) -> ApplyResult { - ApplyResult { - package_key: package_key.to_string(), - package_path: copy_dir.display().to_string(), - success, - files_verified, - files_patched: Vec::new(), - applied_via: HashMap::new(), - error, - sidecar: None, - } -} - -fn already_patched_verify(file: &str) -> VerifyResult { - VerifyResult { - file: file.to_string(), - status: VerifyStatus::AlreadyPatched, - message: None, - current_hash: None, - expected_hash: None, - target_hash: None, - } -} - -fn done( - result: ApplyResult, - entry: Option, - warnings: Vec, -) -> VendorOutcome { - VendorOutcome::Done { - result, - entry, - warnings, - } + matches!( + cargo_lock::detach_lock_entry(project_root, name, version, true).await, + Err(LockEditError::NotRegistry) | Err(LockEditError::NoLockfile) + ) } /// Outcome of attempting to materialise the cargo copy from the patch service. @@ -176,7 +110,7 @@ async fn cargo_service_copy( return CargoServiceCopy::FallBack; } fn hard(code: &'static str, detail: String) -> CargoServiceCopy { - CargoServiceCopy::HardFail(Box::new(VendorOutcome::Refused { code, detail })) + CargoServiceCopy::HardFail(Box::new(refused(code, detail))) } let miss = |warnings: &mut Vec, code: &'static str, reason: String| { if cfg.source.requires_service() { @@ -189,7 +123,7 @@ async fn cargo_service_copy( CargoServiceCopy::FallBack } }; - match fetch_verified_archive(cfg, &record.uuid, name).await { + match fetch_verified_archive(cfg, &record.uuid).await { ServiceArtifact::Ready(archive) => { // Clean copy dir, then extract the `.crate` (tar.gz; strip its // single `{name}-{version}/` top-level dir) into it. @@ -245,6 +179,56 @@ async fn cargo_service_copy( } } +/// Copy the pristine source into `copy_dir` and run the hardened apply +/// pipeline against it (vendor auto-force policy — see +/// [`super::force_apply_staged`]). On failure the whole uuid dir is removed — +/// a partial copy (or an empty `/` husk) under `.socket/vendor/` would +/// be misjudged by verify/sweep — and the failed [`ApplyResult`] is the `Err` +/// for the caller to bubble. On success the copy carries no +/// `.cargo-checksum.json` (a path-dep copy must never have one; the fresh +/// copy excludes it, and it is re-removed defensively in case the patch +/// recreated it). +#[allow(clippy::too_many_arguments)] +async fn copy_and_patch( + purl: &str, + pristine_src: &Path, + copy_dir: &Path, + uuid_dir: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + force: bool, + name: &str, + version: &str, + warnings: &mut Vec, +) -> Result { + if let Err(e) = fresh_copy(pristine_src, copy_dir, Some(".cargo-checksum.json")).await { + let _ = remove_tree(uuid_dir).await; + return Err(synthesized_result( + purl, + copy_dir, + Vec::new(), + false, + Some(format!("failed to copy pristine source: {e}")), + )); + } + let mut result = super::force_apply_staged( + purl, copy_dir, record, sources, false, force, name, version, warnings, + ) + .await; + result.package_path = copy_dir.display().to_string(); + if !result.success { + let _ = remove_tree(uuid_dir).await; + return Err(result); + } + let _ = tokio::fs::remove_file(copy_dir.join(".cargo-checksum.json")).await; + debug_assert!( + result.sidecar.is_none(), + "vendor copy must not produce a cargo sidecar" + ); + result.sidecar = None; + Ok(result) +} + /// Vendor one cargo crate: patched copy + `[patch.crates-io]` entry + /// `Cargo.lock` surgery + marker, returning the ledger entry to persist. /// @@ -270,10 +254,7 @@ pub async fn vendor_cargo_crate( ) -> VendorOutcome { // ── coordinate validation (fail-closed, before any disk access) ────── let Some((name, version)) = parse_cargo_purl(purl) else { - return VendorOutcome::Refused { - code: "unsafe_coordinates", - detail: format!("not a cargo purl: {purl}"), - }; + return refused("unsafe_coordinates", format!("not a cargo purl: {purl}")); }; // SECURITY: `name`/`version` key the on-disk copy dir // (`.socket/vendor/cargo//-/`) and the `[patch]` @@ -281,36 +262,36 @@ pub async fn vendor_cargo_crate( // and the apply pipeline escape `.socket/vendor/` — refuse before any // disk access. if !is_safe_single_segment(name) || !is_safe_single_segment(version) { - return VendorOutcome::Refused { - code: "unsafe_coordinates", - detail: format!( + return refused( + "unsafe_coordinates", + format!( "refusing to vendor unsafe cargo coordinates `{name}`/`{version}` \ (a path separator or `..` would escape .socket/vendor/cargo/)" ), - }; + ); } // SECURITY: the uuid is a dedicated path level created here and deleted by // `--revert`; anything but the canonical UUID grammar is rejected. let Some(base_rel) = vendor_uuid_dir_rel("cargo", &record.uuid) else { - return VendorOutcome::Refused { - code: "unsafe_coordinates", - detail: format!( + return refused( + "unsafe_coordinates", + format!( "refusing to vendor {purl}: patch uuid `{}` is not a canonical uuid", record.uuid ), - }; + ); }; // ── pre-flight refusals (read-only) ─────────────────────────────────── // (a) A real `cargo vendor` tree already provides this crate. if is_vendored(project_root, name, version).await { - return VendorOutcome::Refused { - code: "already_vendored_in_tree", - detail: format!( + return refused( + "already_vendored_in_tree", + format!( "{name}@{version} is provided by the project's `vendor/` tree \ (cargo vendor); patch it in place with `apply` instead" ), - }; + ); } // (b) The lock must resolve this exact version, or the `[patch]` would be // unused and an unlocked build would silently re-lock (spike claim 6). @@ -320,21 +301,19 @@ pub async fn vendor_cargo_crate( Some(versions) => { let mut sorted: Vec<&str> = versions.iter().map(String::as_str).collect(); sorted.sort_unstable(); - return VendorOutcome::Refused { - code: "locked_version_mismatch", - detail: format!( + return refused( + "locked_version_mismatch", + format!( "Cargo.lock resolves `{name}` to {} but the patch targets {version}", sorted.join(", ") ), - }; + ); } None => { - return VendorOutcome::Refused { - code: "locked_version_mismatch", - detail: format!( - "`{name}` is not present in Cargo.lock (patch targets {version})" - ), - }; + return refused( + "locked_version_mismatch", + format!("`{name}` is not present in Cargo.lock (patch targets {version})"), + ); } } } @@ -346,14 +325,14 @@ pub async fn vendor_cargo_crate( .remove(name); if let Some(info) = &prior_entry { if !info.socket_owned { - return VendorOutcome::Refused { - code: "user_authored_patch_entry", - detail: format!( + return refused( + "user_authored_patch_entry", + format!( "`patch.crates-io.{name}` in .cargo/config.toml is user-authored \ ({}); refusing to overwrite", info.path.as_deref().unwrap_or("non-path source") ), - }; + ); } } @@ -397,14 +376,9 @@ pub async fn vendor_cargo_crate( // Hot path: already in sync → touch nothing (entry stays with the caller's // existing ledger record, which holds the unrecoverable lock originals). if wiring_in_sync(project_root, name, version, ©_rel).await { - if copy_hashes_ok(©_dir, &record.files).await { - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); + if copy_matches_after_hashes(©_dir, &record.files).await { return done( - synthesized_result(purl, ©_dir, verified, true, None), + already_patched_result(purl, ©_dir, &record.files), None, Vec::new(), ); @@ -413,41 +387,24 @@ pub async fn vendor_cargo_crate( // ARTIFACT only — config + lock are already correct, and the full // path's surgery would re-record live vendored state over the // first run's unrecoverable lock originals. - if let Err(e) = fresh_copy(pristine_src, ©_dir, Some(".cargo-checksum.json")).await { - let _ = remove_tree(&uuid_dir).await; - return done( - synthesized_result( - purl, - ©_dir, - Vec::new(), - false, - Some(format!("failed to copy pristine source: {e}")), - ), - None, - Vec::new(), - ); - } let mut warnings: Vec = Vec::new(); - let mut result = super::force_apply_staged( + let result = match copy_and_patch( purl, + pristine_src, ©_dir, + &uuid_dir, record, sources, - false, force, name, version, &mut warnings, ) - .await; - result.package_path = copy_dir.display().to_string(); - if !result.success { - let _ = remove_tree(&uuid_dir).await; - return done(result, None, warnings); - } - // Same path-dep invariant as the full path: no checksum sidecar. - let _ = tokio::fs::remove_file(copy_dir.join(".cargo-checksum.json")).await; - result.sidecar = None; + .await + { + Ok(result) => result, + Err(result) => return done(result, None, warnings), + }; warnings.push(VendorWarning::new( "vendor_artifact_rebuilt", format!( @@ -455,24 +412,36 @@ pub async fn vendor_cargo_crate( rebuilt at {copy_rel} (config and lock untouched)" ), )); + // The rebuild may have recreated the whole uuid dir (deleted + // wholesale, marker included): restore the committed marker + // alongside the copy so the re-committed vendor unit is complete. + // Only when missing — a copy-only rebuild keeps the original marker + // (and its vendoredAt). + if tokio::fs::metadata(uuid_dir.join(VENDOR_MARKER_FILE)) + .await + .is_err() + { + let marker = + VendorMarker::new("cargo", strip_purl_qualifiers(purl), record, vendored_at); + if let Err(e) = write_marker(&uuid_dir, &marker).await { + warnings.push(VendorWarning::new( + "marker_write_failed", + format!("could not write the vendor marker: {e}"), + )); + } + } return done(result, None, warnings); } // ── materialise the patched copy ────────────────────────────────────── // Prefer the prebuilt `.crate` from the patch service (download + extract, - // no pristine source needed); else copy the pristine source and patch it. - // Either way a path-dep copy must never carry a `.cargo-checksum.json` - // (cargo 1.93 src dirs no longer have one, but older layouts do and its - // presence would re-enable checksum fixups). + // no pristine source needed); else copy the pristine source and patch it + // (`copy_and_patch`). Either way a path-dep copy must never carry a + // `.cargo-checksum.json` (cargo 1.93 src dirs no longer have one, but + // older layouts do and its presence would re-enable checksum fixups). let mut warnings: Vec = Vec::new(); - if let Some(cfg) = service { - if cfg.source.requires_service() && cfg.offline { - return VendorOutcome::Refused { - code: "vendor_service_offline_conflict", - detail: "--vendor-source=service needs the network but --offline is set" - .to_string(), - }; - } + if let Some(refusal) = service_offline_conflict(service) { + return refusal; } let mut result = match cargo_service_copy( service, @@ -487,64 +456,27 @@ pub async fn vendor_cargo_crate( CargoServiceCopy::Used => { // The service crate is the patched package; trust its verified // integrity (every file reads as AlreadyPatched). - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); - synthesized_result(purl, ©_dir, verified, true, None) + already_patched_result(purl, ©_dir, &record.files) } CargoServiceCopy::HardFail(outcome) => return *outcome, CargoServiceCopy::FallBack => { - if let Err(e) = fresh_copy(pristine_src, ©_dir, Some(".cargo-checksum.json")).await - { - // Clear the whole uuid dir, not just the copy: a partial copy - // (or an empty `/` husk) under .socket/vendor/ would be - // misjudged by verify/sweep. - let _ = remove_tree(&uuid_dir).await; - return done( - synthesized_result( - purl, - ©_dir, - Vec::new(), - false, - Some(format!("failed to copy pristine source: {e}")), - ), - None, - warnings, - ); - } - // Delegate to the hardened pipeline (vendor auto-force policy — - // see `force_apply_staged`), pointed at the copy. - let mut result = super::force_apply_staged( + match copy_and_patch( purl, + pristine_src, ©_dir, + &uuid_dir, record, sources, - false, force, name, version, &mut warnings, ) - .await; - result.package_path = copy_dir.display().to_string(); - if !result.success { - // Don't leave a half-built copy (or an empty uuid husk) that - // verify/sweep would misjudge. - let _ = remove_tree(&uuid_dir).await; - return done(result, None, warnings); + .await + { + Ok(result) => result, + Err(result) => return done(result, None, warnings), } - // A path-dep copy must never carry a checksum sidecar. The fresh - // copy excluded it; enforce defensively in case the patch recreated - // the file. - let _ = tokio::fs::remove_file(copy_dir.join(".cargo-checksum.json")).await; - debug_assert!( - result.sidecar.is_none(), - "vendor copy must not produce a cargo sidecar" - ); - result.sidecar = None; - result } }; @@ -567,7 +499,7 @@ pub async fn vendor_cargo_crate( } // ── detach the lock entry ───────────────────────────────────────────── - let lock_original: Option = + let lock_original: Option = match cargo_lock::detach_lock_entry(project_root, name, version, false).await { Ok(orig) => Some(orig), Err(LockEditError::NoLockfile) => { @@ -579,11 +511,32 @@ pub async fn vendor_cargo_crate( )); None } + Err(LockEditError::NotRegistry) if prior_path.is_some() => { + // Re-vendor over live wiring (a patch update moved the + // manifest to a new uuid): the prior socket-owned run already + // detached this entry — source-less is exactly the shape we + // produce. The lock is in the desired state; the true + // pre-vendor originals live only in the ledger entry being + // replaced, which the caller carries forward. Record nothing. + None + } Err(e) => { // Without the lock edit, `--locked` builds fail closed on the // [patch] we just wired — a half-vendored state. UNWIND the - // config entry + copy so the project is back where it started. - let _ = cargo_config::drop_patch_entry(project_root, name, false).await; + // config edit so the project is back where it started: + // restore the prior socket-owned entry when this was a + // re-vendor (dropping it would destroy the first run's live + // wiring), else drop the entry we just added. Either way + // remove this run's copy. + match prior_path.as_deref() { + Some(p) => { + let _ = + cargo_config::ensure_patch_entry(project_root, name, p, false).await; + } + None => { + let _ = cargo_config::drop_patch_entry(project_root, name, false).await; + } + } let _ = remove_tree(&uuid_dir).await; result.success = false; result.error = Some(format!( @@ -596,16 +549,7 @@ pub async fn vendor_cargo_crate( // ── marker + ledger entry ───────────────────────────────────────────── let base_purl = strip_purl_qualifiers(purl).to_string(); - let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); - vulnerabilities.sort(); - let marker = VendorMarker { - schema_version: 1, - purl: base_purl.clone(), - patch_uuid: record.uuid.clone(), - ecosystem: "cargo".to_string(), - vulnerabilities, - vendored_at: vendored_at.to_string(), - }; + let marker = VendorMarker::new("cargo", &base_purl, record, vendored_at); if let Err(e) = write_marker(&uuid_dir, &marker).await { // The marker is belt-and-braces metadata (never a trust input); a // failed write must not undo a fully-wired vendor — surface it. @@ -652,10 +596,7 @@ pub async fn vendor_cargo_crate( platform_locked: None, }, wiring, - lock: lock_original.map(|o| CargoLockOriginal { - source: o.source, - checksum: o.checksum, - }), + lock: lock_original, took_over_go_patches: false, detached: false, record: None, @@ -699,12 +640,7 @@ pub async fn revert_cargo_vendor( let mut out = RevertOutcome::ok(); if let Some(lock) = &entry.lock { - let original = LockEntryOriginal { - source: lock.source.clone(), - checksum: lock.checksum.clone(), - }; - match cargo_lock::restore_lock_entry(project_root, name, version, &original, dry_run).await - { + match cargo_lock::restore_lock_entry(project_root, name, version, lock, dry_run).await { Ok(true) => {} Ok(false) => out.warnings.push(VendorWarning::new( "lock_restore_skipped", @@ -739,7 +675,7 @@ pub async fn revert_cargo_vendor( } if !dry_run { - let uuid_dir: PathBuf = project_root.join(&base_rel); + let uuid_dir = project_root.join(&base_rel); let _ = remove_tree(&uuid_dir).await; // ignore NotFound // Best-effort: prune the now-empty `.socket/vendor/cargo/` level so a // fully-reverted project carries no vendor residue (`save_state` then @@ -756,10 +692,14 @@ pub async fn revert_cargo_vendor( mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; - use crate::manifest::schema::VulnerabilityInfo; + use crate::manifest::schema::{PatchFileInfo, VulnerabilityInfo}; use crate::patch::vendor::state::VENDOR_MARKER_FILE; + use std::collections::HashMap; + use std::path::PathBuf; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + /// A second canonical uuid, for re-vendor (patch update) scenarios. + const UUID2: &str = "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"; const PURL: &str = "pkg:cargo/cfg-if@1.0.4"; const PRISTINE: &[u8] = b"pub fn cfg() {}\n"; const PATCHED: &[u8] = b"pub fn cfg() { /* patched */ }\n"; @@ -1479,6 +1419,128 @@ mod tests { assert!(cargo_config::read_patch_entries(root).await["cfg-if"].socket_owned); } + /// A patch update moves the manifest to a NEW uuid for the same crate. + /// The CLI re-vendors straight over the first run's live wiring (see + /// `persist_vendor_entry`: originals are carried forward and the old + /// uuid dir swept afterwards — there is no revert-first). The lock is + /// already in the detached shape from the first run, so the re-vendor + /// must accept it as the desired state and succeed — never fail and + /// unwind the live config entry (which bricks every build: no `[patch]` + /// entry left, source-less lock entry). + #[tokio::test] + async fn test_revendor_new_uuid_over_live_wiring_succeeds() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + let lock_detached = tokio::fs::read(root.join("Cargo.lock")).await.unwrap(); + + let mut record2 = record.clone(); + record2.uuid = UUID2.into(); + let (result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record2, false).await); + assert!(result.success, "re-vendor must succeed: {:?}", result.error); + + // The config entry is repointed at the new uuid's copy. + let new_rel = format!(".socket/vendor/cargo/{UUID2}/cfg-if-1.0.4"); + assert_eq!( + cargo_config::read_patch_entries(root).await["cfg-if"] + .path + .as_deref(), + Some(new_rel.as_str()) + ); + // The new copy carries the patched bytes; the old uuid dir is left + // for the caller's stale-artifact sweep (the caller owns the ledger). + assert_eq!( + tokio::fs::read(root.join(&new_rel).join("src/lib.rs")) + .await + .unwrap(), + PATCHED + ); + assert!(root.join(copy_rel()).exists()); + // The already-detached lock is untouched. + assert_eq!( + tokio::fs::read(root.join("Cargo.lock")).await.unwrap(), + lock_detached + ); + // A fresh entry is emitted for the ledger. This run edited no lock, + // so it records no originals — the true pre-vendor source/checksum + // live only in the entry being replaced (the caller carries them + // forward). + let entry = entry.expect("re-vendor emits the new ledger entry"); + assert_eq!(entry.uuid, UUID2); + assert_eq!(entry.artifact.path, new_rel); + assert_eq!(entry.lock, None); + } + + /// When the lock-detach step fails mid-re-vendor (here: the lock went + /// corrupt, which the pre-flight cross-check deliberately skips), the + /// unwind must put the PRIOR socket-owned config entry back — dropping + /// it would destroy the first vendor's live wiring. + #[tokio::test] + async fn test_detach_failure_unwind_restores_prior_socket_entry() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + + tokio::fs::write(root.join("Cargo.lock"), "not = = toml [[[") + .await + .unwrap(); + + let mut record2 = record.clone(); + record2.uuid = UUID2.into(); + let (result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record2, false).await); + assert!(!result.success); + assert!(entry.is_none()); + // The prior entry is restored, not dropped; the new uuid dir is gone. + assert_eq!( + cargo_config::read_patch_entries(root).await["cfg-if"] + .path + .as_deref(), + Some(copy_rel().as_str()), + "unwind must restore the pre-existing socket entry" + ); + assert!(!root.join(format!(".socket/vendor/cargo/{UUID2}")).exists()); + assert!( + root.join(copy_rel()).exists(), + "first vendor's copy untouched" + ); + } + + /// Deleting the WHOLE uuid dir (not just the copy leaf) loses the + /// committed marker; the artifact-only rebuild must restore it alongside + /// the copy (as the golang backend does), or the re-committed vendor + /// unit is incomplete. + #[tokio::test] + async fn test_wired_deleted_uuid_dir_rebuild_restores_marker() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + remove_tree(&root.join(format!(".socket/vendor/cargo/{UUID}"))) + .await + .unwrap(); + + let (result, entry, warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none()); + assert!( + warnings.iter().any(|w| w.code == "vendor_artifact_rebuilt"), + "{warnings:?}" + ); + assert_eq!( + tokio::fs::read(root.join(copy_rel()).join("src/lib.rs")) + .await + .unwrap(), + PATCHED + ); + let marker = root.join(format!(".socket/vendor/cargo/{UUID}/{VENDOR_MARKER_FILE}")); + assert!( + marker.exists(), + "rebuild must restore the committed marker file" + ); + } + #[tokio::test] async fn test_empty_files_is_noop() { let (dir, blobs, pristine, mut record) = fixture().await; diff --git a/crates/socket-patch-core/src/patch/vendor/cargo_config.rs b/crates/socket-patch-core/src/patch/vendor/cargo_config.rs index ba485dd7..d7dcb56b 100644 --- a/crates/socket-patch-core/src/patch/vendor/cargo_config.rs +++ b/crates/socket-patch-core/src/patch/vendor/cargo_config.rs @@ -30,11 +30,12 @@ use std::path::{Path, PathBuf}; use tokio::fs; use toml_edit::{DocumentMut, InlineTable, Item, Table, Value}; -use crate::utils::fs::atomic_write_bytes; +use crate::pth_hook::edit::ensure_table; +use crate::utils::fs::atomic_write_bytes_preserving_mode; /// Project-relative root of the vendor backend's committed crate copies. An /// entry whose `path` is under this prefix is socket-owned. -pub const CARGO_VENDOR_DIR: &str = ".socket/vendor/cargo"; +const CARGO_VENDOR_DIR: &str = ".socket/vendor/cargo"; /// Project-relative root of the retired `[patch]`-redirect backend's copies. /// Entries under this prefix are still recognised as socket-owned so vendor @@ -48,7 +49,7 @@ pub struct PatchEntryInfo { /// The `path` value as written (verbatim), or `None` for a non-path /// source (e.g. `git`/`registry`). pub path: Option, - /// True iff `path` is under [`CARGO_VENDOR_DIR`] or + /// True iff `path` is under `CARGO_VENDOR_DIR` or /// [`LEGACY_CARGO_PATCHES_DIR`]. pub socket_owned: bool, } @@ -98,19 +99,17 @@ pub async fn read_patch_entries(project_root: &Path) -> HashMap/.cargo/`. Prefers an existing -/// `config.toml`, then an existing legacy `config`, else `config.toml` (created -/// on first write). +/// legacy `config`: when both files exist cargo reads the one WITHOUT the +/// extension (and warns) — writing into `config.toml` there would leave the +/// `[patch]` entry silently inert. Falls back to an existing `config.toml`, +/// else `config.toml` (created on first write). async fn config_path(project_root: &Path) -> PathBuf { let dir = project_root.join(".cargo"); - let toml = dir.join("config.toml"); - if fs::metadata(&toml).await.is_ok() { - return toml; - } let legacy = dir.join("config"); if fs::metadata(&legacy).await.is_ok() { return legacy; } - toml + dir.join("config.toml") } /// Apply a pure transform to the config file, writing only if it changed and @@ -161,8 +160,10 @@ async fn edit_config( // comments alongside our `[patch]` entries. Commit // atomically (stage + fsync + rename) so a crash mid-write // can never truncate content we only meant to add one - // entry to. - atomic_write_bytes(&path, new.as_bytes()) + // entry to — and keep the destination's permission bits + // (the rename would otherwise reset them to the fresh + // stage inode's default). + atomic_write_bytes_preserving_mode(&path, new.as_bytes()) .await .map_err(|e| format!("write {}: {e}", path.display()))?; } @@ -194,24 +195,6 @@ fn entry_path(item: &Item) -> Option<&str> { .and_then(Item::as_str) } -/// Ensure `parent[key]` is a table, creating it if absent. Errors if present -/// but a non-table. Mirrors `pth_hook::edit::ensure_table`. -fn ensure_table<'a>( - parent: &'a mut Table, - key: &str, - implicit: bool, -) -> Result<&'a mut Table, String> { - if !parent.contains_key(key) { - let mut t = Table::new(); - t.set_implicit(implicit); - parent.insert(key, Item::Table(t)); - } - parent - .get_mut(key) - .and_then(Item::as_table_mut) - .ok_or_else(|| format!("`{key}` is not a table")) -} - fn upsert_patch_entry(content: &str, name: &str, rel_path: &str) -> Result, String> { let mut doc = content .parse::() @@ -550,6 +533,66 @@ mod tests { assert!(body.contains("jobs = 2")); } + #[tokio::test] + async fn test_prefers_legacy_config_when_both_exist() { + // cargo warns "both `.cargo/config` and `.cargo/config.toml` exist. + // Using `.cargo/config`" — when both are present the entry must land + // in the file cargo actually reads, or the patch is silently inert. + let dir = tempfile::tempdir().unwrap(); + let cargo_dir = dir.path().join(".cargo"); + fs::create_dir_all(&cargo_dir).await.unwrap(); + fs::write(cargo_dir.join("config"), "[build]\njobs = 2\n") + .await + .unwrap(); + fs::write(cargo_dir.join("config.toml"), "[net]\nretry = 3\n") + .await + .unwrap(); + assert!( + ensure_patch_entry(dir.path(), "cfg-if", &vendor_path("cfg-if", "1.0.4"), false) + .await + .unwrap() + ); + let legacy = fs::read_to_string(cargo_dir.join("config")).await.unwrap(); + assert!( + legacy.contains("cfg-if"), + "entry must go into the file cargo uses: {legacy}" + ); + let toml = fs::read_to_string(cargo_dir.join("config.toml")) + .await + .unwrap(); + assert!( + !toml.contains("cfg-if"), + "config.toml is ignored by cargo while `config` exists; must stay untouched" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_edit_preserves_existing_file_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let cargo_dir = dir.path().join(".cargo"); + fs::create_dir_all(&cargo_dir).await.unwrap(); + let cfg = cargo_dir.join("config.toml"); + fs::write(&cfg, "[build]\njobs = 4\n").await.unwrap(); + // 0o640 never matches a fresh-inode default (0666 & !umask is one of + // 600/644/664/666), so a writer that drops the destination's bits is + // caught under any umask. + fs::set_permissions(&cfg, std::fs::Permissions::from_mode(0o640)) + .await + .unwrap(); + assert!( + ensure_patch_entry(dir.path(), "cfg-if", &vendor_path("cfg-if", "1.0.4"), false) + .await + .unwrap() + ); + let mode = fs::metadata(&cfg).await.unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o640, + "editing a user-owned config must not reset its permission bits" + ); + } + // ── exact-restore: emptied socket-created config is deleted ────── #[tokio::test] async fn test_drop_deletes_socket_created_config_and_dir() { diff --git a/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs b/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs index a59bd652..8cb5f4e1 100644 --- a/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/cargo_lock.rs @@ -17,23 +17,16 @@ //! //! The removed `source`/`checksum` pair is not recoverable offline (the //! checksum is the sha256 of the registry `.crate` tarball, not of the -//! extracted tree), so [`detach_lock_entry`] returns it for the vendor ledger -//! ([`super::state::CargoLockOriginal`]) and [`restore_lock_entry`] writes it -//! back on revert. +//! extracted tree), so [`detach_lock_entry`] returns it as the vendor ledger's +//! [`CargoLockOriginal`] and [`restore_lock_entry`] writes it back on revert. use std::collections::{HashMap, HashSet}; use std::path::Path; use toml_edit::{DocumentMut, Item, Table}; -use crate::utils::fs::atomic_write_bytes; - -/// The original lock fields removed by [`detach_lock_entry`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LockEntryOriginal { - pub source: String, - pub checksum: Option, -} +use super::state::CargoLockOriginal; +use crate::utils::fs::atomic_write_bytes_preserving_mode; /// Why a lock edit could not be performed. #[derive(Debug, Clone, PartialEq, Eq)] @@ -83,27 +76,28 @@ async fn read_lock( Ok((path, doc)) } -/// Find the index of the `[[package]]` table matching `name`+`version`. -fn find_package_index(doc: &DocumentMut, name: &str, version: &str) -> Option { - let pkgs = doc.get("package")?.as_array_of_tables()?; - pkgs.iter().position(|t| { - t.get("name").and_then(Item::as_str) == Some(name) - && t.get("version").and_then(Item::as_str) == Some(version) - }) -} - -fn package_table_mut(doc: &mut DocumentMut, idx: usize) -> Result<&mut Table, LockEditError> { - doc.get_mut("package") - .and_then(Item::as_array_of_tables_mut) - .and_then(|a| a.get_mut(idx)) - .ok_or(LockEditError::EntryMissing) +/// Find the `[[package]]` table matching `name`+`version`. +fn find_package_mut<'a>( + doc: &'a mut DocumentMut, + name: &str, + version: &str, +) -> Option<&'a mut Table> { + doc.get_mut("package")? + .as_array_of_tables_mut()? + .iter_mut() + .find(|t| { + t.get("name").and_then(Item::as_str) == Some(name) + && t.get("version").and_then(Item::as_str) == Some(version) + }) } /// Commit the edited lock atomically (stage + fsync + rename). The lock is a /// committed file shared with cargo itself; a torn write would corrupt the -/// whole project's resolution, so never truncate-in-place. +/// whole project's resolution, so never truncate-in-place. Mode-preserving: +/// the lock is a user-owned file we merely edit, so the swapped-in inode must +/// keep its permission bits rather than reset them to umask defaults. async fn write_lock(path: &Path, doc: &DocumentMut) -> Result<(), LockEditError> { - atomic_write_bytes(path, doc.to_string().as_bytes()) + atomic_write_bytes_preserving_mode(path, doc.to_string().as_bytes()) .await .map_err(|e| LockEditError::Io(e.to_string())) } @@ -120,10 +114,9 @@ pub async fn detach_lock_entry( name: &str, version: &str, dry_run: bool, -) -> Result { +) -> Result { let (path, mut doc) = read_lock(project_root).await?; - let idx = find_package_index(&doc, name, version).ok_or(LockEditError::EntryMissing)?; - let table = package_table_mut(&mut doc, idx)?; + let table = find_package_mut(&mut doc, name, version).ok_or(LockEditError::EntryMissing)?; // A workspace/path/git dependency has no `source` — vendoring it would be // wrong (the user already controls those bytes); refuse. @@ -142,7 +135,7 @@ pub async fn detach_lock_entry( if !dry_run { write_lock(&path, &doc).await?; } - Ok(LockEntryOriginal { source, checksum }) + Ok(CargoLockOriginal { source, checksum }) } /// Re-attach the original `source`/`checksum` to the `name`+`version` entry on @@ -154,14 +147,13 @@ pub async fn restore_lock_entry( project_root: &Path, name: &str, version: &str, - original: &LockEntryOriginal, + original: &CargoLockOriginal, dry_run: bool, ) -> Result { let (path, mut doc) = read_lock(project_root).await?; - let Some(idx) = find_package_index(&doc, name, version) else { + let Some(table) = find_package_mut(&mut doc, name, version) else { return Ok(false); }; - let table = package_table_mut(&mut doc, idx)?; if table.get("source").is_some() { return Ok(false); } @@ -196,10 +188,7 @@ pub async fn restore_lock_entry( /// Multi-version aware: a v4 lock may resolve the same name at several /// versions. Reads only the project lockfile: no registry, no network. pub async fn read_locked_versions(project_root: &Path) -> Option>> { - let content = tokio::fs::read_to_string(project_root.join("Cargo.lock")) - .await - .ok()?; - let doc = content.parse::().ok()?; + let (_path, doc) = read_lock(project_root).await.ok()?; let pkgs = doc.get("package")?.as_array_of_tables()?; let mut map: HashMap> = HashMap::new(); for t in pkgs.iter() { @@ -408,7 +397,7 @@ mod tests { #[tokio::test] async fn restore_skips_re_resolved_and_absent_entries() { let dir = fixture().await; - let orig = LockEntryOriginal { + let orig = CargoLockOriginal { source: SOURCE.to_string(), checksum: Some(CHECKSUM.to_string()), }; @@ -505,6 +494,95 @@ mod tests { assert!(read_locked_versions(empty.path()).await.is_none()); } + /// The lock is a user-owned committed file we merely edit: the atomic + /// rename must not reset its permission bits to umask defaults (a 0600 + /// private lock silently becoming 0644, a 0664 group-writable one locking + /// the group out). + #[cfg(unix)] + #[tokio::test] + async fn lock_edits_preserve_file_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = fixture().await; + let path = dir.path().join("Cargo.lock"); + tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .await + .unwrap(); + + let orig = detach_lock_entry(dir.path(), "cfg-if", "1.0.4", false) + .await + .unwrap(); + let mode = tokio::fs::metadata(&path) + .await + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!(mode, 0o600, "detach must not reset the lock's mode"); + + assert!( + restore_lock_entry(dir.path(), "cfg-if", "1.0.4", &orig, false) + .await + .unwrap() + ); + let mode = tokio::fs::metadata(&path) + .await + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!(mode, 0o600, "restore must not reset the lock's mode"); + } + + /// Round trip for a realistic entry: mid-file (another `[[package]]` + /// follows) and carrying a `dependencies` array, so restore's key re-sort + /// must slot source/checksum between `version` and `dependencies`. + #[tokio::test] + async fn round_trip_mid_file_entry_with_dependencies() { + let dir = tempfile::tempdir().unwrap(); + let body = format!( + "# This file is automatically @generated by Cargo.\n\ + # It is not intended for manual editing.\n\ + version = 4\n\ + \n\ + [[package]]\n\ + name = \"app\"\n\ + version = \"0.1.0\"\n\ + dependencies = [\n \"serde\",\n]\n\ + \n\ + [[package]]\n\ + name = \"serde\"\n\ + version = \"1.0.219\"\n\ + source = \"{SOURCE}\"\n\ + checksum = \"{CHECKSUM}\"\n\ + dependencies = [\n \"serde_derive\",\n]\n\ + \n\ + [[package]]\n\ + name = \"serde_derive\"\n\ + version = \"1.0.219\"\n\ + source = \"{SOURCE}\"\n\ + checksum = \"{CHECKSUM}\"\n" + ); + tokio::fs::write(dir.path().join("Cargo.lock"), &body) + .await + .unwrap(); + + let orig = detach_lock_entry(dir.path(), "serde", "1.0.219", false) + .await + .unwrap(); + assert!( + restore_lock_entry(dir.path(), "serde", "1.0.219", &orig, false) + .await + .unwrap() + ); + let after = tokio::fs::read_to_string(dir.path().join("Cargo.lock")) + .await + .unwrap(); + assert_eq!( + after, body, + "mid-file entry with dependencies must round-trip byte-identically" + ); + } + #[tokio::test] async fn edits_leave_no_stage_litter() { let dir = fixture().await; diff --git a/crates/socket-patch-core/src/patch/vendor/common.rs b/crates/socket-patch-core/src/patch/vendor/common.rs new file mode 100644 index 00000000..4838f474 --- /dev/null +++ b/crates/socket-patch-core/src/patch/vendor/common.rs @@ -0,0 +1,515 @@ +//! Leaf helpers shared by the vendor backends (and [`crate::patch::go_redirect`]). +//! +//! Each backend used to carry a private, byte-identical copy of these; they +//! are hoisted here so the shapes stay in lockstep. + +use std::collections::HashMap; +use std::path::Path; + +use serde_json::Value; +use toml_edit::{DocumentMut, Item, Table}; + +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::manifest::schema::PatchFileInfo; +use crate::patch::apply::{ + is_safe_relative_subpath, normalize_file_path, ApplyResult, VerifyResult, VerifyStatus, +}; +use crate::patch::file_hash::compute_file_git_sha256; +use crate::utils::fs::atomic_write_bytes_preserving_mode; + +use super::state::{VendorEntry, WiringAction, WiringRecord}; +use super::{RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; + +/// A [`VerifyResult`] reporting `file` as already patched. +fn already_patched_verify(file: &str) -> VerifyResult { + VerifyResult { + file: file.to_string(), + status: VerifyStatus::AlreadyPatched, + message: None, + current_hash: None, + expected_hash: None, + target_hash: None, + } +} + +/// Shared helper the vendor backends (and `go_redirect`) delegate to: a +/// success [`ApplyResult`] in which every patched file reads as +/// `AlreadyPatched`, synthesized without running the apply pipeline (the +/// in-sync hot paths, and the service-download paths where trust is the +/// verified artifact integrity rather than a local apply). +pub(crate) fn already_patched_result( + package_key: &str, + path: &Path, + files: &HashMap, +) -> ApplyResult { + let files_verified = files.keys().map(|f| already_patched_verify(f)).collect(); + synthesized_result(package_key, path, files_verified, true, None) +} + +/// Shared helper the vendor backends (and `go_redirect`) delegate to: an +/// [`ApplyResult`] synthesized without running the apply pipeline. +pub(crate) fn synthesized_result( + package_key: &str, + path: &Path, + files_verified: Vec, + success: bool, + error: Option, +) -> ApplyResult { + ApplyResult { + package_key: package_key.to_string(), + package_path: path.display().to_string(), + success, + files_verified, + files_patched: Vec::new(), + applied_via: HashMap::new(), + error, + sidecar: None, + } +} + +/// Shared helper the vendor backends delegate to: a [`VendorOutcome::Refused`]. +pub(crate) fn refused(code: &'static str, detail: impl Into) -> VendorOutcome { + VendorOutcome::Refused { + code, + detail: detail.into(), + } +} + +/// Shared helper the vendor backends delegate to: a [`VendorOutcome::Done`]. +pub(crate) fn done( + result: ApplyResult, + entry: Option, + warnings: Vec, +) -> VendorOutcome { + VendorOutcome::Done { + result, + entry, + warnings, + } +} + +/// Shared helper the vendor backends delegate to: the fail-closed refusal +/// for `--vendor-source=service` combined with `--offline`, checked before +/// any service consultation. +pub(crate) fn service_offline_conflict( + service: Option<&VendorServiceConfig>, +) -> Option { + let cfg = service?; + if cfg.source.requires_service() && cfg.offline { + return Some(refused( + "vendor_service_offline_conflict", + "--vendor-source=service needs the network but --offline is set", + )); + } + None +} + +/// Shared helper the vendor backends delegate to: an un-successful +/// [`ApplyResult`] carrying `error`, synthesized without running the apply +/// pipeline. +pub(crate) fn failed_result(package_key: &str, path: &Path, error: String) -> ApplyResult { + synthesized_result(package_key, path, Vec::new(), false, Some(error)) +} + +/// The file's indent unit: the leading whitespace of the first indented +/// line (npm emits 2 spaces; respect whatever formatter the project uses +/// so untouched lines stay byte-identical in diffs). Defaults to 2 spaces. +pub(crate) fn detect_indent(text: &str) -> String { + for line in text.lines() { + let trimmed = line.trim_start_matches([' ', '\t']); + if !trimmed.is_empty() && trimmed.len() < line.len() { + return line[..line.len() - trimmed.len()].to_string(); + } + } + " ".to_string() +} + +/// The file's dominant line terminator (new lines we write use it; bytes +/// outside edited spans keep whatever they had). +pub(crate) fn detect_eol(text: &str) -> &'static str { + if text.contains("\r\n") { + "\r\n" + } else { + "\n" + } +} + +/// Pretty-print JSON with `indent` + a trailing newline (the shape npm and +/// composer themselves emit), so untouched keys stay byte-identical and a +/// later `npm install` / `composer update` produces no format-only churn. +pub(crate) fn serialize_json(value: &Value, indent: &str) -> std::io::Result> { + use serde::Serialize; + let mut out = Vec::new(); + let formatter = serde_json::ser::PrettyFormatter::with_indent(indent.as_bytes()); + let mut ser = serde_json::Serializer::with_formatter(&mut out, formatter); + value.serialize(&mut ser).map_err(std::io::Error::other)?; + out.push(b'\n'); + Ok(out) +} + +/// Serialize `(name, bytes, unix mode)` entries — in the given order — into +/// a deterministic zip: a fixed DOS timestamp (1980-01-01 00:00:00) and a +/// fixed deflate level, so rebuilding the same content always yields +/// identical bytes (churn-free commits, stable checksums). +pub(crate) fn write_zip_entries(entries: &[(String, Vec, u32)]) -> Result, String> { + use std::io::Write as _; + + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes, mode) in entries { + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .compression_level(Some(6)) + .last_modified_time(zip::DateTime::default()) + .unix_permissions(*mode); + writer + .start_file(name, options) + .map_err(|e| format!("zip start {name}: {e}"))?; + writer + .write_all(bytes) + .map_err(|e| format!("zip write {name}: {e}"))?; + } + let cursor = writer.finish().map_err(|e| format!("zip finish: {e}"))?; + Ok(cursor.into_inner()) +} + +/// True when `metadata`'s unix mode carries any exec bit (always false on +/// non-unix, where archive modes are normalized at pack time instead). +pub(crate) fn is_executable(metadata: &std::fs::Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + let _ = metadata; + false + } +} + +/// Re-zip a patched stage into a deterministic archive (see +/// [`write_zip_entries`]) with entries sorted lexicographically. Both +/// consumers (`.jar` / `.nupkg`) are plain zips whose resolvers read the +/// central directory, so entry order is free to be lexicographic. +/// `skip_entry` drops one archive-relative name (NuGet's `.signature.p7s` — +/// the content changed, so the rebuilt package must read as unsigned). +pub(crate) fn rebuild_zip(stage: &Path, skip_entry: Option<&str>) -> Result, String> { + let mut entries: Vec<(String, Vec, u32)> = Vec::new(); + for entry in walkdir::WalkDir::new(stage).follow_links(false) { + let entry = entry.map_err(|e| format!("walk {}: {e}", stage.display()))?; + if !entry.file_type().is_file() { + continue; + } + let rel = entry + .path() + .strip_prefix(stage) + .map_err(|e| format!("strip prefix: {e}"))?; + let name = rel.to_string_lossy().replace('\\', "/"); + if skip_entry == Some(name.as_str()) { + continue; + } + let bytes = std::fs::read(entry.path()).map_err(|e| format!("read {name}: {e}"))?; + entries.push((name, bytes, 0o644)); + } + entries.sort_by(|a, b| a.0.cmp(&b.0)); + write_zip_entries(&entries) +} + +/// True when the committed archive (a plain zip: `.jar` / `.nupkg`) exists and +/// every patched file in it already hashes to its `afterHash` (the zip twin of +/// [`copy_matches_after_hashes`], reading the archive's entries). +pub(crate) async fn zip_matches_after_hashes( + archive_path: &Path, + files: &HashMap, +) -> bool { + use std::io::Read as _; + + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + let Ok(bytes) = tokio::fs::read(archive_path).await else { + return false; + }; + let Ok(mut archive) = zip::ZipArchive::new(std::io::Cursor::new(bytes)) else { + return false; + }; + for (file_name, info) in files { + let normalized = normalize_file_path(file_name); + // SECURITY: never look up a key that escapes the package dir — treat + // it as out-of-sync (the full pipeline would refuse it anyway). + if !is_safe_relative_subpath(normalized) { + return false; + } + let Ok(mut entry) = archive.by_name(normalized) else { + return false; + }; + let mut content = Vec::with_capacity(entry.size() as usize); + if entry.read_to_end(&mut content).is_err() { + return false; + } + if compute_git_sha256_from_bytes(&content) != info.after_hash { + return false; + } + } + true +} + +/// Shared helper the vendor backends (and `go_redirect`) delegate to: true +/// when the copy exists and every patched file in it already hashes to its +/// `afterHash`. +pub(crate) async fn copy_matches_after_hashes( + copy_dir: &Path, + files: &HashMap, +) -> bool { + if tokio::fs::metadata(copy_dir).await.is_err() { + return false; + } + for (file_name, info) in files { + let normalized = normalize_file_path(file_name); + // SECURITY: never hash through a manifest key that escapes the copy + // dir — fail the sync check instead (the full pipeline would refuse + // the key anyway). + if !is_safe_relative_subpath(normalized) { + return false; + } + match compute_file_git_sha256(©_dir.join(normalized)).await { + Ok(h) if h == info.after_hash => {} + _ => return false, + } + } + true +} + +/// Shared [`WiringRecord`] constructor for the lock-splicing backends: +/// `original`/`new` are verbatim text fragments of `file`. +pub(crate) fn record( + file: &str, + kind: &str, + action: WiringAction, + key: &str, + original: Option, + new: String, +) -> WiringRecord { + WiringRecord { + file: file.to_string(), + kind: kind.to_string(), + action, + key: Some(key.to_string()), + original: original.map(Value::String), + new: Some(Value::String(new)), + } +} + +/// `key` looked up through any table-like TOML item (standard or inline +/// table). +pub(crate) fn item_get<'a>(item: &'a Item, key: &str) -> Option<&'a Item> { + item.as_table_like().and_then(|t| t.get(key)) +} + +/// Leading PEP 508 distribution name of a dependency spec. +pub(crate) fn pep508_name(spec: &str) -> &str { + let s = spec.trim_start(); + let end = s + .char_indices() + .find(|(_, c)| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))) + .map(|(i, _)| i) + .unwrap_or(s.len()); + &s[..end] +} + +/// Whether a `[[package]]` unit (as its lines) names `canon` — PEP 503 +/// canonical comparison, the form the pypi lock generators record. +pub(crate) fn unit_has_canon_name(lines: &[&str], canon: &str) -> bool { + lines + .iter() + .find_map(|l| l.strip_prefix("name = ")) + .map(|r| canonicalize_pypi_name(r.trim().trim_matches('"'))) + .as_deref() + == Some(canon) +} + +/// The lock's `[[package]]` tables whose `name` canonicalizes (PEP 503) to +/// `canon_name` — the poetry/pdm target-guard probe (uv records names +/// pre-canonicalized and counts them directly instead). +pub(crate) fn lock_units_named<'a>(lock: &'a DocumentMut, canon_name: &str) -> Vec<&'a Table> { + lock.get("package") + .and_then(Item::as_array_of_tables) + .map(|pkgs| { + pkgs.iter() + .filter(|t| { + t.get("name") + .and_then(Item::as_str) + .map(canonicalize_pypi_name) + .as_deref() + == Some(canon_name) + }) + .collect() + }) + .unwrap_or_default() +} + +/// Collect the PEP 621 `[project] dependencies` / `optional-dependencies` +/// distribution names into `declared` — the pyproject surface shared by the +/// poetry/pdm/uv dep classifiers (each adds its tool-specific tables on top). +pub(crate) fn pep621_declared_names(doc: &DocumentMut, declared: &mut Vec) { + let Some(project) = doc.get("project") else { + return; + }; + if let Some(deps) = item_get(project, "dependencies").and_then(Item::as_array) { + declared.extend( + deps.iter() + .filter_map(toml_edit::Value::as_str) + .map(|s| pep508_name(s).to_string()), + ); + } + if let Some(optional) = item_get(project, "optional-dependencies").and_then(Item::as_table_like) + { + for (_, item) in optional.iter() { + if let Some(arr) = item.as_array() { + declared.extend( + arr.iter() + .filter_map(toml_edit::Value::as_str) + .map(|s| pep508_name(s).to_string()), + ); + } + } + } +} + +/// Shared revert for the single-file, single-kind lock-splice backends +/// (poetry/pdm): restore the verbatim original fragment each wiring record +/// holds for `lock_file`. A fragment that no longer matches what we wrote is +/// left alone with a `vendor_lock_entry_drifted` warning — revert never +/// clobbers third-party edits. +pub(crate) async fn revert_lock_fragment_splice( + entry: &VendorEntry, + root: &Path, + dry_run: bool, + lock_file: &str, + kind: &str, + flavor: &str, +) -> RevertOutcome { + let lock_path = root.join(lock_file); + let mut lock_text = match tokio::fs::read_to_string(&lock_path).await { + Ok(t) => t, + Err(e) => return RevertOutcome::failed(format!("cannot read {lock_file}: {e}")), + }; + let mut warnings: Vec = Vec::new(); + + for rec in entry.wiring.iter().rev() { + // SECURITY: `rec.file` comes verbatim from the committed, tamper-able + // state.json. These backends only ever wrote their single lock file + // (the per-flavor file allowlist); any other recorded path is skipped + // fail-closed with a warning and is NEVER resolved against the + // filesystem. + if rec.file != lock_file { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "ignoring wiring record for unexpected file `{}` (only {lock_file} is \ + {flavor}-owned)", + rec.file + ), + )); + continue; + } + // Forward compatibility: a newer ledger's unknown kind degrades to a + // warning (never guess at a fragment shape). + if rec.kind != kind { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("unknown {flavor} wiring kind {:?}; skipped", rec.kind), + )); + continue; + } + let new_text = rec.new.as_ref().and_then(Value::as_str); + let original_text = rec.original.as_ref().and_then(Value::as_str); + match super::toml_surgery::replace_fragment(&lock_text, new_text, original_text) { + Some(t) => lock_text = t, + None => warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "{lock_file} fragment for {:?} changed since vendoring; left untouched", + rec.key + ), + )), + } + } + + if !dry_run { + // Mode-preserving: the lock is a user-owned file we merely edit, so + // the swapped-in inode must keep its permission bits rather than + // reset them to umask defaults. + if let Err(e) = atomic_write_bytes_preserving_mode(&lock_path, lock_text.as_bytes()).await { + return RevertOutcome { + success: false, + warnings, + error: Some(format!("cannot write {lock_file}: {e}")), + }; + } + } + RevertOutcome { + success: true, + warnings, + error: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The lock file is user-owned: reverting the splice must not reset its + /// permission bits (the `package_json/update.rs` mode-reset bug, same + /// class — see `atomic_write_bytes_preserving_mode`). + #[cfg(unix)] + #[tokio::test] + async fn revert_lock_fragment_splice_preserves_lock_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let lock = dir.path().join("poetry.lock"); + tokio::fs::write(&lock, "alpha\nNEW-FRAGMENT\nomega\n") + .await + .unwrap(); + let mut perms = std::fs::metadata(&lock).unwrap().permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(&lock, perms).unwrap(); + + let mut entry: VendorEntry = serde_json::from_value(serde_json::json!({ + "ecosystem": "pypi", + "basePurl": "pkg:pypi/six@1.16.0", + "uuid": "u", + "artifact": {"path": ".socket/vendor/pypi/u/x.whl"}, + "wiring": [], + })) + .unwrap(); + entry.wiring = vec![record( + "poetry.lock", + "poetry_lock_package", + WiringAction::Rewritten, + "six", + Some("OLD-FRAGMENT".into()), + "NEW-FRAGMENT".into(), + )]; + + let outcome = revert_lock_fragment_splice( + &entry, + dir.path(), + false, + "poetry.lock", + "poetry_lock_package", + "poetry", + ) + .await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!( + tokio::fs::read_to_string(&lock).await.unwrap(), + "alpha\nOLD-FRAGMENT\nomega\n", + "fragment restored" + ); + let mode = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "revert must preserve the lock file's permission bits" + ); + } +} diff --git a/crates/socket-patch-core/src/patch/vendor/composer_lock.rs b/crates/socket-patch-core/src/patch/vendor/composer_lock.rs index 0931857e..b03ad790 100644 --- a/crates/socket-patch-core/src/patch/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/composer_lock.rs @@ -27,23 +27,22 @@ //! (`JSON_PRETTY_PRINT`) + trailing newline; serde_json does not escape `/` //! (matching `JSON_UNESCAPED_SLASHES`). -use std::collections::HashMap; use std::path::Path; -use serde::Serialize; use serde_json::{json, Map, Value}; -use crate::manifest::schema::{PatchFileInfo, PatchRecord}; -use crate::patch::apply::{ - is_safe_relative_subpath, normalize_file_path, ApplyResult, PatchSources, VerifyResult, - VerifyStatus, -}; +use crate::crawlers::composer_crawler::normalize_version; +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::{fresh_copy, remove_tree}; -use crate::patch::file_hash::compute_file_git_sha256; use crate::patch::path_safety::{is_safe_multi_segment, is_safe_single_segment}; -use crate::utils::fs::atomic_write_bytes; +use crate::utils::fs::atomic_write_bytes_preserving_mode; use crate::utils::purl::{build_composer_purl, parse_composer_purl}; +use super::common::{ + already_patched_result, copy_matches_after_hashes, done, refused, serialize_json, + service_offline_conflict, synthesized_result, +}; use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; use super::registry_fetch::extract_zip; use super::service_fetch::{fetch_verified_archive, ServiceArtifact}; @@ -62,23 +61,6 @@ const COMPOSER_LOCK: &str = "composer.lock"; /// package name, so the encoding is unambiguous. const WIRING_KIND: &str = "composer_lock_package"; -/// Marker schema version written into `socket-patch.vendor.json`. -const MARKER_SCHEMA_VERSION: u32 = 1; - -/// Normalize a composer version for identity comparison: strip a single -/// leading `v`/`V` when it directly precedes a digit (`v6.4.1` → `6.4.1`). -/// Local twin of the private `crawlers::composer_crawler::normalize_version` -/// (not visible from here); keep the two in sync. -fn normalize_version(version: &str) -> &str { - let mut chars = version.chars(); - if matches!(chars.next(), Some('v') | Some('V')) - && chars.next().map(|c| c.is_ascii_digit()).unwrap_or(false) - { - return &version[1..]; - } - version -} - /// Vendor a composer package: materialize a patched copy under /// `.socket/vendor/composer///@` and rewire the /// matching `composer.lock` entry at it (see the module doc for the surgery). @@ -133,11 +115,8 @@ pub async fn vendor_composer( // A patch with no files is meaningless to vendor: no-op success, no edits. if record.files.is_empty() { - return VendorOutcome::Done { - result: synthesized_result(purl, ©_dir, Vec::new(), true, None), - entry: None, - warnings: Vec::new(), - }; + let result = synthesized_result(purl, ©_dir, Vec::new(), true, None); + return done(result, None, Vec::new()); } // ── lock presence + entry ──────────────────────────────────────────── @@ -181,60 +160,53 @@ pub async fn vendor_composer( // verbatim pre-vendor original, and re-recording here would clobber it. if entry_is_wired(&lock[section][idx], ©_rel) { if copy_matches_after_hashes(©_dir, &record.files).await { - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); - return VendorOutcome::Done { - result: synthesized_result(purl, ©_dir, verified, true, None), - entry: None, - warnings: Vec::new(), - }; + let result = already_patched_result(purl, ©_dir, &record.files); + return done(result, None, Vec::new()); } // Wired but the committed copy is missing/stale: rebuild the // ARTIFACT only. The lock is already correct and the first run's // ledger entry holds the only pre-vendor original — running the // full path here would re-record the live VENDORED fragment as - // `original`, breaking a later `--revert`. + // `original`, breaking a later `--revert`. Service-preferred like + // the full path (a service-vendored package may have no installed + // copy to rebuild from — only the service can). if !dry_run { - if let Err(e) = fresh_copy(installed_dir, ©_dir, None).await { - return VendorOutcome::Done { - result: synthesized_result( - purl, - ©_dir, - Vec::new(), - false, - Some(format!("failed to copy installed package: {e}")), - ), - entry: None, - warnings: Vec::new(), - }; + if let Some(refusal) = service_offline_conflict(service) { + return refusal; } let mut warnings: Vec = Vec::new(); - let mut result = super::force_apply_staged( - purl, - ©_dir, + let result = match composer_service_copy( + service, record, - sources, - false, - force, &pkg, - version, + ©_dir, + &uuid_dir, &mut warnings, ) - .await; - result.package_path = copy_dir.display().to_string(); - if !result.success { - // Don't leave a half-built copy; the pre-state was already - // broken, so removing restores the (missing) status quo. - let _ = remove_tree(&uuid_dir).await; - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; - } + .await + { + ComposerServiceCopy::Used => already_patched_result(purl, ©_dir, &record.files), + ComposerServiceCopy::HardFail(outcome) => return *outcome, + ComposerServiceCopy::FallBack => { + match copy_and_patch( + purl, + installed_dir, + ©_dir, + &uuid_dir, + record, + sources, + force, + &pkg, + version, + &mut warnings, + ) + .await + { + Ok(result) => result, + Err(result) => return done(result, None, warnings), + } + } + }; warnings.push(VendorWarning::new( "vendor_artifact_rebuilt", format!( @@ -242,11 +214,7 @@ pub async fn vendor_composer( rebuilt at {copy_rel} (composer.lock untouched)" ), )); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } // Dry runs fall through to the verify-only preview below. } @@ -267,11 +235,7 @@ pub async fn vendor_composer( ) .await; result.package_path = copy_dir.display().to_string(); - return VendorOutcome::Done { - result, - entry: None, - warnings: dry_warnings, - }; + return done(result, None, dry_warnings); } // ── copy + patch (wiring last) ─────────────────────────────────────── @@ -279,64 +243,33 @@ pub async fn vendor_composer( // no installed package needed); else copy the installed package and patch // it. let mut warnings: Vec = Vec::new(); - if let Some(cfg) = service { - if cfg.source.requires_service() && cfg.offline { - return refused( - "vendor_service_offline_conflict", - "--vendor-source=service needs the network but --offline is set", - ); - } + if let Some(refusal) = service_offline_conflict(service) { + return refusal; } let mut result = match composer_service_copy(service, record, &pkg, ©_dir, &uuid_dir, &mut warnings) .await { - ComposerServiceCopy::Used => { - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); - synthesized_result(purl, ©_dir, verified, true, None) - } + ComposerServiceCopy::Used => already_patched_result(purl, ©_dir, &record.files), ComposerServiceCopy::HardFail(outcome) => return *outcome, ComposerServiceCopy::FallBack => { - if let Err(e) = fresh_copy(installed_dir, ©_dir, None).await { - return VendorOutcome::Done { - result: synthesized_result( - purl, - ©_dir, - Vec::new(), - false, - Some(format!("failed to copy installed package: {e}")), - ), - entry: None, - warnings, - }; - } - let mut result = super::force_apply_staged( + match copy_and_patch( purl, + installed_dir, ©_dir, + &uuid_dir, record, sources, - false, force, &pkg, version, &mut warnings, ) - .await; - result.package_path = copy_dir.display().to_string(); - if !result.success { - // Don't leave a half-built copy; the lock was never touched. - let _ = remove_tree(&uuid_dir).await; - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + .await + { + Ok(result) => result, + Err(result) => return done(result, None, warnings), } - result } }; @@ -347,41 +280,35 @@ pub async fn vendor_composer( let _ = remove_tree(&uuid_dir).await; result.success = false; result.error = Some("composer.lock entry is not a JSON object".to_string()); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); }; + // Never record one of our own (stale) edits as the "original" — revert + // must restore the pre-vendor registry fragment, not a dangling + // `.socket/vendor/` pointer from an earlier uuid. The persist layer + // carries the true original forward from the entry being replaced when + // the record holds `None`. + let was_vendored = original_obj + .get("dist") + .and_then(|d| d.get("url")) + .and_then(Value::as_str) + .and_then(parse_vendor_path) + .is_some_and(|p| p.eco == "composer"); let rewritten = rewrite_lock_entry(original_obj, ©_rel, &record.uuid); lock[section][idx] = Value::Object(rewritten.clone()); let write_result = match composer_json_bytes(&lock) { - Ok(bytes) => atomic_write_bytes(&lock_path, &bytes).await, + Ok(bytes) => atomic_write_bytes_preserving_mode(&lock_path, &bytes).await, Err(e) => Err(e), }; if let Err(e) = write_result { let _ = remove_tree(&uuid_dir).await; result.success = false; result.error = Some(format!("failed to write composer.lock: {e}")); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } // ── marker + ledger entry ──────────────────────────────────────────── let base_purl = build_composer_purl(&vendor, &name, version); - let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); - vulnerabilities.sort(); - let marker = VendorMarker { - schema_version: MARKER_SCHEMA_VERSION, - purl: base_purl.clone(), - patch_uuid: record.uuid.clone(), - ecosystem: "composer".to_string(), - vulnerabilities, - vendored_at: vendored_at.to_string(), - }; + let marker = VendorMarker::new("composer", &base_purl, record, vendored_at); if let Err(e) = write_marker(&uuid_dir, &marker).await { // The marker is informational only (state.json is the ledger of // record), so its failure must not fail an otherwise-wired vendor. @@ -406,7 +333,7 @@ pub async fn vendor_composer( kind: WIRING_KIND.to_string(), action: WiringAction::Rewritten, key: Some(format!("{section}:{pkg}")), - original: Some(original_entry), + original: (!was_vendored).then_some(original_entry), new: Some(Value::Object(rewritten)), }], lock: None, @@ -421,11 +348,7 @@ pub async fn vendor_composer( pipenv: None, }; - VendorOutcome::Done { - result, - entry: Some(entry), - warnings, - } + done(result, Some(entry), warnings) } /// Revert a composer vendor entry: restore the verbatim original lock entry @@ -515,6 +438,47 @@ pub async fn revert_composer( // ── helpers ────────────────────────────────────────────────────────────────── +/// Copy the installed package into `copy_dir` and run the hardened apply +/// pipeline against it (vendor auto-force policy — see +/// [`super::force_apply_staged`]). On apply failure the whole uuid dir is +/// removed — a partial copy under `.socket/vendor/` would be misjudged by +/// verify/sweep — and the failed [`ApplyResult`] is the `Err` for the caller +/// to bubble (composer.lock is only ever edited after this succeeds). +#[allow(clippy::too_many_arguments)] +async fn copy_and_patch( + purl: &str, + installed_dir: &Path, + copy_dir: &Path, + uuid_dir: &Path, + record: &PatchRecord, + sources: &PatchSources<'_>, + force: bool, + pkg: &str, + version: &str, + warnings: &mut Vec, +) -> Result { + if let Err(e) = fresh_copy(installed_dir, copy_dir, None).await { + return Err(synthesized_result( + purl, + copy_dir, + Vec::new(), + false, + Some(format!("failed to copy installed package: {e}")), + )); + } + let mut result = super::force_apply_staged( + purl, copy_dir, record, sources, false, force, pkg, version, warnings, + ) + .await; + result.package_path = copy_dir.display().to_string(); + if !result.success { + // Don't leave a half-built copy under `.socket/vendor/`. + let _ = remove_tree(uuid_dir).await; + return Err(result); + } + Ok(result) +} + /// Outcome of attempting to materialise the composer copy from the patch service. enum ComposerServiceCopy { /// The prebuilt dist zip was extracted into `copy_dir`. @@ -540,11 +504,11 @@ async fn composer_service_copy( let Some(cfg) = service else { return ComposerServiceCopy::FallBack; }; - if !cfg.service_enabled() || record.files.is_empty() { + if !cfg.service_enabled() { return ComposerServiceCopy::FallBack; } fn hard(code: &'static str, detail: String) -> ComposerServiceCopy { - ComposerServiceCopy::HardFail(Box::new(VendorOutcome::Refused { code, detail })) + ComposerServiceCopy::HardFail(Box::new(refused(code, detail))) } let miss = |warnings: &mut Vec, code: &'static str, reason: String| { if cfg.source.requires_service() { @@ -557,7 +521,7 @@ async fn composer_service_copy( ComposerServiceCopy::FallBack } }; - match fetch_verified_archive(cfg, &record.uuid, pkg).await { + match fetch_verified_archive(cfg, &record.uuid).await { ServiceArtifact::Ready(archive) => { let _ = remove_tree(copy_dir).await; if let Err(e) = tokio::fs::create_dir_all(copy_dir).await { @@ -611,13 +575,6 @@ async fn composer_service_copy( } } -fn refused(code: &'static str, detail: impl Into) -> VendorOutcome { - VendorOutcome::Refused { - code, - detail: detail.into(), - } -} - /// Locate the package's entry: `packages[]` first, then `packages-dev[]`. /// Names are compared case-insensitively, versions through the `v`-prefix /// normalization (see module doc). @@ -695,37 +652,7 @@ fn rewrite_lock_entry( /// (`JSON_PRETTY_PRINT`) + trailing newline. serde_json never escapes `/`, /// matching `JSON_UNESCAPED_SLASHES`. fn composer_json_bytes(value: &Value) -> std::io::Result> { - let mut buf = Vec::new(); - let fmt = serde_json::ser::PrettyFormatter::with_indent(b" "); - let mut ser = serde_json::Serializer::with_formatter(&mut buf, fmt); - value.serialize(&mut ser).map_err(std::io::Error::other)?; - buf.push(b'\n'); - Ok(buf) -} - -/// True when the copy exists and every patched file in it already hashes to -/// its `afterHash` (the vendor twin of `go_redirect::redirect_in_sync`). -async fn copy_matches_after_hashes( - copy_dir: &Path, - files: &HashMap, -) -> bool { - if tokio::fs::metadata(copy_dir).await.is_err() { - return false; - } - for (file_name, info) in files { - let normalized = normalize_file_path(file_name); - // SECURITY: never hash through a manifest key that escapes the copy - // dir — fail the sync check instead (the full pipeline would refuse - // the key anyway). - if !is_safe_relative_subpath(normalized) { - return false; - } - match compute_file_git_sha256(©_dir.join(normalized)).await { - Ok(h) if h == info.after_hash => {} - _ => return false, - } - } - true + serialize_json(value, " ") } /// Restore one `composer_lock_package` wiring record. `Ok(true)` = restored @@ -787,48 +714,21 @@ async fn restore_lock_entry( if !dry_run { lock[section][idx] = original; let bytes = composer_json_bytes(&lock).map_err(|e| e.to_string())?; - atomic_write_bytes(lock_path, &bytes) + atomic_write_bytes_preserving_mode(lock_path, &bytes) .await .map_err(|e| format!("failed to write composer.lock: {e}"))?; } Ok(true) } -fn synthesized_result( - package_key: &str, - copy_dir: &Path, - files_verified: Vec, - success: bool, - error: Option, -) -> ApplyResult { - ApplyResult { - package_key: package_key.to_string(), - package_path: copy_dir.display().to_string(), - success, - files_verified, - files_patched: Vec::new(), - applied_via: HashMap::new(), - error, - sidecar: None, - } -} - -fn already_patched_verify(file: &str) -> VerifyResult { - VerifyResult { - file: file.to_string(), - status: VerifyStatus::AlreadyPatched, - message: None, - current_hash: None, - expected_hash: None, - target_hash: None, - } -} - #[cfg(test)] mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::manifest::schema::PatchFileInfo; + use crate::patch::apply::{ApplyResult, VerifyStatus}; use crate::patch::vendor::state::VENDOR_MARKER_FILE; + use std::collections::HashMap; use std::path::PathBuf; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; @@ -1661,6 +1561,160 @@ mod tests { ); } + /// The vendor rewrite and the revert restore swap `composer.lock`'s inode; + /// both must keep the user's permission bits (a 0640 lock silently + /// becoming umask-default 0644 leaks group/other access the user removed). + #[cfg(unix)] + #[tokio::test] + async fn test_lock_write_preserves_file_mode() { + use std::os::unix::fs::PermissionsExt; + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + let lock_path = root.join(COMPOSER_LOCK); + tokio::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o640)) + .await + .unwrap(); + + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(result.success, "{:?}", result.error); + let mode = tokio::fs::metadata(&lock_path) + .await + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o640, "vendor rewrite must keep composer.lock's mode"); + + let outcome = revert_composer(&entry.unwrap(), root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + let mode = tokio::fs::metadata(&lock_path) + .await + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o640, "revert restore must keep composer.lock's mode"); + } + + /// Re-vendor under a NEW patch uuid (a patch update taking over the + /// entry): the wiring must record `original: None` — never the previous + /// run's own stale path dist. The persist layer carries the true + /// pre-vendor original forward from the entry being replaced and sweeps + /// the old uuid dir, so a recorded stale dist would make a later + /// `--revert` restore a dangling `.socket/vendor/composer/` pointer. + #[tokio::test] + async fn test_takeover_rerun_never_records_own_wiring_as_original() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + + let (r1, e1, _) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(r1.success, "{:?}", r1.error); + assert!(e1.is_some()); + + const UUID_B: &str = "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"; + let mut record_b = record.clone(); + record_b.uuid = UUID_B.to_string(); + let (r2, e2, _) = + unwrap_done(run_vendor(root, &blobs, &installed, &record_b, PURL, false).await); + assert!(r2.success, "{:?}", r2.error); + let e2 = e2.expect("takeover records a fresh entry"); + let w = &e2.wiring[0]; + assert_eq!(w.key.as_deref(), Some("packages:psr/log")); + assert!( + w.original.is_none(), + "own stale wiring must never be recorded as original: {:?}", + w.original + ); + + // The lock is rewired at the new uuid's copy. + let new_lock: Value = serde_json::from_str( + &tokio::fs::read_to_string(root.join(COMPOSER_LOCK)) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!( + new_lock["packages"][0]["dist"]["url"], + format!(".socket/vendor/composer/{UUID_B}/psr/log@3.0.2") + ); + } + + /// Wired lock + missing copy + `--vendor-source=service`: the artifact + /// rebuild must be service-preferred like the full path (a + /// service-vendored package may have no installed copy to rebuild from), + /// and `--offline` + `service` must refuse in the rebuild path too. + #[tokio::test] + async fn service_rebuild_of_missing_copy_uses_service() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, _installed, record) = fixture(&lock).await; + let root = dir.path(); + let zip = make_dist_zip( + "php-fig-log-f16e1d5", + &[ + ("src/LoggerInterface.php", PATCHED), + ("composer.json", b"{\"name\": \"psr/log\"}\n"), + ], + ); + let sri = sri_sha512(&zip); + let server = wiremock::MockServer::start().await; + mount_composer_granted(&server, &sri, &zip).await; + let cfg = composer_service_cfg(&server.uri(), VendorSource::Service, false); + let bogus_installed = root.join("no-such-install"); + + let (r1, e1, _) = + unwrap_done(vendor_with_service(root, &blobs, &bogus_installed, &record, &cfg).await); + assert!(r1.success, "{:?}", r1.error); + assert!(e1.is_some()); + let lock_bytes = tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(); + + // Fresh-clone hole: the committed copy is gone, the lock still wired. + crate::patch::copy_tree::remove_tree(&root.join(copy_rel())) + .await + .unwrap(); + + let (r2, e2, w2) = + unwrap_done(vendor_with_service(root, &blobs, &bogus_installed, &record, &cfg).await); + assert!( + r2.success, + "service-mode rebuild must re-download the prebuilt dist: {:?}", + r2.error + ); + assert!(e2.is_none(), "artifact-only rebuild must not re-record"); + assert!( + w2.iter().any(|w| w.code == "vendor_artifact_rebuilt"), + "{w2:?}" + ); + assert!( + w2.iter().any(|w| w.code == "vendor_prebuilt_downloaded"), + "{w2:?}" + ); + assert_eq!( + tokio::fs::read(root.join(copy_rel()).join("src/LoggerInterface.php")) + .await + .unwrap(), + PATCHED + ); + assert_eq!( + tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), + lock_bytes, + "composer.lock untouched by the rebuild" + ); + + // The offline+service conflict is refused in the rebuild path too. + crate::patch::copy_tree::remove_tree(&root.join(copy_rel())) + .await + .unwrap(); + let offline = composer_service_cfg(&server.uri(), VendorSource::Service, true); + let (code, _) = unwrap_refused( + vendor_with_service(root, &blobs, &bogus_installed, &record, &offline).await, + ); + assert_eq!(code, "vendor_service_offline_conflict"); + } + /// `--offline` + `--vendor-source=service` refuses without any network. #[tokio::test] async fn offline_service_mode_refuses() { diff --git a/crates/socket-patch-core/src/patch/vendor/gem.rs b/crates/socket-patch-core/src/patch/vendor/gem.rs index 7a11550d..f19acf69 100644 --- a/crates/socket-patch-core/src/patch/vendor/gem.rs +++ b/crates/socket-patch-core/src/patch/vendor/gem.rs @@ -34,8 +34,9 @@ //! hard-fails `BUNDLE_FROZEN=true bundle install` (exit 16). //! //! The Gemfile gains `path:` on the gem's declaration (rewritten in place when -//! it is a statically-parseable single top-level line, quote style preserved) -//! or, for a transitive dependency, a managed block appended at EOF. Anything +//! it is a statically-parseable single top-level line, quote style and +//! trailing options like `require: false` preserved) or, for a transitive +//! dependency, a managed block appended at EOF. Anything //! the conservative line grammar cannot prove safe to rewrite is REFUSED — //! never guessed at. //! @@ -46,22 +47,22 @@ //! sources and the missing `.so` only fails at `require` time with a //! confusing error — refusing up front is the honest failure. -use std::collections::HashMap; use std::path::Path; use serde_json::Value; -use crate::manifest::schema::{PatchFileInfo, PatchRecord}; -use crate::patch::apply::{ - is_safe_relative_subpath, normalize_file_path, ApplyResult, PatchSources, VerifyResult, - VerifyStatus, -}; +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::{fresh_copy, remove_tree}; -use crate::patch::file_hash::compute_file_git_sha256; use crate::patch::path_safety::is_safe_single_segment; -use crate::utils::fs::atomic_write_bytes; +use crate::patch::redirect::gem_line_trailing_options; +use crate::utils::fs::atomic_write_bytes_preserving_mode; use crate::utils::purl::{build_gem_purl, parse_gem_purl}; +use super::common::{ + already_patched_result, copy_matches_after_hashes, done, refused, service_offline_conflict, + synthesized_result, +}; use super::path::vendor_uuid_dir_rel; use super::registry_fetch::extract_gem_data; use super::service_fetch::{ @@ -98,9 +99,6 @@ const LOCK_CHECKSUM_WIRING_KIND: &str = "gemfile_lock_checksum"; const MANAGED_OPEN: &str = "# >>> socket-patch vendor (managed) >>>"; const MANAGED_CLOSE: &str = "# <<< socket-patch vendor (managed) <<<"; -/// Marker schema version written into `socket-patch.vendor.json`. -const MARKER_SCHEMA_VERSION: u32 = 1; - /// Vendor a gem: materialize a patched copy (plus its stub gemspec) under /// `.socket/vendor/gem//-` and pair-edit Gemfile + /// Gemfile.lock at it (see the module doc). @@ -168,11 +166,11 @@ pub async fn vendor_gem( // A patch with no files is meaningless to vendor: no-op success, no edits. if record.files.is_empty() { - return VendorOutcome::Done { - result: synthesized_result(purl, ©_dir, Vec::new(), true, None), - entry: None, - warnings: Vec::new(), - }; + return done( + synthesized_result(purl, ©_dir, Vec::new(), true, None), + None, + Vec::new(), + ); } // Platform-suffixed installs (`--x86_64-linux`) ship @@ -264,16 +262,11 @@ pub async fn vendor_gem( if lock_wired { if lock_checksum_in_sync(&lock_text, name, version) { if copy_ok { - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); - return VendorOutcome::Done { - result: synthesized_result(purl, ©_dir, verified, true, None), - entry: None, - warnings: Vec::new(), - }; + return done( + already_patched_result(purl, ©_dir, &record.files), + None, + Vec::new(), + ); } // Wired (Gemfile + lock + CHECKSUMS) but the committed copy is // missing/stale: rebuild the ARTIFACT only — the pair edit is @@ -282,14 +275,8 @@ pub async fn vendor_gem( // Service-preferred like the full path (an auto-fetched gem has no // local stub to rebuild from — only the service can). if !dry_run { - if let Some(cfg) = service { - if cfg.source.requires_service() && cfg.offline { - return refused( - "vendor_service_offline_conflict", - "--vendor-source=service needs the network but --offline is set" - .to_string(), - ); - } + if let Some(refusal) = service_offline_conflict(service) { + return refusal; } let mut warnings: Vec = Vec::new(); let result = match materialise_patched_copy( @@ -311,25 +298,16 @@ pub async fn vendor_gem( Ok(result) => result, Err(outcome) => return *outcome, }; - if !result.success { - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + if result.success { + warnings.push(VendorWarning::new( + "vendor_artifact_rebuilt", + format!( + "the committed vendored copy for {name}@{version} was missing or \ + stale; rebuilt at {copy_rel} (Gemfile and Gemfile.lock untouched)" + ), + )); } - warnings.push(VendorWarning::new( - "vendor_artifact_rebuilt", - format!( - "the committed vendored copy for {name}@{version} was missing or \ - stale; rebuilt at {copy_rel} (Gemfile and Gemfile.lock untouched)" - ), - )); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } // Dry runs fall through to the verify-only preview below. } else { @@ -367,11 +345,7 @@ pub async fn vendor_gem( ) .await; result.package_path = copy_dir.display().to_string(); - return VendorOutcome::Done { - result, - entry: None, - warnings: dry_warnings, - }; + return done(result, None, dry_warnings); } // ── Gemfile edit plan (refusals before any write) ──────────────────── @@ -385,13 +359,8 @@ pub async fn vendor_gem( // (download + extract; no local install or patch-apply needed); else copy // the installed gem, drop in the local stub gemspec, and apply the patch. let mut warnings: Vec = Vec::new(); - if let Some(cfg) = service { - if cfg.source.requires_service() && cfg.offline { - return refused( - "vendor_service_offline_conflict", - "--vendor-source=service needs the network but --offline is set".to_string(), - ); - } + if let Some(refusal) = service_offline_conflict(service) { + return refusal; } let mut result = match materialise_patched_copy( purl, @@ -415,33 +384,29 @@ pub async fn vendor_gem( if !result.success { // The copy / stub / patch step left the result un-successful (and // cleaned up its own partial copy); neither project file was touched. - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } result.package_path = copy_dir.display().to_string(); // ── Gemfile edit ───────────────────────────────────────────────────── + // Both project files are user-owned: preserve their permission bits. let new_gemfile = apply_gemfile_plan(&gemfile_text, &plan); - if let Err(e) = atomic_write_bytes(&gemfile_path, new_gemfile.as_bytes()).await { + if let Err(e) = atomic_write_bytes_preserving_mode(&gemfile_path, new_gemfile.as_bytes()).await + { let _ = remove_tree(&uuid_dir).await; result.success = false; result.error = Some(format!("failed to write Gemfile: {e}")); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } // ── Gemfile.lock edit (a failure here unwinds the Gemfile) ─────────── let lock_edit = match edit_lock(&lock_text, name, version, ©_rel) { - Ok(edit) => match atomic_write_bytes(&lock_path, edit.text.as_bytes()).await { - Ok(()) => Ok(edit), - Err(e) => Err(format!("failed to write Gemfile.lock: {e}")), - }, + Ok(edit) => { + match atomic_write_bytes_preserving_mode(&lock_path, edit.text.as_bytes()).await { + Ok(()) => Ok(edit), + Err(e) => Err(format!("failed to write Gemfile.lock: {e}")), + } + } Err(e) => Err(format!("failed to edit Gemfile.lock: {e}")), }; let lock_edit = match lock_edit { @@ -450,32 +415,21 @@ pub async fn vendor_gem( // Unwind: a Gemfile pointing at a path the lock doesn't agree // with is exactly the half-wired state the pair edit exists to // prevent — restore the recorded original bytes. - if let Err(e) = atomic_write_bytes(&gemfile_path, gemfile_text.as_bytes()).await { + if let Err(e) = + atomic_write_bytes_preserving_mode(&gemfile_path, gemfile_text.as_bytes()).await + { detail.push_str(&format!(" (Gemfile unwind also failed: {e})")); } let _ = remove_tree(&uuid_dir).await; result.success = false; result.error = Some(detail); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } }; // ── marker + ledger entry ──────────────────────────────────────────── let base_purl = build_gem_purl(name, version); - let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); - vulnerabilities.sort(); - let marker = VendorMarker { - schema_version: MARKER_SCHEMA_VERSION, - purl: base_purl.clone(), - patch_uuid: record.uuid.clone(), - ecosystem: "gem".to_string(), - vulnerabilities, - vendored_at: vendored_at.to_string(), - }; + let marker = VendorMarker::new("gem", &base_purl, record, vendored_at); if let Err(e) = write_marker(&uuid_dir, &marker).await { // Informational only (state.json is the ledger of record) — a marker // failure must not fail an otherwise-wired vendor. @@ -567,11 +521,7 @@ pub async fn vendor_gem( pipenv: None, }; - VendorOutcome::Done { - result, - entry: Some(entry), - warnings, - } + done(result, Some(entry), warnings) } // ── materialisation (service download / local build) ────────────────────────── @@ -621,7 +571,7 @@ async fn gem_service_copy( return GemServiceCopy::FallBack; } fn hard(code: &'static str, detail: String) -> GemServiceCopy { - GemServiceCopy::HardFail(Box::new(VendorOutcome::Refused { code, detail })) + GemServiceCopy::HardFail(Box::new(refused(code, detail))) } let miss = |warnings: &mut Vec, code: &'static str, reason: String| { if cfg.source.requires_service() { @@ -636,7 +586,7 @@ async fn gem_service_copy( }; // Step 1: the prebuilt `.gem` (sha512-verified against the reference). - let archive = match fetch_verified_archive(cfg, &record.uuid, name).await { + let archive = match fetch_verified_archive(cfg, &record.uuid).await { ServiceArtifact::Ready(archive) => archive, ServiceArtifact::IntegrityMismatch(reason) => { return miss( @@ -671,7 +621,7 @@ async fn gem_service_copy( }; // Step 2: the stub gemspec the converter generated alongside the `.gem`. - let stub = match fetch_verified_secondary(cfg, &archive, GEM_STUB_ARTIFACT_KIND, name).await { + let stub = match fetch_verified_secondary(cfg, &archive, GEM_STUB_ARTIFACT_KIND).await { SecondaryArtifactResult::Ready(bytes) => bytes, SecondaryArtifactResult::Absent => { return miss( @@ -771,12 +721,7 @@ async fn materialise_patched_copy( GemServiceCopy::Used => { // The service `.gem` is the patched package; trust its verified // integrity (every file reads as AlreadyPatched). - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); - Ok(synthesized_result(purl, copy_dir, verified, true, None)) + Ok(already_patched_result(purl, copy_dir, &record.files)) } GemServiceCopy::HardFail(outcome) => Err(outcome), GemServiceCopy::FallBack => { @@ -977,18 +922,30 @@ fn plan_gemfile_edit( "the `gem \"{name}\"` declaration is not editable: {reason}" )); } + // Trailing options (`require: false`, `group: :test`, …) must survive the + // rewrite: dropping `require: false` auto-requires the gem at boot, + // changing app behavior while vendored. + let opts = gem_line_trailing_options(&rest); + let new_line = if opts.is_empty() { + format!("gem {q}{name}{q}, {q}{version}{q}, path: {q}{rel}{q}") + } else { + format!("gem {q}{name}{q}, {q}{version}{q}, path: {q}{rel}{q}, {opts}") + }; Ok(GemfilePlan::Rewrite { original_line: lines[idx].to_string(), - new_line: format!("gem {q}{name}{q}, {q}{version}{q}, path: {q}{rel}{q}"), + new_line, }) } /// Match `gem ""` / `gem ''` (or the parenthesized call form) at /// the start of a trimmed line. Returns the quote char, everything after the -/// closing quote, and whether the call was parenthesized. +/// closing quote, and whether the call was parenthesized. Space OR tab after +/// the keyword — a tab-separated declaration the grammar cannot see would +/// fall through to the transitive Append plan, leaving the Gemfile declaring +/// the gem twice (bundler hard-fails on the duplicate). fn gem_declaration<'a>(trimmed: &'a str, name: &str) -> Option<(char, &'a str, bool)> { let rest = trimmed.strip_prefix("gem")?; - let (paren, rest) = match rest.strip_prefix(' ') { + let (paren, rest) = match rest.strip_prefix([' ', '\t']) { Some(r) => (false, r), None => (true, rest.strip_prefix('(')?), }; @@ -1006,9 +963,13 @@ fn gem_declaration<'a>(trimmed: &'a str, name: &str) -> Option<(char, &'a str, b } /// Why the text after the gem name blocks an in-place rewrite (`None` = safe). -/// Only the code before any `#` comment counts — a trailing comment is -/// dropped by the rewrite, which is acceptable because the verbatim original -/// line lives in the ledger for revert. +/// Only the code before any `#` comment counts — a comment trailing plain +/// version constraints is dropped by the rewrite (acceptable: the verbatim +/// original line lives in the ledger for revert), while one trailing kept +/// options rides along with them verbatim. Every source-selecting option is +/// blocked, not just `path:`/`git:`: bundler allows ONE source per gem, so a +/// preserved `source:` (etc.) alongside the `path:` we add would fail every +/// `bundle` invocation. fn rest_blocks_edit(rest: &str) -> Option { let code = rest.split('#').next().unwrap_or("").trim(); if code.is_empty() { @@ -1020,7 +981,20 @@ fn rest_blocks_edit(rest: &str) -> Option { if code.ends_with(',') { return Some("the declaration continues on the next line".to_string()); } - for tok in ["path:", ":path", "git:", ":git", "github:", ":github"] { + for tok in [ + "path:", + ":path", + "git:", + ":git", + "github:", + ":github", + "source:", + ":source", + "gist:", + ":gist", + "bitbucket:", + ":bitbucket", + ] { if code.contains(tok) { return Some(format!( "the declaration already carries `{tok}` (revert any previous vendoring first)" @@ -1346,7 +1320,7 @@ async fn revert_gemfile_record( } }; if !dry_run { - atomic_write_bytes(gemfile_path, restored.as_bytes()) + atomic_write_bytes_preserving_mode(gemfile_path, restored.as_bytes()) .await .map_err(|e| format!("failed to write Gemfile: {e}"))?; } @@ -1376,7 +1350,7 @@ async fn revert_lock_record( return Ok(false); }; if !dry_run { - atomic_write_bytes(lock_path, restored.as_bytes()) + atomic_write_bytes_preserving_mode(lock_path, restored.as_bytes()) .await .map_err(|e| format!("failed to write Gemfile.lock: {e}"))?; } @@ -1425,7 +1399,7 @@ async fn revert_lock_checksum_record( }; lines[i] = original.to_string(); if !dry_run { - atomic_write_bytes(lock_path, lines.join("\n").as_bytes()) + atomic_write_bytes_preserving_mode(lock_path, lines.join("\n").as_bytes()) .await .map_err(|e| format!("failed to write Gemfile.lock: {e}"))?; } @@ -1565,73 +1539,14 @@ fn gemspec_declares_extensions(spec_text: &str) -> bool { false } -/// True when the copy exists and every patched file in it already hashes to -/// its `afterHash` (the vendor twin of `go_redirect::redirect_in_sync`). -async fn copy_matches_after_hashes( - copy_dir: &Path, - files: &HashMap, -) -> bool { - if tokio::fs::metadata(copy_dir).await.is_err() { - return false; - } - for (file_name, info) in files { - let normalized = normalize_file_path(file_name); - // SECURITY: never hash through a manifest key that escapes the copy - // dir — fail the sync check instead (the full pipeline would refuse - // the key anyway). - if !is_safe_relative_subpath(normalized) { - return false; - } - match compute_file_git_sha256(©_dir.join(normalized)).await { - Ok(h) if h == info.after_hash => {} - _ => return false, - } - } - true -} - -fn refused(code: &'static str, detail: impl Into) -> VendorOutcome { - VendorOutcome::Refused { - code, - detail: detail.into(), - } -} - -fn synthesized_result( - package_key: &str, - copy_dir: &Path, - files_verified: Vec, - success: bool, - error: Option, -) -> ApplyResult { - ApplyResult { - package_key: package_key.to_string(), - package_path: copy_dir.display().to_string(), - success, - files_verified, - files_patched: Vec::new(), - applied_via: HashMap::new(), - error, - sidecar: None, - } -} - -fn already_patched_verify(file: &str) -> VerifyResult { - VerifyResult { - file: file.to_string(), - status: VerifyStatus::AlreadyPatched, - message: None, - current_hash: None, - expected_hash: None, - target_hash: None, - } -} - #[cfg(test)] mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::manifest::schema::PatchFileInfo; + use crate::patch::apply::VerifyStatus; use crate::patch::vendor::state::VENDOR_MARKER_FILE; + use std::collections::HashMap; use std::path::PathBuf; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; @@ -2844,6 +2759,132 @@ mod tests { ); } + /// Trailing options on the declaration (`require: false`, `group: :test`, + /// …) must survive the rewrite: dropping `require: false` auto-requires + /// the gem at boot, changing app behavior while vendored (the redirect + /// backend's `gem_line_trailing_options` twin, FIXED there 2026-07-06). + #[tokio::test] + async fn test_rewrite_preserves_trailing_options() { + let gemfile = + "source \"https://rubygems.org\"\n\ngem \"puma\"\ngem \"rack\", \"~> 3.1\", require: false\n"; + let (_tmp, root, installed, blobs, record) = fixture(gemfile, LOCK_DIRECT).await; + + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + format!( + "source \"https://rubygems.org\"\n\ngem \"puma\"\ngem \"rack\", \"3.2.6\", path: \"{}\", require: false\n", + copy_rel() + ), + "trailing options must survive the rewrite" + ); + + // Revert restores the original line (options and all) verbatim. + let outcome = revert_gem(&entry.unwrap(), &root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + gemfile + ); + } + + /// `source:` selects a registry — carried alongside the `path:` we add it + /// is a bundler error (one source per gem), and silently dropping it + /// would hide the user's routing. Refused like `git:`/`github:`. + #[tokio::test] + async fn test_refuses_source_option_declaration() { + let gemfile = + "source \"https://rubygems.org\"\n\ngem \"puma\"\ngem \"rack\", \"~> 3.1\", source: \"https://gems.example\"\n"; + let (_tmp, root, installed, blobs, record) = fixture(gemfile, LOCK_DIRECT).await; + + let (code, detail) = + unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); + assert_eq!(code, "gemfile_declaration_not_editable"); + assert!(detail.contains("source:"), "{detail}"); + assert!(!root.join(".socket").exists()); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + gemfile + ); + } + + /// `gem\t"rack"` (tab separator) is a valid ruby call. If the grammar + /// cannot see it, the plan falls through to the transitive Append and the + /// Gemfile ends up declaring rack TWICE (registry line + managed path: + /// block) — bundler hard-fails every install until hand-repaired. + #[tokio::test] + async fn test_tab_separated_declaration_rewritten_not_duplicated() { + let gemfile = "source \"https://rubygems.org\"\n\ngem\t\"rack\", \"~> 3.1\"\n"; + let (_tmp, root, installed, blobs, record) = fixture(gemfile, LOCK_DIRECT).await; + + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + let new_gemfile = tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(); + assert!( + !new_gemfile.contains(MANAGED_OPEN), + "must rewrite in place, never append a duplicate declaration: {new_gemfile}" + ); + assert!( + !new_gemfile.contains("~> 3.1"), + "registry declaration replaced: {new_gemfile}" + ); + assert!(new_gemfile.contains(©_rel()), "{new_gemfile}"); + + let outcome = revert_gem(&entry.unwrap(), &root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + gemfile + ); + } + + /// Gemfile + Gemfile.lock are USER-owned files vendor merely edits: the + /// pair edit and every revert write must keep their permission bits (the + /// plain atomic writer swaps in a umask-default inode — a 0600 private + /// Gemfile silently becomes 0644; see + /// `atomic_write_bytes_preserving_mode`). The CHECKSUMS fixture exercises + /// all three revert writers. + #[cfg(unix)] + #[tokio::test] + async fn test_pair_edit_and_revert_preserve_file_modes() { + use std::os::unix::fs::PermissionsExt; + let (_tmp, root, installed, blobs, record) = + fixture_318(SPIKE_GEMFILE_CHECKSUMS, SPIKE_LOCK_CHECKSUMS_BEFORE).await; + for f in [GEMFILE, GEMFILE_LOCK] { + tokio::fs::set_permissions(root.join(f), std::fs::Permissions::from_mode(0o600)) + .await + .unwrap(); + } + + let (result, entry, _w) = + unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + for f in [GEMFILE, GEMFILE_LOCK] { + let mode = tokio::fs::metadata(root.join(f)) + .await + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "{f} mode reset by the vendor pair edit"); + } + + let outcome = revert_gem(&entry.unwrap(), &root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + for f in [GEMFILE, GEMFILE_LOCK] { + let mode = tokio::fs::metadata(root.join(f)) + .await + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "{f} mode reset by revert"); + } + } + // ─────────────── service-download path (Tier B: gem) ────────────────── // // gem vendors a patched source DIRECTORY plus a stub gemspec, so the diff --git a/crates/socket-patch-core/src/patch/vendor/golang.rs b/crates/socket-patch-core/src/patch/vendor/golang.rs index 60ce79bd..6eaf2c23 100644 --- a/crates/socket-patch-core/src/patch/vendor/golang.rs +++ b/crates/socket-patch-core/src/patch/vendor/golang.rs @@ -19,16 +19,20 @@ use std::path::Path; use crate::manifest::schema::PatchRecord; -use crate::patch::apply::{ApplyResult, PatchSources, VerifyResult, VerifyStatus}; +use crate::patch::apply::{MismatchPolicy, PatchSources}; use crate::patch::copy_tree::remove_tree; use crate::patch::go_mod_edit::{ self, read_replace_entries, replace_target_path, ReplaceOwner, GO_PATCHES_DIR, }; use crate::patch::go_redirect::{ - apply_go_redirect, are_safe_redirect_coords, ensure_module_go_mod, + apply_go_redirect, are_safe_redirect_coords, copy_dir_for, ensure_module_go_mod, }; use crate::utils::purl::{parse_golang_purl, strip_purl_qualifiers}; +use super::common::{ + already_patched_result, copy_matches_after_hashes, done, failed_result, refused, + service_offline_conflict, +}; use super::path::vendor_uuid_dir_rel; use super::registry_fetch::extract_zip_with_prefix; use super::service_fetch::{fetch_verified_archive, ServiceArtifact}; @@ -37,56 +41,6 @@ use super::state::{ }; use super::{RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; -fn already_patched_verify(file: &str) -> VerifyResult { - VerifyResult { - file: file.to_string(), - status: VerifyStatus::AlreadyPatched, - message: None, - current_hash: None, - expected_hash: None, - target_hash: None, - } -} - -/// A synthesized success [`ApplyResult`] for a service-downloaded module: there -/// is no local apply to verify (the downloaded zip IS the patched module), so -/// every patched file reads as `AlreadyPatched` — trust is the verified service -/// integrity (sha512 + the `h1:` module dirhash). -fn synthesized_success( - purl: &str, - copy_dir: &Path, - files_verified: Vec, -) -> ApplyResult { - ApplyResult { - package_key: purl.to_string(), - package_path: copy_dir.display().to_string(), - success: true, - files_verified, - files_patched: Vec::new(), - applied_via: std::collections::HashMap::new(), - error: None, - sidecar: None, - } -} - -/// The committed copy exists and every patched file matches its afterHash. -async fn copy_hashes_ok( - copy_dir: &Path, - files: &std::collections::HashMap, -) -> bool { - if tokio::fs::metadata(copy_dir).await.is_err() { - return false; - } - for (file_name, info) in files { - let path = copy_dir.join(crate::patch::apply::normalize_file_path(file_name)); - match crate::patch::file_hash::compute_file_git_sha256(&path).await { - Ok(h) if h == info.after_hash => {} - _ => return false, - } - } - true -} - /// Vendor one Go module: patched copy in the uuid dir + a vendor-owned /// `replace` directive + marker, returning the ledger entry to persist. /// @@ -112,10 +66,7 @@ pub async fn vendor_go_module( ) -> VendorOutcome { // ── coordinate validation (fail-closed, before any disk access) ────── let Some((module, version)) = parse_golang_purl(purl) else { - return VendorOutcome::Refused { - code: "unsafe_coordinates", - detail: format!("not a golang purl: {purl}"), - }; + return refused("unsafe_coordinates", format!("not a golang purl: {purl}")); }; // SECURITY: `module`+`version` key the on-disk copy dir // (`.socket/vendor/golang//@/`) and the `replace` @@ -123,25 +74,25 @@ pub async fn vendor_go_module( // manifest PURL would let the copy escape `.socket/vendor/` — refuse // before any disk access (same guard the redirect engine applies). if !are_safe_redirect_coords(module, version) { - return VendorOutcome::Refused { - code: "unsafe_coordinates", - detail: format!( + return refused( + "unsafe_coordinates", + format!( "refusing to vendor unsafe golang coordinates `{module}`/`{version}` \ (a `..` segment, absolute path, or separator would escape \ .socket/vendor/golang/)" ), - }; + ); } // SECURITY: the uuid is a dedicated path level created here and deleted by // `--revert`; anything but the canonical UUID grammar is rejected. let Some(base_rel) = vendor_uuid_dir_rel("golang", &record.uuid) else { - return VendorOutcome::Refused { - code: "unsafe_coordinates", - detail: format!( + return refused( + "unsafe_coordinates", + format!( "refusing to vendor {purl}: patch uuid `{}` is not a canonical uuid", record.uuid ), - }; + ); }; // Detect an existing socket-owned directive BEFORE the engine rewrites it: @@ -163,28 +114,12 @@ pub async fn vendor_go_module( // `prior_path` recorded here would be our own vendored pointer. let wired = prior_path.as_deref() == Some(replace_target_path(&base_rel, module, version).as_str()); - let copy_dir = project_root - .join(&base_rel) - .join(format!("{module}@{version}")); - let copy_was_ok = wired && copy_hashes_ok(©_dir, &record.files).await; - - // Vendor auto-force policy (the engine's copy is staged from the - // pristine source, never the user's tree — see `force_apply_staged`): - // missing patch targets still fail closed unless the caller's own - // `--force` asked for the skip tolerance, then the engine apply runs - // forced so a beforeHash mismatch (already-applied module, or a patch - // built against different bytes) overwrites with the verified patched - // content. The engine is shared with the in-place `apply` redirect - // path, whose strict semantics stay unchanged. + let copy_dir = copy_dir_for(project_root, &base_rel, module, version); + let copy_was_ok = wired && copy_matches_after_hashes(©_dir, &record.files).await; + let mut warnings: Vec = Vec::new(); - if let Some(cfg) = service { - if cfg.source.requires_service() && cfg.offline { - return VendorOutcome::Refused { - code: "vendor_service_offline_conflict", - detail: "--vendor-source=service needs the network but --offline is set" - .to_string(), - }; - } + if let Some(refusal) = service_offline_conflict(service) { + return refusal; } // Acquire the patched module: prefer the prebuilt module zip from the patch @@ -199,36 +134,42 @@ pub async fn vendor_go_module( &base_rel, ©_dir, project_root, + dry_run, + copy_was_ok, + wired, &mut warnings, ) .await { GoServiceRedirect::Used => { - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); - synthesized_success(purl, ©_dir, verified) + // No local apply to verify (the downloaded zip IS the patched + // module), so every patched file reads as `AlreadyPatched` — trust + // is the verified service integrity (sha512 + the `h1:` dirhash). + already_patched_result(purl, ©_dir, &record.files) } GoServiceRedirect::HardFail(outcome) => return *outcome, GoServiceRedirect::FallBack => { // Vendor auto-force policy (the engine's copy is staged from the - // pristine source, never the user's tree): missing patch targets - // still fail closed unless the caller's own `--force` asked for the - // skip tolerance. + // pristine source, never the user's tree — see `force_apply_staged`): + // missing patch targets still fail closed unless the caller's own + // `--force` asked for the skip tolerance, then the engine apply runs + // forced so a beforeHash mismatch (already-applied module, or a + // patch built against different bytes) overwrites with the verified + // patched content. The engine is shared with the in-place `apply` + // redirect path, whose strict semantics stay unchanged. if !force { let missing = super::missing_existing_patch_files(pristine_src, &record.files).await; if let Some(first) = missing.first() { - return VendorOutcome::Done { - result: super::failed_apply_result( + return done( + failed_result( purl, + Path::new(""), format!("Cannot apply patch: {first} - File not found"), ), - entry: None, + None, warnings, - }; + ); } } // The engine does the heavy lifting: fresh copy → hardened apply @@ -245,7 +186,7 @@ pub async fn vendor_go_module( sources, Some(&record.uuid), dry_run, - crate::patch::apply::MismatchPolicy::Force, + MismatchPolicy::Force, ) .await; if result.success { @@ -256,11 +197,7 @@ pub async fn vendor_go_module( }; if dry_run { - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } if !result.success { // The engine already rolled back a half-built copy, but its rollback @@ -268,20 +205,12 @@ pub async fn vendor_go_module( // path husks (or a copy left by a failed `replace` upsert) linger // under `.socket/vendor/golang/`. let _ = remove_tree(&project_root.join(&base_rel)).await; - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } // A patch with no files is a no-op success: the engine wrote no copy and // no `replace`, so there is nothing to record or mark. if record.files.is_empty() { - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } if wired { @@ -292,16 +221,8 @@ pub async fn vendor_go_module( // A wholesale-deleted uuid dir lost the informational marker; // restore it alongside the rebuilt copy (never a trust input — // a failed write only warns). - let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); - vulnerabilities.sort(); - let marker = VendorMarker { - schema_version: 1, - purl: strip_purl_qualifiers(purl).to_string(), - patch_uuid: record.uuid.clone(), - ecosystem: "golang".to_string(), - vulnerabilities, - vendored_at: vendored_at.to_string(), - }; + let marker = + VendorMarker::new("golang", strip_purl_qualifiers(purl), record, vendored_at); if let Err(e) = write_marker(&project_root.join(&base_rel), &marker).await { warnings.push(VendorWarning::new( "marker_write_failed", @@ -316,11 +237,7 @@ pub async fn vendor_go_module( ), )); } - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } if takeover { @@ -328,9 +245,7 @@ pub async fn vendor_go_module( // the apply backend's copy is now unreachable — delete it (built from // OUR validated coordinates, never from the go.mod string). NotFound // is fine (the user may have cleaned it already). - let stale = project_root - .join(GO_PATCHES_DIR) - .join(format!("{module}@{version}")); + let stale = copy_dir_for(project_root, GO_PATCHES_DIR, module, version); let _ = remove_tree(&stale).await; // Prune now-empty parent husks (`/example.com/`) up to // and including the go-patches root. `remove_dir` is non-recursive: @@ -358,16 +273,7 @@ pub async fn vendor_go_module( // ── marker + ledger entry ───────────────────────────────────────────── let base_purl = strip_purl_qualifiers(purl).to_string(); - let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); - vulnerabilities.sort(); - let marker = VendorMarker { - schema_version: 1, - purl: base_purl.clone(), - patch_uuid: record.uuid.clone(), - ecosystem: "golang".to_string(), - vulnerabilities, - vendored_at: vendored_at.to_string(), - }; + let marker = VendorMarker::new("golang", &base_purl, record, vendored_at); if let Err(e) = write_marker(&project_root.join(&base_rel), &marker).await { // The marker is belt-and-braces metadata (never a trust input); a // failed write must not undo a fully-wired vendor — surface it. @@ -415,11 +321,7 @@ pub async fn vendor_go_module( pipenv: None, }; - VendorOutcome::Done { - result, - entry: Some(entry), - warnings, - } + done(result, Some(entry), warnings) } /// Outcome of attempting to materialise the go copy from the patch service. @@ -437,6 +339,11 @@ enum GoServiceRedirect { /// `{module}@{version}/` prefix), ensure a `go.mod`, and wire the `replace` /// directive — the same end state `apply_go_redirect` produces, minus the copy /// + local apply. Maps each service outcome onto the `auto` / `service` policy. +/// +/// `wired` — the run started with the vendor `replace` already pointing at +/// THIS uuid's copy; a failure leg must then also drop that directive with the +/// uuid dir it just removed, or go.mod dangles at a deleted path and every +/// `go build` fails (mirrors the engine's `teardown_failed_redirect`). #[allow(clippy::too_many_arguments)] async fn go_service_redirect( service: Option<&VendorServiceConfig>, @@ -446,18 +353,30 @@ async fn go_service_redirect( base_rel: &str, copy_dir: &Path, project_root: &Path, + dry_run: bool, + copy_was_ok: bool, + wired: bool, warnings: &mut Vec, ) -> GoServiceRedirect { let Some(cfg) = service else { return GoServiceRedirect::FallBack; }; + // Dry runs never reach the service: every leg below writes for real + // (copy-dir replace, go.mod upsert) — the engine's read-only verify is + // the preview (the same gate the npm/pypi backends apply). And an intact + // wired copy is already byte-identical to the verified service end state: + // never tear it down for a re-download whose failure would strand go.mod + // pointing at a deleted dir. + if dry_run || copy_was_ok { + return GoServiceRedirect::FallBack; + } // An empty-files patch is a degenerate no-op; let the engine's empty // handling deal with it rather than downloading anything. if !cfg.service_enabled() || record.files.is_empty() { return GoServiceRedirect::FallBack; } fn hard(code: &'static str, detail: String) -> GoServiceRedirect { - GoServiceRedirect::HardFail(Box::new(VendorOutcome::Refused { code, detail })) + GoServiceRedirect::HardFail(Box::new(refused(code, detail))) } let miss = |warnings: &mut Vec, code: &'static str, reason: String| { if cfg.source.requires_service() { @@ -470,12 +389,13 @@ async fn go_service_redirect( GoServiceRedirect::FallBack } }; - match fetch_verified_archive(cfg, &record.uuid, module).await { + match fetch_verified_archive(cfg, &record.uuid).await { ServiceArtifact::Ready(archive) => { // Clean copy dir; extract the module zip (strip its literal // `{module}@{version}/` prefix) into it. let _ = remove_tree(copy_dir).await; if let Err(e) = tokio::fs::create_dir_all(copy_dir).await { + teardown_failed_service_copy(project_root, base_rel, module, wired).await; return hard( "vendor_prebuilt_write_failed", format!("cannot create {}: {e}", copy_dir.display()), @@ -483,7 +403,7 @@ async fn go_service_redirect( } let prefix = format!("{module}@{version}/"); if let Err(e) = extract_zip_with_prefix(&archive.bytes, copy_dir, &prefix) { - let _ = remove_tree(&project_root.join(base_rel)).await; + teardown_failed_service_copy(project_root, base_rel, module, wired).await; return hard( "vendor_prebuilt_extract_failed", format!("cannot extract the prebuilt module zip: {e}"), @@ -492,7 +412,7 @@ async fn go_service_redirect( // A `replace` target needs a go.mod declaring the module path; // pre-modules zips may lack one — synthesize the minimal form. if let Err(e) = ensure_module_go_mod(copy_dir, module).await { - let _ = remove_tree(&project_root.join(base_rel)).await; + teardown_failed_service_copy(project_root, base_rel, module, wired).await; return hard( "vendor_prebuilt_write_failed", format!("cannot synthesize go.mod for the copy: {e}"), @@ -502,7 +422,7 @@ async fn go_service_redirect( go_mod_edit::ensure_replace_entry(project_root, module, version, base_rel, false) .await { - let _ = remove_tree(&project_root.join(base_rel)).await; + teardown_failed_service_copy(project_root, base_rel, module, wired).await; return hard( "vendor_prebuilt_wire_failed", format!("failed to update go.mod: {e}"), @@ -545,6 +465,29 @@ async fn go_service_redirect( } } +/// Failure cleanup for the service legs (the vendor-side sibling of the +/// engine's `teardown_failed_redirect`): the uuid dir just lost its copy to a +/// failed materialisation — clear it, and when the run started `wired` to +/// this uuid also drop the now-dangling vendor `replace` directive. Fall back +/// to the unpatched-module end state a failed first run leaves, never a +/// go.mod whose replacement directory no longer exists (every `go build` +/// would fail). `wired` doubles as the path condition: it asserts the live +/// directive targets THIS uuid, so a directive pointing at another uuid's +/// intact copy is never dropped (and the owner filter protects go-patches / +/// user-authored lines). +async fn teardown_failed_service_copy( + project_root: &Path, + base_rel: &str, + module: &str, + wired: bool, +) { + let _ = remove_tree(&project_root.join(base_rel)).await; + if wired { + let _ = go_mod_edit::drop_replace_entry(project_root, module, ReplaceOwner::Vendor, false) + .await; + } +} + /// Revert one vendored Go module: drop the vendor-owned `replace` directive /// and remove the uuid dir. A taken-over go-patches redirect is **not** /// restored (warned: re-run `socket-patch apply`). @@ -611,7 +554,6 @@ mod tests { use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::{PatchFileInfo, VulnerabilityInfo}; use crate::patch::apply::ApplyResult; - use crate::patch::apply::MismatchPolicy; use crate::patch::vendor::state::VENDOR_MARKER_FILE; use std::collections::HashMap; use std::path::PathBuf; @@ -883,9 +825,7 @@ mod tests { let copy1 = tokio::fs::read(©).await.unwrap(); let mod1 = tokio::fs::read(&gomod).await.unwrap(); - crate::patch::copy_tree::remove_tree(&root.join(copy_rel())) - .await - .unwrap(); + remove_tree(&root.join(copy_rel())).await.unwrap(); let (result, entry, warnings) = expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); @@ -1409,6 +1349,189 @@ mod tests { ); } + /// Dry-run must write nothing and stay off the network even when the + /// service path is enabled (auto/service + client): the prebuilt download + /// would delete/recreate the copy dir and rewrite go.mod for real. + #[tokio::test] + async fn dry_run_with_service_enabled_writes_nothing_and_stays_offline() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + let zip = make_module_zip(&[ + ("go.mod", b"module github.com/foo/bar\n\ngo 1.21\n"), + ("bar.go", PATCHED), + ]); + let sri = sri_sha512(&zip); + let server = wiremock::MockServer::start().await; + mount_go_granted(&server, &sri, None, &zip).await; + let gomod_before = tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(); + let sources = PatchSources::blobs_only(&blobs); + + let outcome = vendor_go_module( + PURL, + &pristine, + root, + &record, + &sources, + "2026-06-09T00:00:00Z", + /*dry_run=*/ true, + false, + Some(&go_service_cfg(&server.uri(), VendorSource::Auto, false)), + ) + .await; + let (result, entry, _warnings) = expect_done(outcome); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none(), "dry-run emits no entry"); + assert_eq!( + tokio::fs::read_to_string(root.join("go.mod")) + .await + .unwrap(), + gomod_before, + "go.mod untouched by a service dry-run" + ); + assert!( + !root.join(format!(".socket/vendor/golang/{UUID}")).exists(), + "no copy dir created" + ); + assert!( + server.received_requests().await.unwrap().is_empty(), + "dry-run must not contact the vendor service" + ); + } + + /// A wired re-run with an intact committed copy must never be degraded by + /// the service path: the current end state already IS the service end + /// state. A re-download that then fails (corrupt zip) would otherwise + /// delete the copy AND the uuid dir while go.mod still points there — + /// bricking a previously healthy build. + #[tokio::test] + async fn service_rerun_with_intact_copy_never_degrades_wired_state() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + // First run: local build wires copy + replace (no service). + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + let copy = root.join(copy_rel()).join("bar.go"); + let gomod = root.join("go.mod"); + let mod1 = tokio::fs::read(&gomod).await.unwrap(); + + // Re-run with the service serving a Ready-but-corrupt artifact: the + // sha512 matches the corrupt bytes, so only zip extraction can fail. + let junk: &[u8] = b"not a zip at all"; + let server = wiremock::MockServer::start().await; + mount_go_granted(&server, &sri_sha512(junk), None, junk).await; + let sources = PatchSources::blobs_only(&blobs); + let outcome = vendor_go_module( + PURL, + &pristine, + root, + &record, + &sources, + "2026-06-10T00:00:00Z", + false, + false, + Some(&go_service_cfg(&server.uri(), VendorSource::Auto, false)), + ) + .await; + + let (result, entry, _warnings) = expect_done(outcome); + assert!( + result.success, + "in-sync re-run must stay healthy: {:?}", + result.error + ); + assert!(entry.is_none(), "no re-recorded entry"); + assert_eq!( + tokio::fs::read(©).await.unwrap(), + PATCHED, + "committed copy intact" + ); + assert_eq!( + tokio::fs::read(&gomod).await.unwrap(), + mod1, + "go.mod byte-stable" + ); + } + + /// A failed rebuild of a wired-but-stale copy must not leave the vendor + /// `replace` directive pointing at the removed uuid dir (go: "replacement + /// directory does not exist" — build bricked). The failure must fall back + /// to the unpatched-module end state: dir gone AND directive gone. + #[tokio::test] + async fn failed_stale_copy_rebuild_drops_dangling_directive() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + + // The committed copy drifts AND the patched blob is gone: the + // artifact rebuild has no source for afterHash content and fails. + tokio::fs::write(root.join(copy_rel()).join("bar.go"), b"drifted\n") + .await + .unwrap(); + tokio::fs::remove_file(blobs.join(git_sha(PATCHED))) + .await + .unwrap(); + + let (result, entry, _warnings) = + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + assert!(!result.success, "rebuild without blobs must fail"); + assert!(entry.is_none()); + assert!( + !root.join(format!(".socket/vendor/golang/{UUID}")).exists(), + "uuid dir cleared" + ); + assert!( + read_replace_entries(root) + .await + .iter() + .all(|e| e.module != MODULE), + "no dangling replace directive at the deleted copy" + ); + } + + /// Same invariant through the service legs: when the service rebuild of a + /// wired-but-stale copy fails mid-materialisation (corrupt zip), the + /// directive from the earlier healthy run is torn down with the uuid dir. + #[tokio::test] + async fn failed_service_rebuild_of_stale_copy_drops_dangling_directive() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + // Stale copy → the service rebuild leg runs on the re-run. + tokio::fs::write(root.join(copy_rel()).join("bar.go"), b"drifted\n") + .await + .unwrap(); + + let junk: &[u8] = b"not a zip at all"; + let server = wiremock::MockServer::start().await; + mount_go_granted(&server, &sri_sha512(junk), None, junk).await; + let sources = PatchSources::blobs_only(&blobs); + let outcome = vendor_go_module( + PURL, + &pristine, + root, + &record, + &sources, + "2026-06-10T00:00:00Z", + false, + false, + Some(&go_service_cfg(&server.uri(), VendorSource::Auto, false)), + ) + .await; + expect_refused(outcome, "vendor_prebuilt_extract_failed"); + assert!( + !root.join(format!(".socket/vendor/golang/{UUID}")).exists(), + "uuid dir cleared" + ); + assert!( + read_replace_entries(root) + .await + .iter() + .all(|e| e.module != MODULE), + "no dangling replace directive at the deleted copy" + ); + } + /// `--offline` + `--vendor-source=service` refuses without any network. #[tokio::test] async fn offline_service_mode_refuses() { diff --git a/crates/socket-patch-core/src/patch/vendor/lock_inventory.rs b/crates/socket-patch-core/src/patch/vendor/lock_inventory.rs index b6aca7f2..3d5aaa21 100644 --- a/crates/socket-patch-core/src/patch/vendor/lock_inventory.rs +++ b/crates/socket-patch-core/src/patch/vendor/lock_inventory.rs @@ -22,9 +22,10 @@ use std::path::Path; use serde_json::Value; +use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::patch::bun_lock_text; use crate::patch::path_safety; -use crate::utils::purl::strip_purl_qualifiers; +use crate::utils::purl::{percent_decode_purl_component, strip_purl_qualifiers}; use super::npm_common::is_safe_npm_name; use super::npm_flavor::{detect_npm_lock_flavor, NpmLockFlavor}; @@ -97,9 +98,7 @@ impl LockfileEntry { /// Inventory the project's npm-family lockfile. Routes by /// [`detect_npm_lock_flavor`] (PnP markers, bun.lockb, unsupported lock /// versions, and a missing lockfile all yield `None`). -pub async fn inventory_npm_lock( - project_root: &Path, -) -> Option<(NpmLockFlavor, Vec)> { +async fn inventory_npm_lock(project_root: &Path) -> Option<(NpmLockFlavor, Vec)> { // Rush monorepos have no root package.json/lock pair; their single // pnpm source-of-truth lives under common/config/rush/. The flavor // probe (root-relative) can't see it, so fall back explicitly when the @@ -129,21 +128,17 @@ pub fn lookup<'a>(entries: &'a [LockfileEntry], purl: &str) -> Option<&'a Lockfi let decoded = crate::utils::purl::normalize_purl(strip_purl_qualifiers(purl)).into_owned(); let rest = decoded.strip_prefix("pkg:")?; let (purl_type, rest) = rest.split_once('/')?; - // purl type → vendor-ecosystem tag (same mapping the dispatcher uses). + // purl types double as the vendor-ecosystem tags (same set the + // dispatcher recognizes). let eco = match purl_type { - "npm" => "npm", - "cargo" => "cargo", - "golang" => "golang", - "pypi" => "pypi", - "gem" => "gem", - "composer" => "composer", + "npm" | "cargo" | "golang" | "pypi" | "gem" | "composer" => purl_type, _ => return None, }; let at = rest.rfind('@').filter(|&i| i > 0)?; let (name, version) = (&rest[..at], &rest[at + 1..]); // pypi names compare in PEP 503 normalized form. let name = if eco == "pypi" { - pep503(name) + canonicalize_pypi_name(name) } else { name.to_string() }; @@ -159,15 +154,12 @@ pub async fn inventory_project(project_root: &Path) -> Vec { if let Some((_, entries)) = inventory_npm_lock(project_root).await { out.extend(entries); } - #[cfg(feature = "cargo")] if let Some(entries) = inventory_cargo_lock(project_root).await { out.extend(entries); } - #[cfg(feature = "golang")] if let Some(entries) = inventory_go_sum(project_root).await { out.extend(entries); } - #[cfg(feature = "composer")] if let Some(entries) = inventory_composer_lock(project_root).await { out.extend(entries); } @@ -222,8 +214,7 @@ fn dedup_prefer_integrity(raw: Vec) -> Vec { /// entries are fetchable (their `checksum` is the sha256 of the `.crate` /// file); workspace members (no `source`) are skipped, and git/custom- /// registry sources stay listed for discovery without a verifier. -#[cfg(feature = "cargo")] -pub async fn inventory_cargo_lock(project_root: &Path) -> Option> { +async fn inventory_cargo_lock(project_root: &Path) -> Option> { let text = tokio::fs::read_to_string(project_root.join("Cargo.lock")) .await .ok()?; @@ -249,11 +240,7 @@ pub async fn inventory_cargo_lock(project_root: &Path) -> Option - { - LockIntegrity::Sha256Hex(c) - } + Some(c) if crates_io && is_hex_of_len(&c, 64) => LockIntegrity::Sha256Hex(c), _ => LockIntegrity::None, }; let purl = format!("pkg:cargo/{name}@{version}"); @@ -301,8 +288,7 @@ pub async fn inventory_cargo_lock(project_root: &Path) -> Option Option> { +async fn inventory_go_sum(project_root: &Path) -> Option> { let text = tokio::fs::read_to_string(project_root.join("go.sum")) .await .ok()?; @@ -342,6 +328,10 @@ fn http_url(raw: &str) -> Option { (raw.starts_with("https://") || raw.starts_with("http://")).then(|| raw.to_string()) } +fn is_hex_of_len(s: &str, len: usize) -> bool { + s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + // ──────────────────── package-lock.json / npm-shrinkwrap ──────────────────── async fn inventory_package_lock(root: &Path) -> Option> { @@ -402,16 +392,6 @@ async fn inventory_package_lock(root: &Path) -> Option> { // ─────────────────────────── pnpm-lock.yaml v9 ─────────────────────────── -/// Extract one value from an inline YAML map fragment like -/// `{integrity: sha512-…, tarball: file:…}` (values optionally quoted). -fn inline_map_value(fragment: &str, field: &str) -> Option { - let at = fragment.find(&format!("{field}:"))?; - let rest = fragment[at + field.len() + 1..].trim_start(); - let end = rest.find([',', '}']).unwrap_or(rest.len()); - let value = rest[..end].trim().trim_matches(['\'', '"']); - (!value.is_empty()).then(|| value.to_string()) -} - async fn inventory_pnpm_lock(root: &Path) -> Option> { inventory_pnpm_lock_at(&root.join("pnpm-lock.yaml")).await } @@ -447,10 +427,10 @@ async fn inventory_pnpm_lock_at(lock_path: &Path) -> Option> for line in &lines[block.header + 1..block.end] { let t = line.trim(); if let Some(rest) = t.strip_prefix("resolution:") { - if let Some(v) = inline_map_value(rest, "integrity") { + if let Some(v) = inline_yaml_field(rest, "integrity:") { integrity = LockIntegrity::Sri(v); } - tarball = inline_map_value(rest, "tarball"); + tarball = inline_yaml_field(rest, "tarball:"); break; } } @@ -546,8 +526,7 @@ async fn inventory_yarn_classic(root: &Path) -> Option> { Some(raw) => match raw.split_once('#') { Some((url, frag)) => ( http_url(url), - (frag.len() == 40 && frag.bytes().all(|b| b.is_ascii_hexdigit())) - .then(|| frag.to_ascii_lowercase()), + is_hex_of_len(frag, 40).then(|| frag.to_ascii_lowercase()), ), None => (http_url(raw), None), }, @@ -657,8 +636,7 @@ async fn inventory_bun(root: &Path) -> Option> { /// (sha1 of the dist zip) is frequently empty — such entries stay /// discovery-only. Names lowercase to the canonical packagist form; /// versions drop the pretty leading `v`. -#[cfg(feature = "composer")] -pub async fn inventory_composer_lock(project_root: &Path) -> Option> { +async fn inventory_composer_lock(project_root: &Path) -> Option> { let bytes = tokio::fs::read(project_root.join("composer.lock")) .await .ok()?; @@ -709,12 +687,11 @@ pub async fn inventory_composer_lock(project_root: &Path) -> Option Option Option> { +async fn inventory_gemfile_lock(project_root: &Path) -> Option> { let text = tokio::fs::read_to_string(project_root.join("Gemfile.lock")) .await .ok()?; @@ -776,8 +753,7 @@ pub async fn inventory_gemfile_lock(project_root: &Path) -> Option = None; let mut version: Option = None; let mut sourced_registry = true; @@ -886,7 +845,7 @@ async fn inventory_uv_lock(project_root: &Path) -> Option> { wheel: &mut Option<(String, String)>, out: &mut Vec| { if let (Some(n), Some(v)) = (name.take(), version.take()) { - let canonical = pep503(&n); + let canonical = canonicalize_pypi_name(&n); if *sourced_registry && path_safety::is_safe_single_segment(&canonical) && path_safety::is_safe_single_segment(&v) @@ -928,21 +887,11 @@ async fn inventory_uv_lock(project_root: &Path) -> Option> { // Registry packages: `source = { registry = "…" }`; editable/ // virtual/path/git sources are not fetchable artifacts. sourced_registry = t.contains("registry"); - } else if wheel.is_none() && t.contains("py3-none-any.whl") { - // `{ url = "…py3-none-any.whl", hash = "sha256:…" }` - let url = t - .split("url = \"") - .nth(1) - .and_then(|r| r.split('"').next()) - .unwrap_or(""); - let sha = t - .split("hash = \"sha256:") - .nth(1) - .and_then(|r| r.split('"').next()) - .unwrap_or(""); - if !url.is_empty() && sha.len() == 64 && sha.bytes().all(|b| b.is_ascii_hexdigit()) { - wheel = Some((url.to_string(), sha.to_ascii_lowercase())); - } + } else if wheel.is_none() { + // One line may hold several `{ url = "…", hash = "sha256:…" }` + // wheels (one-line arrays); pair the pure wheel with ITS OWN + // hash, never the line's first url/hash. + wheel = pure_wheel_from_uv_unit(t); } } flush( @@ -979,7 +928,7 @@ async fn inventory_poetry_lock(project_root: &Path) -> Option continue; } if let Some(v) = t.strip_prefix("name = ") { - name = Some(pep503(v.trim_matches('"'))); + name = Some(canonicalize_pypi_name(v.trim_matches('"'))); } else if let Some(v) = t.strip_prefix("version = ") { if let Some(n) = name.take() { let v = v.trim_matches('"').to_string(); @@ -1021,7 +970,7 @@ async fn inventory_requirements_txt(project_root: &Path) -> Option/@` → (name, version). The name may itself /// contain `/` (npm scopes, go modules); the version is after the LAST `@`. +/// Components percent-decode (`%40scope` → `@scope`): the ledger stores +/// `base_purl` verbatim as the manifest spelled it, while [`LockfileEntry`] +/// carries literal coordinates — the name feeds the registry URL and the +/// berry cache-zip recipe. fn parse_base_purl_coords(base_purl: &str) -> Option<(String, String)> { let rest = base_purl.strip_prefix("pkg:")?; let (_, name_ver) = rest.split_once('/')?; @@ -1269,7 +1222,13 @@ fn parse_base_purl_coords(base_purl: &str) -> Option<(String, String)> { if name.is_empty() || version.is_empty() { return None; } - Some((name.to_string(), version.to_string())) + let name = name + .split('/') + .map(percent_decode_purl_component) + .collect::>() + .join("/"); + let version = percent_decode_purl_component(version).into_owned(); + Some((name, version)) } /// First wiring record of one of `kinds` carrying an `original` payload. @@ -1284,10 +1243,6 @@ fn wiring_original<'a>( .and_then(|r| r.original.as_ref()) } -fn is_hex_of_len(s: &str, len: usize) -> bool { - s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit()) -} - /// Per-flavor npm recovery: the wiring kinds disambiguate the lock flavor, /// each fragment yields (resolved?, integrity). fn recover_npm_fragment( @@ -1893,7 +1848,6 @@ __metadata: assert_eq!(out[0].integrity, LockIntegrity::Sri("sha512-x==".into())); } - #[cfg(feature = "cargo")] #[tokio::test] async fn cargo_lock_inventories_crates_io_entries() { let tmp = tempfile::tempdir().unwrap(); @@ -1946,7 +1900,6 @@ checksum = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" assert_eq!(entry(&entries, "git-dep").integrity, LockIntegrity::None); } - #[cfg(feature = "golang")] #[tokio::test] async fn go_sum_inventories_module_zip_lines() { let tmp = tempfile::tempdir().unwrap(); @@ -1999,7 +1952,6 @@ checksum = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ); } - #[cfg(feature = "composer")] #[tokio::test] async fn composer_lock_inventories_dist_entries() { let tmp = tempfile::tempdir().unwrap(); @@ -2130,6 +2082,35 @@ source = { editable = "." } assert!(!entries.iter().any(|e| e.name == "local-proj")); } + #[tokio::test] + async fn uv_lock_one_line_wheels_array_pairs_the_pure_wheel_with_its_own_hash() { + // A one-line `wheels = […]` array (valid TOML — hand-maintained or + // formatter-collapsed locks) listing a platform wheel BEFORE the + // pure one: the entry must carry the pure wheel's url+hash, never + // the first url/hash on the line. + let tmp = tempfile::tempdir().unwrap(); + let platform_sha = "a".repeat(64); + let pure_sha = "b".repeat(64); + write( + tmp.path(), + "uv.lock", + &format!( + "version = 1\n\n[[package]]\nname = \"six\"\nversion = \"1.16.0\"\n\ + source = {{ registry = \"https://pypi.org/simple\" }}\n\ + wheels = [{{ url = \"https://files.pythonhosted.org/packages/aa/six-1.16.0-cp312-cp312-macosx_11_0_arm64.whl\", hash = \"sha256:{platform_sha}\" }}, {{ url = \"https://files.pythonhosted.org/packages/bb/six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:{pure_sha}\" }}]\n" + ), + ) + .await; + + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + let six = entry(&entries, "six"); + assert!( + six.resolved.as_deref().unwrap().ends_with("-none-any.whl"), + "the platform wheel must never be resolved as pure: {six:?}" + ); + assert_eq!(six.integrity, LockIntegrity::Sha256Hex(pure_sha)); + } + #[tokio::test] async fn poetry_and_requirements_are_discovery_only() { let tmp = tempfile::tempdir().unwrap(); @@ -2422,6 +2403,42 @@ mod recover_tests { assert!(recover_lock_entry(tmp.path(), &locked).await.is_err()); } + #[tokio::test] + async fn recover_decodes_percent_encoded_base_purl() { + // The ledger stores base_purl verbatim as the manifest spelled it — + // often percent-encoded (`pkg:npm/%40scope/x@1.2.3`). The recovered + // entry must carry literal coordinates: the name feeds the registry + // tarball URL and the berry cache-zip recipe (which embeds it in + // member paths), so an encoded name fails every checksum rebuild. + let tmp = tempfile::tempdir().unwrap(); + let e = entry( + "npm", + "pkg:npm/%40scope/x@1.2.3", + vec![rec( + "yarn_berry_lock_entry", + serde_json::json!(["\"@scope/x@npm:1.2.3\":", " checksum: 10c0/abcdef"]), + )], + ); + let got = recover_lock_entry(tmp.path(), &e).await.unwrap(); + assert_eq!(got.name, "@scope/x"); + assert_eq!(got.purl, "pkg:npm/@scope/x@1.2.3"); + + // Version components decode too (`1.0.0%2Bbuild` → `1.0.0+build`). + let e = entry( + "npm", + "pkg:npm/x@1.0.0%2Bbuild", + vec![rec( + "npm_lock_entry", + serde_json::json!({ + "resolved": "https://registry.npmjs.org/x/-/x-1.0.0+build.tgz", + "integrity": "sha512-AAAA", + }), + )], + ); + let got = recover_lock_entry(tmp.path(), &e).await.unwrap(); + assert_eq!(got.version, "1.0.0+build"); + } + #[tokio::test] async fn unrecoverable_fragments_fail_closed() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/patch/vendor/maven_repo.rs b/crates/socket-patch-core/src/patch/vendor/maven_repo.rs index 72a86a6b..043adfef 100644 --- a/crates/socket-patch-core/src/patch/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/patch/vendor/maven_repo.rs @@ -56,9 +56,7 @@ //! artifact never leaves a dangling ``. use std::collections::HashMap; -use std::io::Read as _; -use std::io::Write as _; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::time::Duration; use serde_json::Value; @@ -67,18 +65,19 @@ use sha2::{Digest as _, Sha256}; use crate::constants::USER_AGENT; use crate::manifest::schema::{PatchFileInfo, PatchRecord}; -use crate::patch::apply::{ - is_safe_relative_subpath, normalize_file_path, ApplyResult, PatchSources, VerifyResult, - VerifyStatus, -}; +use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::remove_tree; use crate::patch::path_safety::is_safe_single_segment; -use crate::utils::fs::atomic_write_bytes; +use crate::utils::fs::{atomic_write_bytes, atomic_write_bytes_preserving_mode}; use crate::utils::purl::{build_maven_purl, parse_maven_purl}; +use super::common::{ + already_patched_result, done, failed_result, rebuild_zip, refused, synthesized_result, + zip_matches_after_hashes, +}; use super::path::vendor_uuid_dir_rel; use super::registry_fetch::extract_zip; -use super::service_fetch::{fetch_verified_archive, ServiceArtifact}; +use super::service_fetch::{service_archive_copy, ServiceCopy}; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; @@ -92,9 +91,6 @@ const PROJECT_POM: &str = "pom.xml"; /// repository id we added, which the revert ownership gate keys off. const REPO_WIRING_KIND: &str = "maven_pom_repository"; -/// Marker schema version written into `socket-patch.vendor.json`. -const MARKER_SCHEMA_VERSION: u32 = 1; - /// Bound on a pom download from the registry — a pom is dependency metadata /// (small XML); a multi-MB response is a mirror serving the wrong thing. const MAX_POM_BYTES: usize = 8 * 1024 * 1024; @@ -119,18 +115,12 @@ fn group_id_to_path(group_id: &str) -> String { } /// A groupId is safe to convert to a path and join onto the vendor root: each -/// dot-delimited segment is non-empty and not a traversal token, and the whole -/// string carries no separator/backslash/colon/NUL. Mirrors the maven crawler's +/// dot-delimited segment must be a safe path segment on its own (non-empty, no +/// separator/backslash/colon/NUL), which also rejects the empty string and +/// leading/trailing/double dots. Same delegation as the maven crawler's /// `is_safe_maven_coordinate` group half. Fails closed on tampered coordinates. fn is_safe_group_id(group_id: &str) -> bool { - !group_id.is_empty() - && !group_id.contains('/') - && !group_id.contains('\\') - && !group_id.contains(':') - && !group_id.contains('\0') - && group_id - .split('.') - .all(|seg| !seg.is_empty() && seg != "." && seg != "..") + group_id.split('.').all(is_safe_single_segment) } /// Vendor a Maven package: rebuild a patched `.jar` under a committed maven2 @@ -186,16 +176,20 @@ pub async fn vendor_maven( let jar_copy_rel = format!("{leaf_rel}/{jar_leaf}"); let uuid_dir = project_root.join(&uuid_dir_rel); let leaf_dir = project_root.join(&leaf_rel); - let jar_path = leaf_dir.join(&jar_leaf); + // Join the full forward-slash rel rather than `leaf_dir.join(&jar_leaf)`: + // the joined form puts an OS separator (`\` on Windows) before the leaf + // while every other reported path keeps the rel's forward slashes — + // `package_path` reports (and tests compare) this as a display string. + let jar_path = project_root.join(&jar_copy_rel); let repo_id = format!("socket-patch-vendor-{}", record.uuid); // A patch with no files is meaningless to vendor: no-op success, no edits. if record.files.is_empty() { - return VendorOutcome::Done { - result: synthesized_result(purl, &jar_path, Vec::new(), true, None), - entry: None, - warnings: Vec::new(), - }; + return done( + synthesized_result(purl, &jar_path, Vec::new(), true, None), + None, + Vec::new(), + ); } // ── project pom.xml: presence + aggregator/gradle refusals ──────────── @@ -244,26 +238,20 @@ pub async fn vendor_maven( // sidecars are all in sync → touch nothing, report AlreadyPatched. `entry` // stays `None`: the first run's ledger entry holds the only copy of the // verbatim pre-vendor pom.xml, and re-recording here would clobber it. - let repo_wired = pom_xml_text.contains(&repo_id); - if repo_wired { + if pom_xml_text.contains(&repo_id) { if artifact_in_sync(&leaf_dir, &jar_leaf, &pom_leaf, &record.files).await { - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); - return VendorOutcome::Done { - result: synthesized_result(purl, &jar_path, verified, true, None), - entry: None, - warnings: vec![shadow_warning], - }; + return done( + already_patched_result(purl, &jar_path, &record.files), + None, + vec![shadow_warning], + ); } // Wired but the committed artifact is missing/stale: rebuild the // ARTIFACT only. pom.xml is already correct, and the full path would // re-record the live vendored pom.xml as `original`, breaking revert. if !dry_run { let mut warnings: Vec = vec![shadow_warning]; - let (_bytes, result) = match materialise_and_write( + let (_bytes, mut result) = match materialise_and_write( purl, installed_dir, &uuid_dir, @@ -287,12 +275,9 @@ pub async fn vendor_maven( Err(outcome) => return *outcome, }; if !result.success { - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } + result.package_path = jar_path.display().to_string(); warnings.push(VendorWarning::new( "vendor_artifact_rebuilt", format!( @@ -300,11 +285,7 @@ pub async fn vendor_maven( stale; rebuilt at {leaf_rel} (pom.xml untouched)" ), )); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } // Dry runs fall through to the verify-only preview below. } @@ -324,11 +305,7 @@ pub async fn vendor_maven( &mut dry_warnings, ) .await; - return VendorOutcome::Done { - result, - entry: None, - warnings: dry_warnings, - }; + return done(result, None, dry_warnings); } // ── materialise the patched jar + real pom + sidecars ───────────────── @@ -359,11 +336,7 @@ pub async fn vendor_maven( if !result.success { // The rebuild left the result un-successful (and cleaned up its own // partial artifact); pom.xml was never touched. - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } result.package_path = jar_path.display().to_string(); @@ -374,36 +347,20 @@ pub async fn vendor_maven( let _ = remove_tree(&uuid_dir).await; result.success = false; result.error = Some(detail); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } }; - if let Err(e) = atomic_write_bytes(&pom_xml_path, new_pom_xml.as_bytes()).await { + if let Err(e) = atomic_write_bytes_preserving_mode(&pom_xml_path, new_pom_xml.as_bytes()).await + { let _ = remove_tree(&uuid_dir).await; result.success = false; result.error = Some(format!("failed to write {}: {e}", pom_xml_path.display())); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } // ── marker + ledger entry ───────────────────────────────────────────── let base_purl = build_maven_purl(group_id, artifact_id, version); - let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); - vulnerabilities.sort(); - let marker = VendorMarker { - schema_version: MARKER_SCHEMA_VERSION, - purl: base_purl.clone(), - patch_uuid: record.uuid.clone(), - ecosystem: "maven".to_string(), - vulnerabilities, - vendored_at: vendored_at.to_string(), - }; + let marker = VendorMarker::new("maven", &base_purl, record, vendored_at); if let Err(e) = write_marker(&uuid_dir, &marker).await { // Informational only (state.json is the ledger of record) — a marker // failure must not fail an otherwise-wired vendor. @@ -451,11 +408,7 @@ pub async fn vendor_maven( pipenv: None, }; - VendorOutcome::Done { - result, - entry: Some(entry), - warnings, - } + done(result, Some(entry), warnings) } /// Revert a Maven vendor entry: surgically remove our `` from @@ -561,39 +514,31 @@ async fn materialise_and_write( ) -> Result<(Vec, ApplyResult), Box> { // The patched jar first (service Tier A, else local rebuild). A non-fatal // failure returns an un-successful ApplyResult with nothing written. - let (jar_bytes, result) = match maven_service_copy(service, record, artifact_id, warnings).await - { - MavenServiceCopy::Used(bytes) => { - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); - ( - bytes, - synthesized_result(purl, jar_path, verified, true, None), - ) - } - MavenServiceCopy::HardFail(outcome) => return Err(outcome), - MavenServiceCopy::FallBack => { - match local_rebuild_jar( - purl, - installed_dir, - jar_path, - artifact_id, - version, - record, - sources, - force, - warnings, - ) - .await - { - Ok(pair) => pair, - Err(outcome) => return Err(outcome), + let (jar_bytes, result) = + match service_archive_copy(service, &record.uuid, artifact_id, ".jar", warnings).await { + ServiceCopy::Used(bytes) => { + (bytes, already_patched_result(purl, jar_path, &record.files)) } - } - }; + ServiceCopy::HardFail(outcome) => return Err(outcome), + ServiceCopy::FallBack => { + match local_rebuild_jar( + purl, + installed_dir, + jar_path, + artifact_id, + version, + record, + sources, + force, + warnings, + ) + .await + { + Ok(pair) => pair, + Err(outcome) => return Err(outcome), + } + } + }; if !result.success { // Local rebuild reported a failure; nothing on disk to clean up (the // jar is rebuilt in memory and only written below on success). @@ -682,7 +627,7 @@ async fn local_rebuild_jar( // dependency resolve reads the central directory, so lexicographic entry // order + fixed timestamps yield stable bytes across re-runs). let stage_path = stage.path().to_path_buf(); - let rezip = tokio::task::spawn_blocking(move || rebuild_jar(&stage_path)).await; + let rezip = tokio::task::spawn_blocking(move || rebuild_zip(&stage_path, None)).await; let jar_bytes = match rezip { Ok(Ok(b)) => b, Ok(Err(e)) => { @@ -701,84 +646,6 @@ async fn local_rebuild_jar( Ok((jar_bytes, result)) } -/// Outcome of attempting to materialise the jar from the patch service. -enum MavenServiceCopy { - /// The prebuilt patched `.jar` bytes (write them verbatim). - Used(Vec), - /// Bubble this terminal outcome (boxed — `VendorOutcome` is large). - HardFail(Box), - /// Fall back to the local rebuild. - FallBack, -} - -/// Download + integrity-verify the prebuilt patched `.jar` (Tier A: the verified -/// archive bytes ARE the vendored jar). Maps each service outcome onto the -/// `auto` / `service` fallback policy. -async fn maven_service_copy( - service: Option<&VendorServiceConfig>, - record: &PatchRecord, - artifact_id: &str, - warnings: &mut Vec, -) -> MavenServiceCopy { - let Some(cfg) = service else { - return MavenServiceCopy::FallBack; - }; - if !cfg.service_enabled() { - return MavenServiceCopy::FallBack; - } - fn hard(code: &'static str, detail: String) -> MavenServiceCopy { - MavenServiceCopy::HardFail(Box::new(VendorOutcome::Refused { code, detail })) - } - let miss = |warnings: &mut Vec, code: &'static str, reason: String| { - if cfg.source.requires_service() { - hard("vendor_prebuilt_required", reason) - } else { - warnings.push(VendorWarning::new( - code, - format!("{reason}; building locally instead"), - )); - MavenServiceCopy::FallBack - } - }; - match fetch_verified_archive(cfg, &record.uuid, artifact_id).await { - ServiceArtifact::Ready(archive) => { - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored {artifact_id} from the patch service ({})", - archive.source_url - ), - )); - MavenServiceCopy::Used(archive.bytes) - } - ServiceArtifact::IntegrityMismatch(reason) => miss( - warnings, - "vendor_prebuilt_integrity_mismatch", - format!("prebuilt .jar failed integrity ({reason})"), - ), - ServiceArtifact::Pending => miss( - warnings, - "vendor_prebuilt_pending", - "prebuilt .jar is still building".to_string(), - ), - ServiceArtifact::Unavailable(reason) => { - if cfg.source.requires_service() { - hard( - "vendor_prebuilt_required", - format!("prebuilt .jar unavailable: {reason}"), - ) - } else { - MavenServiceCopy::FallBack - } - } - ServiceArtifact::Failed(reason) => miss( - warnings, - "vendor_prebuilt_unavailable", - format!("patch service request failed ({reason})"), - ), - } -} - /// Acquire the REAL upstream pom bytes: the cached `~/.m2` copy first (the /// common case — the package was resolved locally), then a maven2 registry /// download when the service is enabled. An `Err(detail)` maps to a @@ -922,46 +789,6 @@ async fn extract_jar_to_stage(src_jar: &Path) -> Result Result, String> { - let mut entries: Vec<(String, Vec)> = Vec::new(); - for entry in walkdir::WalkDir::new(stage).follow_links(false) { - let entry = entry.map_err(|e| format!("walk {}: {e}", stage.display()))?; - if !entry.file_type().is_file() { - continue; - } - let rel = entry - .path() - .strip_prefix(stage) - .map_err(|e| format!("strip prefix: {e}"))?; - let name = rel.to_string_lossy().replace('\\', "/"); - let bytes = std::fs::read(entry.path()).map_err(|e| format!("read {name}: {e}"))?; - entries.push((name, bytes)); - } - entries.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - for (name, bytes) in &entries { - let options = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated) - .compression_level(Some(6)) - .last_modified_time(zip::DateTime::default()) - .unix_permissions(0o644); - writer - .start_file(name, options) - .map_err(|e| format!("zip start {name}: {e}"))?; - writer - .write_all(bytes) - .map_err(|e| format!("zip write {name}: {e}"))?; - } - let cursor = writer.finish().map_err(|e| format!("zip finish: {e}"))?; - Ok(cursor.into_inner()) -} - /// Write the jar + pom + their `.sha1` sidecars into the maven2 leaf dir, /// creating it. Errors are strings. async fn write_maven_artifact( @@ -996,67 +823,25 @@ async fn artifact_in_sync( pom_leaf: &str, files: &HashMap, ) -> bool { - let jar = leaf_dir.join(jar_leaf); - if !jar_matches_after_hashes(&jar, files).await { + if !zip_matches_after_hashes(&leaf_dir.join(jar_leaf), files).await { return false; } // The pom + both sidecars must exist and match their bytes. - sidecar_matches(&jar).await && sidecar_matches(&leaf_dir.join(pom_leaf)).await + sidecar_matches(leaf_dir, jar_leaf).await && sidecar_matches(leaf_dir, pom_leaf).await } -/// True when `.sha1` exists and equals the hex sha1 of ``'s bytes. -async fn sidecar_matches(file: &Path) -> bool { - let Ok(bytes) = tokio::fs::read(file).await else { +/// True when `.sha1` exists and equals the hex sha1 of ``'s bytes. +async fn sidecar_matches(leaf_dir: &Path, leaf: &str) -> bool { + let Ok(bytes) = tokio::fs::read(leaf_dir.join(leaf)).await else { return false; }; - let Ok(recorded) = tokio::fs::read_to_string(with_suffix(file, ".sha1")).await else { + let Ok(recorded) = tokio::fs::read_to_string(leaf_dir.join(format!("{leaf}.sha1"))).await + else { return false; }; recorded.trim() == sha1_hex(&bytes) } -/// Append a suffix to a path's file name (`foo.jar` + `.sha1` → `foo.jar.sha1`). -fn with_suffix(path: &Path, suffix: &str) -> PathBuf { - let mut name = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - name.push_str(suffix); - path.with_file_name(name) -} - -/// True when the committed jar exists and every patched file in it already -/// hashes to its `afterHash` (the vendor twin of the NuGet feed's -/// `nupkg_matches_after_hashes`, reading the jar's zip entries). -async fn jar_matches_after_hashes(jar_path: &Path, files: &HashMap) -> bool { - use crate::hash::git_sha256::compute_git_sha256_from_bytes; - let Ok(bytes) = tokio::fs::read(jar_path).await else { - return false; - }; - let Ok(mut archive) = zip::ZipArchive::new(std::io::Cursor::new(bytes)) else { - return false; - }; - for (file_name, info) in files { - let normalized = normalize_file_path(file_name); - // SECURITY: never look up a key that escapes the package dir — treat it - // as out-of-sync (the full pipeline would refuse it anyway). - if !is_safe_relative_subpath(normalized) { - return false; - } - let Ok(mut entry) = archive.by_name(normalized) else { - return false; - }; - let mut content = Vec::with_capacity(entry.size() as usize); - if entry.read_to_end(&mut content).is_err() { - return false; - } - if compute_git_sha256_from_bytes(&content) != info.after_hash { - return false; - } - } - true -} - fn sha1_hex(bytes: &[u8]) -> String { hex::encode(Sha1::digest(bytes)) } @@ -1067,23 +852,123 @@ fn sha1_hex(bytes: &[u8]) -> String { /// `` (or create the section before ``). The pom is /// edited by targeted string insertion so all other bytes — formatting, /// comments, key order — are preserved and a later revert restores it -/// byte-identically. +/// byte-identically. Anchors are chosen with [`find_wireable_anchor`], never a +/// bare substring match: a `` inside an XML comment or inside +/// `` would swallow the block where Maven never reads it, so the +/// build would silently resolve the UNPATCHED jar while vendor reports +/// success. fn build_repo_edit(original: &str, repo_id: &str, uuid_dir_rel: &str) -> Result { let block = repository_block(repo_id, uuid_dir_rel); - if original.contains("") { - insert_before(original, "", &block).ok_or_else(|| { - "could not locate to insert the vendored ".to_string() - }) - } else if original.contains("") { + if let Some(at) = find_wireable_anchor(original, "") { + Ok(insert_block_at(original, at, &block)) + } else if let Some(at) = find_wireable_anchor(original, "") { let section = format!(" \n{block} \n"); - insert_before(original, "", §ion).ok_or_else(|| { - "could not locate to insert a section".to_string() - }) + Ok(insert_block_at(original, at, §ion)) } else { Err("pom.xml has no to edit".to_string()) } } +/// The insertion anchor: the first occurrence of `needle` Maven will actually +/// read — outside every `` comment and outside `` (a +/// profile-scoped `` is only consulted when that profile is +/// activated, so it can never serve the always-on vendored repository). +/// `None` when every occurrence is masked. +fn find_wireable_anchor(text: &str, needle: &str) -> Option { + let mut masked = comment_spans(text); + masked.extend(profiles_spans(text, &masked)); + find_outside(text, needle, 0, &masked) +} + +/// Byte spans of `` comments (an unterminated comment runs to EOF — +/// the same drop-the-tail discipline as [`strip_xml_comments`]). +fn comment_spans(text: &str) -> Vec<(usize, usize)> { + let mut spans = Vec::new(); + let mut from = 0; + while let Some(rel) = text[from..].find("") { + Some(rel_end) => { + let end = start + 4 + rel_end + 3; + spans.push((start, end)); + from = end; + } + None => { + spans.push((start, text.len())); + break; + } + } + } + spans +} + +/// Byte spans covered by `` elements, with the tags +/// themselves matched outside `comments`. A self-closing `` spans +/// nothing; an unclosed element masks through EOF (fail-closed — better to +/// refuse than to wire a block Maven may never read). +fn profiles_spans(text: &str, comments: &[(usize, usize)]) -> Vec<(usize, usize)> { + const OPEN: &str = "` is not ``. + let after = &text[open + OPEN.len()..]; + let boundary_ok = match after.chars().next() { + None => true, + Some(c) => c == '>' || c == '/' || c.is_whitespace(), + }; + if !boundary_ok { + from = open + OPEN.len(); + continue; + } + // A self-closing `` (or ``) has no interior. + if let Some(gt) = text[open..].find('>').map(|r| open + r) { + if text[..gt].ends_with('/') { + from = gt + 1; + continue; + } + } + match find_outside(text, CLOSE, open, comments) { + Some(close) => { + let end = close + CLOSE.len(); + spans.push((open, end)); + from = end; + } + None => { + spans.push((open, text.len())); + break; + } + } + } + spans +} + +/// First occurrence of `needle` at/after `from` whose start lies outside every +/// `spans` range; `None` when only masked occurrences remain. +fn find_outside(text: &str, needle: &str, from: usize, spans: &[(usize, usize)]) -> Option { + let mut at = from; + while let Some(rel) = text[at..].find(needle) { + let pos = at + rel; + if !spans.iter().any(|&(s, e)| pos >= s && pos < e) { + return Some(pos); + } + at = pos + needle.len(); + } + None +} + +/// `insert_before` at a known byte offset: insert `insertion` (already +/// newline-terminated) at the start of the line containing `at`. +fn insert_block_at(haystack: &str, at: usize, insertion: &str) -> String { + let line_start = haystack[..at].rfind('\n').map(|n| n + 1).unwrap_or(0); + let mut out = String::with_capacity(haystack.len() + insertion.len()); + out.push_str(&haystack[..line_start]); + out.push_str(insertion); + out.push_str(&haystack[line_start..]); + out +} + /// The `` element served from the committed maven2 repo. The URL /// uses `${project.basedir}` so it resolves relative to the pom on any checkout; /// `checksumPolicy=fail` makes Maven hard-fail on a jar/pom that doesn't match @@ -1105,21 +990,6 @@ fn repository_block(repo_id: &str, uuid_dir_rel: &str) -> String { ) } -/// Insert `insertion` (already newline-terminated) immediately before the line -/// containing the first occurrence of `needle`. Returns `None` if `needle` is -/// absent. -fn insert_before(haystack: &str, needle: &str, insertion: &str) -> Option { - let idx = haystack.find(needle)?; - // Back up to the start of the needle's line so the insertion lands on its - // own line(s) directly above. - let line_start = haystack[..idx].rfind('\n').map(|n| n + 1).unwrap_or(0); - let mut out = String::with_capacity(haystack.len() + insertion.len()); - out.push_str(&haystack[..line_start]); - out.push_str(insertion); - out.push_str(&haystack[line_start..]); - Some(out) -} - /// True when the pom declares a real (non-commented) `` element — an /// aggregator/multi-module root. Comments are stripped first so a commented-out /// `` never triggers a refusal, and the open tag is boundary-matched @@ -1253,7 +1123,7 @@ async fn revert_repo_record( if dry_run { return Ok(true); } - atomic_write_bytes(pom_xml_path, original.as_bytes()) + atomic_write_bytes_preserving_mode(pom_xml_path, original.as_bytes()) .await .map_err(|e| format!("failed to restore {}: {e}", pom_xml_path.display()))?; return Ok(true); @@ -1272,7 +1142,7 @@ async fn revert_repo_record( return Ok(true); } let excised = strip_empty_repositories(&live.replacen(&block, "", 1)); - atomic_write_bytes(pom_xml_path, excised.as_bytes()) + atomic_write_bytes_preserving_mode(pom_xml_path, excised.as_bytes()) .await .map_err(|e| { format!( @@ -1310,51 +1180,11 @@ fn strip_empty_repositories(pom: &str) -> String { out } -// ── shared helpers ──────────────────────────────────────────────────────────────── - -fn refused(code: &'static str, detail: impl Into) -> VendorOutcome { - VendorOutcome::Refused { - code, - detail: detail.into(), - } -} - -fn synthesized_result( - package_key: &str, - jar_path: &Path, - files_verified: Vec, - success: bool, - error: Option, -) -> ApplyResult { - ApplyResult { - package_key: package_key.to_string(), - package_path: jar_path.display().to_string(), - success, - files_verified, - files_patched: Vec::new(), - applied_via: HashMap::new(), - error, - sidecar: None, - } -} - -fn failed_result(purl: &str, jar_path: &Path, error: String) -> ApplyResult { - synthesized_result(purl, jar_path, Vec::new(), false, Some(error)) -} - -fn already_patched_verify(file: &str) -> VerifyResult { - VerifyResult { - file: file.to_string(), - status: VerifyStatus::AlreadyPatched, - message: None, - current_hash: None, - expected_hash: None, - target_hash: None, - } -} - #[cfg(test)] mod tests { + use std::io::{Read as _, Write as _}; + use std::path::PathBuf; + use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::patch::vendor::state::VENDOR_MARKER_FILE; @@ -2113,4 +1943,191 @@ mod tests { assert!(!is_safe_group_id("a/b")); assert!(!is_safe_group_id("a:b")); } + + #[tokio::test] + async fn wires_outside_commented_repositories() { + // A commented-out section must not capture the insert: + // a block landing inside the comment is invisible to Maven, so the + // build would silently resolve the UNPATCHED jar while vendor reports + // success. + let commented = "\n\ + \x20 4.0.0\n\ + \x20 com.example\n\ + \x20 app\n\ + \x20 1.0.0\n\ + \x20 \n\ + \n"; + let (dir, blobs, installed, record) = fixture(Some(commented), true, true).await; + let root = dir.path(); + let (result, _e, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + let wired = tokio::fs::read_to_string(root.join(PROJECT_POM)) + .await + .unwrap(); + assert!( + strip_xml_comments(&wired).contains(&format!("socket-patch-vendor-{UUID}")), + "the vendored must be outside comments (Maven-visible): {wired}" + ); + } + + #[tokio::test] + async fn wires_project_root_not_profile_repositories() { + // A inside is only consulted when that + // profile is activated; anchoring our block there leaves the default + // build silently resolving the UNPATCHED jar. + let profiled = "\n\ + \x20 4.0.0\n\ + \x20 com.example\n\ + \x20 app\n\ + \x20 1.0.0\n\ + \x20 \n\ + \x20 \n\ + \x20 internal\n\ + \x20 \n\ + \x20 corphttps://corp/repo\n\ + \x20 \n\ + \x20 \n\ + \x20 \n\ + \n"; + let (dir, blobs, installed, record) = fixture(Some(profiled), true, true).await; + let root = dir.path(); + let (result, _e, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + let wired = tokio::fs::read_to_string(root.join(PROJECT_POM)) + .await + .unwrap(); + let id = format!("socket-patch-vendor-{UUID}"); + assert!(wired.contains(&id), "wired: {wired}"); + let p_open = wired.find("").unwrap(); + let p_close = wired.find("").unwrap(); + assert!( + !wired[p_open..p_close].contains(&id), + "the vendored must not land inside : {wired}" + ); + } + + #[test] + fn repo_edit_skips_commented_and_profile_anchors() { + // Only a commented → a NEW real section is created. + let commented = "\n\n\n"; + let out = + build_repo_edit(commented, "socket-patch-vendor-x", ".socket/vendor/maven/x").unwrap(); + assert!( + strip_xml_comments(&out).contains("socket-patch-vendor-x"), + "block must be Maven-visible: {out}" + ); + // Only a profile-scoped → likewise anchored at . + let profiled = "\n \n \n \n \ + \n \n \n\n"; + let out = + build_repo_edit(profiled, "socket-patch-vendor-x", ".socket/vendor/maven/x").unwrap(); + let p_close = out.find("").unwrap(); + let id_at = out.find("socket-patch-vendor-x").unwrap(); + assert!(id_at > p_close, "block must land after : {out}"); + } + + #[cfg(unix)] + #[tokio::test] + async fn wire_preserves_pom_xml_mode() { + use std::os::unix::fs::PermissionsExt as _; + let (dir, blobs, installed, record) = fixture(Some(project_pom()), true, true).await; + let root = dir.path(); + let pom_path = root.join(PROJECT_POM); + tokio::fs::set_permissions(&pom_path, std::fs::Permissions::from_mode(0o600)) + .await + .unwrap(); + + let (result, _e, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + let mode = tokio::fs::metadata(&pom_path) + .await + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!( + mode, 0o600, + "wiring must not reset the user's pom.xml mode" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn revert_preserves_pom_xml_mode() { + use std::os::unix::fs::PermissionsExt as _; + let (dir, blobs, installed, record) = fixture(Some(project_pom()), true, true).await; + let root = dir.path(); + let pom_path = root.join(PROJECT_POM); + + async fn mode_of(p: &Path) -> u32 { + tokio::fs::metadata(p).await.unwrap().permissions().mode() & 0o7777 + } + + // Byte-identical fast path (whole-file restore). + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(result.success); + let entry = entry.unwrap(); + tokio::fs::set_permissions(&pom_path, std::fs::Permissions::from_mode(0o600)) + .await + .unwrap(); + let outcome = revert_maven(&entry, root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + mode_of(&pom_path).await, + 0o600, + "whole-file restore must not reset the pom.xml mode" + ); + + // Re-vendor, drift with a user edit, revert → the excise path writes too. + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(result.success); + let entry = entry.unwrap(); + let wired = tokio::fs::read_to_string(&pom_path).await.unwrap(); + let edited = wired.replacen( + "", + " \n \n", + 1, + ); + tokio::fs::write(&pom_path, &edited).await.unwrap(); + tokio::fs::set_permissions(&pom_path, std::fs::Permissions::from_mode(0o640)) + .await + .unwrap(); + let outcome = revert_maven(&entry, root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + mode_of(&pom_path).await, + 0o640, + "the excise path must not reset the pom.xml mode" + ); + } + + #[tokio::test] + async fn wired_rebuild_reports_vendored_jar_path() { + // The wired-but-missing-artifact rebuild leg must report the vendored + // jar path, not the (deleted) temp stage the rebuild ran in. + let (dir, blobs, installed, record) = fixture(Some(project_pom()), true, true).await; + let root = dir.path(); + let (r1, _e, _w) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r1.success); + remove_tree(&root.join(format!(".socket/vendor/maven/{UUID}"))) + .await + .unwrap(); + let (r2, _e2, _w2) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r2.success, "{:?}", r2.error); + assert_eq!( + r2.package_path, + root.join(jar_rel()).display().to_string(), + "rebuild leg must report the vendored jar, not the temp stage" + ); + } } diff --git a/crates/socket-patch-core/src/patch/vendor/mod.rs b/crates/socket-patch-core/src/patch/vendor/mod.rs index 753e035e..f6ec886b 100644 --- a/crates/socket-patch-core/src/patch/vendor/mod.rs +++ b/crates/socket-patch-core/src/patch/vendor/mod.rs @@ -13,17 +13,20 @@ //! //! | eco | artifact | wiring | //! |----------|---------------------|------------------------------------------------| -//! | npm | deterministic tgz | package-lock.json `resolved`+`integrity` only | +//! | npm | deterministic tgz | per lockfile flavor: package-lock `resolved`+`integrity`, yarn classic, yarn berry, pnpm, bun ([`npm_flavor`] routes) | //! | cargo | crate dir | `.cargo/config.toml` `[patch.crates-io]` + Cargo.lock surgery | //! | golang | module dir | `go.mod` `replace` ([`ReplaceOwner::Vendor`]) | //! | composer | package dir | composer.lock `dist` → `{type: path}` | //! | gem | gem dir (+gemspec) | Gemfile `path:` + Gemfile.lock PATH pair | -//! | pypi | rebuilt wheel | uv: pyproject+uv.lock pair; pip: requirements | +//! | pypi | rebuilt wheel | per manifest flavor: uv, poetry, pdm, pipenv, requirements ([`pypi`] routes) | +//! | maven | rebuilt jar | committed `file://` maven2 repo + pom `` ([`maven_repo`]) | +//! | nuget | rebuilt nupkg | folder feed + `nuget.config` + `packages.lock.json` pin ([`nuget_feed`]) | //! //! npm requests route through [`npm_flavor`], which content-sniffs the -//! project's lockfile (package-lock / yarn / pnpm / bun) and dispatches to -//! the matching backend — today only the package-lock backend exists and -//! the other flavors refuse with stable reason codes. +//! project's lockfile (not just file presence) and dispatches to the +//! matching backend — all five flavors have real backends; a lockfile the +//! probe can't classify (or a berry PnP layout) refuses with a stable +//! reason code. //! //! ## Ownership & reversal //! @@ -44,46 +47,40 @@ pub mod state; mod berry_zip; pub mod bun_lock; -#[cfg(feature = "cargo")] pub mod cargo; -#[cfg(feature = "cargo")] pub mod cargo_config; -#[cfg(feature = "cargo")] -pub mod cargo_lock; -#[cfg(feature = "composer")] +pub(crate) mod cargo_lock; +pub(crate) mod common; pub mod composer_lock; pub mod gem; -#[cfg(feature = "golang")] pub mod golang; pub mod lock_inventory; -#[cfg(feature = "maven")] pub mod maven_repo; mod npm_common; pub mod npm_flavor; pub mod npm_lock; -pub mod npm_pack; -#[cfg(feature = "nuget")] +mod npm_pack; pub mod nuget_feed; pub mod pnpm_lock; pub mod pypi; pub mod pypi_pdm; pub mod pypi_pipenv; pub mod pypi_poetry; -pub mod pypi_requirements; -pub mod pypi_uv; -pub mod pypi_wheel; +mod pypi_requirements; +mod pypi_uv; +mod pypi_wheel; pub mod registry_fetch; pub(crate) mod service_fetch; mod toml_surgery; -pub mod verify; -pub mod yarn_berry_lock; -pub mod yarn_classic_lock; +pub(crate) mod verify; +pub(crate) mod yarn_berry_lock; +mod yarn_classic_lock; -pub use path::{ecosystem_dir_for_purl, parse_vendor_path, VendorPathParts, VENDOR_DIR}; -pub use state::{load_state, save_state, VendorEntry, VendorState, VENDOR_STATE_REL}; +pub use path::{ecosystem_dir_for_purl, parse_vendor_path}; +pub use state::{load_state, lookup_entry, save_state, VendorEntry, VendorState, VENDOR_STATE_REL}; pub use verify::{check_vendored_artifact, file_sha256_hex, ArtifactHealth}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use crate::manifest::schema::{PatchFileInfo, PatchRecord}; @@ -91,6 +88,7 @@ use crate::patch::apply::{ apply_package_patch, is_safe_relative_subpath, normalize_file_path, ApplyResult, PatchSources, VerifyStatus, }; +use crate::utils::purl::strip_purl_qualifiers; /// A non-fatal advisory surfaced as a warning event (`code` is a stable /// reason tag from the CLI contract; `detail` is human text). @@ -237,11 +235,14 @@ pub(crate) fn mismatch_overwrite_warnings( } /// Patch-target files (non-empty `beforeHash`) absent from the staged -/// copy. Vendor staging force-applies (see [`force_apply_staged`]), and -/// force silently SKIPS missing files — which would pack an artifact -/// without the fix. This pre-check restores the strict apply's -/// fail-closed behavior for the non-`--force` path. Unsafe keys are -/// skipped here: the apply pipeline itself rejects them fail-closed. +/// copy — or present but not hashable (a directory, a non-regular file, +/// an unreadable file). Vendor staging force-applies (see +/// [`force_apply_staged`]), and force silently SKIPS every file verify +/// reports as `NotFound` — both truly-missing files AND hash failures — +/// which would pack an artifact without the fix. This pre-check restores +/// the strict apply's fail-closed behavior for the non-`--force` path. +/// Unsafe keys are skipped here: the apply pipeline itself rejects them +/// fail-closed. pub(crate) async fn missing_existing_patch_files( staged_dir: &Path, files: &HashMap, @@ -255,10 +256,15 @@ pub(crate) async fn missing_existing_patch_files( if !is_safe_relative_subpath(normalized) { continue; } - if tokio::fs::metadata(staged_dir.join(normalized)) - .await - .is_err() - { + let path = staged_dir.join(normalized); + let hashable = match tokio::fs::metadata(&path).await { + Err(_) => false, + // The is_file gate must come BEFORE the open probe: opening a + // non-regular file (FIFO) can block indefinitely. + Ok(m) if !m.is_file() => false, + Ok(_) => tokio::fs::File::open(&path).await.is_ok(), + }; + if !hashable { missing.push(file_name.clone()); } } @@ -266,21 +272,6 @@ pub(crate) async fn missing_existing_patch_files( missing } -/// A failed synthesized [`ApplyResult`] in the shape the strict apply -/// pipeline would have produced (success=false, `error` set, no files). -pub(crate) fn failed_apply_result(purl: &str, error: String) -> ApplyResult { - ApplyResult { - package_key: purl.to_string(), - package_path: String::new(), - success: false, - files_verified: Vec::new(), - files_patched: Vec::new(), - applied_via: HashMap::new(), - error: Some(error), - sidecar: None, - } -} - /// Patched-content blobs harvested from the committed vendor artifacts: /// for every manifest record whose patch uuid matches its ledger entry, /// hash the artifact's files (git-sha256, the manifest hash) and keep the @@ -294,7 +285,7 @@ pub(crate) fn failed_apply_result(purl: &str, error: String) -> ApplyResult { /// pipeline's afterHash gate decides correctness either way). pub async fn harvest_artifact_blobs( project_root: &Path, - manifest_patches: &HashMap, + manifest_patches: &HashMap, ) -> HashMap> { use crate::hash::git_sha256::compute_git_sha256_from_bytes; @@ -310,7 +301,7 @@ pub async fn harvest_artifact_blobs( } for (purl, record) in manifest_patches { - let needed: std::collections::HashSet<&str> = record + let needed: HashSet<&str> = record .files .values() .map(|f| f.after_hash.as_str()) @@ -323,7 +314,7 @@ pub async fn harvest_artifact_blobs( state .entries .values() - .find(|e| e.base_purl == crate::utils::purl::strip_purl_qualifiers(purl)) + .find(|e| e.base_purl == strip_purl_qualifiers(purl)) }) else { continue; }; @@ -333,7 +324,7 @@ pub async fn harvest_artifact_blobs( // SECURITY: the artifact path comes from the committed, tamperable // ledger and is joined onto the project root for READING only — // still, never follow an escaping path. - if !crate::patch::apply::is_safe_relative_subpath(&entry.artifact.path) { + if !is_safe_relative_subpath(&entry.artifact.path) { continue; } let artifact = project_root.join(&entry.artifact.path); @@ -359,12 +350,19 @@ pub async fn harvest_artifact_blobs( || lower.ends_with(".nupkg") || lower.ends_with(".jar") { + // Gate on metadata BEFORE reading: opening a non-regular file + // planted at the artifact path (a FIFO) blocks until a writer + // appears — wedging the run — and the size cap must bound the + // read, not audit it after the bytes are already in memory. + if !tokio::fs::metadata(&artifact) + .await + .is_ok_and(|m| m.is_file() && m.len() <= MAX_ARTIFACT_BYTES) + { + continue; + } let Ok(bytes) = tokio::fs::read(&artifact).await else { continue; }; - if bytes.len() as u64 > MAX_ARTIFACT_BYTES { - continue; - } let Ok(mut archive) = zip::ZipArchive::new(std::io::Cursor::new(bytes)) else { continue; }; @@ -398,14 +396,20 @@ pub async fn harvest_artifact_blobs( if !needed.contains(info.after_hash.as_str()) { continue; } - let rel = crate::patch::apply::normalize_file_path(file_name); - if !crate::patch::apply::is_safe_relative_subpath(rel) { + let rel = normalize_file_path(file_name); + if !is_safe_relative_subpath(rel) { continue; } - if let Ok(content) = tokio::fs::read(artifact.join(rel)).await { - if content.len() as u64 > MAX_FILE_BYTES { - continue; - } + let path = artifact.join(rel); + // Same gate as the zip-shaped artifacts above: never open a + // non-regular file (FIFO wedge), bound the read up front. + if !tokio::fs::metadata(&path) + .await + .is_ok_and(|m| m.is_file() && m.len() <= MAX_FILE_BYTES) + { + continue; + } + if let Ok(content) = tokio::fs::read(&path).await { let h = compute_git_sha256_from_bytes(&content); if h == info.after_hash { out.insert(h, content); @@ -448,8 +452,9 @@ pub(crate) async fn force_apply_staged( if !force { let missing = missing_existing_patch_files(staged_dir, &record.files).await; if let Some(first) = missing.first() { - return failed_apply_result( + return common::failed_result( purl, + Path::new(""), format!("Cannot apply patch: {first} - File not found"), ); } @@ -529,28 +534,14 @@ pub fn is_vendorable(purl: &str) -> bool { ecosystem_dir_for_purl(purl).is_some() } -/// Cheap probe used by `apply` to respect vendor ownership: is `purl` -/// recorded as vendored in the committed ledger? -pub async fn is_purl_vendored(project_root: &std::path::Path, purl: &str) -> bool { - match load_state(project_root).await { - Ok(state) => { - state.entries.contains_key(purl) || state.entries.values().any(|e| e.base_purl == purl) - } - Err(_) => false, - } -} - /// Every purl spelling under which the ledger's entries are addressable: /// each entry's map key (the manifest purl, possibly qualified), its -/// resolved base purl, and the qualifier-stripped key. The one-load, -/// many-lookups companion to [`is_purl_vendored`] for callers that match -/// whole purl sets against vendor ownership (apply / rollback / scan -/// prune). An unreadable ledger degrades to the empty set — the same -/// fail-open contract as `is_purl_vendored`; mutating callers that need -/// fail-closed semantics use [`load_state`] directly. -pub async fn vendored_purl_keys( - project_root: &std::path::Path, -) -> std::collections::HashSet { +/// resolved base purl, and the qualifier-stripped key. Loaded once for +/// callers that match whole purl sets against vendor ownership (apply / +/// rollback / scan prune). An unreadable ledger degrades to the empty set +/// (fail-open); mutating callers that need fail-closed semantics use +/// [`load_state`] directly. +pub async fn vendored_purl_keys(project_root: &Path) -> HashSet { match load_state(project_root).await { Ok(state) => state .entries @@ -559,11 +550,11 @@ pub async fn vendored_purl_keys( [ key.clone(), entry.base_purl.clone(), - crate::utils::purl::strip_purl_qualifiers(key).to_string(), + strip_purl_qualifiers(key).to_string(), ] }) .collect(), - Err(_) => std::collections::HashSet::new(), + Err(_) => HashSet::new(), } } @@ -676,6 +667,114 @@ mod vendor_source_tests { } } +#[cfg(test)] +mod staging_tests { + use super::*; + use crate::manifest::schema::{PatchFileInfo, PatchRecord}; + + fn one_file_record(file: &str) -> PatchRecord { + let mut files = HashMap::new(); + files.insert( + file.to_string(), + PatchFileInfo { + before_hash: "aa".repeat(32), + after_hash: "bb".repeat(32), + }, + ); + PatchRecord { + uuid: "11111111-2222-4333-8444-555555555555".to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: String::new(), + license: "MIT".to_string(), + tier: "free".to_string(), + } + } + + /// A patch target that EXISTS but cannot be hashed (here: a directory + /// where a file is expected) must fail the pre-check. The forced apply + /// downgrades verify's hash failure to a silent NotFound skip, which + /// would pack an artifact WITHOUT the fix while reporting success. + #[tokio::test] + async fn directory_at_patch_target_is_flagged_missing() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("index.js")).unwrap(); + let record = one_file_record("index.js"); + let missing = missing_existing_patch_files(tmp.path(), &record.files).await; + assert_eq!(missing, vec!["index.js".to_string()]); + } + + /// Same class via file permissions: an unreadable staged file hash-fails + /// in verify and would be force-skipped silently. + #[cfg(unix)] + #[tokio::test] + async fn unreadable_patch_target_is_flagged_missing() { + use std::os::unix::fs::PermissionsExt as _; + if unsafe { libc::geteuid() } == 0 { + return; // root reads anything; the probe can't fail + } + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("index.js"); + std::fs::write(&target, b"original").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o000)).unwrap(); + let record = one_file_record("index.js"); + let missing = missing_existing_patch_files(tmp.path(), &record.files).await; + assert_eq!(missing, vec!["index.js".to_string()]); + } + + /// A readable staged file (even with mismatched content) is NOT flagged — + /// that's the force-overwrite path, not the missing path. + #[tokio::test] + async fn readable_target_is_not_flagged() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("index.js"), b"whatever").unwrap(); + let record = one_file_record("index.js"); + assert!( + missing_existing_patch_files(tmp.path(), &record.files) + .await + .is_empty() + ); + } + + /// End-to-end through the vendor staging entrypoint: without `--force`, + /// an unhashable target must fail the whole staged apply closed rather + /// than succeed with the file silently skipped. + #[tokio::test] + async fn force_apply_staged_fails_closed_on_unhashable_target() { + let tmp = tempfile::tempdir().unwrap(); + let staged = tmp.path().join("stage"); + std::fs::create_dir_all(staged.join("index.js")).unwrap(); + let blobs = tmp.path().join("blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + let record = one_file_record("index.js"); + let sources = PatchSources::blobs_only(&blobs); + + let mut warnings = Vec::new(); + let result = force_apply_staged( + "pkg:npm/x@1.0.0", + &staged, + &record, + &sources, + false, + false, + "x", + "1.0.0", + &mut warnings, + ) + .await; + assert!( + !result.success, + "an unhashable patch target must fail the staged apply closed, got {result:?}" + ); + assert!( + result.error.as_deref().unwrap_or("").contains("index.js"), + "error names the file: {:?}", + result.error + ); + } +} + #[cfg(test)] mod harvest_tests { use super::*; @@ -803,6 +902,80 @@ mod harvest_tests { assert!(harvest_artifact_blobs(&project, &patches).await.is_empty()); } + /// Release a reader wedged in `open(2)` on `fifo` (pre-fix behavior) so + /// the tokio blocking pool can shut down; the write side closing + /// immediately EOFs the read. + #[cfg(unix)] + fn unblock_fifo_reader(fifo: &Path) { + let fifo = fifo.to_path_buf(); + std::thread::spawn(move || { + let _ = std::fs::OpenOptions::new().write(true).open(fifo); + }); + } + + #[cfg(unix)] + fn mkfifo(path: &Path) { + use std::os::unix::ffi::OsStrExt as _; + let c = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o644) }, 0); + } + + /// A FIFO planted at a zip-shaped artifact path must be skipped, not + /// read: `open(2)` on a FIFO blocks until a writer appears, wedging the + /// whole harvest (and with it the vendor run) forever. + #[cfg(unix)] + #[tokio::test] + async fn fifo_zip_artifact_never_wedges_harvest() { + let tmp = tempfile::tempdir().unwrap(); + let purl = "pkg:pypi/lib@1.0.0"; + let rel = format!(".socket/vendor/pypi/{UUID}/lib-1.0.0-py3-none-any.whl"); + let fifo = tmp.path().join(&rel); + std::fs::create_dir_all(fifo.parent().unwrap()).unwrap(); + mkfifo(&fifo); + write_ledger(tmp.path(), purl, UUID, &rel); + + let (k, r) = record(purl, UUID, "lib/__init__.py", PATCHED); + let patches = HashMap::from([(k, r)]); + let res = tokio::time::timeout( + std::time::Duration::from_secs(5), + harvest_artifact_blobs(tmp.path(), &patches), + ) + .await; + if res.is_err() { + unblock_fifo_reader(&fifo); + } + let map = res.expect("harvest must not hang on a FIFO artifact"); + assert!(map.is_empty(), "a FIFO artifact contributes nothing"); + } + + /// Same wedge through the dir-shaped branch: a FIFO at a record-relative + /// file inside a directory artifact must be skipped, not read. + #[cfg(unix)] + #[tokio::test] + async fn fifo_inside_dir_artifact_never_wedges_harvest() { + let tmp = tempfile::tempdir().unwrap(); + let purl = "pkg:cargo/serde@1.0.0"; + let rel = format!(".socket/vendor/cargo/{UUID}/serde-1.0.0"); + let file_dir = tmp.path().join(&rel).join("src"); + std::fs::create_dir_all(&file_dir).unwrap(); + let fifo = file_dir.join("lib.rs"); + mkfifo(&fifo); + write_ledger(tmp.path(), purl, UUID, &rel); + + let (k, r) = record(purl, UUID, "src/lib.rs", PATCHED); + let patches = HashMap::from([(k, r)]); + let res = tokio::time::timeout( + std::time::Duration::from_secs(5), + harvest_artifact_blobs(tmp.path(), &patches), + ) + .await; + if res.is_err() { + unblock_fifo_reader(&fifo); + } + let map = res.expect("harvest must not hang on a FIFO inside a dir artifact"); + assert!(map.is_empty(), "a FIFO file contributes nothing"); + } + #[tokio::test] async fn dir_shaped_artifact_resolves_record_relative_files() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/patch/vendor/npm_common.rs b/crates/socket-patch-core/src/patch/vendor/npm_common.rs index 1466a4ab..6235ec59 100644 --- a/crates/socket-patch-core/src/patch/vendor/npm_common.rs +++ b/crates/socket-patch-core/src/patch/vendor/npm_common.rs @@ -1,8 +1,8 @@ //! Flavor-agnostic npm vendoring pipeline: coordinate guards plus the shared //! stage→patch→pack steps. //! -//! Every npm lockfile flavor (package-lock today; yarn-classic/pnpm/bun -//! backends to come) vendors the same way up to the wiring: validate the +//! Every npm lockfile flavor (package-lock, yarn-classic/berry, pnpm, bun) +//! vendors the same way up to the wiring: validate the //! coordinates fail-closed, stage a private copy of the installed package in //! a tempdir OUTSIDE the project, prune nested `node_modules`, refuse //! bundled-deps packages, run the hardened apply pipeline against the stage, @@ -12,25 +12,25 @@ //! the project byte-untouched (a dry run stops after verification and //! creates nothing on disk). -use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde_json::Value; use crate::manifest::schema::PatchRecord; -use crate::patch::apply::{ - normalize_file_path, ApplyResult, PatchSources, VerifyResult, VerifyStatus, -}; +use crate::patch::apply::{normalize_file_path, ApplyResult, PatchSources}; use crate::patch::copy_tree::{fresh_copy, remove_tree}; use crate::patch::package::read_archive_to_map; use crate::patch::path_safety; use crate::utils::fs::atomic_write_bytes; use crate::utils::purl::{percent_decode_purl_component, strip_purl_qualifiers}; +use super::common::{ + already_patched_result, done, failed_result, refused, service_offline_conflict, +}; use super::npm_pack::{pack_deterministic, PackedTarball}; use super::path::vendor_uuid_dir_rel; use super::service_fetch::{fetch_verified_archive, ServiceArtifact}; -use super::{VendorOutcome, VendorServiceConfig, VendorWarning}; +use super::{RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; /// Validated npm vendoring coordinates (the output of /// [`guard_coordinates`]). `name`/`version` are the percent-DECODED purl @@ -94,6 +94,21 @@ pub(super) fn guard_coordinates( }) } +/// Validate a revert's patch uuid and return the `.socket/vendor/npm/` +/// dir it names. +/// +/// SECURITY: the uuid comes from the committed, tamper-able state.json and +/// names the directory tree revert is about to DELETE — validate through the +/// same fail-closed grammar vendor used, before any disk access. `Err` +/// carries the ready failure to bubble verbatim. +pub(super) fn guard_revert_uuid_dir(uuid: &str) -> Result { + vendor_uuid_dir_rel("npm", uuid).ok_or_else(|| { + RevertOutcome::failed(format!( + "refusing revert: `{uuid}` is not a canonical patch uuid (tampered state.json?)" + )) + }) +} + /// The shared pipeline's product: a verified, deterministically packed /// tarball plus the facts the flavor wiring needs. pub(super) struct NpmStagedPack { @@ -147,13 +162,10 @@ pub(super) async fn stage_patch_pack( // locally. A dry run previews the local build (no network). Per the // `auto`/`service` policy a non-fatal miss falls back to the local build // below; under `service` it fails closed. + if let Some(refusal) = service_offline_conflict(service) { + return Err(Box::new(refusal)); + } if let Some(cfg) = service { - if cfg.source.requires_service() && cfg.offline { - return Err(Box::new(refused( - "vendor_service_offline_conflict", - "--vendor-source=service needs the network but --offline is set".to_string(), - ))); - } if cfg.service_enabled() && !dry_run { match try_service_pack(purl, project_root, &coords, record, cfg, warnings).await { ServicePackDecision::Used(pair) => return Ok(*pair), @@ -195,7 +207,14 @@ pub(super) async fn stage_patch_pack( // dropped nested node_modules, repacking would produce a tarball npm // cannot satisfy those deps from. Refuse before patching. if let Ok(bytes) = tokio::fs::read(stage.join("package.json")).await { - if let Ok(pkg) = serde_json::from_slice::(&bytes) { + // npm and Node tolerate a leading UTF-8 BOM in package.json (and the + // crawler strips one, so a BOM'd install IS vendored), but serde_json + // rejects it — and a parse failure here fails OPEN, skipping the + // bundled-deps refusal below. + let text = String::from_utf8_lossy(&bytes); + if let Ok(pkg) = + serde_json::from_str::(crate::package_json::detect::strip_bom(&text)) + { if declares_bundled_deps(&pkg) { return Err(Box::new(refused( "vendor_bundled_deps_unsupported", @@ -233,20 +252,7 @@ pub(super) async fn stage_patch_pack( } // ── Pack the deterministic tarball ────────────────────────────────── - let rel_tgz = format!( - "{}/{}", - coords.uuid_dir_rel, - tgz_rel_leaf(&coords.name, &coords.version) - ); - let dest = project_root.join(&rel_tgz); - if let Some(parent) = dest.parent() { - if let Err(e) = tokio::fs::create_dir_all(parent).await { - return Err(Box::new(done_failure( - purl, - format!("cannot create {}: {e}", parent.display()), - ))); - } - } + let (rel_tgz, dest) = prepare_tgz_dest(purl, project_root, &coords).await?; let packed = match pack_deterministic(&stage, &dest).await { Ok(p) => p, Err(e) => { @@ -309,7 +315,7 @@ async fn try_service_pack( ) -> ServicePackDecision { let hard_fail = |detail: String| ServicePackDecision::HardFail(Box::new(done_failure(purl, detail))); - match fetch_verified_archive(cfg, &record.uuid, &coords.name).await { + match fetch_verified_archive(cfg, &record.uuid).await { ServiceArtifact::Ready(archive) => { match staged_pack_from_service_bytes( purl, @@ -329,10 +335,13 @@ async fn try_service_pack( coords.name, coords.version, archive.source_url ), )); - let result = synthesized_service_result( + // No local apply to verify — every patched file reads as + // `AlreadyPatched` (trust is the service-verified + // integrity). + let result = already_patched_result( purl, &project_root.join(&staged.rel_tgz), - record, + &record.files, ); ServicePackDecision::Used(Box::new((Some(staged), result))) } @@ -416,20 +425,7 @@ async fn staged_pack_from_service_bytes( ))); } - let rel_tgz = format!( - "{}/{}", - coords.uuid_dir_rel, - tgz_rel_leaf(&coords.name, &coords.version) - ); - let dest = project_root.join(&rel_tgz); - if let Some(parent) = dest.parent() { - if let Err(e) = tokio::fs::create_dir_all(parent).await { - return Err(Box::new(done_failure( - purl, - format!("cannot create {}: {e}", parent.display()), - ))); - } - } + let (rel_tgz, dest) = prepare_tgz_dest(purl, project_root, coords).await?; if let Err(e) = atomic_write_bytes(&dest, bytes).await { return Err(Box::new(done_failure( purl, @@ -475,37 +471,33 @@ async fn read_package_json_from_vendored_tgz(dest: &Path) -> Result ApplyResult { - let files_verified = record - .files - .keys() - .map(|file| VerifyResult { - file: file.clone(), - status: VerifyStatus::AlreadyPatched, - message: None, - current_hash: None, - expected_hash: None, - target_hash: None, - }) - .collect(); - ApplyResult { - package_key: purl.to_string(), - package_path: dest.display().to_string(), - success: true, - files_verified, - files_patched: Vec::new(), - applied_via: HashMap::new(), - error: None, - sidecar: None, +// ───────────────────────────── small helpers ───────────────────────────── + +/// The artifact's project-relative path (`/`) and absolute +/// destination, with the destination's parent directories created. Shared by +/// the local pack and the service download so the two paths cannot drift. +async fn prepare_tgz_dest( + purl: &str, + project_root: &Path, + coords: &NpmCoords, +) -> Result<(String, PathBuf), Box> { + let rel_tgz = format!( + "{}/{}", + coords.uuid_dir_rel, + tgz_rel_leaf(&coords.name, &coords.version) + ); + let dest = project_root.join(&rel_tgz); + if let Some(parent) = dest.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + return Err(Box::new(done_failure( + purl, + format!("cannot create {}: {e}", parent.display()), + ))); + } } + Ok((rel_tgz, dest)) } -// ───────────────────────────── small helpers ───────────────────────────── - /// `pkg:npm/[@scope/]name@version` → `(name, version)`; scoped names keep /// the `@scope/` prefix. The LAST `@` separates the version (a leading /// scope-`@` is at index 0 and never the last `@` of a versioned purl). @@ -576,19 +568,11 @@ async fn read_staged_package_json(stage: &Path) -> Result { .map_err(|e| format!("patched package.json is not parseable JSON: {e}")) } -pub(super) fn refused(code: &'static str, detail: String) -> VendorOutcome { - VendorOutcome::Refused { code, detail } -} - /// A backend failure after the refusal phase: `Done` with a failed /// synthesized [`ApplyResult`], mirroring `go_redirect`'s synthesized /// results. pub(super) fn done_failure(purl: &str, error: String) -> VendorOutcome { - VendorOutcome::Done { - result: super::failed_apply_result(purl, error), - entry: None, - warnings: Vec::new(), - } + done(failed_result(purl, Path::new(""), error), None, Vec::new()) } #[cfg(test)] diff --git a/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs b/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs index d9932ae8..1867dddc 100644 --- a/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/patch/vendor/npm_flavor.rs @@ -3,30 +3,33 @@ //! `vendor` rewires whichever lockfile actually drives the project's //! installs, so the probe sniffs lockfile CONTENT (not just file presence): //! a `pnpm-lock.yaml` only routes to the pnpm backend when its -//! `lockfileVersion` is one we have fixtures for, and a `yarn.lock` is only -//! "yarn classic" when it carries the v1 header (a berry lock — top-level -//! `__metadata:` — checksums installs against its cache zips even under the -//! node-modules linker, so vendoring is structurally impossible there). +//! `lockfileVersion` is one we have fixtures for, and a `yarn.lock` routes +//! to classic or berry by its header (the v1 comment vs a top-level +//! `__metadata:` key). Only yarn PnP projects (`.pnp.*` loaders) are +//! refused outright — their packages never land on disk to stage. //! -//! The router fans `vendor`/`revert` out per detected flavor. Today only the -//! package-lock backend ([`super::npm_lock`]) exists; the yarn-classic / -//! pnpm / bun arms refuse with the same stable code the CLI's old layout -//! gate used (`vendor_pkg_manager_unsupported`) and will be replaced by real -//! backends. Reverts fail CLOSED on a flavor this build has no backend for — -//! never guess at another flavor's wiring records. +//! The router fans `vendor`/`revert` out per detected flavor. All five +//! flavors have real backends: package-lock ([`super::npm_lock`]), +//! yarn classic ([`super::yarn_classic_lock`]), yarn berry +//! ([`super::yarn_berry_lock`]), pnpm ([`super::pnpm_lock`]), and bun +//! ([`super::bun_lock`]); a lockfile the probe can't classify refuses with +//! a stable code. Reverts fail CLOSED on a flavor this build has no +//! backend for — never guess at another flavor's wiring records. use std::path::Path; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; -use super::npm_lock; use super::state::VendorEntry; -use super::{RevertOutcome, VendorOutcome, VendorWarning}; +use super::{ + bun_lock, npm_lock, pnpm_lock, yarn_berry_lock, yarn_classic_lock, RevertOutcome, + VendorOutcome, VendorWarning, +}; /// Which lockfile flavor drives this project's npm installs. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NpmLockFlavor { +pub(crate) enum NpmLockFlavor { /// `package-lock.json` / `npm-shrinkwrap.json` (npm). PackageLock, /// `yarn.lock` with the `# yarn lockfile v1` header (yarn classic). @@ -41,7 +44,7 @@ pub enum NpmLockFlavor { impl NpmLockFlavor { /// The stable string recorded as [`VendorEntry::flavor`]. - pub fn as_str(self) -> &'static str { + fn as_str(self) -> &'static str { match self { NpmLockFlavor::PackageLock => "package-lock", NpmLockFlavor::YarnClassic => "yarn-classic", @@ -56,14 +59,9 @@ impl NpmLockFlavor { /// so there is nothing on disk to stage and no lockfile entry to rewire. const PNP_MARKERS: [&str; 3] = [".pnp.cjs", ".pnp.js", ".pnp.loader.mjs"]; -/// The pnpm lockfile version the (future) pnpm backend is built against. -const PNPM_SUPPORTED_LOCK_VERSION: &str = "9.0"; - -/// How many head lines the content sniffs read. The markers sit at the very -/// top of their files (pnpm's `lockfileVersion` is line 1; yarn's v1 header -/// is in the leading comment block; berry's `__metadata:` is the first -/// top-level key after it). -const SNIFF_HEAD_LINES: usize = 5; +/// How many head lines the yarn content sniff reads (the v1 header sits in +/// the leading comment block; berry's `__metadata:` is the first top-level +/// key after it). const YARN_SNIFF_HEAD_LINES: usize = 30; /// Every lockfile name the probe knows, grouped into wiring families: the @@ -102,7 +100,7 @@ const LOCKFILE_FAMILIES: [(NpmLockFlavor, &[&str]); 4] = [ /// `Ok` carries one `vendor_multiple_lockfiles` warning per OTHER known /// lockfile present (outside the detected flavor's family): installs driven /// by an unwired lockfile would still install the unpatched registry bytes. -pub async fn detect_npm_lock_flavor( +pub(crate) async fn detect_npm_lock_flavor( project_root: &Path, ) -> Result<(NpmLockFlavor, Vec), (&'static str, String)> { let exists = |name: &str| { @@ -140,9 +138,12 @@ pub async fn detect_npm_lock_flavor( )); } - // 3. pnpm: only lockfileVersion 9.0 has a wiring backend. + // 3. pnpm: only lockfileVersion 9.0 has a wiring backend (the sniff + // is the pnpm backend's own pre-flight check). if exists("pnpm-lock.yaml").await { - sniff_pnpm_lock(project_root).await?; + let text = read_lock(project_root, "pnpm-lock.yaml").await?; + pnpm_lock::check_lock_version(&text) + .map_err(|detail| ("vendor_lockfile_version_unsupported", detail))?; break 'flavor NpmLockFlavor::Pnpm; } @@ -189,10 +190,16 @@ pub async fn detect_npm_lock_flavor( }; // Multiple lockfiles: warn about every present file the detected - // flavor's wiring does not cover. + // flavor's wiring does not cover. Both yarn flavors wire the same + // yarn.lock; the family table keys that family under YarnClassic, so a + // berry detection claims it too (never self-warn about the wired file). + let family_owner = match detected { + NpmLockFlavor::YarnBerry => NpmLockFlavor::YarnClassic, + other => other, + }; let mut warnings = Vec::new(); for (flavor, family) in LOCKFILE_FAMILIES { - if flavor == detected { + if flavor == family_owner { continue; } for file in family { @@ -212,41 +219,17 @@ pub async fn detect_npm_lock_flavor( Ok((detected, warnings)) } -/// `pnpm-lock.yaml` head sniff: the first lines carry -/// `lockfileVersion: '9.0'` (pnpm quotes it; accept double-quoted and bare -/// spellings too). Anything else has no wiring backend. -async fn sniff_pnpm_lock(project_root: &Path) -> Result<(), (&'static str, String)> { - let text = tokio::fs::read_to_string(project_root.join("pnpm-lock.yaml")) +/// Read a lockfile for content-sniffing. An unreadable-but-present file maps +/// to the same stable code as a missing one. +async fn read_lock(project_root: &Path, name: &str) -> Result { + tokio::fs::read_to_string(project_root.join(name)) .await .map_err(|e| { ( "vendor_lockfile_missing", - format!("cannot read pnpm-lock.yaml: {e}"), + format!("cannot read {name}: {e}"), ) - })?; - let version = text - .lines() - .take(SNIFF_HEAD_LINES) - .find_map(|line| line.strip_prefix("lockfileVersion:")) - .map(|rest| rest.trim().trim_matches(['\'', '"']).to_string()); - match version { - Some(v) if v == PNPM_SUPPORTED_LOCK_VERSION => Ok(()), - Some(v) => Err(( - "vendor_lockfile_version_unsupported", - format!( - "pnpm-lock.yaml has lockfileVersion {v}; only {PNPM_SUPPORTED_LOCK_VERSION} \ - is supported — re-lock with pnpm >= 9" - ), - )), - None => Err(( - "vendor_lockfile_version_unsupported", - format!( - "pnpm-lock.yaml has no lockfileVersion in its first {SNIFF_HEAD_LINES} \ - lines; only {PNPM_SUPPORTED_LOCK_VERSION} is supported — re-lock with \ - pnpm >= 9" - ), - )), - } + }) } /// `yarn.lock` head sniff: berry locks carry a top-level (column-0) @@ -254,14 +237,7 @@ async fn sniff_pnpm_lock(project_root: &Path) -> Result<(), (&'static str, Strin /// comment header. Berry wins the check — a berry lock must never be /// mistaken for classic. async fn sniff_yarn_lock(project_root: &Path) -> Result { - let text = tokio::fs::read_to_string(project_root.join("yarn.lock")) - .await - .map_err(|e| { - ( - "vendor_lockfile_missing", - format!("cannot read yarn.lock: {e}"), - ) - })?; + let text = read_lock(project_root, "yarn.lock").await?; let head: Vec<&str> = text.lines().take(YARN_SNIFF_HEAD_LINES).collect(); // Berry wins the check (it must never be mistaken for classic). The // node-modules linker keeps packages on disk for staging, and berry's @@ -303,65 +279,11 @@ pub async fn vendor_npm_any( Ok(found) => found, Err((code, detail)) => return VendorOutcome::Refused { code, detail }, }; - let mut outcome = match flavor { - NpmLockFlavor::PackageLock => { - npm_lock::vendor_npm( - purl, - installed_dir, - project_root, - record, - sources, - vendored_at, - dry_run, - force, - service, - ) - .await - } - NpmLockFlavor::YarnClassic => { - super::yarn_classic_lock::vendor_yarn_classic( - purl, - installed_dir, - project_root, - record, - sources, - vendored_at, - dry_run, - force, - service, - ) - .await - } - NpmLockFlavor::YarnBerry => { - super::yarn_berry_lock::vendor_yarn_berry( - purl, - installed_dir, - project_root, - record, - sources, - vendored_at, - dry_run, - force, - service, - ) - .await - } - NpmLockFlavor::Pnpm => { - super::pnpm_lock::vendor_pnpm( - purl, - installed_dir, - project_root, - record, - sources, - vendored_at, - dry_run, - force, - service, - ) - .await - } - NpmLockFlavor::Bun => { - super::bun_lock::vendor_bun( + // Every backend takes the identical 9-argument tuple; the macro collapses + // the five-way repetition (same shape as the CLI dispatcher's `vend!`). + macro_rules! vend { + ($backend:path) => { + $backend( purl, installed_dir, project_root, @@ -373,7 +295,14 @@ pub async fn vendor_npm_any( service, ) .await - } + }; + } + let mut outcome = match flavor { + NpmLockFlavor::PackageLock => vend!(npm_lock::vendor_npm), + NpmLockFlavor::YarnClassic => vend!(yarn_classic_lock::vendor_yarn_classic), + NpmLockFlavor::YarnBerry => vend!(yarn_berry_lock::vendor_yarn_berry), + NpmLockFlavor::Pnpm => vend!(pnpm_lock::vendor_pnpm), + NpmLockFlavor::Bun => vend!(bun_lock::vendor_bun), }; // Probe warnings (e.g. a sibling lockfile that will install UNPATCHED // bytes) precede the backend's own; the ledger records which flavor wired @@ -384,11 +313,7 @@ pub async fn vendor_npm_any( entry, warnings, .. } = &mut outcome { - if !probe_warnings.is_empty() { - let mut merged = probe_warnings; - merged.append(warnings); - *warnings = merged; - } + warnings.splice(0..0, probe_warnings); if let Some(entry) = entry { entry.flavor = Some(flavor.as_str().to_string()); } @@ -411,7 +336,7 @@ pub async fn vendor_npm_any( /// never be routed here (the probe would always call them unused). pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Option { match entry.flavor.as_deref() { - Some("pnpm") => super::pnpm_lock::pnpm_entry_in_use(entry, project_root).await, + Some("pnpm") => pnpm_lock::pnpm_entry_in_use(entry, project_root).await, // The remaining flavors wire resolutions into the lock itself // (resolved URLs / file: ranges / package tuples), so a textual // probe for the uuid dir is exact: the path appears iff some @@ -456,13 +381,13 @@ pub async fn revert_npm_any( match entry.flavor.as_deref() { None | Some("package-lock") => npm_lock::revert_npm(entry, project_root, dry_run).await, Some("yarn-classic") => { - super::yarn_classic_lock::revert_yarn_classic(entry, project_root, dry_run).await + yarn_classic_lock::revert_yarn_classic(entry, project_root, dry_run).await } Some("yarn-berry") => { - super::yarn_berry_lock::revert_yarn_berry(entry, project_root, dry_run).await + yarn_berry_lock::revert_yarn_berry(entry, project_root, dry_run).await } - Some("pnpm") => super::pnpm_lock::revert_pnpm(entry, project_root, dry_run).await, - Some("bun") => super::bun_lock::revert_bun(entry, project_root, dry_run).await, + Some("pnpm") => pnpm_lock::revert_pnpm(entry, project_root, dry_run).await, + Some("bun") => bun_lock::revert_bun(entry, project_root, dry_run).await, Some(other) => RevertOutcome::failed(format!( "this socket-patch build cannot revert npm vendor flavor `{other}` — upgrade \ socket-patch and re-run" @@ -484,12 +409,6 @@ mod tests { tokio::fs::write(root.join(name), content).await.unwrap(); } - async fn detect( - root: &Path, - ) -> Result<(NpmLockFlavor, Vec), (&'static str, String)> { - detect_npm_lock_flavor(root).await - } - const YARN_V1: &str = "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ # yarn lockfile v1\n\n\nleft-pad@^1.3.0:\n version \"1.3.0\"\n"; const YARN_BERRY: &str = @@ -513,7 +432,7 @@ mod tests { touch(tmp.path(), marker, "/* pnp */").await; // Even with a perfectly good package-lock present. touch(tmp.path(), "package-lock.json", "{}").await; - let (code, detail) = detect(tmp.path()).await.unwrap_err(); + let (code, detail) = detect_npm_lock_flavor(tmp.path()).await.unwrap_err(); assert_eq!(code, "vendor_yarn_berry_unsupported", "{marker}"); assert!(detail.contains(marker), "{detail}"); assert!(detail.contains("yarn patch"), "{detail}"); @@ -524,20 +443,20 @@ mod tests { async fn bun_lock_routes_and_lockb_refuses() { let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "bun.lock", "{\n \"lockfileVersion\": 1\n}\n").await; - let (flavor, warnings) = detect(tmp.path()).await.unwrap(); + let (flavor, warnings) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); assert_eq!(flavor, NpmLockFlavor::Bun); assert!(warnings.is_empty()); // bun.lock wins over a stray bun.lockb (no warning for the sibling). touch(tmp.path(), "bun.lockb", "binary").await; - let (flavor, warnings) = detect(tmp.path()).await.unwrap(); + let (flavor, warnings) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); assert_eq!(flavor, NpmLockFlavor::Bun); assert!(warnings.is_empty(), "{warnings:?}"); // lockb alone: actionable migration pointer. let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "bun.lockb", "binary").await; - let (code, detail) = detect(tmp.path()).await.unwrap_err(); + let (code, detail) = detect_npm_lock_flavor(tmp.path()).await.unwrap_err(); assert_eq!(code, "vendor_bun_lockb_unsupported"); assert!( detail.contains("bun install --save-text-lockfile"), @@ -560,14 +479,14 @@ mod tests { &format!("{head}\n\nsettings: {{}}\n"), ) .await; - let (flavor, _) = detect(tmp.path()).await.unwrap(); + let (flavor, _) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); assert_eq!(flavor, NpmLockFlavor::Pnpm, "{head}"); } // Older version: named in the error. let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '6.0'\n").await; - let (code, detail) = detect(tmp.path()).await.unwrap_err(); + let (code, detail) = detect_npm_lock_flavor(tmp.path()).await.unwrap_err(); assert_eq!(code, "vendor_lockfile_version_unsupported"); assert!(detail.contains("6.0"), "{detail}"); assert!(detail.contains("pnpm >= 9"), "{detail}"); @@ -580,7 +499,7 @@ mod tests { "settings:\n autoInstallPeers: true\n", ) .await; - let (code, _) = detect(tmp.path()).await.unwrap_err(); + let (code, _) = detect_npm_lock_flavor(tmp.path()).await.unwrap_err(); assert_eq!(code, "vendor_lockfile_version_unsupported"); } @@ -588,7 +507,7 @@ mod tests { async fn yarn_sniff_separates_classic_berry_and_unknown() { let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "yarn.lock", YARN_V1).await; - let (flavor, _) = detect(tmp.path()).await.unwrap(); + let (flavor, _) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); assert_eq!(flavor, NpmLockFlavor::YarnClassic); // A berry (node-modules) lock now routes to the YarnBerry backend @@ -596,37 +515,61 @@ mod tests { // Only PnP (`.pnp.*` markers, caught earlier) stays refused. let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "yarn.lock", YARN_BERRY).await; - let (flavor, _) = detect(tmp.path()).await.unwrap(); + let (flavor, _) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); assert_eq!(flavor, NpmLockFlavor::YarnBerry); let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "yarn.lock", "garbage: true\n").await; - let (code, _) = detect(tmp.path()).await.unwrap_err(); + let (code, _) = detect_npm_lock_flavor(tmp.path()).await.unwrap_err(); assert_eq!(code, "vendor_lockfile_version_unsupported"); } + #[tokio::test] + async fn yarn_berry_does_not_warn_about_its_own_yarn_lock() { + // The berry backend wires yarn.lock itself — detecting berry from + // that file must not emit a vendor_multiple_lockfiles warning + // claiming installs driven by yarn.lock get UNPATCHED bytes. + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "yarn.lock", YARN_BERRY).await; + let (flavor, warnings) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnBerry); + assert!(warnings.is_empty(), "{warnings:?}"); + + // Genuinely unwired siblings still warn — exactly one, for the + // stray package-lock.json, never for yarn.lock. + touch(tmp.path(), "package-lock.json", "{}").await; + let (flavor, warnings) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnBerry); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!( + warnings[0].detail.contains("package-lock.json"), + "{warnings:?}" + ); + assert!(!warnings[0].detail.contains("`yarn.lock`"), "{warnings:?}"); + } + #[tokio::test] async fn npm_locks_route_to_package_lock_and_nothing_is_missing() { let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "package-lock.json", "{}").await; assert_eq!( - detect(tmp.path()).await.unwrap().0, + detect_npm_lock_flavor(tmp.path()).await.unwrap().0, NpmLockFlavor::PackageLock ); let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "npm-shrinkwrap.json", "{}").await; - let (flavor, warnings) = detect(tmp.path()).await.unwrap(); + let (flavor, warnings) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); assert_eq!(flavor, NpmLockFlavor::PackageLock); assert!(warnings.is_empty()); // Shrinkwrap + package-lock are the same family: no self-warning. touch(tmp.path(), "package-lock.json", "{}").await; - let (_, warnings) = detect(tmp.path()).await.unwrap(); + let (_, warnings) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); assert!(warnings.is_empty(), "{warnings:?}"); let tmp = tempfile::tempdir().unwrap(); - let (code, _) = detect(tmp.path()).await.unwrap_err(); + let (code, _) = detect_npm_lock_flavor(tmp.path()).await.unwrap_err(); assert_eq!(code, "vendor_lockfile_missing"); } @@ -636,7 +579,7 @@ mod tests { // rush.json and its generated-workspace lock under common/config. let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "rush.json", r#"{"rushVersion":"5.0.0"}"#).await; - let (code, detail) = detect(tmp.path()).await.unwrap_err(); + let (code, detail) = detect_npm_lock_flavor(tmp.path()).await.unwrap_err(); assert_eq!(code, "vendor_rush_unsupported"); // Names the install model and routes to hosted mode. assert!(detail.contains("pnpm-config.json"), "{detail}"); @@ -657,7 +600,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "rush.json", r#"{"rushVersion":"5.0.0"}"#).await; touch(tmp.path(), "package-lock.json", "{}").await; - let (flavor, _) = detect(tmp.path()).await.unwrap(); + let (flavor, _) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); assert_eq!(flavor, NpmLockFlavor::PackageLock); } @@ -670,7 +613,7 @@ mod tests { touch(tmp.path(), "pnpm-lock.yaml", PNPM_9).await; touch(tmp.path(), "yarn.lock", YARN_V1).await; touch(tmp.path(), "package-lock.json", "{}").await; - let (flavor, warnings) = detect(tmp.path()).await.unwrap(); + let (flavor, warnings) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); assert_eq!(flavor, NpmLockFlavor::Bun); let named: Vec<&str> = warnings.iter().map(|w| w.detail.as_str()).collect(); assert_eq!(warnings.len(), 3, "{named:?}"); @@ -691,7 +634,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); touch(tmp.path(), "package-lock.json", "{}").await; touch(tmp.path(), "yarn.lock", YARN_V1).await; - let (flavor, warnings) = detect(tmp.path()).await.unwrap(); + let (flavor, warnings) = detect_npm_lock_flavor(tmp.path()).await.unwrap(); assert_eq!(flavor, NpmLockFlavor::YarnClassic); assert_eq!(warnings.len(), 1, "{warnings:?}"); assert!( @@ -840,28 +783,7 @@ mod tests { #[tokio::test] async fn revert_routes_by_flavor_and_fails_closed_on_unknown() { let tmp = tempfile::tempdir().unwrap(); - let mut entry = VendorEntry { - ecosystem: "npm".into(), - base_purl: "pkg:npm/left-pad@1.3.0".into(), - uuid: UUID.into(), - artifact: VendorArtifact { - path: format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"), - sha256: String::new(), - size: None, - platform_locked: None, - }, - wiring: Vec::new(), - lock: None, - took_over_go_patches: false, - detached: false, - record: None, - flavor: Some("future-pm".into()), - uv: None, - pnpm: None, - poetry: None, - pdm: None, - pipenv: None, - }; + let mut entry = probe_entry(Some("future-pm")); // A flavor this build has no backend for: fail closed, name it. let outcome = revert_npm_any(&entry, tmp.path(), false).await; @@ -884,7 +806,7 @@ mod tests { } } - /// One minimal entry per flavor for the in-use probe. + /// One minimal npm vendor entry stamped with the given flavor. fn probe_entry(flavor: Option<&str>) -> VendorEntry { VendorEntry { ecosystem: "npm".into(), diff --git a/crates/socket-patch-core/src/patch/vendor/npm_lock.rs b/crates/socket-patch-core/src/patch/vendor/npm_lock.rs index ccfc6ae7..38f5f2a1 100644 --- a/crates/socket-patch-core/src/patch/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/npm_lock.rs @@ -1,7 +1,7 @@ //! npm vendor backend: lock surgery + orchestration. //! //! Vendoring an npm package = pack the patched tree into a deterministic -//! tarball under `.socket/vendor/npm//` ([`super::npm_pack`]) and +//! tarball under `.socket/vendor/npm//` (`super::npm_pack`) and //! rewrite every matching lockfile entry's `resolved` to a relative `file:` //! spec + `integrity` to the tarball's recomputed sha512. That lock-only //! rewrite passes `npm ci` (spike-proven; see `spikes/PHASE0-FINDINGS.txt`): @@ -14,18 +14,18 @@ //! bytes — no error, no patch. Every rewrite therefore carries the packed //! tarball's own hash, never an inherited one. -use std::collections::HashMap; use std::path::Path; use serde_json::Value; use crate::manifest::schema::PatchRecord; -use crate::patch::apply::{ApplyResult, PatchSources, VerifyResult, VerifyStatus}; +use crate::patch::apply::PatchSources; use crate::patch::copy_tree::remove_tree; -use crate::utils::fs::atomic_write_bytes; +use crate::utils::fs::atomic_write_bytes_preserving_mode; -use super::npm_common::{done_failure, guard_coordinates, refused, stage_patch_pack}; -use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; +use super::common::{already_patched_result, detect_indent, done, refused, serialize_json}; +use super::npm_common::{done_failure, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack}; +use super::path::parse_vendor_path; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; @@ -187,11 +187,7 @@ pub async fn vendor_npm( let Some(staged) = staged else { // Failed patch (no lock writes — wiring is last, so the project is // byte-untouched) or a dry run (stops after the verify). - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); }; // `staged.name`/`staged.version` echo the validated coords (the wiring // below keeps using the borrowed `name`/`version`). @@ -202,7 +198,6 @@ pub async fn vendor_npm( let rel_tgz = staged.rel_tgz; let packed = staged.packed; let staged_pkg_json = staged.staged_pkg_json; - let dest = project_root.join(&rel_tgz); // Forward slashes by construction (uuid_dir_rel + leaf are built with // `/`), relative to the project dir — the spelling npm resolves // `file:` specs against. @@ -290,24 +285,20 @@ pub async fn vendor_npm( // integrity: the project is in sync. Touch nothing (the tarball // rewrite above was byte-identical by determinism) and synthesize an // AlreadyPatched-style success, mirroring the go_redirect hot path. - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); - return VendorOutcome::Done { - result: synthesized_result(purl, &dest, verified, true, None), - entry: None, + return done( + already_patched_result(purl, &project_root.join(&rel_tgz), &record.files), + None, warnings, - }; + ); } let indent = detect_indent(&String::from_utf8_lossy(&lock_bytes)); - let out = match serialize_lock(&lock, &indent) { + let out = match serialize_json(&lock, &indent) { Ok(out) => out, Err(e) => return done_failure(purl, format!("cannot serialize {lock_name}: {e}")), }; - if let Err(e) = atomic_write_bytes(&project_root.join(&lock_name), &out).await { + if let Err(e) = atomic_write_bytes_preserving_mode(&project_root.join(&lock_name), &out).await + { return done_failure(purl, format!("cannot write {lock_name}: {e}")); } @@ -315,16 +306,7 @@ pub async fn vendor_npm( // The marker is informational belt-and-braces (never a trust input), so // a write failure downgrades to a warning rather than failing a vendor // whose lock is already correctly wired. - let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); - vulnerabilities.sort(); - let marker = VendorMarker { - schema_version: 1, - purl: base_purl.clone(), - patch_uuid: record.uuid.clone(), - ecosystem: "npm".to_string(), - vulnerabilities, - vendored_at: vendored_at.to_string(), - }; + let marker = VendorMarker::new("npm", &base_purl, record, vendored_at); if let Err(e) = write_marker(&project_root.join(&uuid_dir_rel), &marker).await { warnings.push(VendorWarning::new( "vendor_marker_write_failed", @@ -354,11 +336,7 @@ pub async fn vendor_npm( pdm: None, pipenv: None, }; - VendorOutcome::Done { - result, - entry: Some(entry), - warnings, - } + done(result, Some(entry), warnings) } /// Undo one vendored npm package: restore the recorded lock fragments and @@ -368,11 +346,9 @@ pub async fn revert_npm(entry: &VendorEntry, project_root: &Path, dry_run: bool) // state.json and names the directory tree we are about to DELETE. // Validate through the same fail-closed grammar vendor used before any // disk access — never delete by an unvalidated path. - let Some(uuid_dir_rel) = vendor_uuid_dir_rel("npm", &entry.uuid) else { - return RevertOutcome::failed(format!( - "refusing revert: `{}` is not a canonical patch uuid (tampered state.json?)", - entry.uuid - )); + let uuid_dir_rel = match guard_revert_uuid_dir(&entry.uuid) { + Ok(d) => d, + Err(outcome) => return outcome, }; if dry_run { return RevertOutcome::ok(); @@ -438,13 +414,13 @@ pub async fn revert_npm(entry: &VendorEntry, project_root: &Path, dry_run: bool) if changed { let indent = detect_indent(&String::from_utf8_lossy(&lock_bytes)); - let out = match serialize_lock(&lock, &indent) { + let out = match serialize_json(&lock, &indent) { Ok(out) => out, Err(e) => { return RevertOutcome::failed(format!("cannot serialize {lock_name}: {e}")) } }; - if let Err(e) = atomic_write_bytes(&lock_path, &out).await { + if let Err(e) = atomic_write_bytes_preserving_mode(&lock_path, &out).await { return RevertOutcome::failed(format!("cannot write {lock_name}: {e}")); } } @@ -742,70 +718,16 @@ async fn select_lockfile(project_root: &Path) -> std::io::Result String { - for line in text.lines() { - let trimmed = line.trim_start_matches([' ', '\t']); - if !trimmed.is_empty() && trimmed.len() < line.len() { - return line[..line.len() - trimmed.len()].to_string(); - } - } - " ".to_string() -} - -/// Pretty-print with the detected indent + trailing newline — npm's own -/// output shape, so `npm install` after vendoring produces no format-only -/// churn. -fn serialize_lock(lock: &Value, indent: &str) -> std::io::Result> { - use serde::Serialize; - let mut out = Vec::new(); - let formatter = serde_json::ser::PrettyFormatter::with_indent(indent.as_bytes()); - let mut ser = serde_json::Serializer::with_formatter(&mut out, formatter); - lock.serialize(&mut ser).map_err(std::io::Error::other)?; - out.push(b'\n'); - Ok(out) -} - -fn synthesized_result( - package_key: &str, - path: &Path, - files_verified: Vec, - success: bool, - error: Option, -) -> ApplyResult { - ApplyResult { - package_key: package_key.to_string(), - package_path: path.display().to_string(), - success, - files_verified, - files_patched: Vec::new(), - applied_via: HashMap::new(), - error, - sidecar: None, - } -} - -fn already_patched_verify(file: &str) -> VerifyResult { - VerifyResult { - file: file.to_string(), - status: VerifyStatus::AlreadyPatched, - message: None, - current_hash: None, - expected_hash: None, - target_hash: None, - } -} - #[cfg(test)] mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; + use crate::patch::apply::{ApplyResult, VerifyStatus}; use base64::Engine as _; use serde_json::json; use sha2::{Digest, Sha512}; + use std::collections::HashMap; use std::path::PathBuf; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; @@ -938,7 +860,7 @@ mod tests { .await .unwrap(); - let lock_bytes = serialize_lock(&lock, " ").unwrap(); + let lock_bytes = serialize_json(&lock, " ").unwrap(); tokio::fs::write(root.join(PACKAGE_LOCK), &lock_bytes) .await .unwrap(); @@ -1448,6 +1370,28 @@ mod tests { ); } + /// npm and Node tolerate a leading UTF-8 BOM in package.json + /// (Windows-authored packages ship them, and the crawler strips it — so + /// a BOM'd install IS discovered and vendored), but serde_json rejects + /// one, and the bundled-deps guard fails OPEN on a parse error: a BOM + /// must not skip the refusal and pack a tarball whose bundled + /// node_modules was pruned. + #[tokio::test] + async fn bundled_deps_refusal_survives_package_json_bom() { + let fx = fixture().await; + let mut pkg_json = b"\xEF\xBB\xBF".to_vec(); + pkg_json + .extend_from_slice(br#"{"name":"left-pad","version":"1.3.0","bundleDependencies":["dep"]}"#); + tokio::fs::write(fx.installed().join("package.json"), pkg_json) + .await + .unwrap(); + expect_refused(fx.vendor(false).await, "vendor_bundled_deps_unsupported"); + assert!( + !fx.root().join(".socket/vendor").exists(), + "refusal writes nothing" + ); + } + #[tokio::test] async fn lockfile_v1_is_refused() { let lock = json!({ @@ -1640,7 +1584,7 @@ mod tests { // Give one lock instance dep-mirror fields the patch obsoletes. let mut lock = default_lock(); lock["packages"]["node_modules/left-pad"]["peerDependencies"] = json!({ "gone": "^1.0.0" }); - let lock_bytes = serialize_lock(&lock, " ").unwrap(); + let lock_bytes = serialize_json(&lock, " ").unwrap(); tokio::fs::write(fx.lock_path(), &lock_bytes).await.unwrap(); fx.lock_bytes = lock_bytes; @@ -1719,6 +1663,40 @@ mod tests { ); } + /// The lockfile is a user-owned file we merely edit: both the vendor + /// rewrite and the revert restore must keep its permission bits (a 0600 + /// private lock must not silently become umask-default 0644). + #[cfg(unix)] + #[tokio::test] + async fn lock_writes_preserve_file_mode() { + use std::os::unix::fs::PermissionsExt; + let fx = fixture().await; + tokio::fs::set_permissions(fx.lock_path(), std::fs::Permissions::from_mode(0o600)) + .await + .unwrap(); + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + let mode = tokio::fs::metadata(fx.lock_path()) + .await + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!(mode, 0o600, "vendor must preserve the lockfile's mode"); + + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + let mode = tokio::fs::metadata(fx.lock_path()) + .await + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!(mode, 0o600, "revert must preserve the lockfile's mode"); + } + #[tokio::test] async fn revert_leaves_drifted_entries_alone_with_warning() { let fx = fixture().await; @@ -1729,7 +1707,7 @@ mod tests { let mut live = fx.read_lock().await; live["packages"]["node_modules/left-pad"]["resolved"] = json!("https://example.com/their-fork.tgz"); - tokio::fs::write(fx.lock_path(), serialize_lock(&live, " ").unwrap()) + tokio::fs::write(fx.lock_path(), serialize_json(&live, " ").unwrap()) .await .unwrap(); diff --git a/crates/socket-patch-core/src/patch/vendor/npm_pack.rs b/crates/socket-patch-core/src/patch/vendor/npm_pack.rs index 733eeb56..0e71be15 100644 --- a/crates/socket-patch-core/src/patch/vendor/npm_pack.rs +++ b/crates/socket-patch-core/src/patch/vendor/npm_pack.rs @@ -17,6 +17,8 @@ use sha2::{Digest, Sha256, Sha512}; use crate::utils::fs::atomic_write_bytes; +use super::common::is_executable; + /// npm's fixed tar entry mtime: `1985-10-26T08:15:00Z`. Every `npm pack` /// tarball carries this timestamp (npm pins it for reproducible packs); /// reusing it keeps our artifacts byte-deterministic AND familiar to any @@ -171,19 +173,6 @@ fn collect_regular_files(staged_dir: &Path) -> std::io::Result bool { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - metadata.permissions().mode() & 0o111 != 0 - } - #[cfg(not(unix))] - { - let _ = metadata; - false - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/socket-patch-core/src/patch/vendor/nuget_feed.rs b/crates/socket-patch-core/src/patch/vendor/nuget_feed.rs index ce24165e..c5ae487d 100644 --- a/crates/socket-patch-core/src/patch/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/patch/vendor/nuget_feed.rs @@ -40,29 +40,30 @@ //! Edit order: artifact → nuget.config → packages.lock.json. Any failure after //! the artifact removes the uuid dir; a lock-write failure additionally unwinds //! the config to its recorded pre-vendor bytes, so the pair is never half-wired. +//! (On the wired hot path the config is already correct and stays, so a lock +//! re-pin failure there keeps the rebuilt artifact — deleting it would leave +//! the wired config pointing at nothing.) -use std::collections::HashMap; -use std::io::Read as _; -use std::io::Write as _; use std::path::{Path, PathBuf}; use base64::Engine as _; use serde_json::Value; use sha2::{Digest as _, Sha512}; -use crate::manifest::schema::{PatchFileInfo, PatchRecord}; -use crate::patch::apply::{ - is_safe_relative_subpath, normalize_file_path, ApplyResult, PatchSources, VerifyResult, - VerifyStatus, -}; +use crate::manifest::schema::PatchRecord; +use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::remove_tree; use crate::patch::path_safety::is_safe_single_segment; -use crate::utils::fs::{atomic_write_bytes, list_dir_entries}; +use crate::utils::fs::{atomic_write_bytes, atomic_write_bytes_preserving_mode, list_dir_entries}; use crate::utils::purl::{build_nuget_purl, parse_nuget_purl}; +use super::common::{ + already_patched_result, done, failed_result, rebuild_zip, refused, synthesized_result, + zip_matches_after_hashes, +}; use super::path::vendor_uuid_dir_rel; use super::registry_fetch::extract_zip; -use super::service_fetch::{fetch_verified_archive, ServiceArtifact}; +use super::service_fetch::{service_archive_copy, ServiceCopy}; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; @@ -82,9 +83,6 @@ const CONFIG_SOURCE_WIRING_KIND: &str = "nuget_config_source"; const CONFIG_MAPPING_WIRING_KIND: &str = "nuget_config_mapping"; const LOCK_WIRING_KIND: &str = "nuget_lock_entry"; -/// Marker schema version written into `socket-patch.vendor.json`. -const MARKER_SCHEMA_VERSION: u32 = 1; - /// The embedded package signature part; dropped from the rebuilt nupkg so the /// patched (content-changed) package reads as unsigned rather than /// invalid-signed. @@ -206,11 +204,11 @@ pub async fn vendor_nuget( // A patch with no files is meaningless to vendor: no-op success, no edits. if record.files.is_empty() { - return VendorOutcome::Done { - result: synthesized_result(purl, &nupkg_path, Vec::new(), true, None), - entry: None, - warnings: Vec::new(), - }; + return done( + synthesized_result(purl, &nupkg_path, Vec::new(), true, None), + None, + Vec::new(), + ); } let config_path = existing_config_path(project_root).await; @@ -248,32 +246,35 @@ pub async fn vendor_nuget( .as_deref() .is_some_and(|t| t.contains(&source_key)); if config_wired { - let nupkg_ok = nupkg_matches_after_hashes(&nupkg_path, &record.files).await; + let nupkg_ok = zip_matches_after_hashes(&nupkg_path, &record.files).await; let lock_ok = match &lock_text { None => true, Some(text) => match tokio::fs::read(&nupkg_path).await { - Ok(bytes) => lock_pinned(text, name, &version_norm, &content_hash(&bytes)), + Ok(bytes) => { + let expected = content_hash(&bytes); + // Pinned at our bytes, or no matching resolved entry at + // all — the same absence `edit_lock` tolerates with a + // warning on the first run. Treating absence as stale + // would misreport "missing or stale; rebuilt" on every + // rerun with nothing to actually pin. + lock_pinned(text, name, &version_norm, &expected) + || matches!(edit_lock(text, name, &version_norm, &expected), Ok(None)) + } Err(_) => false, }, }; if nupkg_ok && lock_ok { - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); - return VendorOutcome::Done { - result: synthesized_result(purl, &nupkg_path, verified, true, None), - entry: None, - warnings: Vec::new(), - }; + return done( + already_patched_result(purl, &nupkg_path, &record.files), + None, + Vec::new(), + ); } // Wired but the committed nupkg is missing/stale: rebuild the ARTIFACT // only (and re-pin the lock at the rebuilt bytes). The config is // already correct and the full path would re-record the live vendored // fragments as `original`, breaking a later `--revert`. if !dry_run { - check_service_offline_conflict(service); let mut warnings: Vec = Vec::new(); let (bytes, mut result) = match materialise_patched_nupkg( purl, @@ -294,25 +295,33 @@ pub async fn vendor_nuget( Err(outcome) => return *outcome, }; if !result.success { - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } + result.package_path = nupkg_path.display().to_string(); // Re-pin the lock at the rebuilt bytes (the config is untouched). + // A failure here keeps the rebuilt artifact: the config (from the + // first run) is still wired at this feed, so deleting the uuid dir + // would leave a wired config pointing at nothing and brick every + // restore. if let Some(text) = &lock_text { let new_hash = content_hash(&bytes); - if let Ok(Some(edit)) = edit_lock(text, name, &version_norm, &new_hash) { - if let Err(e) = atomic_write_bytes(&lock_path, edit.text.as_bytes()).await { - let _ = remove_tree(&uuid_dir).await; + match edit_lock(text, name, &version_norm, &new_hash) { + Ok(Some(edit)) => { + if let Err(e) = + atomic_write_bytes_preserving_mode(&lock_path, edit.text.as_bytes()) + .await + { + result.success = false; + result.error = + Some(format!("failed to rewrite {PACKAGES_LOCK}: {e}")); + return done(result, None, warnings); + } + } + Ok(None) => {} + Err(detail) => { result.success = false; - result.error = Some(format!("failed to rewrite {PACKAGES_LOCK}: {e}")); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + result.error = Some(detail); + return done(result, None, warnings); } } } @@ -323,11 +332,7 @@ pub async fn vendor_nuget( rebuilt at {copy_rel} (nuget.config untouched)" ), )); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } // Dry runs fall through to the verify-only preview below. } @@ -348,15 +353,10 @@ pub async fn vendor_nuget( ) .await; result.package_path = nupkg_path.display().to_string(); - return VendorOutcome::Done { - result, - entry: None, - warnings: dry_warnings, - }; + return done(result, None, dry_warnings); } // ── materialise the patched nupkg (service download / local rebuild) ── - check_service_offline_conflict(service); let mut warnings: Vec = Vec::new(); let (nupkg_bytes, mut result) = match materialise_patched_nupkg( purl, @@ -379,11 +379,7 @@ pub async fn vendor_nuget( if !result.success { // The rebuild left the result un-successful (and cleaned up its own // partial artifact); no project file was touched. - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } result.package_path = nupkg_path.display().to_string(); let new_hash = content_hash(&nupkg_bytes); @@ -396,25 +392,19 @@ pub async fn vendor_nuget( let _ = remove_tree(&uuid_dir).await; result.success = false; result.error = Some(detail); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } }; let config_target = config_path .clone() .unwrap_or_else(|| project_root.join("nuget.config")); - if let Err(e) = atomic_write_bytes(&config_target, config_edit.new_text.as_bytes()).await { + if let Err(e) = + atomic_write_bytes_preserving_mode(&config_target, config_edit.new_text.as_bytes()).await + { let _ = remove_tree(&uuid_dir).await; result.success = false; result.error = Some(format!("failed to write {}: {e}", config_target.display())); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } // ── packages.lock.json pinning (a failure here unwinds the config) ──── @@ -422,15 +412,13 @@ pub async fn vendor_nuget( if let Some(text) = &lock_text { match edit_lock(text, name, &version_norm, &new_hash) { Ok(Some(edit)) => { - if let Err(e) = atomic_write_bytes(&lock_path, edit.text.as_bytes()).await { + if let Err(e) = + atomic_write_bytes_preserving_mode(&lock_path, edit.text.as_bytes()).await + { unwind_config(&config_target, config_text.as_deref(), &uuid_dir).await; result.success = false; result.error = Some(format!("failed to write {PACKAGES_LOCK}: {e}")); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } lock_record = Some(WiringRecord { file: PACKAGES_LOCK.to_string(), @@ -457,11 +445,7 @@ pub async fn vendor_nuget( unwind_config(&config_target, config_text.as_deref(), &uuid_dir).await; result.success = false; result.error = Some(detail); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } } } else { @@ -476,16 +460,7 @@ pub async fn vendor_nuget( // ── marker + ledger entry ──────────────────────────────────────────── let base_purl = build_nuget_purl(name, version); - let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); - vulnerabilities.sort(); - let marker = VendorMarker { - schema_version: MARKER_SCHEMA_VERSION, - purl: base_purl.clone(), - patch_uuid: record.uuid.clone(), - ecosystem: "nuget".to_string(), - vulnerabilities, - vendored_at: vendored_at.to_string(), - }; + let marker = VendorMarker::new("nuget", &base_purl, record, vendored_at); if let Err(e) = write_marker(&uuid_dir, &marker).await { // Informational only (state.json is the ledger of record) — a marker // failure must not fail an otherwise-wired vendor. @@ -500,8 +475,14 @@ pub async fn vendor_nuget( // `Rewritten` (revert restores `original`); when we created it, an `Added` // (revert deletes the file). The mapping record is audit-only. let created_config = config_text.is_none(); + // Both records name the config by its basename (nuget.config always sits + // at the project root). + let config_rel = config_target + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "nuget.config".to_string()); let source_record = WiringRecord { - file: config_file_rel(&config_target, project_root), + file: config_rel.clone(), kind: CONFIG_SOURCE_WIRING_KIND.to_string(), action: if created_config { WiringAction::Added @@ -513,7 +494,7 @@ pub async fn vendor_nuget( new: Some(Value::String(config_edit.new_text.clone())), }; let mapping_record = WiringRecord { - file: config_file_rel(&config_target, project_root), + file: config_rel, kind: CONFIG_MAPPING_WIRING_KIND.to_string(), action: WiringAction::Added, key: Some(name.to_string()), @@ -553,11 +534,7 @@ pub async fn vendor_nuget( pipenv: None, }; - VendorOutcome::Done { - result, - entry: Some(entry), - warnings, - } + done(result, Some(entry), warnings) } /// Revert a NuGet vendor entry: undo the lock pin, restore/delete the @@ -662,24 +639,19 @@ async fn materialise_patched_nupkg( service: Option<&VendorServiceConfig>, warnings: &mut Vec, ) -> Result<(Vec, ApplyResult), Box> { - match nuget_service_copy(service, record, name, warnings).await { - NugetServiceCopy::Used(bytes) => { + match service_archive_copy(service, &record.uuid, name, ".nupkg", warnings).await { + ServiceCopy::Used(bytes) => { if let Err(e) = write_nupkg(uuid_dir, nupkg_path, &bytes).await { let _ = remove_tree(uuid_dir).await; return Err(Box::new(refused("vendor_prebuilt_write_failed", e))); } - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); Ok(( bytes, - synthesized_result(purl, nupkg_path, verified, true, None), + already_patched_result(purl, nupkg_path, &record.files), )) } - NugetServiceCopy::HardFail(outcome) => Err(outcome), - NugetServiceCopy::FallBack => { + ServiceCopy::HardFail(outcome) => Err(outcome), + ServiceCopy::FallBack => { local_rebuild( purl, installed_dir, @@ -780,7 +752,8 @@ async fn local_rebuild( // plain OPC zip; NuGet reads the central directory, so entry order is free // to be lexicographic for stable bytes across re-runs). let stage_path = stage.path().to_path_buf(); - let rezip = tokio::task::spawn_blocking(move || rebuild_nupkg(&stage_path)).await; + let rezip = + tokio::task::spawn_blocking(move || rebuild_zip(&stage_path, Some(SIGNATURE_PART))).await; let nupkg_bytes = match rezip { Ok(Ok(b)) => b, Ok(Err(e)) => { @@ -804,84 +777,6 @@ async fn local_rebuild( Ok((nupkg_bytes, result)) } -/// Outcome of attempting to materialise the nupkg from the patch service. -enum NugetServiceCopy { - /// The prebuilt patched `.nupkg` bytes (write them verbatim). - Used(Vec), - /// Bubble this terminal outcome (boxed — `VendorOutcome` is large). - HardFail(Box), - /// Fall back to the local rebuild. - FallBack, -} - -/// Download + integrity-verify the prebuilt patched `.nupkg` (Tier A: the -/// verified archive bytes ARE the vendored nupkg). Maps each service outcome -/// onto the `auto` / `service` fallback policy. -async fn nuget_service_copy( - service: Option<&VendorServiceConfig>, - record: &PatchRecord, - name: &str, - warnings: &mut Vec, -) -> NugetServiceCopy { - let Some(cfg) = service else { - return NugetServiceCopy::FallBack; - }; - if !cfg.service_enabled() { - return NugetServiceCopy::FallBack; - } - fn hard(code: &'static str, detail: String) -> NugetServiceCopy { - NugetServiceCopy::HardFail(Box::new(VendorOutcome::Refused { code, detail })) - } - let miss = |warnings: &mut Vec, code: &'static str, reason: String| { - if cfg.source.requires_service() { - hard("vendor_prebuilt_required", reason) - } else { - warnings.push(VendorWarning::new( - code, - format!("{reason}; building locally instead"), - )); - NugetServiceCopy::FallBack - } - }; - match fetch_verified_archive(cfg, &record.uuid, name).await { - ServiceArtifact::Ready(archive) => { - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored {name} from the patch service ({})", - archive.source_url - ), - )); - NugetServiceCopy::Used(archive.bytes) - } - ServiceArtifact::IntegrityMismatch(reason) => miss( - warnings, - "vendor_prebuilt_integrity_mismatch", - format!("prebuilt .nupkg failed integrity ({reason})"), - ), - ServiceArtifact::Pending => miss( - warnings, - "vendor_prebuilt_pending", - "prebuilt .nupkg is still building".to_string(), - ), - ServiceArtifact::Unavailable(reason) => { - if cfg.source.requires_service() { - hard( - "vendor_prebuilt_required", - format!("prebuilt .nupkg unavailable: {reason}"), - ) - } else { - NugetServiceCopy::FallBack - } - } - ServiceArtifact::Failed(reason) => miss( - warnings, - "vendor_prebuilt_unavailable", - format!("patch service request failed ({reason})"), - ), - } -} - /// Write `bytes` to `nupkg_path`, creating the uuid dir. Errors are strings. async fn write_nupkg(uuid_dir: &Path, nupkg_path: &Path, bytes: &[u8]) -> Result<(), String> { tokio::fs::create_dir_all(uuid_dir) @@ -912,85 +807,6 @@ async fn locate_cached_nupkg(installed_dir: &Path) -> Option { None } -/// Re-zip the patched stage into a deterministic `.nupkg`: entries sorted -/// lexicographically, a fixed timestamp, and a fixed deflate level so -/// rebuilding the same patched tree always yields identical bytes (and thus a -/// stable `contentHash`, churn-free commits). The embedded `.signature.p7s` -/// part is dropped — the content changed, so the package is no longer the -/// signed original and must read as unsigned. -fn rebuild_nupkg(stage: &Path) -> Result, String> { - let mut entries: Vec<(String, Vec)> = Vec::new(); - for entry in walkdir::WalkDir::new(stage).follow_links(false) { - let entry = entry.map_err(|e| format!("walk {}: {e}", stage.display()))?; - if !entry.file_type().is_file() { - continue; - } - let rel = entry - .path() - .strip_prefix(stage) - .map_err(|e| format!("strip prefix: {e}"))?; - let name = rel.to_string_lossy().replace('\\', "/"); - if name == SIGNATURE_PART { - continue; - } - let bytes = std::fs::read(entry.path()).map_err(|e| format!("read {name}: {e}"))?; - entries.push((name, bytes)); - } - entries.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - for (name, bytes) in &entries { - let options = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated) - .compression_level(Some(6)) - .last_modified_time(zip::DateTime::default()) - .unix_permissions(0o644); - writer - .start_file(name, options) - .map_err(|e| format!("zip start {name}: {e}"))?; - writer - .write_all(bytes) - .map_err(|e| format!("zip write {name}: {e}"))?; - } - let cursor = writer.finish().map_err(|e| format!("zip finish: {e}"))?; - Ok(cursor.into_inner()) -} - -/// True when the committed nupkg exists and every patched file in it already -/// hashes to its `afterHash` (the vendor twin of composer's -/// `copy_matches_after_hashes`, reading the nupkg's zip entries). -async fn nupkg_matches_after_hashes( - nupkg_path: &Path, - files: &HashMap, -) -> bool { - use crate::hash::git_sha256::compute_git_sha256_from_bytes; - let Ok(bytes) = tokio::fs::read(nupkg_path).await else { - return false; - }; - let Ok(mut archive) = zip::ZipArchive::new(std::io::Cursor::new(bytes)) else { - return false; - }; - for (file_name, info) in files { - let normalized = normalize_file_path(file_name); - // SECURITY: never look up a key that escapes the package dir — treat - // it as out-of-sync (the full pipeline would refuse it anyway). - if !is_safe_relative_subpath(normalized) { - return false; - } - let Ok(mut entry) = archive.by_name(normalized) else { - return false; - }; - let mut content = Vec::with_capacity(entry.size() as usize); - if entry.read_to_end(&mut content).is_err() { - return false; - } - if compute_git_sha256_from_bytes(&content) != info.after_hash { - return false; - } - } - true -} - // ── nuget.config editing ─────────────────────────────────────────────────────── /// The planned config edit: the whole new file text plus the mapping fragment @@ -1012,15 +828,6 @@ async fn existing_config_path(project_root: &Path) -> Option { None } -/// The project-relative, forward-slashed name of the config file (its basename; -/// nuget.config always sits at the project root). -fn config_file_rel(config_path: &Path, _project_root: &Path) -> String { - config_path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "nuget.config".to_string()) -} - /// Build the wired `nuget.config` text. Creating from scratch seeds the default /// nuget.org source so the load-bearing catch-all has a target; editing an /// existing file inserts our source (and, only when no `packageSourceMapping` @@ -1062,18 +869,26 @@ fn build_config_edit( }) } Some(text) => { + // Every anchor find and source scan runs against the + // comment-blanked view (same length, so offsets splice into + // `text`). NuGet never reads a comment: a commented-out section + // must not capture an insert (the wired source would be invisible + // and restore would silently serve the UNPATCHED package), and a + // commented-out `` must not become a catch-all target (the + // mapping would fan `*` out to a source that does not exist). + let visible = blank_comments(text); // Whether we are about to CREATE the mapping section (vs. extend an // existing one) — decided against the pre-edit text. - let creating_mapping = !text.contains(""); + let creating_mapping = !visible.contains(""); // The pre-existing sources the catch-all fans `*` out to. When the // config has NONE and we are creating a mapping from scratch, a // socket-only mapping would NU1100 every other package, so seed the // implicit default nuget.org source (unless already present) and map // `*` to it. Mirrors redirect::add_nuget_source. - let mut catch_all_keys = parse_config_source_keys(text); + let mut catch_all_keys = parse_config_source_keys(&visible); let seed_nuget_org = creating_mapping && catch_all_keys.is_empty() - && !text.contains(NUGET_ORG_SOURCE_KEY); + && !visible.contains(NUGET_ORG_SOURCE_KEY); let source_add = format!(" \n"); let org_add = format!( @@ -1091,7 +906,7 @@ fn build_config_edit( // self-closing `` carries no children, so // expand it in place into an open/close pair rather than leaving // it dangling beside a duplicate element. - let with_source = if let Some((start, end)) = self_closing_package_sources(text) { + let with_source = if let Some((start, end)) = self_closing_package_sources(&visible) { let mut expanded = String::with_capacity(text.len() + injected_sources.len() + 40); expanded.push_str(&text[..start]); expanded.push_str(&format!( @@ -1099,26 +914,23 @@ fn build_config_edit( )); expanded.push_str(&text[end..]); expanded - } else if text.contains("") { - insert_before(text, "", &injected_sources).ok_or_else(|| { - "could not locate to insert the vendored source".to_string() - })? - } else if text.contains("") { + } else if let Some(at) = visible.find("") { + insert_at_line(text, at, &injected_sources) + } else if let Some(at) = visible.find("") { let block = format!(" \n{injected_sources} \n"); - insert_before(text, "", &block).ok_or_else(|| { - "could not locate to insert a packageSources section" - .to_string() - })? + insert_at_line(text, at, &block) } else { return Err("nuget.config has no to edit".to_string()); }; // 2. Mapping: extend an existing section, or create one over the - // pre-existing sources (the load-bearing catch-all). + // pre-existing sources (the load-bearing catch-all). The blanked + // view is recomputed — step 1 shifted the offsets. + let visible_ws = blank_comments(&with_source); let new_text = if !creating_mapping { - insert_before(&with_source, "", &mapping_fragment) - .ok_or_else(|| { - "could not locate to insert the mapping".to_string() - })? + let at = visible_ws.find("").ok_or_else(|| { + "could not locate to insert the mapping".to_string() + })?; + insert_at_line(&with_source, at, &mapping_fragment) } else { let mut block = String::from(" \n"); for key in &catch_all_keys { @@ -1128,10 +940,11 @@ fn build_config_edit( } block.push_str(&mapping_fragment); block.push_str(" \n"); - insert_before(&with_source, "", &block).ok_or_else(|| { + let at = visible_ws.find("").ok_or_else(|| { "could not locate to insert a packageSourceMapping section" .to_string() - })? + })?; + insert_at_line(&with_source, at, &block) }; Ok(ConfigEdit { new_text, @@ -1141,25 +954,50 @@ fn build_config_edit( } } -/// Insert `insertion` (already newline-terminated) immediately before the line -/// containing the first occurrence of `needle`. Returns `None` if `needle` is -/// absent. -fn insert_before(haystack: &str, needle: &str, insertion: &str) -> Option { - let idx = haystack.find(needle)?; - // Back up to the start of the needle's line so the insertion lands on its - // own line(s) directly above. - let line_start = haystack[..idx].rfind('\n').map(|n| n + 1).unwrap_or(0); - let mut out = String::with_capacity(haystack.len() + insertion.len()); - out.push_str(&haystack[..line_start]); +/// `text` with every `` comment blanked to spaces (newlines kept), +/// preserving length so offsets found in the blanked view splice into the +/// original. NuGet never reads a comment, so anchors and source keys inside +/// one must be invisible to the wiring logic — the nuget twin of maven's +/// `find_wireable_anchor` comment masking. An unterminated comment blanks +/// through EOF (fail-closed). +fn blank_comments(text: &str) -> String { + let mut out = text.as_bytes().to_vec(); + let mut from = 0; + while let Some(rel) = text[from..].find("") { + Some(rel_end) => start + 4 + rel_end + 3, + None => text.len(), + }; + for b in &mut out[start..end] { + if *b != b'\n' { + *b = b' '; + } + } + from = end; + } + // Every replaced byte became ASCII space; newlines are never continuation + // bytes, so the result is valid UTF-8. + String::from_utf8(out).expect("blanking preserves UTF-8") +} + +/// Insert `insertion` (already newline-terminated) at the start of the line +/// containing byte offset `at` — the offset comes from the comment-blanked +/// view, which shares offsets with `text`. +fn insert_at_line(text: &str, at: usize, insertion: &str) -> String { + let line_start = text[..at].rfind('\n').map(|n| n + 1).unwrap_or(0); + let mut out = String::with_capacity(text.len() + insertion.len()); + out.push_str(&text[..line_start]); out.push_str(insertion); - out.push_str(&haystack[line_start..]); - Some(out) + out.push_str(&text[line_start..]); + out } /// Extract the `key` attribute of every `` element inside /// ``. Deliberately minimal (no XML parser dependency): scans /// the packageSources span for `` elements. These are -/// the "pre-existing sources" the catch-all maps `*` to. +/// the "pre-existing sources" the catch-all maps `*` to. Callers pass the +/// comment-blanked text so a commented-out source never contributes a key. fn parse_config_source_keys(text: &str) -> Vec { let mut out = Vec::new(); let Some(start) = text.find(" Result { + // SECURITY: state.json is committed and tamper-able; `w.file` is joined + // under the project root and then written through. Vendor only ever + // records a root-level config basename, so anything else — a `../`, an + // absolute path — would turn the whole-file restore into an arbitrary + // file overwrite/delete. Rejected fail-closed, like the uuid above. + if !is_safe_single_segment(&w.file) { + return Err(format!( + "refusing revert: unsafe wiring file path {:?}", + w.file + )); + } let config_path = project_root.join(&w.file); let Some(source_key) = w.key.as_deref() else { return Ok(false); @@ -1258,7 +1107,7 @@ async fn revert_config_record( match &w.original { // Pre-existed → restore the verbatim original bytes. Some(Value::String(orig)) => { - atomic_write_bytes(&config_path, orig.as_bytes()) + atomic_write_bytes_preserving_mode(&config_path, orig.as_bytes()) .await .map_err(|e| format!("failed to restore {}: {e}", config_path.display()))?; } @@ -1289,7 +1138,7 @@ async fn revert_config_record( if let Some(block) = mapping_block { out = out.replacen(&block, "", 1); } - atomic_write_bytes(&config_path, out.as_bytes()) + atomic_write_bytes_preserving_mode(&config_path, out.as_bytes()) .await .map_err(|e| { format!( @@ -1450,28 +1299,18 @@ async fn revert_lock_record( return Ok(true); } let restored = text.replace(&ours_q, &format!("\"{orig}\"")); - atomic_write_bytes(lock_path, restored.as_bytes()) + atomic_write_bytes_preserving_mode(lock_path, restored.as_bytes()) .await .map_err(|e| format!("failed to restore {}: {e}", lock_path.display()))?; Ok(true) } -// ── shared helpers ────────────────────────────────────────────────────────────── - -/// If a service run needs the network but `--offline` is set, that conflict is -/// surfaced by the CLI layer's `--offline` gate; the backend only needs the -/// service enabled/disabled decision (`service_enabled()` already returns false -/// when offline), so this is a no-op guard kept for parity with the other -/// backends' explicit conflict refusal. Left as a thin marker so the offline -/// contract is visible at each entry point. -fn check_service_offline_conflict(_service: Option<&VendorServiceConfig>) {} - /// Restore the config to its pre-vendor state (or delete a created file) after /// a later wiring step failed, then remove the partial uuid dir. async fn unwind_config(config_target: &Path, original: Option<&str>, uuid_dir: &Path) { match original { Some(orig) => { - let _ = atomic_write_bytes(config_target, orig.as_bytes()).await; + let _ = atomic_write_bytes_preserving_mode(config_target, orig.as_bytes()).await; } None => { let _ = tokio::fs::remove_file(config_target).await; @@ -1480,51 +1319,14 @@ async fn unwind_config(config_target: &Path, original: Option<&str>, uuid_dir: & let _ = remove_tree(uuid_dir).await; } -fn refused(code: &'static str, detail: impl Into) -> VendorOutcome { - VendorOutcome::Refused { - code, - detail: detail.into(), - } -} - -fn synthesized_result( - package_key: &str, - nupkg_path: &Path, - files_verified: Vec, - success: bool, - error: Option, -) -> ApplyResult { - ApplyResult { - package_key: package_key.to_string(), - package_path: nupkg_path.display().to_string(), - success, - files_verified, - files_patched: Vec::new(), - applied_via: HashMap::new(), - error, - sidecar: None, - } -} - -fn failed_result(purl: &str, nupkg_path: &Path, error: String) -> ApplyResult { - synthesized_result(purl, nupkg_path, Vec::new(), false, Some(error)) -} - -fn already_patched_verify(file: &str) -> VerifyResult { - VerifyResult { - file: file.to_string(), - status: VerifyStatus::AlreadyPatched, - message: None, - current_hash: None, - expected_hash: None, - target_hash: None, - } -} - #[cfg(test)] mod tests { + use std::collections::HashMap; + use std::io::{Read as _, Write as _}; + use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::manifest::schema::PatchFileInfo; use crate::patch::vendor::state::VENDOR_MARKER_FILE; use serde_json::json; @@ -2306,4 +2108,556 @@ mod tests { assert!(!root.join(".socket").exists()); assert!(!root.join("nuget.config").exists()); } + + // ── comment-blind wiring regressions ─────────────────────────────────── + + /// `t` with every `` span dropped — what NuGet actually reads. + fn visible_text(t: &str) -> String { + let mut out = String::new(); + let mut rest = t; + while let Some(start) = rest.find("") { + Some(end) => rest = &rest[start + 4 + end + 3..], + None => rest = "", + } + } + out.push_str(rest); + out + } + + #[test] + fn wires_outside_commented_package_sources() { + // A commented-out block above the real one: the + // vendored must land in the REAL section. NuGet never reads a + // comment — a source wired into one silently restores the UNPATCHED + // package while vendor reports success. + let orig = "\n\ + \n\ + \x20 \n\ + \x20 \n\ + \x20 \n\ + \x20 \n\ + \n"; + let edit = build_config_edit( + Some(orig), + &source_key(), + &format!(".socket/vendor/nuget/{UUID}"), + "Newtonsoft.Json", + ) + .unwrap(); + let vis = visible_text(&edit.new_text); + assert!( + vis.contains(&format!(""), + "the id mapping must be outside comments: {}", + edit.new_text + ); + // The catch-all fans out to the ACTIVE source, not the commented one. + assert!( + vis.contains(""), + "catch-all target is the active corp source: {}", + edit.new_text + ); + assert!( + !edit.new_text.contains(""), + "a commented-out source must not become a catch-all target: {}", + edit.new_text + ); + } + + #[test] + fn commented_mapping_section_gets_real_mapping() { + // The only is inside a comment: treating it as + // an existing section drops our mapping inside the comment (invisible + // to NuGet) and skips the load-bearing catch-all. + let orig = "\n\ + \n\ + \x20 \n\ + \x20 \n\ + \x20 \n\ + \x20 \n\ + \n"; + let edit = build_config_edit( + Some(orig), + &source_key(), + &format!(".socket/vendor/nuget/{UUID}"), + "Newtonsoft.Json", + ) + .unwrap(); + let vis = visible_text(&edit.new_text); + assert!( + vis.contains(&format!("", source_key())), + "our mapping must live in a REAL section: {}", + edit.new_text + ); + assert!( + vis.contains("") + && vis.contains(""), + "creating the mapping from scratch needs the catch-all: {}", + edit.new_text + ); + } + + #[test] + fn catch_all_skips_commented_sources() { + // A commented-out inside the real packageSources must not become + // a catch-all target — mapping `*` to a source NuGet cannot see + // hard-fails every restore. + let orig = "\n\ + \n\ + \x20 \n\ + \x20 \n\ + \x20 \n\ + \x20 \n\ + \n"; + let edit = build_config_edit( + Some(orig), + &source_key(), + &format!(".socket/vendor/nuget/{UUID}"), + "Newtonsoft.Json", + ) + .unwrap(); + let t = &edit.new_text; + assert!(t.contains(""), "{t}"); + assert!( + !t.contains(""), + "a commented-out source must not become a catch-all target: {t}" + ); + } + + #[test] + fn seeds_org_when_sources_all_commented() { + // Every source is commented out and the org URL only appears inside + // the comment: a from-scratch mapping must seed a REAL nuget.org + // source, else the socket-only mapping NU1100s every other package. + let orig = "\n\ + \n\ + \x20 \n\ + \x20 \n\ + \x20 \n\ + \n"; + let edit = build_config_edit( + Some(orig), + &source_key(), + &format!(".socket/vendor/nuget/{UUID}"), + "Newtonsoft.Json", + ) + .unwrap(); + let vis = visible_text(&edit.new_text); + assert!( + vis.contains("\n " + ), + "the catch-all must target the seeded active source: {}", + edit.new_text + ); + } + + // ── wired hot-path rebuild regressions ───────────────────────────────── + + #[tokio::test] + async fn wired_rebuild_reports_vendored_nupkg_path() { + let (dir, blobs, installed, record) = fixture(true, None).await; + let root = dir.path(); + let (r1, _e, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r1.success); + tokio::fs::remove_file(root.join(copy_rel())).await.unwrap(); + + let (r2, e2, w2) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r2.success, "{:?}", r2.error); + assert!(e2.is_none()); + assert!(w2.iter().any(|w| w.code == "vendor_artifact_rebuilt")); + assert_eq!( + r2.package_path, + root.join(copy_rel()).display().to_string(), + "the hot-path rebuild must report the vendored nupkg, not the deleted temp stage" + ); + } + + #[tokio::test] + async fn rerun_with_no_matching_lock_entry_is_idempotent() { + // A lock that never resolved the patched id: nothing to pin (run 1 + // warns), and a rerun must be AlreadyPatched — not a phantom "missing + // or stale" artifact rebuild on every invocation. + let (dir, blobs, installed, record) = fixture(false, None).await; + let root = dir.path(); + let other_lock = serde_json::to_string_pretty(&json!({ + "version": 1, + "dependencies": { + "net8.0": { + "Some.Other.Pkg": { + "type": "Direct", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "OTHERhash==" + } + } + } + })) + .unwrap(); + tokio::fs::write(root.join(PACKAGES_LOCK), &other_lock) + .await + .unwrap(); + + let (r1, e1, w1) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r1.success, "{:?}", r1.error); + assert!(e1.is_some()); + assert!(w1.iter().any(|w| w.code == "vendor_nuget_lock_entry_absent")); + + let (r2, e2, w2) = unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r2.success, "{:?}", r2.error); + assert!(e2.is_none()); + assert!( + !w2.iter().any(|w| w.code == "vendor_artifact_rebuilt"), + "an unpinnable lock must not force a phantom rebuild on every rerun: {w2:?}" + ); + assert_eq!( + tokio::fs::read_to_string(root.join(PACKAGES_LOCK)) + .await + .unwrap(), + other_lock, + "nothing to pin — the lock must stay untouched" + ); + } + + #[tokio::test] + async fn wired_rerun_with_corrupt_lock_fails() { + let (dir, blobs, installed, record) = fixture(true, None).await; + let root = dir.path(); + let (r1, _e, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r1.success); + // The lock rots after vendoring. The fresh path fails closed on an + // unparseable lock; the wired rebuild leg must not silently skip the + // re-pin and report success instead. + tokio::fs::write(root.join(PACKAGES_LOCK), b"{ not json") + .await + .unwrap(); + let (r2, e2, _w2) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(e2.is_none()); + assert!(!r2.success, "a corrupt lock must fail the rerun, not vanish"); + assert!( + r2.error.as_deref().unwrap_or("").contains("unparseable"), + "{:?}", + r2.error + ); + assert!( + root.join(copy_rel()).exists(), + "the wired feed keeps its artifact" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn hot_path_lock_write_failure_keeps_wired_artifact() { + use std::os::unix::fs::PermissionsExt as _; + let (dir, blobs, installed, record) = fixture(true, None).await; + let root = dir.path(); + let (r1, _e, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r1.success); + tokio::fs::remove_file(root.join(copy_rel())).await.unwrap(); + + // A read-only project root blocks the lock's atomic stage file. Skip + // when the environment ignores modes (running as root). + tokio::fs::set_permissions(root, std::fs::Permissions::from_mode(0o555)) + .await + .unwrap(); + if std::fs::write(root.join(".probe"), b"x").is_ok() { + let _ = std::fs::remove_file(root.join(".probe")); + tokio::fs::set_permissions(root, std::fs::Permissions::from_mode(0o755)) + .await + .unwrap(); + return; + } + let outcome = run_vendor(root, &blobs, &installed, &record, false).await; + tokio::fs::set_permissions(root, std::fs::Permissions::from_mode(0o755)) + .await + .unwrap(); + + let (r2, _e2, _w2) = unwrap_done(outcome); + assert!(!r2.success, "the failed lock re-pin must be reported"); + assert!( + root.join(copy_rel()).exists(), + "nuget.config (from run 1) still points at the feed — the rebuilt \ + nupkg must survive a lock re-pin failure or restore bricks" + ); + } + + // ── tamper-able wiring `file` regression ─────────────────────────────── + + #[tokio::test] + async fn revert_refuses_wiring_file_outside_project_root() { + // state.json is committed and tamper-able: a crafted wiring `file` + // must not read or write through `../` out of the project root (the + // fast path would overwrite an arbitrary file with attacker-chosen + // `original` bytes). + let dir = tempfile::tempdir().unwrap(); + let outside = dir.path().join("outside.txt"); + tokio::fs::write(&outside, b"precious").await.unwrap(); + let root = dir.path().join("proj"); + tokio::fs::create_dir_all(&root).await.unwrap(); + + let entry = VendorEntry { + ecosystem: "nuget".to_string(), + base_purl: PURL.to_string(), + uuid: UUID.to_string(), + artifact: VendorArtifact { + path: copy_rel(), + sha256: String::new(), + size: None, + platform_locked: None, + }, + wiring: vec![WiringRecord { + file: "../outside.txt".to_string(), + kind: CONFIG_SOURCE_WIRING_KIND.to_string(), + action: WiringAction::Rewritten, + key: Some(source_key()), + original: Some(Value::String("EVIL".to_string())), + new: Some(Value::String("precious".to_string())), + }], + lock: None, + took_over_go_patches: false, + detached: false, + record: None, + flavor: None, + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + }; + let outcome = revert_nuget(&entry, &root, false).await; + assert!( + !outcome.success, + "an escaping wiring file must refuse the revert: {outcome:?}" + ); + assert_eq!( + tokio::fs::read(&outside).await.unwrap(), + b"precious", + "the file outside the project root must not be touched" + ); + } + + // ── mode preservation (unix) ─────────────────────────────────────────── + + #[cfg(unix)] + async fn mode_of(p: &Path) -> u32 { + use std::os::unix::fs::PermissionsExt as _; + tokio::fs::metadata(p).await.unwrap().permissions().mode() & 0o7777 + } + + #[cfg(unix)] + fn preexisting_cfg() -> &'static str { + "\n\ + \n\ + \x20 \n\ + \x20 \n\ + \x20 \n\ + \n" + } + + #[cfg(unix)] + #[tokio::test] + async fn vendor_preserves_config_and_lock_modes() { + use std::os::unix::fs::PermissionsExt as _; + let (dir, blobs, installed, record) = fixture(true, Some(preexisting_cfg())).await; + let root = dir.path(); + tokio::fs::set_permissions( + root.join("nuget.config"), + std::fs::Permissions::from_mode(0o600), + ) + .await + .unwrap(); + tokio::fs::set_permissions( + root.join(PACKAGES_LOCK), + std::fs::Permissions::from_mode(0o640), + ) + .await + .unwrap(); + + let (result, _e, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + assert_eq!( + mode_of(&root.join("nuget.config")).await, + 0o600, + "wiring must not reset nuget.config's mode" + ); + assert_eq!( + mode_of(&root.join(PACKAGES_LOCK)).await, + 0o640, + "the lock pin must not reset packages.lock.json's mode" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn hot_path_repin_preserves_lock_mode() { + use std::os::unix::fs::PermissionsExt as _; + let (dir, blobs, installed, record) = fixture(true, None).await; + let root = dir.path(); + let (r1, _e, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r1.success); + tokio::fs::set_permissions( + root.join(PACKAGES_LOCK), + std::fs::Permissions::from_mode(0o600), + ) + .await + .unwrap(); + tokio::fs::remove_file(root.join(copy_rel())).await.unwrap(); + + let (r2, _e2, _w2) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r2.success, "{:?}", r2.error); + assert_eq!( + mode_of(&root.join(PACKAGES_LOCK)).await, + 0o600, + "the hot-path re-pin must not reset packages.lock.json's mode" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn revert_preserves_config_and_lock_modes() { + use std::os::unix::fs::PermissionsExt as _; + let (dir, blobs, installed, record) = fixture(true, Some(preexisting_cfg())).await; + let root = dir.path(); + let (r1, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r1.success); + let entry = entry.unwrap(); + tokio::fs::set_permissions( + root.join("nuget.config"), + std::fs::Permissions::from_mode(0o600), + ) + .await + .unwrap(); + tokio::fs::set_permissions( + root.join(PACKAGES_LOCK), + std::fs::Permissions::from_mode(0o640), + ) + .await + .unwrap(); + + let outcome = revert_nuget(&entry, root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + tokio::fs::read_to_string(root.join("nuget.config")) + .await + .unwrap(), + preexisting_cfg() + ); + assert_eq!( + mode_of(&root.join("nuget.config")).await, + 0o600, + "the whole-file config restore must not reset its mode" + ); + assert_eq!( + mode_of(&root.join(PACKAGES_LOCK)).await, + 0o640, + "the lock unpin must not reset packages.lock.json's mode" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn revert_excise_preserves_config_mode() { + use std::os::unix::fs::PermissionsExt as _; + let (dir, blobs, installed, record) = fixture(true, Some(preexisting_cfg())).await; + let root = dir.path(); + let (r1, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(r1.success); + let entry = entry.unwrap(); + // A user edit after vendoring forces the excise path. + let wired = tokio::fs::read_to_string(root.join("nuget.config")) + .await + .unwrap(); + let edited = wired.replacen("", "\n", 1); + assert_ne!(edited, wired); + tokio::fs::write(root.join("nuget.config"), &edited) + .await + .unwrap(); + tokio::fs::set_permissions( + root.join("nuget.config"), + std::fs::Permissions::from_mode(0o600), + ) + .await + .unwrap(); + + let outcome = revert_nuget(&entry, root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + let after = tokio::fs::read_to_string(root.join("nuget.config")) + .await + .unwrap(); + assert!(!after.contains(&source_key()), "excised: {after}"); + assert!(after.contains("user note"), "user edit kept: {after}"); + assert_eq!( + mode_of(&root.join("nuget.config")).await, + 0o600, + "the excise write must not reset nuget.config's mode" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn failed_lock_edit_unwind_preserves_config_mode() { + use std::os::unix::fs::PermissionsExt as _; + let (dir, blobs, installed, record) = fixture(false, Some(preexisting_cfg())).await; + let root = dir.path(); + tokio::fs::write(root.join(PACKAGES_LOCK), b"{ not json") + .await + .unwrap(); + tokio::fs::set_permissions( + root.join("nuget.config"), + std::fs::Permissions::from_mode(0o600), + ) + .await + .unwrap(); + + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(!result.success, "unparseable lock fails the vendor"); + assert!(entry.is_none()); + assert_eq!( + tokio::fs::read_to_string(root.join("nuget.config")) + .await + .unwrap(), + preexisting_cfg(), + "the config unwind restores the original" + ); + assert_eq!( + mode_of(&root.join("nuget.config")).await, + 0o600, + "the unwind restore must not reset nuget.config's mode" + ); + assert!( + !root.join(format!(".socket/vendor/nuget/{UUID}")).exists(), + "partial uuid dir removed" + ); + } } diff --git a/crates/socket-patch-core/src/patch/vendor/path.rs b/crates/socket-patch-core/src/patch/vendor/path.rs index 0f0516c4..bdb4ded1 100644 --- a/crates/socket-patch-core/src/patch/vendor/path.rs +++ b/crates/socket-patch-core/src/patch/vendor/path.rs @@ -31,53 +31,30 @@ use std::path::{Path, PathBuf}; +use crate::crawlers::Ecosystem; use crate::patch::path_safety::{is_canonical_uuid, is_safe_multi_segment, is_safe_single_segment}; use crate::utils::fs::list_dir_entries; /// Project-relative root of all vendored artifacts. -pub const VENDOR_DIR: &str = ".socket/vendor"; +pub(crate) const VENDOR_DIR: &str = ".socket/vendor"; /// The ecosystem directory names under [`VENDOR_DIR`]. These double as the -/// `` capture of the recovery convention and are independent of which -/// features this binary was compiled with (an orphan sweep must still -/// recognise — and report, not delete — a dir for a compiled-out ecosystem). -pub const ECOSYSTEM_DIRS: &[&str] = &[ +/// `` capture of the recovery convention. +pub(crate) const ECOSYSTEM_DIRS: &[&str] = &[ "npm", "cargo", "golang", "composer", "gem", "pypi", "nuget", "maven", ]; /// The vendor ecosystem-dir name for a PURL, or `None` when the ecosystem has -/// no vendor backend (jsr) or is compiled out of this binary. +/// no vendor backend (jsr). +/// +/// The dir name is `Ecosystem::cli_name()`: both are persisted contracts +/// (cli_name in manifests/sidecars, the dir in committed vendor paths) and +/// they deliberately share one spelling — see `ECOSYSTEM_DIRS` above. pub fn ecosystem_dir_for_purl(purl: &str) -> Option<&'static str> { - if purl.starts_with("pkg:npm/") { - return Some("npm"); - } - if purl.starts_with("pkg:pypi/") { - return Some("pypi"); - } - if purl.starts_with("pkg:gem/") { - return Some("gem"); - } - #[cfg(feature = "cargo")] - if purl.starts_with("pkg:cargo/") { - return Some("cargo"); - } - #[cfg(feature = "golang")] - if purl.starts_with("pkg:golang/") { - return Some("golang"); + match Ecosystem::from_purl(purl)? { + Ecosystem::Deno => None, + eco => Some(eco.cli_name()), } - #[cfg(feature = "composer")] - if purl.starts_with("pkg:composer/") { - return Some("composer"); - } - #[cfg(feature = "nuget")] - if purl.starts_with("pkg:nuget/") { - return Some("nuget"); - } - #[cfg(feature = "maven")] - if purl.starts_with("pkg:maven/") { - return Some("maven"); - } - None } /// The project-relative uuid dir (`.socket/vendor//`), validated. @@ -94,7 +71,7 @@ pub fn vendor_uuid_dir_rel(eco: &str, uuid: &str) -> Option { } /// One parsed vendored path (the output of [`parse_vendor_path`]). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug)] pub struct VendorPathParts { /// Ecosystem dir name (`npm`, `cargo`, …). pub eco: String, @@ -197,7 +174,7 @@ fn split_nuget_leaf(stem: &str) -> Option<(&str, &str)> { /// Reconstruct the base PURL from a vendored leaf. This is the orphan-sweep /// FALLBACK identification (state.json is the ledger of record); `None` means /// "unrecognisable — report, never delete by guess". -pub fn leaf_to_purl(eco: &str, leaf: &str) -> Option { +fn leaf_to_purl(eco: &str, leaf: &str) -> Option { match eco { "npm" => { let stem = leaf.strip_suffix(".tgz")?; @@ -283,7 +260,7 @@ pub fn leaf_to_purl(eco: &str, leaf: &str) -> Option { } /// One swept vendored unit: the uuid dir and what could be learned about it. -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct SweptVendorDir { pub eco: String, pub uuid: String, @@ -309,7 +286,10 @@ pub async fn sweep_vendor_dirs(project_root: &Path) -> Vec { continue; } let dir = entry.path(); - if !crate::utils::fs::entry_is_dir(&entry).await { + // Symlink-strict (lstat, not stat): vendor staging never writes + // symlinks, so a symlinked uuid dir cannot be ours — sweeping it + // would read (and let callers delete through) its target. + if !entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) { continue; } let purls = collect_leaf_purls(eco, &dir).await; @@ -344,8 +324,11 @@ async fn collect_leaf_purls(eco: &str, uuid_dir: &Path) -> Vec { } // Keep descending through structural levels (go module path // segments, composer vendor dirs, npm @scope dirs) up to a sane - // depth bound. - if crate::utils::fs::entry_is_dir(&entry).await && leaf.matches('/').count() < 8 { + // depth bound. Symlink-strict like the go-patches walker: a + // symlink in a committed unit is never ours and must not pull + // out-of-tree paths into the walk. + let is_real_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false); + if is_real_dir && leaf.matches('/').count() < 8 { stack.push((entry.path(), leaf)); } } @@ -375,6 +358,32 @@ mod tests { ); } + /// `ecosystem_dir_for_purl` derives the dir from `Ecosystem::cli_name()`. + /// The dir is an on-disk contract (committed vendor paths), so every + /// classification must land inside `ECOSYSTEM_DIRS` — a cli_name rename + /// must fail here rather than silently move the vendor layout. JSR stays + /// backend-less. + #[test] + fn ecosystem_dir_matches_contract_dirs() { + for eco in Ecosystem::all() { + let purl = format!("pkg:{}/example@1.0.0", eco.cli_name()); + match ecosystem_dir_for_purl(&purl) { + Some(dir) => { + assert_eq!(dir, eco.cli_name()); + assert!( + ECOSYSTEM_DIRS.contains(&dir), + "dir {dir:?} missing from ECOSYSTEM_DIRS" + ); + } + None => { + assert_eq!(*eco, Ecosystem::Deno, "only deno lacks a vendor backend"); + } + } + } + assert_eq!(ecosystem_dir_for_purl("pkg:jsr/@std/path@0.220.0"), None); + assert_eq!(ecosystem_dir_for_purl("pkg:unknown/foo@1.0"), None); + } + #[test] fn recovery_parses_every_lockfile_spelling() { // npm file: spec @@ -563,4 +572,51 @@ mod tests { ); assert_eq!(go.uuid, UUID); } + + /// SECURITY: the vendor tree is committed and tamper-able, and vendor + /// staging never writes symlinks — so a symlink anywhere under + /// `.socket/vendor/` cannot be ours. The sweep must be symlink-strict + /// (like the go-patches walker it mirrors): never descend through a + /// symlinked dir inside a unit, and never sweep a symlinked uuid dir — + /// otherwise a committed `link -> /` makes the sweep read arbitrary + /// out-of-tree paths and attribute purls found there to the unit. + #[cfg(unix)] + #[tokio::test] + async fn sweep_never_follows_symlinks_out_of_the_vendor_tree() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + // An outside tree containing a perfectly parseable npm leaf. + let outside = root.join("outside"); + tokio::fs::create_dir_all(&outside).await.unwrap(); + tokio::fs::write(outside.join("lodash-4.17.21.tgz"), b"x") + .await + .unwrap(); + + // A real uuid dir whose CONTENTS include a symlink to the outside dir. + let unit = root.join(format!(".socket/vendor/npm/{UUID}")); + tokio::fs::create_dir_all(&unit).await.unwrap(); + std::os::unix::fs::symlink(&outside, unit.join("esc")).unwrap(); + + // A uuid dir that IS a symlink to the outside dir. + tokio::fs::create_dir_all(root.join(".socket/vendor/cargo")) + .await + .unwrap(); + std::os::unix::fs::symlink( + &outside, + root.join(".socket/vendor/cargo/11111111-2222-4333-8444-555555555555"), + ) + .unwrap(); + + let swept = sweep_vendor_dirs(root).await; + assert!( + !swept.iter().any(|s| s.eco == "cargo"), + "symlinked uuid dir must not be swept as a unit: {swept:?}" + ); + let npm = swept.iter().find(|s| s.eco == "npm").unwrap(); + assert!( + npm.purls.is_empty(), + "purls must never be reconstructed through a symlink: {:?}", + npm.purls + ); + } } diff --git a/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs index 8aef1d92..d3fc358d 100644 --- a/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/pnpm_lock.rs @@ -33,18 +33,20 @@ //! unwinds package.json to its original bytes so the P3 desync pair is //! never left behind. -use std::collections::HashMap; use std::path::Path; use serde_json::Value; use crate::manifest::schema::PatchRecord; -use crate::patch::apply::{ApplyResult, PatchSources, VerifyResult, VerifyStatus}; +use crate::patch::apply::PatchSources; use crate::patch::copy_tree::remove_tree; -use crate::utils::fs::atomic_write_bytes; +use crate::utils::fs::atomic_write_bytes_preserving_mode; -use super::npm_common::{done_failure, guard_coordinates, refused, stage_patch_pack, tgz_rel_leaf}; -use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; +use super::common::{already_patched_result, detect_indent, done, refused, serialize_json}; +use super::npm_common::{ + done_failure, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, +}; +use super::path::parse_vendor_path; use super::state::{ write_marker, PnpmMeta, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; @@ -159,6 +161,9 @@ pub async fn vendor_pnpm( ), ); } + if let Err(detail) = check_rewritable_refs(&lines, name, version) { + return refused("vendor_lock_entry_unsupported", detail); + } // ── 4. Stage → patch → pack (shared flavor-agnostic pipeline) ──────── let (staged, result) = match stage_patch_pack( @@ -179,11 +184,7 @@ pub async fn vendor_pnpm( }; let Some(staged) = staged else { // Failed patch or dry run: wiring never ran, project byte-untouched. - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); }; debug_assert_eq!(staged.rel_tgz, rel_tgz); let packed = staged.packed; @@ -236,16 +237,11 @@ pub async fn vendor_pnpm( // project is in sync. The tarball re-pack above was byte-identical // by determinism; synthesize AlreadyPatched and record nothing (the // existing ledger entry stays authoritative). - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); - return VendorOutcome::Done { - result: synthesized_result(purl, &project_root.join(&rel_tgz), verified, true, None), - entry: None, + return done( + already_patched_result(purl, &project_root.join(&rel_tgz), &record.files), + None, warnings, - }; + ); } // ── 6. Commit: package.json FIRST, lock second, unwind on failure ──── @@ -254,7 +250,7 @@ pub async fn vendor_pnpm( Ok(bytes) => bytes, Err(e) => return done_failure(purl, format!("cannot serialize {PACKAGE_JSON}: {e}")), }; - let lock_out = join_lines(&lines); + let lock_out = lines.join("\n"); if let Err(e) = commit_pair( project_root, pkg_changed.then_some(new_pkg_bytes.as_slice()), @@ -267,16 +263,7 @@ pub async fn vendor_pnpm( } // ── 7. Marker + ledger entry ───────────────────────────────────────── - let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); - vulnerabilities.sort(); - let marker = VendorMarker { - schema_version: 1, - purl: coords.base_purl.clone(), - patch_uuid: record.uuid.clone(), - ecosystem: "npm".to_string(), - vulnerabilities, - vendored_at: vendored_at.to_string(), - }; + let marker = VendorMarker::new("npm", &coords.base_purl, record, vendored_at); if let Err(e) = write_marker(&project_root.join(&coords.uuid_dir_rel), &marker).await { warnings.push(VendorWarning::new( "vendor_marker_write_failed", @@ -309,11 +296,7 @@ pub async fn vendor_pnpm( pdm: None, pipenv: None, }; - VendorOutcome::Done { - result, - entry: Some(entry), - warnings, - } + done(result, Some(entry), warnings) } /// Is this pnpm-vendored entry still consumed by the lock's dependency @@ -363,11 +346,9 @@ pub async fn revert_pnpm(entry: &VendorEntry, project_root: &Path, dry_run: bool // SECURITY: `entry.uuid` comes from the committed, tamper-able // state.json and names the directory tree we are about to DELETE. // Validate through the same fail-closed grammar vendor used. - let Some(uuid_dir_rel) = vendor_uuid_dir_rel("npm", &entry.uuid) else { - return RevertOutcome::failed(format!( - "refusing revert: `{}` is not a canonical patch uuid (tampered state.json?)", - entry.uuid - )); + let uuid_dir_rel = match guard_revert_uuid_dir(&entry.uuid) { + Ok(d) => d, + Err(outcome) => return outcome, }; if dry_run { return RevertOutcome::ok(); @@ -495,9 +476,11 @@ pub async fn revert_pnpm(entry: &VendorEntry, project_root: &Path, dry_run: bool // Reverse write order: lock first, package.json second. if lock_dirty { if let Some(lines) = &lock_lines { - if let Err(e) = - atomic_write_bytes(&project_root.join(PNPM_LOCK), join_lines(lines).as_bytes()) - .await + if let Err(e) = atomic_write_bytes_preserving_mode( + &project_root.join(PNPM_LOCK), + lines.join("\n").as_bytes(), + ) + .await { return RevertOutcome::failed(format!("cannot write {PNPM_LOCK}: {e}")); } @@ -511,7 +494,9 @@ pub async fn revert_pnpm(entry: &VendorEntry, project_root: &Path, dry_run: bool return RevertOutcome::failed(format!("cannot serialize {PACKAGE_JSON}: {e}")) } }; - if let Err(e) = atomic_write_bytes(&project_root.join(PACKAGE_JSON), &bytes).await { + if let Err(e) = + atomic_write_bytes_preserving_mode(&project_root.join(PACKAGE_JSON), &bytes).await + { return RevertOutcome::failed(format!("cannot write {PACKAGE_JSON}: {e}")); } } @@ -554,13 +539,18 @@ impl EditCtx<'_> { /// Does `value` point at OUR vendored tarball for THIS name@version /// (any uuid — a stale uuid is rewritten to the current one with - /// `original: None`)? The leaf binding is load-bearing: a project can - /// vendor the SAME package at several versions, and a name-only match - /// would let one version's edit clobber another's entries. + /// `original: None`)? See [`vendor_value_is_for`] on why the leaf + /// binding is load-bearing. fn is_ours(&self, value: &str) -> bool { - parse_vendor_path(value).is_some_and(|p| { - p.eco == "npm" && p.leaf == super::npm_common::tgz_rel_leaf(self.name, self.version) - }) + vendor_value_is_for(value, self.name, self.version) + } + + /// Is `key` our rekeyed `name@file:` packages/snapshots key for + /// THIS name@version (any uuid)? + fn is_ours_key(&self, key: &str) -> bool { + key.strip_prefix(self.name) + .and_then(|rest| rest.strip_prefix("@file:")) + .is_some_and(|rest| self.is_ours(rest)) } /// The per-importer `specifier:` spelling: re-relativized for nested @@ -581,8 +571,8 @@ impl EditCtx<'_> { // ─────────────────────────── pre-flight checks ─────────────────────────── /// `lockfileVersion: '9.0'` head check (accept pnpm's single quotes plus -/// double-quoted/bare spellings, mirroring the flavor router's sniff). -fn check_lock_version(text: &str) -> Result<(), String> { +/// double-quoted/bare spellings). Also serves as the flavor router's sniff. +pub(super) fn check_lock_version(text: &str) -> Result<(), String> { let version = text .lines() .take(5) @@ -630,7 +620,7 @@ fn is_vendor_value(value: &str) -> bool { /// override/entry as their own. fn vendor_value_is_for(value: &str, name: &str, version: &str) -> bool { parse_vendor_path(value) - .is_some_and(|p| p.eco == "npm" && p.leaf == super::npm_common::tgz_rel_leaf(name, version)) + .is_some_and(|p| p.eco == "npm" && p.leaf == tgz_rel_leaf(name, version)) } /// How the package.json `pnpm.overrides` table relates to the package @@ -638,7 +628,6 @@ fn vendor_value_is_for(value: &str, name: &str, version: &str) -> bool { /// key-for-key (pnpm hard-checks the two and fails /// `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` on any drift), so whichever key /// this classification yields is the one BOTH surfaces edit. -#[derive(Debug, Clone, PartialEq, Eq)] enum OverrideDisposition { /// No same-name key: insert our canonical `name@version` key. Insert, @@ -651,9 +640,10 @@ enum OverrideDisposition { /// vendored (`"tar-fs": "3.1.0"` or `"tar-fs@3.1.0": "3.1.0"`): take /// the key over — rewrite its VALUE to the `file:` spec (the user's /// pin already forces every `tar-fs` to this exact version, so - /// redirecting the same key preserves their semantics) and record - /// the pin as the wiring `original` so revert restores it exactly. - Takeover { key: String, original: String }, + /// redirecting the same key preserves their semantics). The edit + /// sites re-read the pin from the live surface and record it as the + /// wiring `original` so revert restores it exactly. + Takeover { key: String }, } impl OverrideDisposition { @@ -662,7 +652,7 @@ impl OverrideDisposition { fn effective_key<'a>(&'a self, our_key: &'a str) -> &'a str { match self { OverrideDisposition::Insert => our_key, - OverrideDisposition::Ours { key } | OverrideDisposition::Takeover { key, .. } => key, + OverrideDisposition::Ours { key } | OverrideDisposition::Takeover { key } => key, } } } @@ -706,10 +696,7 @@ fn classify_pkg_override( } else if is_vendor_value(value_str) { Some(OverrideDisposition::Ours { key: key.clone() }) } else if value_str == version && (key == name || key == our_key) { - Some(OverrideDisposition::Takeover { - key: key.clone(), - original: value_str.to_string(), - }) + Some(OverrideDisposition::Takeover { key: key.clone() }) } else { None }; @@ -772,7 +759,9 @@ fn check_lock_override( /// Pre-flight: does the lock have a packages entry vendoring can target — /// the registry `name@version` key, or our own rekeyed `name@file:` key -/// (the in-sync / stale-uuid re-run)? +/// (the in-sync / stale-uuid re-run)? The ours-probe binds to THIS +/// version's leaf — a sibling version's vendored entry is not a target +/// (see `vendor_value_is_for`). fn lock_has_target_package(lines: &[String], name: &str, version: &str) -> bool { let Some((start, end)) = section_bounds(lines, "packages") else { return false; @@ -785,7 +774,7 @@ fn lock_has_target_package(lines: &[String], name: &str, version: &str) -> bool return true; } if let Some(rest) = block.key.strip_prefix(&ours_prefix) { - if parse_vendor_path(rest).is_some_and(|p| p.eco == "npm") { + if vendor_value_is_for(rest, name, version) { return true; } } @@ -794,6 +783,79 @@ fn lock_has_target_package(lines: &[String], name: &str, version: &str) -> bool false } +/// Pre-flight fail-closed guard against reference forms the surgery does +/// not rewrite. The five edits move the plain registry forms only — the +/// `name@version` packages/snapshots keys, bare-`version` importer fields +/// and snapshot dep refs. The same package instance can also be referenced +/// through a PEER-SUFFIXED dep path (`name@version(peer@…)`, emitted +/// whenever the package has peerDependencies) or an ALIASED value +/// (`npm:name@version` specs record `name@version` as the dep value). +/// Those spellings would survive the rekey verbatim and dangle — pnpm then +/// hard-rejects the lock (a snapshot/importer referencing a dep path whose +/// packages entry no longer exists) — so refuse before anything is staged +/// or written. The spike has no pnpm-blessed fixtures for either shape; +/// guessing their rewritten spelling risks worse corruption than refusing. +fn check_rewritable_refs(lines: &[String], name: &str, version: &str) -> Result<(), String> { + let reg_key = format!("{name}@{version}"); + let key_peer_prefix = format!("{reg_key}("); + let val_peer_prefix = format!("{version}("); + let refuse = |what: &str, spelling: &str| { + Err(format!( + "{PNPM_LOCK} references {reg_key} through {what} (`{spelling}`) that the \ + pair surgery cannot rewrite — vendoring would leave a dangling reference \ + pnpm rejects; this lock shape is not supported yet" + )) + }; + if let Some((start, end)) = section_bounds(lines, "snapshots") { + let mut i = start + 1; + while let Some(block) = next_block(lines, i, end) { + if block.key.starts_with(&key_peer_prefix) { + return refuse("a peer-suffixed snapshot key", &block.key); + } + for line in &lines[block.header + 1..block.end] { + let Some((dep, _repr, rest)) = parse_key_line(line, 6) else { + continue; + }; + if rest == reg_key || rest.starts_with(&key_peer_prefix) { + return refuse("an aliased snapshot reference", &rest); + } + if dep == name && rest.starts_with(&val_peer_prefix) { + return refuse("a peer-suffixed snapshot reference", &rest); + } + } + i = block.end; + } + } + if let Some((start, end)) = section_bounds(lines, "importers") { + let mut i = start + 1; + while let Some(importer) = next_block(lines, i, end) { + let mut k = importer.header + 1; + while k < importer.end { + let Some((dep, _repr, rest)) = parse_key_line(&lines[k], 6) else { + k += 1; + continue; + }; + if !rest.is_empty() { + k += 1; + continue; + } + let (_, ver, f) = dep_field_lines(lines, k + 1, importer.end); + if let Some((_, v)) = ver { + if v == reg_key || v.starts_with(&key_peer_prefix) { + return refuse("an aliased importer version", &v); + } + if dep == name && v.starts_with(&val_peer_prefix) { + return refuse("a peer-suffixed importer version", &v); + } + } + k = f; + } + i = importer.end; + } + } + Ok(()) +} + // ───────────────────────── package.json override ───────────────────────── /// Add/refresh `pnpm.overrides[@] = file:` on the @@ -935,6 +997,31 @@ fn overrides_record( } } +/// Locate a dep entry's `specifier:`/`version:` field lines (8-space +/// indent) starting at `f`. Returns the two `(line_idx, value)` pairs plus +/// the index of the first non-field line. +#[allow(clippy::type_complexity)] +fn dep_field_lines( + lines: &[String], + mut f: usize, + end: usize, +) -> (Option<(usize, String)>, Option<(usize, String)>, usize) { + let mut spec = None; + let mut ver = None; + while f < end { + let Some((field, _repr, fval)) = parse_key_line(&lines[f], 8) else { + break; + }; + match field.as_str() { + "specifier" => spec = Some((f, fval)), + "version" => ver = Some((f, fval)), + _ => {} + } + f += 1; + } + (spec, ver, f) +} + /// Edit 2: every importer's dep entry for the exact `name@version` — /// `specifier:` (re-relativized per importer) AND `version:` move to the /// `file:` spec. @@ -965,21 +1052,7 @@ fn edit_importers( k += 1; continue; } - // Locate this dep's specifier/version field lines. - let mut spec_idx = None; - let mut ver_idx = None; - let mut f = k + 1; - while f < importer.end { - let Some((field, _frepr, fval)) = parse_key_line(&lines[f], 8) else { - break; - }; - match field.as_str() { - "specifier" => spec_idx = Some((f, fval)), - "version" => ver_idx = Some((f, fval)), - _ => {} - } - f += 1; - } + let (spec_idx, ver_idx, f) = dep_field_lines(lines, k + 1, importer.end); if let (Some((si, old_spec)), Some((vi, old_ver))) = (spec_idx, ver_idx) { let target = old_ver == ctx.version || (old_ver != ctx.spec && ctx.is_ours(&old_ver)); @@ -1016,6 +1089,40 @@ fn edit_importers( Ok(changed) } +/// Fail closed on a half-drifted lock: when a `[start, end)` section +/// carries BOTH the registry-keyed entry and a socket file:-keyed entry +/// for this package, a rekey would splice a DUPLICATE mapping key (pnpm +/// refuses to parse those) and surgery cannot decide which block carries +/// the truth. +fn check_no_split_entry( + lines: &[String], + start: usize, + end: usize, + ctx: &EditCtx<'_>, + section: &str, +) -> Result<(), String> { + let reg_key = ctx.reg_key(); + let mut has_registry = false; + let mut has_ours = false; + let mut j = start + 1; + while let Some(block) = next_block(lines, j, end) { + if block.key == reg_key { + has_registry = true; + } else if ctx.is_ours_key(&block.key) { + has_ours = true; + } + j = block.end; + } + if has_registry && has_ours { + return Err(format!( + "{section} section carries BOTH `{reg_key}` and a `{}@file:…` entry (a \ + half-edited lock); run `pnpm install` to re-resolve it, then re-vendor", + ctx.name + )); + } + Ok(()) +} + /// Edit 3: rekey the `packages:` entry and rewrite its body — /// `resolution: {integrity: , tarball: }`, a `version:` line /// inserted after it, `deprecated:` dropped, everything else verbatim. @@ -1027,43 +1134,12 @@ fn edit_packages( let (start, end) = section_bounds(lines, "packages").ok_or("no packages: section")?; let reg_key = ctx.reg_key(); let new_key = ctx.new_key(); - let ours_prefix = format!("{}@file:", ctx.name); - - // Fail closed on a half-drifted lock: when BOTH the registry-keyed - // entry and a socket file:-keyed entry for this package exist, a rekey - // would splice a DUPLICATE mapping key (pnpm refuses to parse those) - // and surgery cannot decide which block carries the truth. - { - let mut has_registry = false; - let mut has_ours = false; - let mut j = start + 1; - while let Some(block) = next_block(lines, j, end) { - if block.key == reg_key { - has_registry = true; - } else if block - .key - .strip_prefix(&ours_prefix) - .is_some_and(|rest| ctx.is_ours(rest)) - { - has_ours = true; - } - j = block.end; - } - if has_registry && has_ours { - return Err(format!( - "packages section carries BOTH `{reg_key}` and a `{ours_prefix}…` entry (a \ - half-edited lock); run `pnpm install` to re-resolve it, then re-vendor" - )); - } - } + check_no_split_entry(lines, start, end, ctx, "packages")?; let mut i = start + 1; while let Some(block) = next_block(lines, i, end) { let is_registry = block.key == reg_key; - let is_ours_key = block - .key - .strip_prefix(&ours_prefix) - .is_some_and(|rest| ctx.is_ours(rest)); + let is_ours_key = ctx.is_ours_key(&block.key); if !is_registry && !is_ours_key { i = block.end; continue; @@ -1104,9 +1180,7 @@ fn edit_packages( block.key )); } - let header = block.header; - let block_end = block.end; - lines.splice(header..block_end, new_lines.clone()); + lines.splice(block.header..block.end, new_lines.clone()); wiring.push(WiringRecord { file: PNPM_LOCK.to_string(), kind: KIND_LOCK_PACKAGE.to_string(), @@ -1138,38 +1212,12 @@ fn edit_snapshot_rekey( }; let reg_key = ctx.reg_key(); let new_key = ctx.new_key(); - let ours_prefix = format!("{}@file:", ctx.name); - // Same duplicate-key fail-closed guard as edit_packages. - { - let mut has_registry = false; - let mut has_ours = false; - let mut j = start + 1; - while let Some(block) = next_block(lines, j, end) { - if block.key == reg_key { - has_registry = true; - } else if block - .key - .strip_prefix(&ours_prefix) - .is_some_and(|rest| ctx.is_ours(rest)) - { - has_ours = true; - } - j = block.end; - } - if has_registry && has_ours { - return Err(format!( - "snapshots section carries BOTH `{reg_key}` and a `{ours_prefix}…` entry (a \ - half-edited lock); run `pnpm install` to re-resolve it, then re-vendor" - )); - } - } + check_no_split_entry(lines, start, end, ctx, "snapshots")?; + let mut i = start + 1; while let Some(block) = next_block(lines, i, end) { let is_registry = block.key == reg_key; - let is_ours_key = block - .key - .strip_prefix(&ours_prefix) - .is_some_and(|rest| ctx.is_ours(rest)); + let is_ours_key = ctx.is_ours_key(&block.key); if !is_registry && !is_ours_key { i = block.end; continue; @@ -1184,9 +1232,7 @@ fn edit_snapshot_rekey( yaml_key_like(&new_key, &block.repr), block.rest_suffix() ); - let header = block.header; - let block_end = block.end; - lines.splice(header..block_end, new_lines.clone()); + lines.splice(block.header..block.end, new_lines.clone()); wiring.push(WiringRecord { file: PNPM_LOCK.to_string(), kind: KIND_LOCK_SNAPSHOT.to_string(), @@ -1434,21 +1480,8 @@ fn revert_importer_dep( k += 1; continue; } - let mut spec_idx = None; - let mut ver_idx = None; - let mut f = k + 1; - while f < importer.end { - let Some((field, _fr, fval)) = parse_key_line(&lines[f], 8) else { - break; - }; - match field.as_str() { - "specifier" => spec_idx = Some(f), - "version" => ver_idx = Some((f, fval)), - _ => {} - } - f += 1; - } - let (Some(si), Some((vi, live_ver))) = (spec_idx, ver_idx) else { + let (spec_idx, ver_idx, _) = dep_field_lines(lines, k + 1, importer.end); + let (Some((si, _)), Some((vi, live_ver))) = (spec_idx, ver_idx) else { break; }; let new_ver = rec @@ -1549,9 +1582,7 @@ fn revert_block( ))); return; }; - let header = block.header; - let block_end = block.end; - lines.splice(header..block_end, original); + lines.splice(block.header..block.end, original); *dirty = true; return; } @@ -1634,17 +1665,22 @@ async fn commit_pair( new_lock: Option<&[u8]>, ) -> Result<(), String> { if let Some(bytes) = new_pkg { - atomic_write_bytes(&project_root.join(PACKAGE_JSON), bytes) + atomic_write_bytes_preserving_mode(&project_root.join(PACKAGE_JSON), bytes) .await .map_err(|e| format!("cannot write {PACKAGE_JSON}: {e}"))?; } if let Some(bytes) = new_lock { - if let Err(e) = atomic_write_bytes(&project_root.join(PNPM_LOCK), bytes).await { + if let Err(e) = atomic_write_bytes_preserving_mode(&project_root.join(PNPM_LOCK), bytes).await + { if new_pkg.is_some() { // Unwind (best effort): a failure here leaves the desync pair // anyway, but the lock write failing usually means the // restore fails identically loudly. - let _ = atomic_write_bytes(&project_root.join(PACKAGE_JSON), original_pkg).await; + let _ = atomic_write_bytes_preserving_mode( + &project_root.join(PACKAGE_JSON), + original_pkg, + ) + .await; } return Err(format!( "cannot write {PNPM_LOCK}: {e} ({PACKAGE_JSON} restored to its original bytes)" @@ -1662,10 +1698,6 @@ pub(super) fn split_lines(text: &str) -> Vec { text.split('\n').map(str::to_string).collect() } -fn join_lines(lines: &[String]) -> String { - lines.join("\n") -} - /// `(header_idx, end_idx)` of a top-level `name:` section; `end` is the /// first following column-0 line (exclusive), so trailing blank separator /// lines belong to the section. @@ -1803,70 +1835,15 @@ fn value_lines(v: &Value) -> Option> { }) } -// ───────────────────────── small shared helpers ─────────────────────────── -// (same shapes as npm_lock's; duplicated because that module's helpers are -// private and this file is the only allowed edit surface) - -/// The file's indent unit: the leading whitespace of the first indented -/// line. Defaults to 2 spaces (npm/pnpm's own emission). -fn detect_indent(text: &str) -> String { - for line in text.lines() { - let trimmed = line.trim_start_matches([' ', '\t']); - if !trimmed.is_empty() && trimmed.len() < line.len() { - return line[..line.len() - trimmed.len()].to_string(); - } - } - " ".to_string() -} - -/// Pretty-print JSON with the detected indent + trailing newline. -fn serialize_json(doc: &Value, indent: &str) -> std::io::Result> { - use serde::Serialize; - let mut out = Vec::new(); - let formatter = serde_json::ser::PrettyFormatter::with_indent(indent.as_bytes()); - let mut ser = serde_json::Serializer::with_formatter(&mut out, formatter); - doc.serialize(&mut ser).map_err(std::io::Error::other)?; - out.push(b'\n'); - Ok(out) -} - -fn synthesized_result( - package_key: &str, - path: &Path, - files_verified: Vec, - success: bool, - error: Option, -) -> ApplyResult { - ApplyResult { - package_key: package_key.to_string(), - package_path: path.display().to_string(), - success, - files_verified, - files_patched: Vec::new(), - applied_via: HashMap::new(), - error, - sidecar: None, - } -} - -fn already_patched_verify(file: &str) -> VerifyResult { - VerifyResult { - file: file.to_string(), - status: VerifyStatus::AlreadyPatched, - message: None, - current_hash: None, - expected_hash: None, - target_hash: None, - } -} - #[cfg(test)] mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; + use crate::patch::apply::{ApplyResult, VerifyStatus}; use base64::Engine as _; use sha2::{Digest, Sha512}; + use std::collections::HashMap; use std::path::PathBuf; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; @@ -2727,18 +2704,43 @@ snapshots: /// must bind to its own version's entries — a name-only "ours" match /// would let the second vendor clobber/rekey the first one's blocks /// (live-debugged on Flowise: identical duplicated mapping keys). + /// 1.2.0 is reachable through a transitive dependent's snapshot ref + /// (the Flowise shape) — the P1 `npm:` ALIAS shape now refuses + /// fail-closed instead (see + /// `aliased_same_version_reference_refuses_fail_closed`; the surgery + /// cannot rewrite alias dep paths and used to strand them dangling). #[tokio::test] async fn multi_version_vendor_does_not_clobber_sibling_entries() { - let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + // P1 with the `left-pad-old` alias swapped for a `dep-two` + // dependent whose snapshot pulls left-pad@1.2.0 transitively. + let pkg = P1_BEFORE_PKG.replace( + "\"left-pad-old\": \"npm:left-pad@1.2.0\"", + "\"dep-two\": \"1.0.0\"", + ); + assert_ne!(pkg, P1_BEFORE_PKG); + let lock = P1_BEFORE_LOCK + .replace( + " left-pad-old:\n specifier: npm:left-pad@1.2.0\n version: left-pad@1.2.0", + " dep-two:\n specifier: 1.0.0\n version: 1.0.0", + ) + .replace( + " left-pad@1.2.0:\n resolution: {integrity: sha512-OQadpCyF", + " dep-two@1.0.0:\n resolution: {integrity: sha512-depTwo==}\n\n left-pad@1.2.0:\n resolution: {integrity: sha512-OQadpCyF", + ) + .replace( + " left-pad@1.2.0: {}", + " dep-two@1.0.0:\n dependencies:\n left-pad: 1.2.0\n\n left-pad@1.2.0: {}", + ); + assert_ne!(lock, P1_BEFORE_LOCK); + let fx = fixture_with(&pkg, &lock).await; let (r1, e1, _) = expect_done(fx.vendor(false).await); - assert!(r1.success); + assert!(r1.success, "{:?}", r1.error); assert!(e1.is_some()); let tgz_13 = fx.rel_tgz(); - // Vendor left-pad@1.2.0 under a DIFFERENT uuid (the `left-pad-old` - // npm: alias resolves it in the same lock). + // Vendor left-pad@1.2.0 under a DIFFERENT uuid. let uuid2 = "22222222-3333-4444-8555-666666666666"; - let installed2 = fx.root().join("node_modules/left-pad-old"); + let installed2 = fx.root().join("node_modules/dep-two/node_modules/left-pad"); tokio::fs::create_dir_all(&installed2).await.unwrap(); tokio::fs::write( installed2.join("package.json"), @@ -2772,6 +2774,13 @@ snapshots: let lock = fx.read(PNPM_LOCK).await; let key13 = format!(" left-pad@file:{tgz_13}:"); let key12 = format!(" left-pad@file:.socket/vendor/npm/{uuid2}/left-pad-1.2.0.tgz:"); + // dep-two's transitive snapshot ref moved to the bare file form. + assert!( + lock.contains(&format!( + " left-pad: file:.socket/vendor/npm/{uuid2}/left-pad-1.2.0.tgz" + )), + "transitive 1.2.0 ref rewritten:\n{lock}" + ); // Both versions' packages + snapshots blocks exist exactly once // each (snapshot entries may be inline `key: {}`). for (key, label) in [(&key13, "1.3.0"), (&key12, "1.2.0")] { @@ -2848,6 +2857,240 @@ snapshots: ); } + /// The pair are user-owned files the surgery merely edits: vendor and + /// revert writes must keep their permission bits (a 0600 private lock + /// must not silently become umask-default 0644) — same contract as + /// npm_lock's `lock_writes_preserve_file_mode`. + #[cfg(unix)] + #[tokio::test] + async fn pair_writes_preserve_file_modes() { + use std::os::unix::fs::PermissionsExt; + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + tokio::fs::set_permissions( + fx.root().join(PNPM_LOCK), + std::fs::Permissions::from_mode(0o600), + ) + .await + .unwrap(); + tokio::fs::set_permissions( + fx.root().join(PACKAGE_JSON), + std::fs::Permissions::from_mode(0o640), + ) + .await + .unwrap(); + let mode_of = |name: &str| { + let path = fx.root().join(name); + async move { + tokio::fs::metadata(path) + .await + .unwrap() + .permissions() + .mode() + & 0o7777 + } + }; + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + assert_eq!(mode_of(PNPM_LOCK).await, 0o600, "vendor: lock mode kept"); + assert_eq!(mode_of(PACKAGE_JSON).await, 0o640, "vendor: pkg mode kept"); + + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!(mode_of(PNPM_LOCK).await, 0o600, "revert: lock mode kept"); + assert_eq!(mode_of(PACKAGE_JSON).await, 0o640, "revert: pkg mode kept"); + } + + /// A lock referencing the target through a PEER-SUFFIXED dep path + /// (`left-pad@1.3.0(react@18.2.0)` — what pnpm emits whenever the + /// package has peerDependencies): the surgery rewrites only the plain + /// registry forms, so proceeding would rekey the `packages:` entry + /// while the suffixed snapshot key and importer version keep pointing + /// at the now-missing `left-pad@1.3.0` — a lock pnpm hard-rejects. + /// Must refuse fail-closed BEFORE anything is staged or written. + #[tokio::test] + async fn peer_suffixed_target_refuses_fail_closed() { + let peer_lock = "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + left-pad: + specifier: 1.3.0 + version: 1.3.0(react@18.2.0) + react: + specifier: 18.2.0 + version: 18.2.0 + +packages: + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + peerDependencies: + react: '>=16' + + react@18.2.0: + resolution: {integrity: sha512-reactreactreactreactreactreactreactreactreactreactreactreactreactreactreactreactreactre==} + +snapshots: + + left-pad@1.3.0(react@18.2.0): + dependencies: + react: 18.2.0 + + react@18.2.0: {} +"; + let fx = fixture_with(P1_BEFORE_PKG, peer_lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_unsupported"); + assert!(detail.contains("left-pad@1.3.0(react@18.2.0)"), "{detail}"); + assert_eq!(fx.read(PNPM_LOCK).await, peer_lock, "lock untouched"); + assert_eq!(fx.read(PACKAGE_JSON).await, P1_BEFORE_PKG, "pkg untouched"); + assert!( + !fx.root().join(".socket/vendor").exists(), + "refusal stages nothing" + ); + } + + /// A lock referencing the target through an ALIAS at the exact patched + /// version (`renamed-pad: npm:left-pad@1.3.0` → `version: left-pad@1.3.0`, + /// same for snapshot dep refs): the surgery leaves those spellings + /// verbatim, so the rekey would strand them pointing at a + /// packages/snapshots entry that no longer exists. Must refuse + /// fail-closed, nothing staged or written. + #[tokio::test] + async fn aliased_same_version_reference_refuses_fail_closed() { + // Importer-level alias. + let importer_alias_lock = "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + left-pad: + specifier: 1.3.0 + version: 1.3.0 + renamed-pad: + specifier: npm:left-pad@1.3.0 + version: left-pad@1.3.0 + +packages: + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + +snapshots: + + left-pad@1.3.0: {} +"; + let fx = fixture_with(P1_BEFORE_PKG, importer_alias_lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_unsupported"); + assert!(detail.contains("left-pad@1.3.0"), "{detail}"); + assert_eq!(fx.read(PNPM_LOCK).await, importer_alias_lock); + assert!(!fx.root().join(".socket/vendor").exists()); + + // Snapshot-level alias ref inside a dependent's block. + let snapshot_alias_lock = "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + host: + specifier: 1.0.0 + version: 1.0.0 + left-pad: + specifier: 1.3.0 + version: 1.3.0 + +packages: + + host@1.0.0: + resolution: {integrity: sha512-hosthosthosthosthosthosthosthosthosthosthosthosthosthosthosthosthosthosthosthosthosthos==} + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + +snapshots: + + host@1.0.0: + dependencies: + renamed-pad: left-pad@1.3.0 + + left-pad@1.3.0: {} +"; + let fx = fixture_with(P1_BEFORE_PKG, snapshot_alias_lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_unsupported"); + assert!(detail.contains("left-pad@1.3.0"), "{detail}"); + assert_eq!(fx.read(PNPM_LOCK).await, snapshot_alias_lock); + assert!(!fx.root().join(".socket/vendor").exists()); + } + + /// A lock whose ONLY same-name entry is a SIBLING version's vendored + /// key (`left-pad@file:…/left-pad-1.2.0.tgz` while vendoring 1.3.0) + /// has no entry the surgery can target: pre-flight must refuse + /// `vendor_lock_entry_not_found` BEFORE staging, not pass a + /// version-blind ours-probe and then die mid-surgery with a + /// misleading "vanished mid-rewrite" error + an orphaned artifact dir. + #[tokio::test] + async fn sibling_only_vendored_lock_refuses_entry_not_found() { + let uuid2 = "22222222-3333-4444-8555-666666666666"; + let tgz12 = format!(".socket/vendor/npm/{uuid2}/left-pad-1.2.0.tgz"); + let pkg = format!( + "{{\n \"name\": \"x\",\n \"dependencies\": {{\n \"left-pad\": \"1.2.0\"\n }},\n \"pnpm\": {{\n \"overrides\": {{\n \"left-pad@1.2.0\": \"file:{tgz12}\"\n }}\n }}\n}}\n" + ); + let lock = format!( + "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + left-pad@1.2.0: file:{tgz12} + +importers: + + .: + dependencies: + left-pad: + specifier: file:{tgz12} + version: file:{tgz12} + +packages: + + left-pad@file:{tgz12}: + resolution: {{integrity: sha512-sibling==, tarball: file:{tgz12}}} + version: 1.2.0 + +snapshots: + + left-pad@file:{tgz12}: {{}} +" + ); + let fx = fixture_with(&pkg, &lock).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_found"); + assert!(detail.contains("left-pad@1.3.0"), "{detail}"); + assert_eq!(fx.read(PNPM_LOCK).await, lock, "lock untouched"); + assert!( + !fx.root().join(format!(".socket/vendor/npm/{UUID}")).exists(), + "refusal stages no artifact" + ); + } + #[tokio::test] async fn dry_run_writes_nothing() { let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; diff --git a/crates/socket-patch-core/src/patch/vendor/pypi.rs b/crates/socket-patch-core/src/patch/vendor/pypi.rs index 62a32b83..3a075191 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi.rs @@ -12,18 +12,21 @@ use sha2::{Digest as _, Sha256}; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::manifest::schema::PatchRecord; -use crate::patch::apply::{ApplyResult, PatchSources, VerifyResult, VerifyStatus}; +use crate::patch::apply::{ApplyResult, PatchSources}; +use crate::pth_hook::detect::has_table; use crate::utils::fs::atomic_write_bytes; use crate::utils::purl::{parse_pypi_purl, strip_purl_qualifiers}; +use super::common::{already_patched_result, done, refused, service_offline_conflict}; use super::path::vendor_uuid_dir_rel; use super::pypi_pdm::{PdmProject, PdmTarget}; use super::pypi_pipenv::{PipenvProject, PipenvTarget}; use super::pypi_poetry::{PoetryProject, PoetryTarget}; -use super::pypi_requirements::{preflight_requirements, revert_requirements, wire_requirements}; +use super::pypi_requirements::{ + preflight_requirements, revert_requirements, wire_requirements, RequirementsTarget, +}; use super::pypi_uv::{ - check_target_guards, classify_dependency, load_uv_project, revert_uv, wire_uv, UvDepClass, - UvProject, UvTarget, + check_target_guards, load_uv_project, revert_uv, wire_uv, UvProject, UvTarget, }; use super::pypi_wheel::{ build_patched_wheel, locate_installed_dist, wheel_file_name, WheelArtifact, @@ -37,7 +40,7 @@ use super::{RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; /// Which wiring backend serves this project. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PypiFlavor { +enum PypiFlavor { /// `uv.lock`-managed project → paired pyproject + lock surgery. UvProject, /// `poetry.lock`-managed project → lock-only `[[package]]` splice. @@ -82,7 +85,7 @@ const SETUP_ALTERNATIVE: &str = /// When more than one tool lockfile coexists, the winner is wired and a LOUD /// `pypi_multiple_lockfiles` warning names the ignored locks — they go /// stale-but-valid, which is otherwise invisible. -pub async fn detect_pypi_flavor( +async fn detect_pypi_flavor( project_root: &Path, ) -> Result<(PypiFlavor, Vec), (&'static str, String)> { let exists = |name: &str| { @@ -96,13 +99,15 @@ pub async fn detect_pypi_flavor( let has_pipfile = exists("Pipfile").await; // Coexisting tool locks: wire the precedence winner, warn about the rest. - let locks: Vec<(&str, bool)> = vec![ + let present: Vec<&str> = [ ("uv.lock", has_uv_lock), ("poetry.lock", has_poetry_lock), ("pdm.lock", has_pdm_lock), ("Pipfile.lock", has_pipfile_lock), - ]; - let present: Vec<&str> = locks.iter().filter(|(_, p)| *p).map(|(n, _)| *n).collect(); + ] + .into_iter() + .filter_map(|(name, present)| present.then_some(name)) + .collect(); let mut warnings = Vec::new(); if present.len() > 1 { let winner = present[0]; @@ -201,28 +206,10 @@ pub async fn detect_pypi_flavor( )) } -/// `[prefix]` / `[prefix.*]` table-header probe. Mirrors the private -/// `has_table` in `pth_hook/detect.rs` (header-anchored so a substring in a -/// value or comment cannot misroute the flavor). -fn has_table(content: &str, prefix: &str) -> bool { - content.lines().any(|line| { - let line = line.trim(); - let Some(rest) = line.strip_prefix('[') else { - return false; - }; - let rest = rest.trim_start_matches('['); - let Some(end) = rest.find(']') else { - return false; - }; - let header = rest[..end].trim(); - header == prefix || header.starts_with(&format!("{prefix}.")) - }) -} - /// Per-flavor pre-flight result carried into the wiring step (the loaded /// project is reused so the lock is parsed once). enum WiringPlan { - Uv(Box, UvDepClass), + Uv(Box), Requirements, Poetry(Box), Pdm(Box), @@ -255,55 +242,6 @@ async fn uuid_dir_has_wheel(uuid_dir: &Path) -> bool { false } -/// Build the synthesized AlreadyPatched outcome for an in-sync re-run: the -/// artifact + lockfile already point at THIS patch uuid, so nothing is built -/// or recorded (the first run's ledger entry holds the only copy of the -/// originals). Mirrors the npm flavors' in-sync hot path. -fn in_sync_outcome( - base_purl: &str, - record: &PatchRecord, - warnings: Vec, -) -> VendorOutcome { - VendorOutcome::Done { - result: synthesized_apply_result(base_purl, record, String::new()), - entry: None, - warnings, - } -} - -/// A synthesized success [`ApplyResult`] in which every patched file reads as -/// `AlreadyPatched` — used by the in-sync hot path AND the service-download -/// path (where there is no local apply to verify; trust is the service-verified -/// integrity). -fn synthesized_apply_result( - base_purl: &str, - record: &PatchRecord, - package_path: String, -) -> ApplyResult { - let files_verified = record - .files - .keys() - .map(|f| VerifyResult { - file: f.clone(), - status: VerifyStatus::AlreadyPatched, - message: None, - current_hash: None, - expected_hash: None, - target_hash: None, - }) - .collect(); - ApplyResult { - package_key: base_purl.to_string(), - package_path, - success: true, - files_verified, - files_patched: Vec::new(), - applied_via: std::collections::HashMap::new(), - error: None, - sidecar: None, - } -} - /// Vendor one pypi package: route the flavor, pre-flight every guard, build /// the patched wheel at `.socket/vendor/pypi//`, write the /// marker, then wire the project files (LAST). @@ -323,10 +261,10 @@ pub async fn vendor_pypi( // keys off the qualifier-free base. let base = strip_purl_qualifiers(purl); let Some((raw_name, version)) = parse_pypi_purl(base) else { - return VendorOutcome::Refused { - code: "pypi_invalid_purl", - detail: format!("{purl} is not a pkg:pypi PURL with a version"), - }; + return refused( + "pypi_invalid_purl", + format!("{purl} is not a pkg:pypi PURL with a version"), + ); }; let canon_name = canonicalize_pypi_name(raw_name); @@ -335,19 +273,19 @@ pub async fn vendor_pypi( // deletes). Anything but the canonical UUID grammar is rejected // fail-closed before any disk access. let Some(uuid_dir_rel) = vendor_uuid_dir_rel("pypi", &record.uuid) else { - return VendorOutcome::Refused { - code: "vendor_unsafe_uuid", - detail: format!( + return refused( + "vendor_unsafe_uuid", + format!( "patch uuid {:?} is not a canonical lowercase uuid; refusing to derive a \ vendor path from it", record.uuid ), - }; + ); }; let (flavor, flavor_warnings) = match detect_pypi_flavor(project_root).await { Ok(f) => f, - Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + Err((code, detail)) => return refused(code, detail), }; // Pre-flight the wiring guards BEFORE building anything, so refusals @@ -357,30 +295,28 @@ pub async fn vendor_pypi( PypiFlavor::UvProject => { let project = match load_uv_project(project_root).await { Ok(p) => p, - Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + Err((code, detail)) => return refused(code, detail), }; match check_target_guards(&project, &canon_name, &record.uuid) { Ok(UvTarget::InSync) => WiringPlan::InSync, Ok(UvTarget::Fresh) => { warnings.extend(project.warnings.iter().cloned()); - let class = classify_dependency(&project, &canon_name); - WiringPlan::Uv(Box::new(project), class) + WiringPlan::Uv(Box::new(project)) } - Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + Err((code, detail)) => return refused(code, detail), } } PypiFlavor::Requirements => { - if let Err((code, detail)) = - preflight_requirements(project_root, &canon_name, version).await - { - return VendorOutcome::Refused { code, detail }; + match preflight_requirements(project_root, &canon_name, version, &record.uuid).await { + Ok(RequirementsTarget::InSync) => WiringPlan::InSync, + Ok(RequirementsTarget::Fresh) => WiringPlan::Requirements, + Err((code, detail)) => return refused(code, detail), } - WiringPlan::Requirements } PypiFlavor::Poetry => { let project = match super::pypi_poetry::load_poetry_project(project_root).await { Ok(p) => p, - Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + Err((code, detail)) => return refused(code, detail), }; match super::pypi_poetry::check_target_guards( &project, @@ -393,13 +329,13 @@ pub async fn vendor_pypi( warnings.extend(project.warnings.iter().cloned()); WiringPlan::Poetry(Box::new(project)) } - Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + Err((code, detail)) => return refused(code, detail), } } PypiFlavor::Pdm => { let project = match super::pypi_pdm::load_pdm_project(project_root).await { Ok(p) => p, - Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + Err((code, detail)) => return refused(code, detail), }; match super::pypi_pdm::check_target_guards(&project, &canon_name, version, &record.uuid) { @@ -408,13 +344,13 @@ pub async fn vendor_pypi( warnings.extend(project.warnings.iter().cloned()); WiringPlan::Pdm(Box::new(project)) } - Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + Err((code, detail)) => return refused(code, detail), } } PypiFlavor::Pipenv => { let project = match super::pypi_pipenv::load_pipenv_project(project_root).await { Ok(p) => p, - Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + Err((code, detail)) => return refused(code, detail), }; match super::pypi_pipenv::check_target_guards(&project, &canon_name, &record.uuid) { Ok(PipenvTarget::InSync) => WiringPlan::InSync, @@ -422,7 +358,7 @@ pub async fn vendor_pypi( warnings.extend(project.warnings.iter().cloned()); WiringPlan::Pipenv(Box::new(project)) } - Err((code, detail)) => return VendorOutcome::Refused { code, detail }, + Err((code, detail)) => return refused(code, detail), } } }; @@ -430,12 +366,17 @@ pub async fn vendor_pypi( let in_sync = matches!(plan, WiringPlan::InSync); if in_sync { // Wired to this uuid already. Intact artifact → the classic in-sync - // skip (no dist lookup — a not-installed re-run must stay green). - // Missing artifact → rebuild the wheel only; the wiring is correct - // and re-running it would re-record live vendored fragments as - // pre-vendor originals. + // skip: nothing is built or recorded — the first run's ledger entry + // holds the only copy of the originals (and no dist lookup, so a + // not-installed re-run stays green). Missing artifact → rebuild the + // wheel only; the wiring is correct and re-running it would re-record + // live vendored fragments as pre-vendor originals. if uuid_dir_has_wheel(&project_root.join(&uuid_dir_rel)).await || dry_run { - return in_sync_outcome(base, record, warnings); + return done( + already_patched_result(base, Path::new(""), &record.files), + None, + warnings, + ); } } @@ -469,22 +410,14 @@ pub async fn vendor_pypi( Err(outcome) => return outcome, }; if dry_run || !result.success { - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } let Some(artifact) = artifact else { // Defensive: success without an artifact would be a bug upstream. let mut result = result; result.success = false; result.error = Some("wheel build reported success without an artifact".to_string()); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); }; // A compiled-extension wheel (cp311/manylinux tags) only installs on this @@ -523,58 +456,32 @@ pub async fn vendor_pypi( ), )); // Restore the informational marker the deleted uuid dir lost. - let mut vulns: Vec = record.vulnerabilities.keys().cloned().collect(); - vulns.sort(); - let marker = VendorMarker { - schema_version: 1, - purl: base.to_string(), - patch_uuid: record.uuid.clone(), - ecosystem: "pypi".to_string(), - vulnerabilities: vulns, - vendored_at: vendored_at.to_string(), - }; + let marker = VendorMarker::new("pypi", base, record, vendored_at); if let Err(e) = write_marker(&project_root.join(&uuid_dir_rel), &marker).await { warnings.push(VendorWarning::new( "marker_write_failed", format!("could not write the vendor marker: {e}"), )); } - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } // Marker: artifact-side breadcrumb in the uuid dir (informational only — // sweep/verify key off state.json + the path uuid). Written before the // wiring so lockfile edits stay the last mutation. - let mut vulns: Vec = record.vulnerabilities.keys().cloned().collect(); - vulns.sort(); - let marker = VendorMarker { - schema_version: 1, - purl: base.to_string(), - patch_uuid: record.uuid.clone(), - ecosystem: "pypi".to_string(), - vulnerabilities: vulns, - vendored_at: vendored_at.to_string(), - }; + let marker = VendorMarker::new("pypi", base, record, vendored_at); if let Err(e) = write_marker(&project_root.join(&uuid_dir_rel), &marker).await { let _ = tokio::fs::remove_dir_all(project_root.join(&uuid_dir_rel)).await; let mut result = result; result.success = false; result.error = Some(format!("cannot write vendor marker: {e}")); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } // Wiring LAST. On failure the wheel artifact is swept back out so a // failed vendor leaves no committed residue. let wired: Result<(Vec<_>, MetaSlot), (&'static str, String)> = match plan { - WiringPlan::Uv(project, class) => wire_uv( + WiringPlan::Uv(project) => wire_uv( &project, project_root, &canon_name, @@ -582,7 +489,6 @@ pub async fn vendor_pypi( &rel_wheel, &wheel_name, &artifact.sha256_hex, - class, &record.uuid, ) .await @@ -640,11 +546,7 @@ pub async fn vendor_pypi( let mut result = result; result.success = false; result.error = Some(format!("{code}: {detail}")); - return VendorOutcome::Done { - result, - entry: None, - warnings, - }; + return done(result, None, warnings); } }; @@ -677,11 +579,7 @@ pub async fn vendor_pypi( MetaSlot::Pipenv(m) => entry.pipenv = Some(m), MetaSlot::None => {} } - VendorOutcome::Done { - result, - entry: Some(entry), - warnings, - } + done(result, Some(entry), warnings) } /// Revert one pypi vendor entry: reverse the wiring per flavor, then remove @@ -759,27 +657,15 @@ async fn acquire_patched_wheel( service: Option<&VendorServiceConfig>, warnings: &mut Vec, ) -> Result { + if let Some(refusal) = service_offline_conflict(service) { + return Err(refusal); + } if let Some(cfg) = service { - if cfg.source.requires_service() && cfg.offline { - return Err(VendorOutcome::Refused { - code: "vendor_service_offline_conflict", - detail: "--vendor-source=service needs the network but --offline is set" - .to_string(), - }); - } // A dry run previews the local build; the service is only consulted for // a real vendor. if cfg.service_enabled() && !dry_run { - match try_pypi_service_wheel( - base, - raw_name, - uuid_dir_rel, - project_root, - record, - cfg, - warnings, - ) - .await + match try_pypi_service_wheel(base, uuid_dir_rel, project_root, record, cfg, warnings) + .await { PypiServiceWheel::Used(acq) => return Ok(*acq), PypiServiceWheel::HardFail(outcome) => return Err(*outcome), @@ -791,11 +677,11 @@ async fn acquire_patched_wheel( // Local build from the installed dist. let dist = match locate_installed_dist(site_packages, raw_name, version).await { Ok(d) => d, - Err((code, detail)) => return Err(VendorOutcome::Refused { code, detail }), + Err((code, detail)) => return Err(refused(code, detail)), }; let wheel_name = match wheel_file_name(&dist) { Ok(n) => n, - Err((code, detail)) => return Err(VendorOutcome::Refused { code, detail }), + Err((code, detail)) => return Err(refused(code, detail)), }; let rel_wheel = format!("{uuid_dir_rel}/{wheel_name}"); let dest = project_root.join(uuid_dir_rel).join(&wheel_name); @@ -815,7 +701,7 @@ async fn acquire_patched_wheel( .await { Ok(pair) => pair, - Err((code, detail)) => return Err(VendorOutcome::Refused { code, detail }), + Err((code, detail)) => return Err(refused(code, detail)), }; Ok(AcquiredWheel { wheel_name, @@ -843,7 +729,6 @@ enum PypiServiceWheel { /// `auto` and a hard fail under `service`. async fn try_pypi_service_wheel( base: &str, - name: &str, uuid_dir_rel: &str, project_root: &Path, record: &PatchRecord, @@ -853,7 +738,7 @@ async fn try_pypi_service_wheel( // A terminal `service`-mode refusal (boxed — the enum's other variants are // small). A nested fn so both `miss` and the write-failure sites can use it. fn hard_fail(code: &'static str, detail: String) -> PypiServiceWheel { - PypiServiceWheel::HardFail(Box::new(VendorOutcome::Refused { code, detail })) + PypiServiceWheel::HardFail(Box::new(refused(code, detail))) } // service-required → hard fail; `auto` → warn + fall back to the local build. let miss = |warnings: &mut Vec, code: &'static str, reason: String| { @@ -868,7 +753,7 @@ async fn try_pypi_service_wheel( } }; - match fetch_verified_archive(cfg, &record.uuid, name).await { + match fetch_verified_archive(cfg, &record.uuid).await { ServiceArtifact::Ready(archive) => { let Some(wheel_name) = wheel_filename_from_url(&archive.source_url) else { return miss( @@ -905,7 +790,7 @@ async fn try_pypi_service_wheel( )); PypiServiceWheel::Used(Box::new(AcquiredWheel { rel_wheel, - result: synthesized_apply_result(base, record, dest.display().to_string()), + result: already_patched_result(base, &dest, &record.files), artifact: Some(WheelArtifact { file_name: wheel_name.clone(), sha256_hex: hex::encode(Sha256::digest(&archive.bytes)), @@ -1498,6 +1383,144 @@ wheels = [ ); } + /// Re-running vendor on an already-wired requirements project must be + /// the same in-sync skip the lock flavors report — NOT a second + /// `(transitive)` line append: the duplicate hands pip two competing + /// requirements, and re-recording the entry would clobber the original + /// pin's wiring record (the only copy of the pre-vendor line). + #[tokio::test] + async fn requirements_revendor_is_in_sync_skip() { + let fx = e2e_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let vendor_one = || { + vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + }; + let VendorOutcome::Done { result, entry, .. } = vendor_one().await else { + panic!("first vendor must be Done"); + }; + assert!(result.success, "{:?}", result.error); + assert!(entry.is_some()); + let wired = tokio::fs::read_to_string(fx.root.join("requirements.txt")) + .await + .unwrap(); + + // Intact wheel: in-sync skip — nothing recorded, file byte-identical. + let VendorOutcome::Done { + result: r2, + entry: e2, + warnings: w2, + } = vendor_one().await + else { + panic!("re-run must be Done"); + }; + assert!(r2.success, "{:?}", r2.error); + assert!(e2.is_none(), "in-sync re-run records nothing"); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("requirements.txt")) + .await + .unwrap(), + wired, + "re-run must not touch requirements.txt" + ); + assert!( + !w2.iter().any(|w| w.code == "vendor_artifact_rebuilt"), + "intact wheel must not claim a rebuild: {w2:?}" + ); + + // Deleted wheel: artifact-only rebuild, wiring untouched. + let uuid_dir = fx.root.join(format!(".socket/vendor/pypi/{UUID}")); + tokio::fs::remove_dir_all(&uuid_dir).await.unwrap(); + let VendorOutcome::Done { + result: r3, + entry: e3, + warnings: w3, + } = vendor_one().await + else { + panic!("rebuild run must be Done"); + }; + assert!(r3.success, "{:?}", r3.error); + assert!(e3.is_none(), "artifact-only rebuild records no entry"); + assert!( + w3.iter().any(|w| w.code == "vendor_artifact_rebuilt"), + "rebuild is surfaced: {w3:?}" + ); + assert!(uuid_dir.join("six-1.16.0-py2.py3-none-any.whl").is_file()); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("requirements.txt")) + .await + .unwrap(), + wired, + "rebuild must not touch requirements.txt" + ); + } + + /// A requirements file already wired to an EARLIER patch uuid for the + /// same package refuses (mirrors uv/poetry): appending a second wheel + /// line would leave pip two competing requirements, and the new entry + /// would clobber the old one's ledger record, orphaning its line. + #[tokio::test] + async fn requirements_stale_uuid_vendor_line_refuses() { + const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + let fx = e2e_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let vendor_with = |record: PatchRecord| { + let sources = &sources; + let fx = &fx; + async move { + vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &record, + sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await + } + }; + let VendorOutcome::Done { result, .. } = vendor_with(fx.record.clone()).await else { + panic!("first vendor must be Done"); + }; + assert!(result.success, "{:?}", result.error); + let wired = tokio::fs::read_to_string(fx.root.join("requirements.txt")) + .await + .unwrap(); + + // Same package, new patch generation (different uuid). + let mut record2 = fx.record.clone(); + record2.uuid = UUID2.to_string(); + let outcome = vendor_with(record2).await; + let VendorOutcome::Refused { code, detail } = outcome else { + panic!("expected Refused, got {outcome:?}"); + }; + assert_eq!(code, "pypi_requirements_already_vendored"); + assert!(detail.contains(UUID), "{detail}"); + // Pre-flight refusal: no second line, no new uuid dir. + assert_eq!( + tokio::fs::read_to_string(fx.root.join("requirements.txt")) + .await + .unwrap(), + wired + ); + assert!(!fx + .root + .join(format!(".socket/vendor/pypi/{UUID2}")) + .exists()); + } + #[tokio::test] async fn platform_specific_tags_set_platform_locked_and_warn() { let fx = e2e_fixture().await; diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs b/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs index 299693b6..d72e0ff0 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_pdm.rs @@ -26,11 +26,15 @@ use std::path::Path; use toml_edit::{DocumentMut, Item, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::utils::fs::atomic_write_bytes; +use crate::utils::fs::atomic_write_bytes_preserving_mode; +use super::common::{ + item_get, lock_units_named, pep508_name, pep621_declared_names, record, + revert_lock_fragment_splice, unit_has_canon_name, +}; use super::path::parse_vendor_path; use super::state::{PdmMeta, VendorEntry, WiringAction, WiringRecord}; -use super::toml_surgery::find_unit_span; +use super::toml_surgery::{find_unit_span, package_unit_lines, replace_files_array}; use super::{RevertOutcome, VendorWarning}; /// The only file this backend ever writes (and the revert allowlist). @@ -172,7 +176,7 @@ pub async fn load_pdm_project(root: &Path) -> Result &'static str { +fn classify_dependency(p: &PdmProject, canon_name: &str) -> &'static str { let Some(text) = p.pyproject_text.as_deref() else { return "transitive"; }; @@ -180,28 +184,7 @@ pub fn classify_dependency(p: &PdmProject, canon_name: &str) -> &'static str { return "transitive"; }; let mut declared: Vec = Vec::new(); - if let Some(project) = doc.get("project") { - if let Some(deps) = item_get(project, "dependencies").and_then(Item::as_array) { - declared.extend( - deps.iter() - .filter_map(Value::as_str) - .map(|s| pep508_name(s).to_string()), - ); - } - if let Some(optional) = - item_get(project, "optional-dependencies").and_then(Item::as_table_like) - { - for (_, item) in optional.iter() { - if let Some(arr) = item.as_array() { - declared.extend( - arr.iter() - .filter_map(Value::as_str) - .map(|s| pep508_name(s).to_string()), - ); - } - } - } - } + pep621_declared_names(&doc, &mut declared); for groups in [ doc.get("tool") .and_then(|t| item_get(t, "pdm")) @@ -243,22 +226,7 @@ pub(super) fn check_target_guards( version: &str, record_uuid: &str, ) -> Result { - let units: Vec<&toml_edit::Table> = p - .lock - .get("package") - .and_then(Item::as_array_of_tables) - .map(|pkgs| { - pkgs.iter() - .filter(|t| { - t.get("name") - .and_then(Item::as_str) - .map(canonicalize_pypi_name) - .as_deref() - == Some(canon_name) - }) - .collect() - }) - .unwrap_or_default(); + let units = lock_units_named(&p.lock, canon_name); if units.is_empty() { return Err(( "pypi_pdm_lock_package_missing", @@ -395,7 +363,10 @@ pub async fn wire_pdm( wheel_sha256_hex, )?; let new_lock = p.lock_text.replacen(&old_unit, &new_unit, 1); - atomic_write_bytes(&root.join(LOCK_FILE), new_lock.as_bytes()) + // Mode-preserving: the lock is a user-owned file we merely edit, so the + // swapped-in inode must keep its permission bits rather than reset them + // to umask defaults (same class as the revert leg in common.rs). + atomic_write_bytes_preserving_mode(&root.join(LOCK_FILE), new_lock.as_bytes()) .await .map_err(|e| { ( @@ -405,6 +376,7 @@ pub async fn wire_pdm( })?; let wiring = vec![record( + LOCK_FILE, KIND_LOCK_PACKAGE, WiringAction::Rewritten, canon_name, @@ -419,80 +391,11 @@ pub async fn wire_pdm( Ok((wiring, meta)) } -/// Reverse the wiring: restore the verbatim original `[[package]]` unit. A -/// fragment that no longer matches what we wrote is left alone with a -/// `vendor_lock_entry_drifted` warning — revert never clobbers third-party -/// edits. +/// Reverse the wiring: restore the verbatim original `[[package]]` unit via +/// the shared fragment-splice revert (drift-tolerant, pdm.lock-only +/// allowlist). pub async fn revert_pdm(entry: &VendorEntry, root: &Path, dry_run: bool) -> RevertOutcome { - let lock_path = root.join(LOCK_FILE); - let mut lock_text = match tokio::fs::read_to_string(&lock_path).await { - Ok(t) => t, - Err(e) => return RevertOutcome::failed(format!("cannot read {LOCK_FILE}: {e}")), - }; - let mut warnings: Vec = Vec::new(); - - for rec in entry.wiring.iter().rev() { - // SECURITY: `rec.file` comes verbatim from the committed, tamper-able - // state.json. This backend only ever wrote pdm.lock (the per-flavor - // file allowlist); any other recorded path is skipped fail-closed with - // a warning and is NEVER resolved against the filesystem. - if rec.file != LOCK_FILE { - warnings.push(VendorWarning::new( - "vendor_lock_entry_drifted", - format!( - "ignoring wiring record for unexpected file `{}` (only {LOCK_FILE} is \ - pdm-owned)", - rec.file - ), - )); - continue; - } - let drifted = || { - VendorWarning::new( - "vendor_lock_entry_drifted", - format!( - "{LOCK_FILE} fragment for {:?} changed since vendoring; left untouched", - rec.key - ), - ) - }; - match rec.kind.as_str() { - KIND_LOCK_PACKAGE => { - let new_text = rec.new.as_ref().and_then(serde_json::Value::as_str); - let original_text = rec.original.as_ref().and_then(serde_json::Value::as_str); - let (Some(new), Some(orig)) = (new_text, original_text) else { - warnings.push(drifted()); - continue; - }; - if lock_text.contains(new) { - lock_text = lock_text.replacen(new, orig, 1); - } else { - warnings.push(drifted()); - } - } - // Forward compatibility: a newer ledger's unknown kind degrades - // to a warning (never guess at a fragment shape). - other => warnings.push(VendorWarning::new( - "vendor_lock_entry_drifted", - format!("unknown pdm wiring kind {other:?}; skipped"), - )), - } - } - - if !dry_run { - if let Err(e) = atomic_write_bytes(&lock_path, lock_text.as_bytes()).await { - return RevertOutcome { - success: false, - warnings, - error: Some(format!("cannot write {LOCK_FILE}: {e}")), - }; - } - } - RevertOutcome { - success: true, - warnings, - error: None, - } + revert_lock_fragment_splice(entry, root, dry_run, LOCK_FILE, KIND_LOCK_PACKAGE, "pdm").await } // ── helpers ────────────────────────────────────────────────────────────── @@ -516,47 +419,6 @@ fn lock_version_series(v: &str) -> LockVersionSeries { } } -fn record( - kind: &str, - action: WiringAction, - key: &str, - original: Option, - new: String, -) -> WiringRecord { - WiringRecord { - file: LOCK_FILE.to_string(), - kind: kind.to_string(), - action, - key: Some(key.to_string()), - original: original.map(serde_json::Value::String), - new: Some(serde_json::Value::String(new)), - } -} - -fn item_get<'a>(item: &'a Item, key: &str) -> Option<&'a Item> { - item.as_table_like().and_then(|t| t.get(key)) -} - -/// Leading PEP 508 distribution name of a dependency spec. -fn pep508_name(spec: &str) -> &str { - let s = spec.trim_start(); - let end = s - .char_indices() - .find(|(_, c)| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))) - .map(|(i, _)| i) - .unwrap_or(s.len()); - &s[..end] -} - -fn unit_has_canon_name(lines: &[&str], canon: &str) -> bool { - lines - .iter() - .find_map(|l| l.strip_prefix("name = ")) - .map(|r| canonicalize_pypi_name(r.trim().trim_matches('"'))) - .as_deref() - == Some(canon) -} - /// Rewrite the target `[[package]]` unit to the D1-captured local-file /// shape: insert `path = "./"` right after `requires_python` /// (falling back to `version`/`name` — pdm's own key order) and reduce @@ -577,57 +439,29 @@ fn rewrite_target_package_unit( format!("{LOCK_FILE} has no [[package]] entry for {canon}"), ) })?; - // `find_unit_span` ends a unit at the NEXT `[[package]]` or EOF; truncate - // defensively at any foreign top-level header so the splice never - // swallows a trailing section (pdm's [metadata] leads the file today, but - // the truncation keeps the cut correct if a section ever trails). - let mut unit: Vec<&str> = lock_text[span].lines().collect(); - if let Some(stop) = unit - .iter() - .enumerate() - .skip(1) - .find_map(|(i, l)| (l.starts_with('[') && !l.starts_with("[package.")).then_some(i)) - { - unit.truncate(stop); - while unit.last().is_some_and(|l| l.trim().is_empty()) { - unit.pop(); - } - } + let unit = package_unit_lines(&lock_text[span]); let old_unit = unit.join("\n"); - let files_lines = [ - "files = [".to_string(), - format!(" {{file = \"{wheel_file_name}\", hash = \"sha256:{wheel_sha256_hex}\"}},"), - "]".to_string(), - ]; - - let mut out: Vec = Vec::new(); - let mut files_done = false; - let mut i = 0; - while i < unit.len() { - let line = unit[i]; - if line.starts_with("files = [") { - out.extend(files_lines.iter().cloned()); - files_done = true; - if !line.trim_end().ends_with(']') { - // skip the original multi-line array body + closing bracket - while i + 1 < unit.len() && unit[i + 1].trim() != "]" { - i += 1; - } - i += 1; - } - } else { - out.push(line.to_string()); - } - i += 1; - } - if !files_done { - // The hash guard already requires hashed files entries; reaching here - // means the parsed and textual views disagree — fail closed. + // The splice is a literal-text replace of this LF-joined fragment; a lock + // whose physical lines differ from the logical ones (CRLF endings) would + // silently no-op the replace while still recording the wiring — refuse. + if !lock_text.contains(&old_unit) { return Err(( "pypi_pdm_lock_parse_failed", - format!("the {canon} [[package]] entry has no files array to rewrite"), + format!( + "the {canon} [[package]] entry does not match {LOCK_FILE} byte-for-byte \ + (CRLF line endings?); re-lock with pdm so the lock is LF-normalized" + ), )); } + let mut out = + replace_files_array(&unit, wheel_file_name, wheel_sha256_hex).ok_or_else(|| { + // The hash guard already requires hashed files entries; reaching here + // means the parsed and textual views disagree — fail closed. + ( + "pypi_pdm_lock_parse_failed", + format!("the {canon} [[package]] entry has no files array to rewrite"), + ) + })?; let anchor = out .iter() @@ -1229,6 +1063,61 @@ distribution = false } } + /// The lock file is user-owned: wiring the splice must not reset its + /// permission bits (the `package_json/update.rs` mode-reset bug, same + /// class — see `atomic_write_bytes_preserving_mode`; the revert leg is + /// covered in common.rs). + #[cfg(unix)] + #[tokio::test] + async fn wire_preserves_lock_file_mode() { + use std::os::unix::fs::PermissionsExt; + let tmp = write_project(LOCK_DIRECT_REGISTRY, PYPROJECT_DIRECT).await; + let lock_path = tmp.path().join("pdm.lock"); + let mut perms = std::fs::metadata(&lock_path).unwrap().permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(&lock_path, perms).unwrap(); + + let p = load_pdm_project(tmp.path()).await.unwrap(); + wire_default(&p, tmp.path()).await; + assert_eq!(read_lock(tmp.path()).await, LOCK_DIRECT_VENDORED); + let mode = std::fs::metadata(&lock_path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "wiring must preserve the lock file's permission bits" + ); + } + + /// A CRLF lock (e.g. a `core.autocrlf` checkout) parses fine but its + /// physical lines differ from the LF-joined splice fragment, so the + /// literal-text replace would match nothing — wiring must refuse + /// fail-closed rather than report success with the lock never rewired. + #[tokio::test] + async fn crlf_lock_refuses_instead_of_silently_not_wiring() { + let crlf = LOCK_DIRECT_REGISTRY.replace('\n', "\r\n"); + let tmp = write_project(&crlf, PYPROJECT_DIRECT).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + assert_eq!( + check_target_guards(&p, "six", "1.16.0", UUID).unwrap(), + PdmTarget::Fresh, + "the parsed view accepts CRLF; only the splice must refuse" + ); + + let err = wire_pdm( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_pdm_lock_parse_failed"); + assert_eq!(read_lock(tmp.path()).await, crlf, "refusal writes nothing"); + } + #[tokio::test] async fn revert_dry_run_changes_nothing() { let tmp = write_project(LOCK_DIRECT_REGISTRY, PYPROJECT_DIRECT).await; diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs b/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs index a9e903c6..c957def3 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_pipenv.rs @@ -25,12 +25,12 @@ use std::path::Path; -use serde::Serialize; use serde_json::{Map, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::utils::fs::atomic_write_bytes; +use crate::utils::fs::atomic_write_bytes_preserving_mode; +use super::common::serialize_json; use super::path::parse_vendor_path; use super::state::{PipenvMeta, VendorEntry, WiringAction, WiringRecord}; use super::{RevertOutcome, VendorWarning}; @@ -49,9 +49,7 @@ const NON_REGISTRY_KEYS: [&str; 6] = ["path", "git", "hg", "svn", "bzr", "editab /// A loaded-and-guard-checked pipenv project. #[derive(Debug)] -pub struct PipenvProject { - /// Verbatim Pipfile.lock text (byte-stability oracle for reverts). - pub lock_text: String, +pub(super) struct PipenvProject { /// Parsed lock (the edit substrate — re-serialized canonically). pub lock: Value, /// Non-fatal advisories raised during load. ALWAYS contains the @@ -62,7 +60,7 @@ pub struct PipenvProject { /// What the target entries already look like. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PipenvTarget { +pub(super) enum PipenvTarget { /// At least one registry-shaped entry: proceed to build the wheel and /// wire. Fresh, @@ -76,7 +74,9 @@ pub enum PipenvTarget { /// Read + parse Pipfile.lock and run every project-level guard. Refuses /// before ANY write — the orchestrator runs this (and the target guards) /// before the wheel is built, so a refusal leaves the tree byte-untouched. -pub async fn load_pipenv_project(root: &Path) -> Result { +pub(super) async fn load_pipenv_project( + root: &Path, +) -> Result { let lock_text = match tokio::fs::read_to_string(root.join(LOCK_FILE)).await { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { @@ -127,11 +127,7 @@ pub async fn load_pipenv_project(root: &Path) -> Result/`, no `./` prefix — the fixture's /// `./` spelling is applied here). -pub async fn wire_pipenv( +pub(super) async fn wire_pipenv( p: &PipenvProject, root: &Path, canon_name: &str, @@ -297,7 +293,7 @@ pub async fn wire_pipenv( } let new_text = to_canonical_json(&lock); - atomic_write_bytes(&root.join(LOCK_FILE), new_text.as_bytes()) + atomic_write_bytes_preserving_mode(&root.join(LOCK_FILE), new_text.as_bytes()) .await .map_err(|e| { ( @@ -312,7 +308,11 @@ pub async fn wire_pipenv( /// gated). An entry that no longer matches what we wrote is left alone with /// a `vendor_lock_entry_drifted` warning — revert never clobbers third-party /// edits. -pub async fn revert_pipenv(entry: &VendorEntry, root: &Path, dry_run: bool) -> RevertOutcome { +pub(super) async fn revert_pipenv( + entry: &VendorEntry, + root: &Path, + dry_run: bool, +) -> RevertOutcome { let lock_path = root.join(LOCK_FILE); let lock_text = match tokio::fs::read_to_string(&lock_path).await { Ok(t) => t, @@ -407,7 +407,7 @@ pub async fn revert_pipenv(entry: &VendorEntry, root: &Path, dry_run: bool) -> R // churn a lock whose formatting we did not produce. if changed && !dry_run { let new_text = to_canonical_json(&lock); - if let Err(e) = atomic_write_bytes(&lock_path, new_text.as_bytes()).await { + if let Err(e) = atomic_write_bytes_preserving_mode(&lock_path, new_text.as_bytes()).await { return RevertOutcome { success: false, warnings, @@ -460,15 +460,9 @@ fn to_canonical_json(value: &Value) -> String { other => other.clone(), } } - let mut buf = Vec::new(); - let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); - let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter); - sorted(value) - .serialize(&mut ser) + let bytes = serialize_json(&sorted(value), " ") .expect("serializing a serde_json::Value cannot fail"); - let mut text = String::from_utf8(buf).expect("serde_json emits UTF-8"); - text.push('\n'); - text + String::from_utf8(bytes).expect("serde_json emits UTF-8") } #[cfg(test)] @@ -994,6 +988,41 @@ mod tests { ); } + /// Pipfile.lock is a user-owned file we merely edit: both the vendor + /// rewrite and the revert restore must keep its permission bits (a 0600 + /// private lock must not silently become umask-default 0644). + #[cfg(unix)] + #[tokio::test] + async fn lock_writes_preserve_file_mode() { + use std::os::unix::fs::PermissionsExt; + let tmp = write_lock(LOCK_DIRECT_REGISTRY).await; + let lock_path = tmp.path().join("Pipfile.lock"); + tokio::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600)) + .await + .unwrap(); + + let p = load_pipenv_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_default(&p, tmp.path()).await; + let mode = tokio::fs::metadata(&lock_path) + .await + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!(mode, 0o600, "wire must preserve the lockfile's mode"); + + let outcome = revert_pipenv(&entry_for(wiring, meta), tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + let mode = tokio::fs::metadata(&lock_path) + .await + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!(mode, 0o600, "revert must preserve the lockfile's mode"); + assert_eq!(read_lock(tmp.path()).await, LOCK_DIRECT_REGISTRY); + } + /// A third-party edit to the entry we wrote (e.g. `pipenv lock` /// regenerated it — spike V6) is left alone with a drift warning; /// unknown wiring kinds from a newer ledger degrade the same way. diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs b/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs index 7c9c18dd..5fee31c1 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_poetry.rs @@ -19,14 +19,18 @@ use std::path::Path; -use toml_edit::{DocumentMut, Item, Value}; +use toml_edit::{DocumentMut, Item}; use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::utils::fs::atomic_write_bytes; +use crate::utils::fs::atomic_write_bytes_preserving_mode; +use super::common::{ + item_get, lock_units_named, pep621_declared_names, record, revert_lock_fragment_splice, + unit_has_canon_name, +}; use super::path::parse_vendor_path; use super::state::{PoetryMeta, VendorEntry, WiringAction, WiringRecord}; -use super::toml_surgery::find_unit_span; +use super::toml_surgery::{find_unit_span, package_unit_lines, replace_files_array}; use super::{RevertOutcome, VendorWarning}; /// The only file this backend ever writes (and the revert allowlist). @@ -37,7 +41,7 @@ const KIND_LOCK_PACKAGE: &str = "poetry_lock_package"; /// A loaded-and-guard-checked poetry project. #[derive(Debug)] -pub struct PoetryProject { +pub(super) struct PoetryProject { /// Verbatim poetry.lock text (the surgery substrate). pub lock_text: String, /// Parsed lock (guard checks only — every edit is text surgery). @@ -53,7 +57,7 @@ pub struct PoetryProject { /// What the target `[[package]]` unit already looks like. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PoetryTarget { +pub(super) enum PoetryTarget { /// Registry-shaped: proceed to build the wheel and wire. Fresh, /// Already wired to THIS patch uuid — the caller synthesizes an @@ -65,7 +69,9 @@ pub enum PoetryTarget { /// Read + parse poetry.lock and run every project-level guard. Refuses /// before ANY write — the orchestrator runs this (and the target guards) /// before the wheel is built, so a refusal leaves the tree byte-untouched. -pub async fn load_poetry_project(root: &Path) -> Result { +pub(super) async fn load_poetry_project( + root: &Path, +) -> Result { let lock_text = tokio::fs::read_to_string(root.join(LOCK_FILE)) .await .map_err(|e| { @@ -135,7 +141,7 @@ pub async fn load_poetry_project(root: &Path) -> Result &'static str { +fn classify_dependency(p: &PoetryProject, canon_name: &str) -> &'static str { let Some(text) = p.pyproject_text.as_deref() else { return "transitive"; }; @@ -157,28 +163,7 @@ pub fn classify_dependency(p: &PoetryProject, canon_name: &str) -> &'static str } } } - if let Some(project) = doc.get("project") { - if let Some(deps) = item_get(project, "dependencies").and_then(Item::as_array) { - declared.extend( - deps.iter() - .filter_map(Value::as_str) - .map(|s| pep508_name(s).to_string()), - ); - } - if let Some(optional) = - item_get(project, "optional-dependencies").and_then(Item::as_table_like) - { - for (_, item) in optional.iter() { - if let Some(arr) = item.as_array() { - declared.extend( - arr.iter() - .filter_map(Value::as_str) - .map(|s| pep508_name(s).to_string()), - ); - } - } - } - } + pep621_declared_names(&doc, &mut declared); if declared .iter() .any(|n| canonicalize_pypi_name(n) == canon_name) @@ -199,22 +184,7 @@ pub(super) fn check_target_guards( version: &str, record_uuid: &str, ) -> Result { - let units: Vec<&toml_edit::Table> = p - .lock - .get("package") - .and_then(Item::as_array_of_tables) - .map(|pkgs| { - pkgs.iter() - .filter(|t| { - t.get("name") - .and_then(Item::as_str) - .map(canonicalize_pypi_name) - .as_deref() - == Some(canon_name) - }) - .collect() - }) - .unwrap_or_default(); + let units = lock_units_named(&p.lock, canon_name); if units.is_empty() { return Err(( "pypi_poetry_lock_package_missing", @@ -291,7 +261,7 @@ pub(super) fn check_target_guards( /// (`.socket/vendor/pypi//`, no `./` prefix — the lock url is /// recorded exactly as poetry itself writes it, fixture-pinned). #[allow(clippy::too_many_arguments)] -pub async fn wire_poetry( +pub(super) async fn wire_poetry( p: &PoetryProject, root: &Path, canon_name: &str, @@ -325,7 +295,10 @@ pub async fn wire_poetry( wheel_sha256_hex, )?; let new_lock = p.lock_text.replacen(&old_unit, &new_unit, 1); - atomic_write_bytes(&root.join(LOCK_FILE), new_lock.as_bytes()) + // Mode-preserving: the lock is a user-owned file we merely edit, so the + // swapped-in inode must keep its permission bits rather than reset them + // to umask defaults (same class as the revert leg in common.rs). + atomic_write_bytes_preserving_mode(&root.join(LOCK_FILE), new_lock.as_bytes()) .await .map_err(|e| { ( @@ -335,6 +308,7 @@ pub async fn wire_poetry( })?; let wiring = vec![record( + LOCK_FILE, KIND_LOCK_PACKAGE, WiringAction::Rewritten, canon_name, @@ -348,105 +322,19 @@ pub async fn wire_poetry( Ok((wiring, meta)) } -/// Reverse the wiring: restore the verbatim original `[[package]]` unit. A -/// fragment that no longer matches what we wrote is left alone with a -/// `vendor_lock_entry_drifted` warning — revert never clobbers third-party -/// edits. -pub async fn revert_poetry(entry: &VendorEntry, root: &Path, dry_run: bool) -> RevertOutcome { - let lock_path = root.join(LOCK_FILE); - let mut lock_text = match tokio::fs::read_to_string(&lock_path).await { - Ok(t) => t, - Err(e) => return RevertOutcome::failed(format!("cannot read {LOCK_FILE}: {e}")), - }; - let mut warnings: Vec = Vec::new(); - - for rec in entry.wiring.iter().rev() { - // SECURITY: `rec.file` comes verbatim from the committed, tamper-able - // state.json. This backend only ever wrote poetry.lock (the per-flavor - // file allowlist); any other recorded path is skipped fail-closed with - // a warning and is NEVER resolved against the filesystem. - if rec.file != LOCK_FILE { - warnings.push(VendorWarning::new( - "vendor_lock_entry_drifted", - format!( - "ignoring wiring record for unexpected file `{}` (only {LOCK_FILE} is \ - poetry-owned)", - rec.file - ), - )); - continue; - } - let drifted = || { - VendorWarning::new( - "vendor_lock_entry_drifted", - format!( - "{LOCK_FILE} fragment for {:?} changed since vendoring; left untouched", - rec.key - ), - ) - }; - match rec.kind.as_str() { - KIND_LOCK_PACKAGE => { - let new_text = rec.new.as_ref().and_then(serde_json::Value::as_str); - let original_text = rec.original.as_ref().and_then(serde_json::Value::as_str); - let (Some(new), Some(orig)) = (new_text, original_text) else { - warnings.push(drifted()); - continue; - }; - if lock_text.contains(new) { - lock_text = lock_text.replacen(new, orig, 1); - } else { - warnings.push(drifted()); - } - } - // Forward compatibility: a newer ledger's unknown kind degrades - // to a warning (never guess at a fragment shape). - other => warnings.push(VendorWarning::new( - "vendor_lock_entry_drifted", - format!("unknown poetry wiring kind {other:?}; skipped"), - )), - } - } - - if !dry_run { - if let Err(e) = atomic_write_bytes(&lock_path, lock_text.as_bytes()).await { - return RevertOutcome { - success: false, - warnings, - error: Some(format!("cannot write {LOCK_FILE}: {e}")), - }; - } - } - RevertOutcome { - success: true, - warnings, - error: None, - } +/// Reverse the wiring: restore the verbatim original `[[package]]` unit via +/// the shared fragment-splice revert (drift-tolerant, poetry.lock-only +/// allowlist). +pub(super) async fn revert_poetry( + entry: &VendorEntry, + root: &Path, + dry_run: bool, +) -> RevertOutcome { + revert_lock_fragment_splice(entry, root, dry_run, LOCK_FILE, KIND_LOCK_PACKAGE, "poetry").await } // ── helpers ────────────────────────────────────────────────────────────── -fn record( - kind: &str, - action: WiringAction, - key: &str, - original: Option, - new: String, -) -> WiringRecord { - WiringRecord { - file: LOCK_FILE.to_string(), - kind: kind.to_string(), - action, - key: Some(key.to_string()), - original: original.map(serde_json::Value::String), - new: Some(serde_json::Value::String(new)), - } -} - -fn item_get<'a>(item: &'a Item, key: &str) -> Option<&'a Item> { - item.as_table_like().and_then(|t| t.get(key)) -} - /// `2.` with minor > 1 (the lock-versions newer than the fixtures). fn is_newer_2x(v: &str) -> bool { v.strip_prefix("2.") @@ -455,26 +343,6 @@ fn is_newer_2x(v: &str) -> bool { .is_some_and(|minor| minor > 1) } -/// Leading PEP 508 distribution name of a dependency spec. -fn pep508_name(spec: &str) -> &str { - let s = spec.trim_start(); - let end = s - .char_indices() - .find(|(_, c)| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))) - .map(|(i, _)| i) - .unwrap_or(s.len()); - &s[..end] -} - -fn unit_has_canon_name(lines: &[&str], canon: &str) -> bool { - lines - .iter() - .find_map(|l| l.strip_prefix("name = ")) - .map(|r| canonicalize_pypi_name(r.trim().trim_matches('"'))) - .as_deref() - == Some(canon) -} - /// Rewrite the target `[[package]]` unit to the file-source shape proven by /// the fixture pairs: `files = [...]` becomes the single /// `{file = "", hash = "sha256:"}` element and a @@ -497,57 +365,30 @@ fn rewrite_target_package_unit( format!("{LOCK_FILE} has no [[package]] entry for {canon}"), ) })?; - // `find_unit_span` ends a unit at the NEXT `[[package]]` or EOF, but - // poetry's `[metadata]` section trails the LAST unit — truncate at the - // first top-level header that is not a `[package.*]` subtable so the - // splice never swallows it. - let mut unit: Vec<&str> = lock_text[span].lines().collect(); - if let Some(stop) = unit - .iter() - .enumerate() - .skip(1) - .find_map(|(i, l)| (l.starts_with('[') && !l.starts_with("[package.")).then_some(i)) - { - unit.truncate(stop); - while unit.last().is_some_and(|l| l.trim().is_empty()) { - unit.pop(); - } - } + let unit = package_unit_lines(&lock_text[span]); let old_unit = unit.join("\n"); - let files_lines = [ - "files = [".to_string(), - format!(" {{file = \"{wheel_file_name}\", hash = \"sha256:{wheel_sha256_hex}\"}},"), - "]".to_string(), - ]; - - let mut out: Vec = Vec::new(); - let mut files_done = false; - let mut i = 0; - while i < unit.len() { - let line = unit[i]; - if line.starts_with("files = [") { - out.extend(files_lines.iter().cloned()); - files_done = true; - if !line.trim_end().ends_with(']') { - // skip the original multi-line array body + closing bracket - while i + 1 < unit.len() && unit[i + 1].trim() != "]" { - i += 1; - } - i += 1; - } - } else { - out.push(line.to_string()); - } - i += 1; - } - if !files_done { - // 2.x locks always carry files[]; a unit without one is a shape we - // have no fixture for — fail closed rather than guess a placement. + // The unit's lines were re-derived via `str::lines()`, which strips `\r` + // — on a CRLF lock (git autocrlf checkout) the joined fragment can never + // byte-match the file and the caller's replacen splice would silently + // no-op while still recording a rewrite. Fail closed instead. + if !lock_text.contains(&old_unit) { return Err(( "pypi_poetry_lock_parse_failed", - format!("the {canon} [[package]] entry has no files array to rewrite"), + format!( + "the {canon} [[package]] entry cannot be spliced back into {LOCK_FILE} \ + (non-LF line endings?); normalize the lock to LF and retry" + ), )); } + let mut out = + replace_files_array(&unit, wheel_file_name, wheel_sha256_hex).ok_or_else(|| { + // 2.x locks always carry files[]; a unit without one is a shape we + // have no fixture for — fail closed rather than guess a placement. + ( + "pypi_poetry_lock_parse_failed", + format!("the {canon} [[package]] entry has no files array to rewrite"), + ) + })?; out.push(String::new()); out.push("[package.source]".to_string()); out.push("type = \"file\"".to_string()); @@ -966,6 +807,61 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca } } + /// The lock is a user-owned file we merely edit: wiring must not reset + /// its permission bits (same class as the revert leg in common.rs and + /// pdm's wire — the plain atomic writer swaps in a umask-mode inode). + #[cfg(unix)] + #[tokio::test] + async fn wire_preserves_lock_file_mode() { + use std::os::unix::fs::PermissionsExt; + let tmp = write_project(LOCK21_DIRECT_REGISTRY, PYPROJECT_DIRECT).await; + let lock = tmp.path().join("poetry.lock"); + let mut perms = std::fs::metadata(&lock).unwrap().permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(&lock, perms).unwrap(); + + let p = load_poetry_project(tmp.path()).await.unwrap(); + let _ = wire_default(&p, tmp.path()).await; + + assert_eq!(read_lock(tmp.path()).await, LOCK21_DIRECT_VENDORED); + let mode = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "wiring must preserve the lock file's permission bits" + ); + } + + /// A CRLF lock (git autocrlf checkout) parses fine, but the splice + /// fragment is re-derived via `str::lines()` (which strips `\r`) and can + /// never byte-match the file — the replacen would silently no-op while + /// still reporting success and recording a rewrite that never landed. + /// Wiring must refuse instead. + #[tokio::test] + async fn crlf_lock_refuses_instead_of_silently_wiring_nothing() { + let crlf = LOCK21_DIRECT_REGISTRY.replace('\n', "\r\n"); + let tmp = write_project(&crlf, PYPROJECT_DIRECT).await; + let p = load_poetry_project(tmp.path()).await.unwrap(); + assert_eq!( + check_target_guards(&p, "six", "1.16.0", UUID).unwrap(), + PoetryTarget::Fresh + ); + + let err = wire_poetry( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_poetry_lock_parse_failed"); + assert_eq!(read_lock(tmp.path()).await, crlf, "refusal writes nothing"); + } + #[tokio::test] async fn guards_refuse_parse_version_missing_forked_and_sources() { // unreadable / unparseable lock diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs index d02360de..b06f898d 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_requirements.rs @@ -11,29 +11,26 @@ //! //! Logical-line model: physical lines join on a trailing `\`; comments start //! at a `#` preceded by whitespace (or column 0) outside that. The dominant -//! newline style is preserved (mirroring `pth_hook/edit.rs`). +//! newline style is preserved. use std::collections::HashSet; use std::path::{Path, PathBuf}; use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::utils::fs::atomic_write_bytes; +use crate::utils::fs::atomic_write_bytes_preserving_mode; +use super::common::detect_eol; use super::state::{VendorEntry, WiringAction, WiringRecord}; use super::{RevertOutcome, VendorWarning}; /// Classification of the target package within the requirements tree. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PinSearch { +#[derive(Debug, PartialEq, Eq)] +enum PinSearch { /// A clean `name==version` pin (no extras). `line_start` / `line_count` /// span the PHYSICAL lines (0-based) of the first matching logical line. Exact { line_start: usize, line_count: usize, - /// Always `None` today — pins WITH extras classify as [`PinSearch::Extras`]; - /// the field is kept so the span type can carry them if extras - /// rewriting is ever supported. - extras: Option, /// The environment marker verbatim (text after `;`), to carry over. marker: Option, /// The pin carries `--hash` options (informational; the rewrite @@ -50,13 +47,18 @@ pub enum PinSearch { Absent, } -/// Find the target pin in one file's content. Precedence is fail-closed: -/// any extras occurrence wins over any non-pin occurrence wins over a clean -/// exact pin — a file that names the package ambiguously is never rewritten. -pub fn find_pin(content: &str, canon_name: &str, version: &str) -> PinSearch { +/// One clean exact pin occurrence: `(line_start, line_count, marker, hashed)` +/// — the PHYSICAL-line span (0-based) of the logical line, the environment +/// marker to carry over, and whether the pin carried `--hash` options. +type PinSpan = (usize, usize, Option, bool); + +/// Scan one file for the target package: every clean exact +/// `canon_name==version` pin, plus whether any occurrence carries extras or +/// a non-exact specifier. +fn scan_pins(content: &str, canon_name: &str, version: &str) -> (Vec, bool, bool) { + let mut exact = Vec::new(); let mut found_extras = false; let mut found_range = false; - let mut exact: Option = None; for ll in logical_lines(content) { let Some(req) = parse_requirement_line(&ll.text) else { continue; @@ -74,44 +76,106 @@ pub fn find_pin(content: &str, canon_name: &str, version: &str) -> PinSearch { .filter(|c| !c.is_whitespace()) .collect(); if spec_no_ws == format!("=={version}") { - if exact.is_none() { - exact = Some(PinSearch::Exact { - line_start: ll.start, - line_count: ll.physical.len(), - extras: None, - marker: req.marker, - hashed: req.hashed, - }); - } + exact.push((ll.start, ll.physical.len(), req.marker, req.hashed)); } else { found_range = true; } } + (exact, found_extras, found_range) +} + +/// Find the target pin in one file's content. Precedence is fail-closed: +/// any extras occurrence wins over any non-pin occurrence wins over a clean +/// exact pin — a file that names the package ambiguously is never rewritten. +fn find_pin(content: &str, canon_name: &str, version: &str) -> PinSearch { + let (exact, found_extras, found_range) = scan_pins(content, canon_name, version); if found_extras { return PinSearch::Extras; } if found_range { return PinSearch::Range; } - exact.unwrap_or(PinSearch::Absent) + match exact.into_iter().next() { + Some((line_start, line_count, marker, hashed)) => PinSearch::Exact { + line_start, + line_count, + marker, + hashed, + }, + None => PinSearch::Absent, + } +} + +/// Pre-flight verdict: wire fresh, or the files are already wired to this +/// exact patch generation (mirrors `UvTarget` / `PoetryTarget`). +pub(super) enum RequirementsTarget { + Fresh, + InSync, } /// Pre-flight the wiring without writing — the orchestrator runs this before /// building the wheel so every refusal happens with the tree byte-untouched. +/// +/// A file already carrying a socket vendor line for this package +/// short-circuits the plan: at the SAME patch uuid it is our own first-run +/// edit (in sync — the artifact-only rebuild path handles a deleted wheel); +/// at a DIFFERENT uuid it refuses — appending a second wheel line would +/// leave pip two competing requirements, and the new ledger entry would +/// clobber the old one's record, orphaning its line. pub(super) async fn preflight_requirements( root: &Path, canon_name: &str, version: &str, -) -> Result<(), (&'static str, String)> { + record_uuid: &str, +) -> Result { + let files = collect_requirements_files(root).await?; + for file in &files { + if let Some(found) = vendored_uuid_for(&file.content, canon_name) { + if found == record_uuid { + return Ok(RequirementsTarget::InSync); + } + return Err(( + "pypi_requirements_already_vendored", + format!( + "{}: already routes {canon_name} to the socket-patch vendored wheel for \ + patch {found}; run `socket-patch vendor --revert` before re-vendoring", + file.rel + ), + )); + } + } plan_requirements(root, canon_name, version, "", "") .await - .map(|_| ()) + .map(|_| RequirementsTarget::Fresh) +} + +/// Find a socket vendor line for `canon_name` in one file's content and +/// return the patch uuid its wheel path names. Matches the exact shape +/// [`vendor_line`] writes: a wheel-path token plus the +/// `# socket-patch vendor: ==` comment tag. +fn vendored_uuid_for(content: &str, canon_name: &str) -> Option { + for line in content.lines() { + let trimmed = line.trim(); + let Some((_, tag)) = trimmed.split_once("# socket-patch vendor: ") else { + continue; + }; + if !tag.starts_with(canon_name) || !tag[canon_name.len()..].starts_with("==") { + continue; + } + let token = trimmed.split_whitespace().next().unwrap_or(""); + if let Some(parts) = super::path::parse_vendor_path(token) { + if parts.eco == "pypi" { + return Some(parts.uuid); + } + } + } + None } /// Rewrite every exact pin across the root `requirements.txt` and its `-r` /// includes (or append a managed transitive line at the root EOF when the /// package is absent). Returns the wiring records in application order. -pub async fn wire_requirements( +pub(super) async fn wire_requirements( root: &Path, canon_name: &str, version: &str, @@ -120,15 +184,28 @@ pub async fn wire_requirements( ) -> Result, (&'static str, String)> { let plan = plan_requirements(root, canon_name, version, rel_wheel, wheel_sha256_hex).await?; let mut wiring = Vec::new(); + let mut written: Vec<&PlannedFile> = Vec::new(); for file in &plan { - atomic_write_bytes(&root.join(&file.rel), file.new_content.as_bytes()) - .await - .map_err(|e| { - ( - "pypi_requirements_write_failed", - format!("cannot write {}: {e}", file.rel), + if let Err(e) = + atomic_write_bytes_preserving_mode(&root.join(&file.rel), file.new_content.as_bytes()) + .await + { + // Unwind: the orchestrator sweeps the wheel dir on a wiring + // error, so a surviving half-wired file would reference a + // deleted artifact — with no ledger entry recorded to revert it. + for w in written.iter().rev() { + let _ = atomic_write_bytes_preserving_mode( + &root.join(&w.rel), + w.original_content.as_bytes(), ) - })?; + .await; + } + return Err(( + "pypi_requirements_write_failed", + format!("cannot write {}: {e}", file.rel), + )); + } + written.push(file); wiring.extend(file.records.iter().cloned()); } Ok(wiring) @@ -139,7 +216,11 @@ pub async fn wire_requirements( /// what vendor wrote are left alone with `vendor_revert_line_drifted`; any /// surviving reference to the vendored uuid dir afterwards raises /// `vendor_revert_residual_reference`. -pub async fn revert_requirements(entry: &VendorEntry, root: &Path, dry_run: bool) -> RevertOutcome { +pub(super) async fn revert_requirements( + entry: &VendorEntry, + root: &Path, + dry_run: bool, +) -> RevertOutcome { let mut warnings: Vec = Vec::new(); // Group records per file, preserving application order within each. @@ -185,7 +266,7 @@ pub async fn revert_requirements(entry: &VendorEntry, root: &Path, dry_run: bool return RevertOutcome::failed(format!("cannot read {file}: {e}")); } }; - let nl = newline_of(&content); + let nl = detect_eol(&content); let had_trailing_newline = content.ends_with('\n'); let mut lines: Vec = content.lines().map(str::to_string).collect(); @@ -230,7 +311,9 @@ pub async fn revert_requirements(entry: &VendorEntry, root: &Path, dry_run: bool if !dry_run { for (file, content) in &reverted { - if let Err(e) = atomic_write_bytes(&root.join(file), content.as_bytes()).await { + if let Err(e) = + atomic_write_bytes_preserving_mode(&root.join(file), content.as_bytes()).await + { return RevertOutcome { success: false, warnings, @@ -274,6 +357,9 @@ fn drift_warning(file: &str, rec: &WiringRecord) -> VendorWarning { struct PlannedFile { /// Root-relative, forward-slashed path. rel: String, + /// The pre-edit content, kept so a multi-file write that fails partway + /// can restore the files already written. + original_content: String, new_content: String, records: Vec, } @@ -342,31 +428,15 @@ async fn plan_requirements( // Rewrite EVERY exact-pin occurrence in this file, bottom-up so the // recorded spans (against the original content) stay valid. - let mut spans: Vec<(usize, usize, Option)> = Vec::new(); - for ll in logical_lines(&file.content) { - let Some(req) = parse_requirement_line(&ll.text) else { - continue; - }; - if canonicalize_pypi_name(&req.name) != canon_name || req.extras.is_some() { - continue; - } - let spec: String = req - .specifier - .chars() - .filter(|c| !c.is_whitespace()) - .collect(); - if spec == format!("=={version}") { - spans.push((ll.start, ll.physical.len(), req.marker)); - } - } + let (spans, _, _) = scan_pins(&file.content, canon_name, version); if spans.is_empty() { continue; } - let nl = newline_of(&file.content); + let nl = detect_eol(&file.content); let original_lines: Vec = file.content.lines().map(str::to_string).collect(); let mut lines = original_lines.clone(); let mut records = Vec::new(); - for (start, count, marker) in spans.iter().rev() { + for (start, count, marker, _) in spans.iter().rev() { let line = vendor_line( rel_wheel, wheel_sha256_hex, @@ -398,6 +468,7 @@ async fn plan_requirements( } planned.push(PlannedFile { rel: file.rel.clone(), + original_content: file.content.clone(), new_content, records, }); @@ -419,7 +490,7 @@ async fn plan_requirements( &None, true, ); - let nl = newline_of(&root_file.content); + let nl = detect_eol(&root_file.content); let mut new_content = root_file.content.clone(); if !new_content.is_empty() && !new_content.ends_with('\n') { new_content.push_str(nl); @@ -428,6 +499,7 @@ async fn plan_requirements( new_content.push_str(nl); planned.push(PlannedFile { rel: root_file.rel.clone(), + original_content: root_file.content.clone(), new_content, records: vec![WiringRecord { file: root_file.rel.clone(), @@ -489,7 +561,10 @@ async fn collect_requirements_files(root: &Path) -> Result, (&'stat // see inside it. Skip. continue; }; - let editable = !rel.starts_with("../"); + // Out-of-root (`../`) and absolute includes resolve outside any + // committable root — readable so a pin inside can refuse, never + // editable. (`Path::join` passes an absolute `rel` through verbatim.) + let editable = !rel.starts_with("../") && !Path::new(&rel).is_absolute(); let include_dir = match rel.rfind('/') { Some(i) => rel[..i].to_string(), None => String::new(), @@ -532,11 +607,13 @@ fn include_target(text: &str) -> Option<&str> { } /// Lexically normalize a relative path (`a/../b` → `b`); escapes above the -/// root keep their `../` prefix so the caller can spot out-of-root includes. +/// root keep their `../` prefix and absolute paths keep their leading `/`, +/// so the caller can spot out-of-root includes. fn normalize_rel_path(path: &str) -> String { let mut stack: Vec<&str> = Vec::new(); let mut leading_parents = 0usize; let normalized = path.replace('\\', "/"); + let absolute = normalized.starts_with('/'); for comp in normalized.split('/') { match comp { "" | "." => {} @@ -551,6 +628,9 @@ fn normalize_rel_path(path: &str) -> String { } } let mut out = String::new(); + if absolute { + out.push('/'); + } for _ in 0..leading_parents { out.push_str("../"); } @@ -576,7 +656,12 @@ fn logical_lines(content: &str) -> Vec { while i < lines.len() { let start = i; let mut physical = vec![lines[i].to_string()]; - while lines[i].trim_end().ends_with('\\') && i + 1 < lines.len() { + // pip's join_lines never continues a comment line: `# ...\` is a + // complete comment, not a continuation of the next line. + while lines[i].trim_end().ends_with('\\') + && !lines[i].trim_start().starts_with('#') + && i + 1 < lines.len() + { i += 1; physical.push(lines[i].to_string()); } @@ -666,15 +751,6 @@ fn parse_requirement_line(text: &str) -> Option { }) } -/// The file's dominant newline style (mirrors `pth_hook/edit.rs`). -fn newline_of(content: &str) -> &'static str { - if content.contains("\r\n") { - "\r\n" - } else { - "\n" - } -} - #[cfg(test)] mod tests { use super::*; @@ -761,13 +837,11 @@ mod tests { PinSearch::Exact { line_start, line_count, - extras, marker, hashed, } => { assert_eq!(line_start, 1); assert_eq!(line_count, 1); - assert_eq!(extras, None); assert_eq!(marker.as_deref(), Some("python_version >= \"3.8\"")); assert!(hashed); } @@ -929,6 +1003,139 @@ mod tests { ); } + /// Multi-file wiring is transactional: when the write to the SECOND + /// planned file fails, the already-written first file is restored. The + /// orchestrator sweeps the wheel dir on a wiring error, so a surviving + /// half-wired file would reference a deleted artifact — with no ledger + /// entry recorded to revert it. (wire_uv rolls its pyproject write back + /// the same way when the lock write fails.) + #[cfg(unix)] + #[tokio::test] + async fn wire_failure_rolls_back_already_written_files() { + use std::os::unix::fs::PermissionsExt as _; + let original_root = "six==1.16.0\n-r deps/pinned.txt\n"; + let tmp = write_root(original_root).await; + tokio::fs::create_dir_all(tmp.path().join("deps")) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("deps/pinned.txt"), "six==1.16.0\n") + .await + .unwrap(); + // Read-only include dir: planning READS it fine, the atomic write + // (temp file in the same dir) fails. Root is planned/written first. + let deps = tmp.path().join("deps"); + let mut perms = std::fs::metadata(&deps).unwrap().permissions(); + perms.set_mode(0o555); + std::fs::set_permissions(&deps, perms.clone()).unwrap(); + + let err = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap_err(); + perms.set_mode(0o755); + std::fs::set_permissions(&deps, perms).unwrap(); + assert_eq!(err.0, "pypi_requirements_write_failed"); + assert_eq!( + read_root(tmp.path()).await, + original_root, + "the already-written root must be rolled back on a later write failure" + ); + assert_eq!( + tokio::fs::read_to_string(tmp.path().join("deps/pinned.txt")) + .await + .unwrap(), + "six==1.16.0\n" + ); + } + + /// Wire and revert both rewrite requirements files in place; a committed + /// file's mode (e.g. group-readable 0o640 under a strict umask) must + /// survive both. The plain atomic writer swaps in a fresh umask-default + /// inode — same class as the npm/pipenv lockfile mode resets. + #[cfg(unix)] + #[tokio::test] + async fn wire_and_revert_preserve_requirements_file_mode() { + use std::os::unix::fs::PermissionsExt as _; + let tmp = write_root("six==1.16.0\n").await; + let path = tmp.path().join("requirements.txt"); + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o640); + std::fs::set_permissions(&path, perms).unwrap(); + + let wiring = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o640, + "wire must preserve the file mode" + ); + + let outcome = revert_requirements(&entry_for(wiring), tmp.path(), false).await; + assert!(outcome.success); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o640, + "revert must preserve the file mode" + ); + } + + /// pip's join_lines never treats a comment line ending in `\` as a + /// continuation (COMMENT_RE guard) — the pin on the next physical line is + /// a real requirement. Swallowing it into the comment classifies the + /// package as absent, and the appended duplicate makes + /// `pip install -r requirements.txt` fail with a double requirement. + #[tokio::test] + async fn comment_ending_in_backslash_does_not_swallow_the_next_line() { + let tmp = write_root("# vendored from C:\\deps\\\nsix==1.16.0\n").await; + let wiring = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap(); + assert_eq!(wiring.len(), 1); + assert_eq!( + wiring[0].action, + WiringAction::Rewritten, + "the pin below the comment must be rewritten in place, not duplicated" + ); + assert_eq!( + read_root(tmp.path()).await, + format!("# vendored from C:\\deps\\\n{}\n", expected_line()) + ); + } + + /// An absolute `-r /abs/path.txt` include resolves outside any committable + /// root; a pin there must refuse exactly like a `../` include. Mangling it + /// into an in-root relative path silently skips the file pip *can* read, + /// and the transitive line appended at the root EOF gives pip a "double + /// requirement" error. + #[tokio::test] + async fn pin_in_absolute_include_refuses() { + let outer = tempfile::tempdir().unwrap(); + let root = outer.path().join("project"); + tokio::fs::create_dir_all(&root).await.unwrap(); + let shared = outer.path().join("shared.txt"); + tokio::fs::write(&shared, "six==1.16.0\n").await.unwrap(); + let root_content = format!("-r {}\n", shared.display()); + tokio::fs::write(root.join("requirements.txt"), &root_content) + .await + .unwrap(); + let err = wire_requirements(&root, "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_requirements_outside_root"); + assert_eq!( + tokio::fs::read_to_string(root.join("requirements.txt")) + .await + .unwrap(), + root_content, + "refusal leaves the root untouched" + ); + assert_eq!( + tokio::fs::read_to_string(&shared).await.unwrap(), + "six==1.16.0\n", + "the absolute include is never edited" + ); + } + #[tokio::test] async fn include_cycles_terminate() { let tmp = write_root("-r a.txt\nsix==1.16.0\n").await; diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs b/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs index 2f895ab3..70751e88 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_uv.rs @@ -19,12 +19,13 @@ use std::path::Path; use toml_edit::{DocumentMut, Item, Table, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::utils::fs::atomic_write_bytes; +use crate::utils::fs::atomic_write_bytes_preserving_mode; +use super::common::{item_get, pep508_name, pep621_declared_names, record}; use super::state::{UvMeta, VendorEntry, WiringAction, WiringRecord}; use super::toml_surgery::{ balanced_span, find_unit_span, line_index, remove_exact_line, remove_substring, - remove_table_if_empty, split_top_level_commas, top_level_brace_groups, + remove_table_if_empty, replace_fragment, split_top_level_commas, top_level_brace_groups, }; use super::{RevertOutcome, VendorWarning}; @@ -35,7 +36,7 @@ const HIGHEST_TESTED_LOCK_REVISION: u64 = 3; /// How the target package is declared, which picks the wiring strategy. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UvDepClass { +enum UvDepClass { /// Declared in `project.dependencies` / `optional-dependencies` / /// `dependency-groups` — a `[tool.uv.sources]` entry suffices. Direct, @@ -46,14 +47,11 @@ pub enum UvDepClass { /// A loaded-and-guard-checked uv project pair. #[derive(Debug)] -pub struct UvProject { +pub(super) struct UvProject { pub pyproject_text: String, pub lock_text: String, pub pyproject: DocumentMut, pub lock: DocumentMut, - /// `[project] name` — identifies the root `[[package]]` whose - /// `requires-dist` carries the direct-dep specifier. - pub root_name: String, /// uv.lock `revision` (diagnostics; recorded into [`UvMeta`]). pub lock_revision: Option, /// Non-fatal advisories raised during load (untested lock revision). @@ -63,7 +61,7 @@ pub struct UvProject { /// Read + parse the pair and run every project-level guard. Refuses before /// ANY write — the orchestrator runs this (and the target guards) before the /// wheel is even built, so a refusal leaves the tree byte-untouched. -pub async fn load_uv_project(root: &Path) -> Result { +pub(super) async fn load_uv_project(root: &Path) -> Result { let pyproject_text = tokio::fs::read_to_string(root.join("pyproject.toml")) .await .map_err(|e| { @@ -177,7 +175,7 @@ pub async fn load_uv_project(root: &Path) -> Result Result Result UvDepClass { - let mut declared: Vec<&str> = Vec::new(); - if let Some(project) = p.pyproject.get("project") { - if let Some(deps) = item_get(project, "dependencies").and_then(Item::as_array) { - declared.extend(deps.iter().filter_map(Value::as_str)); - } - if let Some(optional) = - item_get(project, "optional-dependencies").and_then(Item::as_table_like) - { - for (_, item) in optional.iter() { - if let Some(arr) = item.as_array() { - declared.extend(arr.iter().filter_map(Value::as_str)); - } - } - } - } +fn classify_dependency(p: &UvProject, canon_name: &str) -> UvDepClass { + let mut declared: Vec = Vec::new(); + pep621_declared_names(&p.pyproject, &mut declared); if let Some(groups) = p .pyproject .get("dependency-groups") @@ -241,13 +225,17 @@ pub fn classify_dependency(p: &UvProject, canon_name: &str) -> UvDepClass { if let Some(arr) = item.as_array() { // Non-string members are `{include-group = "..."}` includes; // the included group's own array is already scanned above. - declared.extend(arr.iter().filter_map(Value::as_str)); + declared.extend( + arr.iter() + .filter_map(Value::as_str) + .map(|s| pep508_name(s).to_string()), + ); } } } if declared .iter() - .any(|spec| canonicalize_pypi_name(pep508_name(spec)) == canon_name) + .any(|n| canonicalize_pypi_name(n) == canon_name) { UvDepClass::Direct } else { @@ -255,10 +243,6 @@ pub fn classify_dependency(p: &UvProject, canon_name: &str) -> UvDepClass { } } -/// Target-specific guards (also re-run by [`wire_uv`] right before writing). -/// Split out of [`load_uv_project`] because they need the target name; the -/// orchestrator runs them pre-flight so a refusal happens before the wheel -/// artifact is built. /// Pre-flight wiring state for one package (mirrors `PdmTarget`). #[derive(Debug, PartialEq, Eq)] pub(super) enum UvTarget { @@ -268,6 +252,10 @@ pub(super) enum UvTarget { InSync, } +/// Target-specific guards (also re-run by [`wire_uv`] right before writing). +/// Split out of [`load_uv_project`] because they need the target name; the +/// orchestrator runs them pre-flight so a refusal happens before the wheel +/// artifact is built. pub(super) fn check_target_guards( p: &UvProject, canon_name: &str, @@ -370,7 +358,7 @@ pub(super) fn check_target_guards( /// original so the pair is never left half-wired (either half alone is a /// silent no-op or a silent revert — spike claims 7/9). #[allow(clippy::too_many_arguments)] -pub async fn wire_uv( +pub(super) async fn wire_uv( p: &UvProject, root: &Path, canon_name: &str, @@ -378,10 +366,25 @@ pub async fn wire_uv( rel_wheel: &str, wheel_file_name: &str, wheel_sha256_hex: &str, - class: UvDepClass, record_uuid: &str, ) -> Result<(Vec, UvMeta), (&'static str, String)> { - check_target_guards(p, canon_name, record_uuid)?; + match check_target_guards(p, canon_name, record_uuid)? { + // Defensive: the orchestrator short-circuits in-sync pre-flight and + // never calls wire on it (we must never re-record our own edit as an + // "original", and a re-run requires-dist rewrite would append a + // duplicate `path` key — unparseable TOML). + UvTarget::InSync => { + return Err(( + "pypi_uv_source_already_exists", + format!( + "pyproject.toml already wires {canon_name} to this patch's vendored wheel; \ + nothing to wire" + ), + )) + } + UvTarget::Fresh => {} + } + let class = classify_dependency(p, canon_name); let mut wiring: Vec = Vec::new(); // ── pyproject.toml (computed in memory; committed before the lock) ──── @@ -528,8 +531,11 @@ pub async fn wire_uv( } // ── commit: pyproject first, then the lock; unwind on lock failure ──── + // Mode-preserving: both are user-owned files we merely edit, so the + // swapped-in inode must keep its permission bits rather than reset them + // to umask defaults (same class as the poetry/pdm/pipenv writers). let pyproject_path = root.join("pyproject.toml"); - atomic_write_bytes(&pyproject_path, new_pyproject.as_bytes()) + atomic_write_bytes_preserving_mode(&pyproject_path, new_pyproject.as_bytes()) .await .map_err(|e| { ( @@ -537,11 +543,14 @@ pub async fn wire_uv( format!("cannot write pyproject.toml: {e}"), ) })?; - if let Err(e) = atomic_write_bytes(&root.join("uv.lock"), new_lock.as_bytes()).await { + if let Err(e) = + atomic_write_bytes_preserving_mode(&root.join("uv.lock"), new_lock.as_bytes()).await + { // Unwind so a sources-bearing pyproject is never paired with the old // registry lock (that combo makes `uv lock --check` fail and plain // `uv sync` rewrite the lock under the user). - let _ = atomic_write_bytes(&pyproject_path, p.pyproject_text.as_bytes()).await; + let _ = + atomic_write_bytes_preserving_mode(&pyproject_path, p.pyproject_text.as_bytes()).await; return Err(( "pypi_uv_write_failed", format!("cannot write uv.lock: {e}; pyproject.toml was restored"), @@ -564,7 +573,7 @@ pub async fn wire_uv( /// in reverse application order. A live fragment that no longer matches what /// we wrote is left alone with a `vendor_lock_entry_drifted` warning — revert /// must never clobber third-party edits. -pub async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) -> RevertOutcome { +pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) -> RevertOutcome { let pyproject_path = root.join("pyproject.toml"); let lock_path = root.join("uv.lock"); let mut pyproject_text = match tokio::fs::read_to_string(&pyproject_path).await { @@ -596,14 +605,9 @@ pub async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) -> Rever }; match rec.kind.as_str() { "uv_lock_package" | "uv_lock_requires_dist" => { - let (Some(new), Some(orig)) = (new_text, original_text) else { - warnings.push(drifted("uv.lock")); - continue; - }; - if lock_text.contains(new) { - lock_text = lock_text.replacen(new, orig, 1); - } else { - warnings.push(drifted("uv.lock")); + match replace_fragment(&lock_text, new_text, original_text) { + Some(t) => lock_text = t, + None => warnings.push(drifted("uv.lock")), } } "uv_lock_manifest_overrides" => match rec.action { @@ -625,14 +629,9 @@ pub async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) -> Rever } } WiringAction::Rewritten => { - let (Some(new), Some(orig)) = (new_text, original_text) else { - warnings.push(drifted("uv.lock")); - continue; - }; - if lock_text.contains(new) { - lock_text = lock_text.replacen(new, orig, 1); - } else { - warnings.push(drifted("uv.lock")); + match replace_fragment(&lock_text, new_text, original_text) { + Some(t) => lock_text = t, + None => warnings.push(drifted("uv.lock")), } } }, @@ -669,14 +668,9 @@ pub async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) -> Rever } } WiringAction::Rewritten => { - let (Some(new), Some(orig)) = (new_text, original_text) else { - warnings.push(drifted("pyproject.toml")); - continue; - }; - if pyproject_text.contains(new) { - pyproject_text = pyproject_text.replacen(new, orig, 1); - } else { - warnings.push(drifted("pyproject.toml")); + match replace_fragment(&pyproject_text, new_text, original_text) { + Some(t) => pyproject_text = t, + None => warnings.push(drifted("pyproject.toml")), } } }, @@ -689,14 +683,17 @@ pub async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) -> Rever if !dry_run { // Reverse of the wire order: the lock first, then the pyproject. - if let Err(e) = atomic_write_bytes(&lock_path, lock_text.as_bytes()).await { + if let Err(e) = atomic_write_bytes_preserving_mode(&lock_path, lock_text.as_bytes()).await + { return RevertOutcome { success: false, warnings, error: Some(format!("cannot write uv.lock: {e}")), }; } - if let Err(e) = atomic_write_bytes(&pyproject_path, pyproject_text.as_bytes()).await { + if let Err(e) = + atomic_write_bytes_preserving_mode(&pyproject_path, pyproject_text.as_bytes()).await + { return RevertOutcome { success: false, warnings, @@ -713,39 +710,6 @@ pub async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) -> Rever // ── helpers ────────────────────────────────────────────────────────────── -fn record( - file: &str, - kind: &str, - action: WiringAction, - key: &str, - original: Option, - new: String, -) -> WiringRecord { - WiringRecord { - file: file.to_string(), - kind: kind.to_string(), - action, - key: Some(key.to_string()), - original: original.map(serde_json::Value::String), - new: Some(serde_json::Value::String(new)), - } -} - -fn item_get<'a>(item: &'a Item, key: &str) -> Option<&'a Item> { - item.as_table_like().and_then(|t| t.get(key)) -} - -/// Leading PEP 508 distribution name of a dependency spec. -fn pep508_name(spec: &str) -> &str { - let s = spec.trim_start(); - let end = s - .char_indices() - .find(|(_, c)| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))) - .map(|(i, _)| i) - .unwrap_or(s.len()); - &s[..end] -} - /// Walk/create the table chain, marking CREATED intermediates implicit so /// they never render stray `[tool]` headers. fn ensure_table<'a>( @@ -754,41 +718,34 @@ fn ensure_table<'a>( ) -> Result<&'a mut Table, (&'static str, String)> { let mut table: &mut Table = doc.as_table_mut(); for key in path { - if !table.contains_key(key) { - let mut t = Table::new(); - t.set_implicit(true); - table.insert(key, Item::Table(t)); - } - table = table - .get_mut(key) - .and_then(Item::as_table_mut) - .ok_or_else(|| { - ( - "pypi_uv_lock_parse_failed", - format!( - "pyproject.toml [{}] is not a standard table", - path.join(".") - ), - ) - })?; + table = crate::pth_hook::edit::ensure_table(table, key, true).map_err(|_| { + ( + "pypi_uv_lock_parse_failed", + format!( + "pyproject.toml [{}] is not a standard table", + path.join(".") + ), + ) + })?; } Ok(table) } -fn find_root_package_name(lock: &DocumentMut) -> Option { - let packages = lock.get("package")?.as_array_of_tables()?; - for table in packages { - let Some(source) = table.get("source").and_then(Item::as_inline_table) else { - continue; - }; - let is_root = ["virtual", "editable"] - .iter() - .any(|k| source.get(k).and_then(Value::as_str) == Some(".")); - if is_root { - return table.get("name").and_then(Item::as_str).map(str::to_string); - } - } - None +/// Whether the lock has a root `[[package]]` (source virtual/editable `.`). +fn lock_has_root_package(lock: &DocumentMut) -> bool { + lock.get("package") + .and_then(Item::as_array_of_tables) + .is_some_and(|pkgs| { + pkgs.iter().any(|t| { + t.get("source") + .and_then(Item::as_inline_table) + .is_some_and(|source| { + ["virtual", "editable"] + .iter() + .any(|k| source.get(k).and_then(Value::as_str) == Some(".")) + }) + }) + }) } fn unit_has_name(lines: &[&str], canon: &str) -> bool { @@ -910,7 +867,7 @@ fn rewrite_requires_dist_entry( ) })?; let arr_open = rd_rel + "requires-dist = ".len(); - let arr_end = balanced_span(unit_text, arr_open, '[', ']').ok_or_else(|| { + let arr_end = balanced_span(unit_text, arr_open).ok_or_else(|| { ( "pypi_uv_lock_parse_failed", "uv.lock requires-dist array is unbalanced".to_string(), @@ -1017,7 +974,7 @@ fn add_manifest_override( if let Some(ov_rel) = section_text.find("overrides = [") { let arr_open = ov_rel + "overrides = ".len(); - let arr_end = balanced_span(section_text, arr_open, '[', ']').ok_or_else(|| { + let arr_end = balanced_span(section_text, arr_open).ok_or_else(|| { ( "pypi_uv_lock_parse_failed", "uv.lock [manifest] overrides array is unbalanced".to_string(), @@ -1313,7 +1270,6 @@ wheels = [ REL_WHEEL, WHEEL_NAME, WHEEL_SHA, - UvDepClass::Direct, "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f", ) .await @@ -1361,7 +1317,6 @@ wheels = [ REL_WHEEL, WHEEL_NAME, WHEEL_SHA, - UvDepClass::Transitive, "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f", ) .await @@ -1461,7 +1416,6 @@ wheels = [ REL_WHEEL, WHEEL_NAME, WHEEL_SHA, - UvDepClass::Direct, "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f", ) .await @@ -1479,7 +1433,6 @@ wheels = [ REL_WHEEL, WHEEL_NAME, WHEEL_SHA, - UvDepClass::Transitive, "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f", ) .await @@ -1501,7 +1454,6 @@ wheels = [ REL_WHEEL, WHEEL_NAME, WHEEL_SHA, - UvDepClass::Direct, "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f", ) .await @@ -1525,7 +1477,6 @@ wheels = [ REL_WHEEL, WHEEL_NAME, WHEEL_SHA, - UvDepClass::Direct, "11111111-2222-4333-8444-555555555555", ) .await @@ -1553,7 +1504,6 @@ wheels = [ REL_WHEEL, WHEEL_NAME, WHEEL_SHA, - UvDepClass::Transitive, "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f", ) .await @@ -1580,6 +1530,16 @@ wheels = [ #[tokio::test] async fn lock_write_failure_unwinds_pyproject() { let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions( + tmp.path().join("pyproject.toml"), + std::fs::Permissions::from_mode(0o600), + ) + .await + .unwrap(); + } let p = load_uv_project(tmp.path()).await.unwrap(); // Make the lock unwritable: a directory can't be renamed over. tokio::fs::remove_file(tmp.path().join("uv.lock")) @@ -1597,7 +1557,6 @@ wheels = [ REL_WHEEL, WHEEL_NAME, WHEEL_SHA, - UvDepClass::Direct, "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f", ) .await @@ -1610,6 +1569,17 @@ wheels = [ pyproject, DIRECT_REGISTRY_PYPROJECT, "pyproject must be unwound" ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = tokio::fs::metadata(tmp.path().join("pyproject.toml")) + .await + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "the unwind write reset the mode"); + } } #[tokio::test] @@ -1624,7 +1594,6 @@ wheels = [ REL_WHEEL, WHEEL_NAME, WHEEL_SHA, - UvDepClass::Direct, "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f", ) .await @@ -1654,7 +1623,6 @@ wheels = [ REL_WHEEL, WHEEL_NAME, WHEEL_SHA, - UvDepClass::Transitive, "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f", ) .await @@ -1675,6 +1643,94 @@ wheels = [ ); } + /// wire_uv must refuse an in-sync pair (defensive parity with the + /// poetry/pdm/pipenv backends): re-wiring would append a SECOND `path` + /// key to the requires-dist entry (duplicate-key TOML — the lock stops + /// parsing) and re-record our own vendored fragments as pre-vendor + /// "originals", so a later revert would restore the vendored state. + #[tokio::test] + async fn wire_refuses_in_sync_pair_instead_of_corrupting_it() { + let tmp = write_pair(DIRECT_PATH_PYPROJECT, DIRECT_PATH_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + assert_eq!( + check_target_guards(&p, "six", UUID), + Ok(UvTarget::InSync), + "precondition: the pair is in sync at this uuid" + ); + + let err = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_uv_source_already_exists"); + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, DIRECT_PATH_PYPROJECT, "pair must be untouched"); + assert_eq!(lock, DIRECT_PATH_LOCK, "pair must be untouched"); + } + + /// Wire and revert edit user-owned files in place — the swapped-in inode + /// must keep the destination's permission bits rather than reset them to + /// umask defaults (same class as the poetry/pdm/pipenv writers). + #[cfg(unix)] + #[tokio::test] + async fn wire_and_revert_preserve_file_modes() { + use std::os::unix::fs::PermissionsExt; + let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; + for f in ["pyproject.toml", "uv.lock"] { + tokio::fs::set_permissions( + tmp.path().join(f), + std::fs::Permissions::from_mode(0o600), + ) + .await + .unwrap(); + } + let mode_of = |f: &str| { + let path = tmp.path().join(f); + async move { + tokio::fs::metadata(path) + .await + .unwrap() + .permissions() + .mode() + & 0o777 + } + }; + + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f", + ) + .await + .unwrap(); + assert_eq!(mode_of("pyproject.toml").await, 0o600, "wire reset the mode"); + assert_eq!(mode_of("uv.lock").await, 0o600, "wire reset the mode"); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + mode_of("pyproject.toml").await, + 0o600, + "revert reset the mode" + ); + assert_eq!(mode_of("uv.lock").await, 0o600, "revert reset the mode"); + } + #[tokio::test] async fn revert_dry_run_changes_nothing() { let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; @@ -1687,7 +1743,6 @@ wheels = [ REL_WHEEL, WHEEL_NAME, WHEEL_SHA, - UvDepClass::Direct, "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f", ) .await @@ -1716,7 +1771,6 @@ wheels = [ REL_WHEEL, WHEEL_NAME, WHEEL_SHA, - UvDepClass::Direct, "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f", ) .await diff --git a/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs b/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs index 4b851ff4..bee31f18 100644 --- a/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs +++ b/crates/socket-patch-core/src/patch/vendor/pypi_wheel.rs @@ -11,7 +11,6 @@ //! is stable across re-runs and never churns committed files. use std::collections::{HashMap, HashSet}; -use std::io::Write as _; use std::path::{Path, PathBuf}; use base64::Engine as _; @@ -24,15 +23,7 @@ use crate::patch::apply::{ }; use crate::utils::fs::{atomic_write_bytes, list_dir_entries}; -/// One parsed `RECORD` row (`path,hash,size`). `hash` keeps the raw field -/// (`sha256=`); empty fields become `None` (the RECORD/ -/// signature rows legitimately carry no hash or size). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RecordEntry { - pub path: String, - pub hash: Option, - pub size: Option, -} +use super::common::{failed_result, is_executable, write_zip_entries}; /// The located installed distribution for one `name@version`. #[derive(Debug, Clone)] @@ -44,8 +35,8 @@ pub struct InstalledDist { /// the wheel-filename escaping, NOT a canonical PEP 503 name. pub dist_name: String, pub version: String, - /// Parsed `RECORD` rows. - pub record: Vec, + /// Member paths parsed from `RECORD` (the path field of each row). + pub record: Vec, /// Raw `Tag:` header values from the `WHEEL` file, in file order. pub wheel_tags: Vec, } @@ -103,7 +94,7 @@ pub async fn locate_installed_dist( ), ) })?; - let record = parse_record_text(&record_text); + let record = parse_record_paths(&record_text); if record.is_empty() { return Err(( "pypi_missing_record", @@ -285,8 +276,8 @@ pub async fn build_patched_wheel( // Select the wheel members from the installed RECORD. let mut members: Vec = Vec::new(); let mut out_of_tree: Vec = Vec::new(); - for row in &dist.record { - let path = row.path.as_str(); + for path in &dist.record { + let path = path.as_str(); if path.is_empty() || is_installer_bookkeeping(path, &dist_info_name) || path.ends_with(".pyc") @@ -353,7 +344,11 @@ pub async fn build_patched_wheel( )) } }; - exec_bits.insert(member.clone(), file_is_executable(&src).await); + let exec = tokio::fs::metadata(&src) + .await + .map(|m| is_executable(&m)) + .unwrap_or(false); + exec_bits.insert(member.clone(), exec); let dst = stage.path().join(member); if let Some(parent) = dst.parent() { if let Err(e) = tokio::fs::create_dir_all(parent).await { @@ -404,9 +399,10 @@ pub async fn build_patched_wheel( members.sort(); // Regenerate RECORD from the staged (patched) bytes and assemble the - // deterministic zip entry list: lexicographic order, RECORD forced last + // deterministic zip entry list (`(name, bytes, unix mode)` with the exec + // bit preserved as 0o755): lexicographic order, RECORD forced last // (installers stream-read it; last is also what bdist_wheel emits). - let mut entries: Vec = Vec::with_capacity(members.len() + 1); + let mut entries: Vec<(String, Vec, u32)> = Vec::with_capacity(members.len() + 1); let mut record_lines = String::new(); for member in &members { let bytes = match tokio::fs::read(stage.path().join(member)).await { @@ -425,33 +421,33 @@ pub async fn build_patched_wheel( b64, bytes.len() )); - entries.push(ZipEntry { - name: member.clone(), - bytes, - executable: exec_bits.get(member).copied().unwrap_or(false), - }); + let mode = if exec_bits.get(member).copied().unwrap_or(false) { + 0o755 + } else { + 0o644 + }; + entries.push((member.clone(), bytes, mode)); } record_lines.push_str(&format!("{}/RECORD,,\n", csv_quote(&dist_info_name))); - entries.push(ZipEntry { - name: format!("{dist_info_name}/RECORD"), - bytes: record_lines.into_bytes(), - executable: false, - }); - - let zip_bytes = - match tokio::task::spawn_blocking(move || build_deterministic_zip(&entries)).await { - Ok(Ok(bytes)) => bytes, - Ok(Err(e)) => { - result.success = false; - result.error = Some(format!("wheel zip assembly failed: {e}")); - return Ok((result, None)); - } - Err(e) => { - result.success = false; - result.error = Some(format!("wheel zip task failed: {e}")); - return Ok((result, None)); - } - }; + entries.push(( + format!("{dist_info_name}/RECORD"), + record_lines.into_bytes(), + 0o644, + )); + + let zip_bytes = match tokio::task::spawn_blocking(move || write_zip_entries(&entries)).await { + Ok(Ok(bytes)) => bytes, + Ok(Err(e)) => { + result.success = false; + result.error = Some(format!("wheel zip assembly failed: {e}")); + return Ok((result, None)); + } + Err(e) => { + result.success = false; + result.error = Some(format!("wheel zip task failed: {e}")); + return Ok((result, None)); + } + }; if let Some(parent) = dest.parent() { if let Err(e) = tokio::fs::create_dir_all(parent).await { @@ -551,27 +547,18 @@ fn is_console_script_artifact(final_component: &str, script_names: &HashSet Vec { - let mut out = Vec::new(); - for line in text.lines() { - if line.trim().is_empty() { - continue; - } - let fields = parse_csv_record(line); - let Some(path) = fields.first().filter(|p| !p.is_empty()) else { - continue; - }; - out.push(RecordEntry { - path: path.clone(), - hash: fields.get(1).filter(|h| !h.is_empty()).cloned(), - size: fields.get(2).and_then(|s| s.parse().ok()), - }); - } - out +fn parse_record_paths(text: &str) -> Vec { + text.lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| parse_csv_record(line).into_iter().next()) + .filter(|path| !path.is_empty()) + .collect() } /// Minimal CSV record parser (RFC 4180 quoting: `"a,b"`, doubled `""`). @@ -613,63 +600,6 @@ fn csv_quote(field: &str) -> String { } } -#[cfg(unix)] -async fn file_is_executable(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt; - tokio::fs::metadata(path) - .await - .map(|m| m.permissions().mode() & 0o111 != 0) - .unwrap_or(false) -} - -#[cfg(not(unix))] -async fn file_is_executable(_path: &Path) -> bool { - false -} - -struct ZipEntry { - name: String, - bytes: Vec, - executable: bool, -} - -/// Serialize `entries` (already ordered, RECORD last) into a deterministic -/// zip: fixed DOS timestamp (1980-01-01 00:00:00), fixed deflate level, unix -/// mode 0o644 / 0o755 (preserved exec bit) — so rebuilding from the same -/// patched tree always yields identical bytes and a stable hash pin. -fn build_deterministic_zip(entries: &[ZipEntry]) -> Result, String> { - use std::io::Cursor; - use zip::write::SimpleFileOptions; - - let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); - for entry in entries { - let options = SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated) - .compression_level(Some(6)) - .last_modified_time(zip::DateTime::default()) - .unix_permissions(if entry.executable { 0o755 } else { 0o644 }); - writer - .start_file(&entry.name, options) - .map_err(|e| e.to_string())?; - writer.write_all(&entry.bytes).map_err(|e| e.to_string())?; - } - let cursor = writer.finish().map_err(|e| e.to_string())?; - Ok(cursor.into_inner()) -} - -fn failed_result(purl: &str, site_packages: &Path, error: String) -> ApplyResult { - ApplyResult { - package_key: purl.to_string(), - package_path: site_packages.display().to_string(), - success: false, - files_verified: Vec::new(), - files_patched: Vec::new(), - applied_via: HashMap::new(), - error: Some(error), - sidecar: None, - } -} - #[cfg(test)] mod tests { use super::*; @@ -785,16 +715,13 @@ mod tests { \"weird,name.py\",sha256=zz,9\n\ six-1.16.0.dist-info/RECORD,,\n\ \n"; - let rows = parse_record_text(text); - assert_eq!(rows.len(), 3); - assert_eq!(rows[0].path, "six.py"); - assert_eq!(rows[0].hash.as_deref(), Some("sha256=abc_DEF")); - assert_eq!(rows[0].size, Some(123)); - // Quoted CSV path with an embedded comma. - assert_eq!(rows[1].path, "weird,name.py"); - // Empty hash + size stay None. - assert_eq!(rows[2].hash, None); - assert_eq!(rows[2].size, None); + let rows = parse_record_paths(text); + // Quoted CSV path with an embedded comma survives; the empty-field + // RECORD row and the blank line don't derail the parse. + assert_eq!( + rows, + ["six.py", "weird,name.py", "six-1.16.0.dist-info/RECORD"] + ); // Emit side: a path needing quoting survives a parse round-trip. let quoted = csv_quote("weird,\"name\".py"); assert_eq!(parse_csv_record("ed)[0], "weird,\"name\".py"); @@ -850,7 +777,7 @@ mod tests { assert_eq!(dist.dist_name, "six"); assert_eq!(dist.version, "1.16.0"); assert_eq!(dist.wheel_tags, vec!["py2-none-any", "py3-none-any"]); - assert!(dist.record.iter().any(|r| r.path == "six.py")); + assert!(dist.record.iter().any(|r| r == "six.py")); let err = locate_installed_dist(&fx.site_packages, "six", "1.17.0") .await diff --git a/crates/socket-patch-core/src/patch/vendor/registry_fetch.rs b/crates/socket-patch-core/src/patch/vendor/registry_fetch.rs index 8754fbfd..851d3aca 100644 --- a/crates/socket-patch-core/src/patch/vendor/registry_fetch.rs +++ b/crates/socket-patch-core/src/patch/vendor/registry_fetch.rs @@ -24,6 +24,7 @@ use sha1::Sha1; use sha2::{Digest, Sha256, Sha384, Sha512}; use crate::constants::USER_AGENT; +use crate::crawlers::go_crawler::encode_module_path; use crate::patch::apply::is_safe_relative_subpath; use super::lock_inventory::{LockIntegrity, LockfileEntry}; @@ -116,11 +117,8 @@ pub async fn fetch_and_stage( } match entry.ecosystem { "npm" => fetch_npm(entry, client).await, - #[cfg(feature = "cargo")] "cargo" => fetch_cargo(entry, client).await, - #[cfg(feature = "golang")] "golang" => fetch_golang(entry, client).await, - #[cfg(feature = "composer")] "composer" => fetch_composer(entry, client).await, "gem" => fetch_gem(entry, client).await, "pypi" => fetch_pypi(entry, client).await, @@ -200,7 +198,6 @@ pub(crate) fn extract_zip(bytes: &[u8], dest: &Path, strip_first: bool) -> Resul /// Composer dist zips (packagist/GitHub zipballs): sha1-verified, variable /// top dir stripped. The extracted dir plays the installed package dir. -#[cfg(feature = "composer")] async fn fetch_composer( entry: &LockfileEntry, client: &reqwest::Client, @@ -288,10 +285,8 @@ async fn fetch_pypi( } /// crates.io static download host; override with `SOCKET_CRATES_REGISTRY`. -#[cfg(feature = "cargo")] pub const DEFAULT_CRATES_REGISTRY: &str = "https://static.crates.io/crates"; -#[cfg(feature = "cargo")] fn crates_registry_base() -> String { std::env::var("SOCKET_CRATES_REGISTRY") .ok() @@ -303,7 +298,6 @@ fn crates_registry_base() -> String { /// `.crate` files are tar.gz with a `{name}-{version}/` top dir — the same /// extraction path as npm tarballs. The Cargo.lock `checksum` is the sha256 /// of the `.crate` bytes. -#[cfg(feature = "cargo")] async fn fetch_cargo( entry: &LockfileEntry, client: &reqwest::Client, @@ -339,10 +333,8 @@ async fn fetch_cargo( /// Default Go module proxy; `SOCKET_GOPROXY` wins, else the standard /// `GOPROXY` env (first element that isn't `direct`/`off`). -#[cfg(feature = "golang")] pub const DEFAULT_GOPROXY: &str = "https://proxy.golang.org"; -#[cfg(feature = "golang")] fn goproxy_base() -> String { if let Ok(v) = std::env::var("SOCKET_GOPROXY") { let v = v.trim_end_matches('/').to_string(); @@ -361,27 +353,13 @@ fn goproxy_base() -> String { DEFAULT_GOPROXY.to_string() } -/// Go's module-path case encoding for proxy URLs: an uppercase letter `X` -/// becomes `!x` (applies to the module path and the version). -#[cfg(feature = "golang")] -fn go_escape(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - if c.is_ascii_uppercase() { - out.push('!'); - out.push(c.to_ascii_lowercase()); - } else { - out.push(c); - } - } - out -} - /// go.sum's `h1:` dirhash over a module zip: sha256 of the sorted /// `"{sha256hex(content)} {entry name}\n"` lines, base64-encoded /// (golang.org/x/mod/sumdb/dirhash Hash1/HashZip). Computed in memory /// BEFORE extraction. -#[cfg(feature = "golang")] +/// +/// Runs in the ecosystem-agnostic service-download path whenever the +/// service reports a `dirhashH1`. fn go_h1_of_zip(bytes: &[u8]) -> Result { use std::io::Read as _; let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)) @@ -459,7 +437,6 @@ pub(crate) fn verify_go_h1(bytes: &[u8], expected_h1: &str) -> Result<(), String /// (`@/` — go module paths contain slashes, so a /// first-component strip would be wrong). Same guard family as /// [`extract_tgz`]; an entry outside the prefix fails the whole artifact. -#[cfg(feature = "golang")] /// `pub(crate)` so the golang service-download path can extract a downloaded /// module zip (entries prefixed `{module}@{version}/`) into the vendor copy dir. pub(crate) fn extract_zip_with_prefix( @@ -506,7 +483,6 @@ pub(crate) fn extract_zip_with_prefix( Ok(()) } -#[cfg(feature = "golang")] async fn fetch_golang( entry: &LockfileEntry, client: &reqwest::Client, @@ -520,8 +496,8 @@ async fn fetch_golang( format!( "{}/{}/@v/{}.zip", goproxy_base(), - go_escape(&entry.name), - go_escape(&entry.version) + encode_module_path(&entry.name), + encode_module_path(&entry.version) ) }); let bytes = download(client, &url).await.map_err(FetchError::Failed)?; @@ -1218,7 +1194,6 @@ mod tests { } } - #[cfg(feature = "cargo")] #[tokio::test] async fn cargo_crate_fetch_verifies_sha256_and_extracts() { // .crate = tar.gz with a {name}-{version}/ top dir. @@ -1265,7 +1240,6 @@ mod tests { /// Build a go module zip in memory (files only, `module@version/` /// prefix — the go zip layout). - #[cfg(feature = "golang")] fn make_module_zip(prefix: &str, files: &[(&str, &[u8])]) -> Vec { use std::io::Write as _; let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); @@ -1284,7 +1258,6 @@ mod tests { /// Independent spec-mirror of dirhash Hash1/HashZip, structured /// differently from the production fn to catch encoding slips. - #[cfg(feature = "golang")] fn spec_h1(files: &[(&str, &[u8])], prefix: &str) -> String { // dirhash.Hash1 sorts the FILE NAMES, then emits one line per file. let mut named: Vec<(String, &[u8])> = files @@ -1303,7 +1276,6 @@ mod tests { ) } - #[cfg(feature = "golang")] #[tokio::test] async fn golang_module_fetch_verifies_h1_dirhash_and_extracts() { // Out-of-order files prove the sort; nested module path proves the @@ -1356,14 +1328,13 @@ mod tests { } } - #[cfg(feature = "golang")] #[test] fn go_escape_uppercase_and_zip_prefix_guards() { assert_eq!( - go_escape("github.com/Azure/azure-sdk"), + encode_module_path("github.com/Azure/azure-sdk"), "github.com/!azure/azure-sdk" ); - assert_eq!(go_escape("v1.0.0-RC1"), "v1.0.0-!r!c1"); + assert_eq!(encode_module_path("v1.0.0-RC1"), "v1.0.0-!r!c1"); // An entry outside the module prefix fails the whole artifact. let zip_bytes = make_module_zip("github.com/x/y@v1.0.0/", &[("go.mod", b"m\n")]); @@ -1390,7 +1361,6 @@ mod tests { writer.finish().unwrap().into_inner() } - #[cfg(feature = "composer")] #[tokio::test] async fn composer_dist_fetch_verifies_sha1_and_strips_top_dir() { // GitHub zipballs carry an `owner-repo-sha/` top dir. diff --git a/crates/socket-patch-core/src/patch/vendor/service_fetch.rs b/crates/socket-patch-core/src/patch/vendor/service_fetch.rs index 702366f1..e4b536b3 100644 --- a/crates/socket-patch-core/src/patch/vendor/service_fetch.rs +++ b/crates/socket-patch-core/src/patch/vendor/service_fetch.rs @@ -12,13 +12,17 @@ use crate::api::client::{SecondaryArtifact, VendorServiceOutcome}; use crate::patch::vendor::lock_inventory::LockIntegrity; use crate::patch::vendor::registry_fetch::{artifact_matches_integrity, verify_go_h1}; use crate::patch::vendor::VendorServiceConfig; +use crate::patch::vendor::{ + common::{refused, service_offline_conflict}, + VendorOutcome, VendorWarning, +}; /// A service archive whose bytes have passed integrity verification. /// /// Deliberately minimal: every consumer recomputes the hashes it needs from /// `bytes` (so a service-downloaded artifact describes itself byte-identically /// to a local build), so the service-reported sha1/md5/size are not re-carried. -#[derive(Debug, Clone)] +#[derive(Debug)] pub(crate) struct VerifiedArchive { /// The verified archive bytes (npm `.tgz`, pypi `.whl`/sdist, cargo /// `.crate`, golang/composer `.zip`, gem `.gem`, …). @@ -56,15 +60,12 @@ pub(crate) enum ServiceArtifact { /// Download and integrity-verify the prebuilt archive for `uuid`. /// -/// `verify_name` is only consulted for the (npm) yarn-berry checksum kind, -/// which v1 never verifies here — pass the package's bare name for forward -/// compatibility. Verification always checks the sha512 floor and, when the -/// service supplied a golang `h1:` dirhash, that too (it covers the zip's -/// contents, which `go mod verify` relies on). +/// Verification always checks the sha512 floor and, when the service supplied +/// a golang `h1:` dirhash, that too (it covers the zip's contents, which +/// `go mod verify` relies on). pub(crate) async fn fetch_verified_archive( cfg: &VendorServiceConfig, uuid: &str, - verify_name: &str, ) -> ServiceArtifact { let Some(client) = cfg.client.as_ref() else { return ServiceArtifact::Unavailable("vendor service not configured".to_string()); @@ -86,10 +87,11 @@ pub(crate) async fn fetch_verified_archive( VendorServiceOutcome::Failed(err) => return ServiceArtifact::Failed(err.to_string()), }; - // sha512 floor — every ecosystem's tarball carries it. + // sha512 floor — every ecosystem's tarball carries it. The name arg only + // feeds the yarn-berry checksum recipe; the Sri verifier ignores it. if let Err(e) = artifact_matches_integrity( &pkg.tarball, - verify_name, + "", &LockIntegrity::Sri(pkg.integrity_sri.clone()), ) { return ServiceArtifact::IntegrityMismatch(e); @@ -110,6 +112,93 @@ pub(crate) async fn fetch_verified_archive( }) } +/// Outcome of attempting to materialise a single-file artifact from the patch +/// service (the Tier-A backends — maven `.jar`, nuget `.nupkg` — where the +/// verified archive bytes ARE the vendored artifact, written verbatim). +pub(crate) enum ServiceCopy { + /// The prebuilt patched bytes (write them verbatim). + Used(Vec), + /// Bubble this terminal outcome (boxed — `VendorOutcome` is large). + HardFail(Box), + /// Fall back to the local rebuild. + FallBack, +} + +/// Download + integrity-verify the prebuilt patched archive for the Tier-A +/// backends, mapping each service outcome onto the `auto` / `service` fallback +/// policy. `noun` is the artifact kind used in messages (".jar" / ".nupkg"). +pub(crate) async fn service_archive_copy( + service: Option<&VendorServiceConfig>, + uuid: &str, + name: &str, + noun: &str, + warnings: &mut Vec, +) -> ServiceCopy { + // The maven/nuget flows have no earlier guard, so the fail-closed + // `--vendor-source=service` + `--offline` refusal lives here (the other + // backends check the same helper at their entry points). + if let Some(refusal) = service_offline_conflict(service) { + return ServiceCopy::HardFail(Box::new(refusal)); + } + let Some(cfg) = service else { + return ServiceCopy::FallBack; + }; + if !cfg.service_enabled() { + return ServiceCopy::FallBack; + } + fn hard(code: &'static str, detail: String) -> ServiceCopy { + ServiceCopy::HardFail(Box::new(refused(code, detail))) + } + let miss = |warnings: &mut Vec, code: &'static str, reason: String| { + if cfg.source.requires_service() { + hard("vendor_prebuilt_required", reason) + } else { + warnings.push(VendorWarning::new( + code, + format!("{reason}; building locally instead"), + )); + ServiceCopy::FallBack + } + }; + match fetch_verified_archive(cfg, uuid).await { + ServiceArtifact::Ready(archive) => { + warnings.push(VendorWarning::new( + "vendor_prebuilt_downloaded", + format!( + "vendored {name} from the patch service ({})", + archive.source_url + ), + )); + ServiceCopy::Used(archive.bytes) + } + ServiceArtifact::IntegrityMismatch(reason) => miss( + warnings, + "vendor_prebuilt_integrity_mismatch", + format!("prebuilt {noun} failed integrity ({reason})"), + ), + ServiceArtifact::Pending => miss( + warnings, + "vendor_prebuilt_pending", + format!("prebuilt {noun} is still building"), + ), + ServiceArtifact::Unavailable(reason) => { + if cfg.source.requires_service() { + hard( + "vendor_prebuilt_required", + format!("prebuilt {noun} unavailable: {reason}"), + ) + } else { + ServiceCopy::FallBack + } + } + ServiceArtifact::Failed(reason) => miss( + warnings, + "vendor_prebuilt_unavailable", + format!("patch service request failed ({reason})"), + ), + } +} + /// Outcome of fetching + verifying a named secondary artifact. pub(crate) enum SecondaryArtifactResult { /// Bytes downloaded and sha512-verified. @@ -126,16 +215,14 @@ pub(crate) enum SecondaryArtifactResult { /// Download + integrity-verify the secondary artifact of `kind` (e.g. /// `gem-stub-gemspec`) referenced by a [`VerifiedArchive`]. /// -/// `verify_name` is the package's bare name (only consulted by the yarn-berry -/// checksum kind, which never reaches here). The bytes are verified against the -/// artifact's own sha512 SRI, fail-closed like the primary archive. Returns -/// `Absent` when the archive referenced no artifact of this kind — the caller -/// treats that as a miss (fall back under `auto`, refuse under `service`). +/// The bytes are verified against the artifact's own sha512 SRI, fail-closed +/// like the primary archive. Returns `Absent` when the archive referenced no +/// artifact of this kind — the caller treats that as a miss (fall back under +/// `auto`, refuse under `service`). pub(crate) async fn fetch_verified_secondary( cfg: &VendorServiceConfig, archive: &VerifiedArchive, kind: &str, - verify_name: &str, ) -> SecondaryArtifactResult { let Some(client) = cfg.client.as_ref() else { return SecondaryArtifactResult::Failed("vendor service not configured".to_string()); @@ -149,9 +236,10 @@ pub(crate) async fn fetch_verified_secondary( Err(e) => return SecondaryArtifactResult::Failed(e.to_string()), }; + // As above: the Sri verifier never reads the name arg. if let Err(e) = artifact_matches_integrity( &bytes, - verify_name, + "", &LockIntegrity::Sri(artifact.integrity_sri.clone()), ) { return SecondaryArtifactResult::IntegrityMismatch(e); @@ -217,7 +305,7 @@ mod tests { let sri = PackedTarball::from_bytes(body).integrity; mount_granted(&server, &sri, body).await; - match fetch_verified_archive(&cfg_for(&server), UUID, "x").await { + match fetch_verified_archive(&cfg_for(&server), UUID).await { ServiceArtifact::Ready(v) => { assert_eq!(v.bytes, body); assert_eq!(v.integrity_sri, sri); @@ -237,11 +325,50 @@ mod tests { mount_granted(&server, &wrong, body).await; assert!(matches!( - fetch_verified_archive(&cfg_for(&server), UUID, "x").await, + fetch_verified_archive(&cfg_for(&server), UUID).await, ServiceArtifact::IntegrityMismatch(_) )); } + /// `--vendor-source=service --offline` is a fail-closed refusal (the same + /// `vendor_service_offline_conflict` the other backends give via + /// `service_offline_conflict`), never a silent local-build fallback — + /// maven/nuget funnel through here and have no earlier guard. + #[tokio::test] + async fn service_copy_offline_conflict_hard_fails() { + let server = MockServer::start().await; + let mut cfg = cfg_for(&server); + cfg.offline = true; + let mut warnings = Vec::new(); + match service_archive_copy(Some(&cfg), UUID, "x", ".jar", &mut warnings).await { + ServiceCopy::HardFail(outcome) => match *outcome { + VendorOutcome::Refused { code, .. } => { + assert_eq!(code, "vendor_service_offline_conflict"); + } + other => panic!("expected Refused, got {other:?}"), + }, + ServiceCopy::Used(_) => panic!("offline run must not download"), + ServiceCopy::FallBack => { + panic!("--vendor-source=service --offline fell back to a local build") + } + } + } + + /// Under `auto`, offline stays a quiet fallback to the local build. + #[tokio::test] + async fn service_copy_offline_auto_falls_back() { + let server = MockServer::start().await; + let mut cfg = cfg_for(&server); + cfg.source = VendorSource::Auto; + cfg.offline = true; + let mut warnings = Vec::new(); + assert!(matches!( + service_archive_copy(Some(&cfg), UUID, "x", ".jar", &mut warnings).await, + ServiceCopy::FallBack + )); + assert!(warnings.is_empty(), "quiet fallback, no warning"); + } + /// A config without a client is a quiet Unavailable, not a panic. #[tokio::test] async fn unavailable_when_client_absent() { @@ -254,7 +381,7 @@ mod tests { offline: false, }; assert!(matches!( - fetch_verified_archive(&cfg, UUID, "x").await, + fetch_verified_archive(&cfg, UUID).await, ServiceArtifact::Unavailable(_) )); } diff --git a/crates/socket-patch-core/src/patch/vendor/state.rs b/crates/socket-patch-core/src/patch/vendor/state.rs index fe885732..2a7a26b9 100644 --- a/crates/socket-patch-core/src/patch/vendor/state.rs +++ b/crates/socket-patch-core/src/patch/vendor/state.rs @@ -23,12 +23,14 @@ //! flavor strings they have no backend for. Both keep an old binary safe //! against a newer project checkout. -use std::collections::{BTreeMap, HashMap}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; -use serde::{Deserialize, Serialize, Serializer}; +use serde::{Deserialize, Serialize}; +use crate::manifest::schema::PatchRecord; use crate::utils::fs::atomic_write_bytes; +use crate::utils::serde::serialize_sorted; use super::path::VENDOR_DIR; @@ -36,15 +38,7 @@ use super::path::VENDOR_DIR; pub const VENDOR_STATE_REL: &str = ".socket/vendor/state.json"; /// Current schema version. -pub const VENDOR_STATE_VERSION: u32 = 1; - -fn serialize_sorted(map: &HashMap, serializer: S) -> Result -where - S: Serializer, - V: Serialize, -{ - map.iter().collect::>().serialize(serializer) -} +const VENDOR_STATE_VERSION: u32 = 1; /// The vendored artifact (a tarball/wheel file, or the copy directory for the /// dir-shaped ecosystems). @@ -262,10 +256,6 @@ impl VendorState { entries: HashMap::new(), } } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } } impl Default for VendorState { @@ -274,6 +264,18 @@ impl Default for VendorState { } } +/// The ledger entry addressable as `purl`: the exact map key first, then +/// any entry whose resolved `base_purl` equals it (a qualified manifest +/// key resolves to the entry recorded under the base PURL). +pub fn lookup_entry<'a>( + entries: &'a HashMap, + purl: &str, +) -> Option<&'a VendorEntry> { + entries + .get(purl) + .or_else(|| entries.values().find(|e| e.base_purl == purl)) +} + fn state_path(project_root: &Path) -> PathBuf { project_root.join(VENDOR_STATE_REL) } @@ -313,7 +315,7 @@ pub async fn load_state(project_root: &Path) -> std::io::Result { /// empty, so a fully-reverted project carries no vendor residue. pub async fn save_state(project_root: &Path, state: &VendorState) -> std::io::Result<()> { let path = state_path(project_root); - if state.is_empty() { + if state.entries.is_empty() { match tokio::fs::remove_file(&path).await { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} @@ -343,7 +345,7 @@ pub async fn save_state(project_root: &Path, state: &VendorState) -> std::io::Re /// a trust input — sweep/verify key off state.json + the path uuid. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct VendorMarker { +pub(crate) struct VendorMarker { pub schema_version: u32, pub purl: String, pub patch_uuid: String, @@ -355,11 +357,33 @@ pub struct VendorMarker { pub vendored_at: String, } +impl VendorMarker { + /// The schema-v1 marker every backend writes: `record`'s uuid plus its + /// vulnerability ids, sorted. + pub(crate) fn new( + ecosystem: &str, + purl: &str, + record: &PatchRecord, + vendored_at: &str, + ) -> Self { + let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); + vulnerabilities.sort(); + VendorMarker { + schema_version: 1, + purl: purl.to_string(), + patch_uuid: record.uuid.clone(), + ecosystem: ecosystem.to_string(), + vulnerabilities, + vendored_at: vendored_at.to_string(), + } + } +} + /// File name of the marker inside the uuid dir. -pub const VENDOR_MARKER_FILE: &str = "socket-patch.vendor.json"; +pub(crate) const VENDOR_MARKER_FILE: &str = "socket-patch.vendor.json"; /// Write the marker atomically into `uuid_dir`. -pub async fn write_marker(uuid_dir: &Path, marker: &VendorMarker) -> std::io::Result<()> { +pub(crate) async fn write_marker(uuid_dir: &Path, marker: &VendorMarker) -> std::io::Result<()> { let mut bytes = serde_json::to_vec_pretty(marker).map_err(std::io::Error::other)?; bytes.push(b'\n'); atomic_write_bytes(&uuid_dir.join(VENDOR_MARKER_FILE), &bytes).await @@ -608,7 +632,7 @@ mod tests { async fn missing_file_is_empty_corrupt_file_is_error() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - assert!(load_state(root).await.unwrap().is_empty()); + assert!(load_state(root).await.unwrap().entries.is_empty()); tokio::fs::create_dir_all(root.join(".socket/vendor")) .await @@ -639,7 +663,7 @@ mod tests { .await .unwrap(); assert!( - load_state(root).await.unwrap().is_empty(), + load_state(root).await.unwrap().entries.is_empty(), "a foreign mode-tagged ledger is not vendor data" ); diff --git a/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs b/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs index 9e4915d7..6c514f72 100644 --- a/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs +++ b/crates/socket-patch-core/src/patch/vendor/toml_surgery.rs @@ -1,7 +1,7 @@ //! Pure text-surgery helpers for lockfile-shaped TOML. //! -//! The pypi/uv backend (and the upcoming poetry/pdm/pipenv ones) edit locks -//! by TARGETED text surgery rather than a TOML re-serialize: the spike +//! The pypi/uv, poetry, and pdm backends edit locks by TARGETED text +//! surgery rather than a TOML re-serialize: the spike //! proved a surgical edit reproduces the lock generator's own serializer //! output byte-identically, which keeps `--check`-style validations green //! and the committed diff minimal. These helpers are the shared, purely @@ -54,9 +54,69 @@ where None } -/// Exclusive end index of the bracket opened at `open_idx` (quote-aware; +/// The unit's lines with any trailing foreign top-level section cut off. +/// [`find_unit_span`] ends a unit at the NEXT `[[package]]` or EOF, but a +/// trailing section (poetry's `[metadata]`) would otherwise be swallowed — +/// truncate at the first top-level header that is not a `[package.*]` +/// sub-table, dropping the blank separator. +pub(super) fn package_unit_lines(unit_text: &str) -> Vec<&str> { + let mut unit: Vec<&str> = unit_text.lines().collect(); + if let Some(stop) = unit + .iter() + .enumerate() + .skip(1) + .find_map(|(i, l)| (l.starts_with('[') && !l.starts_with("[package.")).then_some(i)) + { + unit.truncate(stop); + while unit.last().is_some_and(|l| l.trim().is_empty()) { + unit.pop(); + } + } + unit +} + +/// Rewrite the unit's `files = [...]` array (single- or multi-line) to the +/// single patched-wheel `{file, hash}` element, preserving every other line +/// verbatim — the splice shape shared by the poetry and pdm locks. `None` +/// when the unit has no files array (the callers fail closed rather than +/// guess a placement). +pub(super) fn replace_files_array( + unit: &[&str], + wheel_file_name: &str, + wheel_sha256_hex: &str, +) -> Option> { + let files_lines = [ + "files = [".to_string(), + format!(" {{file = \"{wheel_file_name}\", hash = \"sha256:{wheel_sha256_hex}\"}},"), + "]".to_string(), + ]; + + let mut out: Vec = Vec::new(); + let mut files_done = false; + let mut i = 0; + while i < unit.len() { + let line = unit[i]; + if line.starts_with("files = [") { + out.extend(files_lines.iter().cloned()); + files_done = true; + if !line.trim_end().ends_with(']') { + // skip the original multi-line array body + closing bracket + while i + 1 < unit.len() && unit[i + 1].trim() != "]" { + i += 1; + } + i += 1; + } + } else { + out.push(line.to_string()); + } + i += 1; + } + files_done.then_some(out) +} + +/// Exclusive end index of the `[` array opened at `open_idx` (quote-aware; /// TOML basic strings with backslash escapes). -pub(super) fn balanced_span(text: &str, open_idx: usize, open: char, close: char) -> Option { +pub(super) fn balanced_span(text: &str, open_idx: usize) -> Option { let mut depth = 0i32; let mut in_str = false; let mut escaped = false; @@ -73,12 +133,12 @@ pub(super) fn balanced_span(text: &str, open_idx: usize, open: char, close: char } if c == '"' { in_str = true; - } else if c == open { + } else if c == '[' { depth += 1; - } else if c == close { + } else if c == ']' { depth -= 1; if depth == 0 { - return Some(open_idx + i + c.len_utf8()); + return Some(open_idx + i + 1); } } } @@ -158,13 +218,22 @@ pub(super) fn split_top_level_commas(text: &str) -> Vec<&str> { out } +/// The drift-tolerant revert splice: replace the first occurrence of `new` +/// with `orig`. `None` when either fragment is missing (a malformed wiring +/// record) or `new` no longer appears (the fragment drifted) — the callers +/// warn and leave the text untouched. +pub(super) fn replace_fragment( + text: &str, + new: Option<&str>, + orig: Option<&str>, +) -> Option { + let (new, orig) = (new?, orig?); + text.contains(new).then(|| text.replacen(new, orig, 1)) +} + /// Remove the first exact occurrence of `needle`; `None` when absent. pub(super) fn remove_substring(text: &str, needle: &str) -> Option { - let idx = text.find(needle)?; - let mut out = String::with_capacity(text.len() - needle.len()); - out.push_str(&text[..idx]); - out.push_str(&text[idx + needle.len()..]); - Some(out) + text.contains(needle).then(|| text.replacen(needle, "", 1)) } /// Remove the first line that equals `line` exactly; `None` when absent. @@ -257,14 +326,61 @@ mod tests { assert!(find_unit_span(LOCK, |lines| lines.contains(&"name = \"absent\"")).is_none()); } + #[test] + fn package_unit_lines_truncates_trailing_foreign_section() { + // A [package.*] sub-table stays; a trailing [metadata] (plus its + // blank separator) is cut. + let unit = "[[package]]\nname = \"six\"\n\n[package.source]\ntype = \"file\"\n\n[metadata]\nlock-version = \"2.1\""; + assert_eq!( + package_unit_lines(unit), + vec![ + "[[package]]", + "name = \"six\"", + "", + "[package.source]", + "type = \"file\"" + ] + ); + // No foreign section → untouched. + assert_eq!( + package_unit_lines("[[package]]\nname = \"six\""), + vec!["[[package]]", "name = \"six\""] + ); + } + + #[test] + fn replace_files_array_handles_multi_line_inline_and_absent() { + let multi = ["name = \"six\"", "files = [", " {file = \"a\"},", "]"]; + assert_eq!( + replace_files_array(&multi, "w.whl", "beef").unwrap(), + vec![ + "name = \"six\"", + "files = [", + " {file = \"w.whl\", hash = \"sha256:beef\"},", + "]" + ] + ); + let inline = ["files = []", "summary = \"x\""]; + assert_eq!( + replace_files_array(&inline, "w.whl", "beef").unwrap(), + vec![ + "files = [", + " {file = \"w.whl\", hash = \"sha256:beef\"},", + "]", + "summary = \"x\"" + ] + ); + assert!(replace_files_array(&["name = \"six\""], "w.whl", "beef").is_none()); + } + #[test] fn balanced_span_is_quote_aware() { let text = "x = [\"a]b\", [1, 2], \"c\\\"]d\"] tail"; let open = text.find('[').unwrap(); - let end = balanced_span(text, open, '[', ']').unwrap(); + let end = balanced_span(text, open).unwrap(); assert_eq!(&text[open..end], "[\"a]b\", [1, 2], \"c\\\"]d\"]"); // Unbalanced → None. - assert!(balanced_span("[1, 2", 0, '[', ']').is_none()); + assert!(balanced_span("[1, 2", 0).is_none()); } #[test] @@ -281,6 +397,14 @@ mod tests { #[test] fn removal_helpers_round_trip() { + assert_eq!( + replace_fragment("a new b", Some("new"), Some("old")).as_deref(), + Some("a old b") + ); + assert_eq!(replace_fragment("a b", Some("new"), Some("old")), None); + assert_eq!(replace_fragment("a new b", None, Some("old")), None); + assert_eq!(replace_fragment("a new b", Some("new"), None), None); + assert_eq!(remove_substring("abcdef", "cd").as_deref(), Some("abef")); assert_eq!(remove_substring("abcdef", "xy"), None); diff --git a/crates/socket-patch-core/src/patch/vendor/verify.rs b/crates/socket-patch-core/src/patch/vendor/verify.rs index 22dec7a2..44e0f2dd 100644 --- a/crates/socket-patch-core/src/patch/vendor/verify.rs +++ b/crates/socket-patch-core/src/patch/vendor/verify.rs @@ -81,21 +81,29 @@ pub async fn verify_vendored_patch_record( return Err("vendor_artifact_missing".to_string()); } - let path_str = artifact.to_string_lossy().to_string(); - if path_str.ends_with(".tgz") || path_str.ends_with(".tar.gz") { - verify_tarball_members(&artifact, record).await - } else if path_str.ends_with(".whl") - || path_str.ends_with(".nupkg") - || path_str.ends_with(".jar") - { - // A `.nupkg` is a plain OPC zip (NuGet) and a `.jar` is a plain zip - // (Maven) — both carry member paths that are package-relative, exactly - // the manifest key space, so the bounded zip reader used for wheels - // verifies them verbatim. - verify_wheel_members(&artifact, record).await - } else { - verify_dir_members(&artifact, record).await + // Archive-shaped artifacts are decoded in memory and their members hashed: + // npm tarballs via the bomb-capped patch-archive reader (it strips the + // `package/` prefix, matching `normalize_file_path`'d keys); `.whl` / + // `.nupkg` (a plain OPC zip) / `.jar` (a plain zip) via the bounded zip + // reader — their member paths are package-relative, exactly the manifest + // key space. Everything else is a dir-shaped copy hashed in place. + let path_str = artifact.to_string_lossy(); + let is_tarball = path_str.ends_with(".tgz") || path_str.ends_with(".tar.gz"); + let is_zip = + path_str.ends_with(".whl") || path_str.ends_with(".nupkg") || path_str.ends_with(".jar"); + if !is_tarball && !is_zip { + return verify_dir_members(&artifact, record).await; } + let map = tokio::task::spawn_blocking(move || { + if is_tarball { + read_archive_to_map(&artifact).map_err(|_| "vendor_artifact_unreadable".to_string()) + } else { + read_wheel_to_map(&artifact) + } + }) + .await + .map_err(|_| "vendor_artifact_unreadable".to_string())??; + verify_member_map(&map, record) } /// Dir-shaped ecosystems (cargo/golang/composer/gem): hash files in place, @@ -115,37 +123,33 @@ async fn verify_dir_members(dir: &Path, record: &PatchRecord) -> Result<(), Stri Ok(()) } -/// npm tarballs: decode in memory via the bomb-capped patch-archive reader -/// (it strips the `package/` prefix, matching `normalize_file_path`'d keys) -/// and hash each member against its afterHash. -async fn verify_tarball_members(tgz: &Path, record: &PatchRecord) -> Result<(), String> { - let tgz = tgz.to_path_buf(); - let map = tokio::task::spawn_blocking(move || read_archive_to_map(&tgz)) - .await - .map_err(|_| "vendor_artifact_unreadable".to_string())? - .map_err(|_| "vendor_artifact_unreadable".to_string())?; - verify_member_map(&map, record) -} - -/// pypi wheels: bounded zip decode (member names are site-packages-relative, -/// exactly the manifest's pypi key space). -async fn verify_wheel_members(whl: &Path, record: &PatchRecord) -> Result<(), String> { - let whl = whl.to_path_buf(); - let map = tokio::task::spawn_blocking(move || read_wheel_to_map(&whl)) - .await - .map_err(|_| "vendor_artifact_unreadable".to_string())??; - verify_member_map(&map, record) -} - fn read_wheel_to_map(whl: &Path) -> Result>, String> { + // Open non-blockingly and require a regular file: a FIFO planted at the + // artifact path would otherwise wedge the audit in `open(2)` waiting for + // a writer that never comes (mirrors `read_archive_to_map`; O_NONBLOCK + // has no effect on regular-file reads). + #[cfg(unix)] + let file = { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(whl) + .map_err(|_| "vendor_artifact_unreadable".to_string())? + }; + #[cfg(not(unix))] let file = std::fs::File::open(whl).map_err(|_| "vendor_artifact_unreadable".to_string())?; + if !file.metadata().map(|m| m.is_file()).unwrap_or(false) { + return Err("vendor_artifact_unreadable".to_string()); + } let mut zip = zip::ZipArchive::new(file).map_err(|_| "vendor_artifact_unreadable".to_string())?; if zip.len() > MAX_WHEEL_ENTRIES { return Err("vendor_artifact_unreadable".to_string()); } let mut out = HashMap::new(); - let mut total: u64 = 0; + let mut declared: u64 = 0; + let mut actual: u64 = 0; for i in 0..zip.len() { let mut entry = zip .by_index(i) @@ -153,19 +157,29 @@ fn read_wheel_to_map(whl: &Path) -> Result>, String> { if !entry.is_file() { continue; } - // SECURITY: bound the cumulative decompressed size before reading — - // a committed-but-tampered wheel must not balloon an audit's memory. - total = total.saturating_add(entry.size()); - if total > MAX_WHEEL_DECOMPRESSED_BYTES { + // SECURITY: bound the cumulative decompressed size — a + // committed-but-tampered wheel must not balloon an audit's memory. + // The declared `entry.size()` is header data the attacker controls + // and the zip reader never enforces, so the binding budget is bytes + // ACTUALLY decompressed; the declared check just fails honest + // oversized wheels before reading anything. + declared = declared.saturating_add(entry.size()); + if declared > MAX_WHEEL_DECOMPRESSED_BYTES { return Err("vendor_artifact_unreadable".to_string()); } let name = entry.name().to_string(); let mut bytes = Vec::new(); + // +1 so an entry that would exceed the remaining budget reads one + // byte past it and is rejected, rather than truncating silently. entry .by_ref() - .take(MAX_WHEEL_DECOMPRESSED_BYTES) + .take(MAX_WHEEL_DECOMPRESSED_BYTES - actual + 1) .read_to_end(&mut bytes) .map_err(|_| "vendor_artifact_unreadable".to_string())?; + actual = actual.saturating_add(bytes.len() as u64); + if actual > MAX_WHEEL_DECOMPRESSED_BYTES { + return Err("vendor_artifact_unreadable".to_string()); + } out.insert(name, bytes); } Ok(out) @@ -593,6 +607,95 @@ mod tests { ); } + /// Rewrite every declared uncompressed size in `zip_path` (central + /// directory AND local headers) to 0, leaving compressed data and CRCs + /// intact — the header lie a tampered wheel uses to slip a decompression + /// bomb past size accounting that trusts `entry.size()`. + fn zero_declared_sizes(zip_path: &Path) { + let mut bytes = std::fs::read(zip_path).unwrap(); + let eocd = bytes.len() - 22; + assert_eq!(&bytes[eocd..eocd + 4], b"PK\x05\x06", "EOCD not found"); + let cd_count = u16::from_le_bytes([bytes[eocd + 10], bytes[eocd + 11]]) as usize; + let mut off = + u32::from_le_bytes(bytes[eocd + 16..eocd + 20].try_into().unwrap()) as usize; + for _ in 0..cd_count { + assert_eq!(&bytes[off..off + 4], b"PK\x01\x02", "central header not found"); + let name_len = u16::from_le_bytes([bytes[off + 28], bytes[off + 29]]) as usize; + let extra_len = u16::from_le_bytes([bytes[off + 30], bytes[off + 31]]) as usize; + let comment_len = u16::from_le_bytes([bytes[off + 32], bytes[off + 33]]) as usize; + let lho = + u32::from_le_bytes(bytes[off + 42..off + 46].try_into().unwrap()) as usize; + bytes[off + 24..off + 28].fill(0); + assert_eq!(&bytes[lho..lho + 4], b"PK\x03\x04", "local header not found"); + bytes[lho + 22..lho + 26].fill(0); + off += 46 + name_len + extra_len + comment_len; + } + std::fs::write(zip_path, bytes).unwrap(); + } + + /// SECURITY: the declared `entry.size()` is attacker-controlled header + /// data the zip reader never enforces — accounting must budget by bytes + /// ACTUALLY decompressed, or a wheel declaring 0 everywhere buffers up to + /// 64 MiB × 10_000 entries into the audit's memory. + #[test] + fn wheel_bomb_with_lying_declared_sizes_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let whl = tmp.path().join("bomb-1.0.0-py3-none-any.whl"); + // 5 × 16 MiB of zeros = 80 MiB actual (over the 64 MiB cap), a few + // KiB compressed; every header then claims 0 uncompressed bytes. + let file = std::fs::File::create(&whl).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let member = vec![0u8; 16 * 1024 * 1024]; + for i in 0..5 { + zip.start_file::<_, ()>(format!("pad{i}.bin"), Default::default()) + .unwrap(); + zip.write_all(&member).unwrap(); + } + zip.finish().unwrap(); + zero_declared_sizes(&whl); + + assert!( + read_wheel_to_map(&whl).is_err(), + "an 80 MiB-actual wheel declaring 0 bytes must not be buffered past the cap" + ); + } + + /// SECURITY: a FIFO planted at the artifact path must fail verification, + /// not wedge the audit in `open(2)` waiting for a writer that never + /// comes (the tarball reader and file hasher already guard this). + #[cfg(unix)] + #[tokio::test] + async fn fifo_wheel_artifact_fails_instead_of_wedging() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let rel = format!(".socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl"); + tokio::fs::create_dir_all(root.join(format!(".socket/vendor/pypi/{UUID}"))) + .await + .unwrap(); + let fifo = root.join(&rel); + let c_path = std::ffi::CString::new(fifo.as_os_str().as_encoded_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }, 0); + + let rec = record(UUID, "six.py"); + let ent = entry("pypi", UUID, &rel); + let verdict = tokio::time::timeout( + std::time::Duration::from_secs(5), + verify_vendored_patch_record(root, &ent, &rec), + ) + .await; + // Release any opener still blocked on the FIFO (the buggy case) so + // runtime shutdown doesn't hang on its spawn_blocking thread. + { + use std::os::unix::fs::OpenOptionsExt; + let _ = std::fs::OpenOptions::new() + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(&fifo); + } + let verdict = verdict.expect("a planted FIFO must not wedge verification"); + assert_eq!(verdict.unwrap_err(), "vendor_artifact_unreadable"); + } + /// Full classification matrix for the repair-facing health check. #[tokio::test] async fn artifact_health_classification_matrix() { diff --git a/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs index 459c0010..3901744b 100644 --- a/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/yarn_berry_lock.rs @@ -36,18 +36,21 @@ use sha2::{Digest, Sha512}; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{normalize_file_path, PatchSources}; use crate::patch::copy_tree::remove_tree; -use crate::utils::fs::atomic_write_bytes; +use crate::utils::fs::atomic_write_bytes_preserving_mode; use crate::utils::uri::encode_uri_component; use super::berry_zip::berry_cache_checksum_10c0; -use super::npm_common::{done_failure, guard_coordinates, refused, stage_patch_pack, tgz_rel_leaf}; -use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; +use super::common::{already_patched_result, detect_eol, detect_indent, refused, serialize_json}; +use super::npm_common::{ + done_failure, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, +}; +use super::path::parse_vendor_path; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::yarn_classic_lock::{ - already_patched_verify, body_field_line, detect_eol, json_to_lines, lines_to_json, - replace_block, scan_blocks, split_key_patterns, split_pattern, synthesized_result, LockBlock, + body_field_line, lines_to_json, read_yarn_lock, replace_block, revert_recorded_block, + scan_blocks, split_key_patterns, split_pattern, LockBlock, }; use super::{RevertOutcome, VendorOutcome, VendorWarning}; @@ -93,25 +96,9 @@ pub async fn vendor_yarn_berry( let spec = format!("file:./{rel_tgz}"); // ── 2. Lockfile + cacheKey gate ─────────────────────────────────────── - let lock_path = project_root.join(YARN_LOCK); - let lock_text = match tokio::fs::read_to_string(&lock_path).await { + let lock_text = match read_yarn_lock(project_root).await { Ok(t) => t, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return refused( - "vendor_lockfile_missing", - format!( - "no {YARN_LOCK} at {} — vendoring rewires the lockfile, so one must \ - exist (run `yarn install` first)", - project_root.display() - ), - ); - } - Err(e) => { - return refused( - "vendor_lockfile_missing", - format!("cannot read {YARN_LOCK}: {e}"), - ); - } + Err(outcome) => return *outcome, }; let blocks = scan_blocks(&lock_text); let Some(meta) = blocks.iter().find(|b| b.key == "__metadata") else { @@ -242,8 +229,8 @@ pub async fn vendor_yarn_berry( } // ── 6. The single replaceable lock entry ────────────────────────────── - let target = match scan_berry_target(&blocks, name, version) { - Ok(Some(t)) => t, + let (target, target_is_ours) = match scan_berry_target(&blocks, name, version) { + Ok(Some((idx, is_ours))) => (&blocks[idx], is_ours), Ok(None) => { return refused( "vendor_lock_entry_not_found", @@ -325,32 +312,32 @@ pub async fn vendor_yarn_berry( ), )); } - let new_lines = build_entry_lines(&lock_key, version, &resolution, &checksum, &carried); + // The exact entry yarn 4 emits for a resolutions-driven `file:` tarball + // (spike B3, verbatim), carried sections in yarn's position between + // `resolution` and `checksum`. + let mut new_lines = vec![ + format!("{lock_key}:"), + format!(" version: {version}"), + format!(" resolution: \"{resolution}\""), + ]; + new_lines.extend(carried); + new_lines.push(format!(" checksum: {checksum}")); + new_lines.push(" languageName: node".to_string()); + new_lines.push(" linkType: hard".to_string()); // ── 10. In-sync hot path: nothing to write, nothing to record ───────── - let pkg_in_sync = pkg_obj - .get("resolutions") - .and_then(|r| r.get(name)) - .and_then(Value::as_str) - == Some(spec.as_str()); - if pkg_in_sync && target.is_ours && target.lines == new_lines { - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); + let existing_res = pkg_obj.get("resolutions").and_then(|r| r.get(name)); + let pkg_in_sync = existing_res.and_then(Value::as_str) == Some(spec.as_str()); + if pkg_in_sync && target_is_ours && target.lines == new_lines { return VendorOutcome::Done { - result: synthesized_result(purl, &dest, verified, true, None), + result: already_patched_result(purl, &dest, &record.files), entry: None, warnings, }; } // ── 11. Build both new byte images, then commit pkg-first/lock-second ─ - let existing_entry = pkg_obj - .get("resolutions") - .and_then(|r| r.get(name)) - .is_some(); + let existing_entry = existing_res.is_some(); let mut new_pkg = pkg.clone(); { let obj = new_pkg.as_object_mut().expect("validated above"); @@ -367,16 +354,7 @@ pub async fn vendor_yarn_berry( Ok(b) => b, Err(e) => return done_failure(purl, format!("cannot serialize {PACKAGE_JSON}: {e}")), }; - let new_lock_text = { - let blocks_now = scan_blocks(&lock_text); - let Some(block) = blocks_now.iter().find(|b| b.key == target.key) else { - return done_failure( - purl, - format!("lock entry `{}` vanished mid-rewrite", target.key), - ); - }; - replace_block(&lock_text, block, &new_lines, detect_eol(&lock_text)) - }; + let new_lock_text = replace_block(&lock_text, target, &new_lines, detect_eol(&lock_text)); if let Err(e) = commit_pair( project_root, &new_pkg_bytes, @@ -389,16 +367,7 @@ pub async fn vendor_yarn_berry( } // ── 12. Marker + ledger entry ───────────────────────────────────────── - let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); - vulnerabilities.sort(); - let marker = VendorMarker { - schema_version: 1, - purl: base_purl.clone(), - patch_uuid: record.uuid.clone(), - ecosystem: "npm".to_string(), - vulnerabilities, - vendored_at: vendored_at.to_string(), - }; + let marker = VendorMarker::new("npm", &base_purl, record, vendored_at); if let Err(e) = write_marker(&project_root.join(&uuid_dir_rel), &marker).await { warnings.push(VendorWarning::new( "vendor_marker_write_failed", @@ -428,7 +397,7 @@ pub async fn vendor_yarn_berry( kind: KIND_LOCK_ENTRY.to_string(), action: WiringAction::Rewritten, key: Some(lock_key), - original: if target.is_ours { + original: if target_is_ours { None } else { Some(lines_to_json(&target.lines)) @@ -472,13 +441,11 @@ pub async fn revert_yarn_berry( project_root: &Path, dry_run: bool, ) -> RevertOutcome { - // SECURITY: validate the tamper-able uuid before any disk access — it - // names the directory tree this revert DELETES. - let Some(uuid_dir_rel) = vendor_uuid_dir_rel("npm", &entry.uuid) else { - return RevertOutcome::failed(format!( - "refusing revert: `{}` is not a canonical patch uuid (tampered state.json?)", - entry.uuid - )); + // SECURITY: shared fail-closed guard on the tamper-able uuid, before any + // disk access. + let uuid_dir_rel = match guard_revert_uuid_dir(&entry.uuid) { + Ok(d) => d, + Err(outcome) => return outcome, }; if dry_run { return RevertOutcome::ok(); @@ -512,16 +479,18 @@ pub async fn revert_yarn_berry( Ok(mut text) => { let mut changed = false; for rec in lock_recs { - revert_lock_record( + changed |= revert_recorded_block( &mut text, rec, &entry.uuid, - &mut changed, + KIND_LOCK_ENTRY, + "lock entry", + |lines| berry_field(lines, "resolution"), &mut outcome.warnings, ); } if changed { - if let Err(e) = atomic_write_bytes(&lock_path, text.as_bytes()).await { + if let Err(e) = atomic_write_bytes_preserving_mode(&lock_path, text.as_bytes()).await { return RevertOutcome::failed(format!("cannot write {YARN_LOCK}: {e}")); } } @@ -565,7 +534,7 @@ pub async fn revert_yarn_berry( let indent = detect_indent(&String::from_utf8_lossy(&bytes)); match serialize_json(&pkg, &indent) { Ok(out) => { - if let Err(e) = atomic_write_bytes(&pkg_path, &out).await { + if let Err(e) = atomic_write_bytes_preserving_mode(&pkg_path, &out).await { return RevertOutcome::failed(format!( "cannot write {PACKAGE_JSON}: {e}" )); @@ -598,64 +567,6 @@ pub async fn revert_yarn_berry( // ───────────────────────────── revert internals ───────────────────────────── -/// Restore one recorded lock entry iff the live entry is still ours -/// (resolution parses into `.socket/vendor/npm//…`). -fn revert_lock_record( - text: &mut String, - rec: &WiringRecord, - entry_uuid: &str, - changed: &mut bool, - warnings: &mut Vec, -) { - let Some(key) = rec.key.as_deref() else { - warnings.push(VendorWarning::new( - "vendor_lock_entry_drifted", - format!("wiring record in {} has no key; left alone", rec.file), - )); - return; - }; - if rec.kind != KIND_LOCK_ENTRY { - warnings.push(VendorWarning::new( - "vendor_lock_entry_drifted", - format!("unknown wiring kind `{}` for `{key}`; left alone", rec.kind), - )); - return; - } - let edit = { - let blocks = scan_blocks(text); - let Some(block) = blocks.iter().find(|b| b.key == key) else { - warnings.push(VendorWarning::new( - "vendor_lock_entry_drifted", - format!("lock entry `{key}` no longer exists; nothing to restore"), - )); - return; - }; - let ours = berry_field(&block.lines, "resolution") - .and_then(parse_vendor_path) - .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); - if !ours { - warnings.push(VendorWarning::new( - "vendor_lock_entry_drifted", - format!("lock entry `{key}` was re-resolved since vendoring; left alone"), - )); - return; - } - let Some(original) = rec.original.as_ref().and_then(json_to_lines) else { - warnings.push(VendorWarning::new( - "vendor_lock_entry_drifted", - format!( - "lock entry `{key}` has no recorded pre-vendor original; left as-is \ - (re-run `yarn install` to re-resolve it from the registry)" - ), - )); - return; - }; - replace_block(text, block, &original, detect_eol(text)) - }; - *text = edit; - *changed = true; -} - /// Remove our resolutions entry iff the live value still points into our /// uuid dir; drop the `resolutions` table when that leaves it empty (we only /// ever ADD entries — an empty table would be vendor residue). @@ -733,11 +644,11 @@ async fn commit_pair( new_lock: &[u8], ) -> Result<(), String> { let pkg_path = project_root.join(PACKAGE_JSON); - atomic_write_bytes(&pkg_path, new_pkg) + atomic_write_bytes_preserving_mode(&pkg_path, new_pkg) .await .map_err(|e| format!("cannot write {PACKAGE_JSON}: {e}"))?; - if let Err(e) = atomic_write_bytes(&project_root.join(YARN_LOCK), new_lock).await { - return match atomic_write_bytes(&pkg_path, orig_pkg).await { + if let Err(e) = atomic_write_bytes_preserving_mode(&project_root.join(YARN_LOCK), new_lock).await { + return match atomic_write_bytes_preserving_mode(&pkg_path, orig_pkg).await { Ok(()) => Err(format!( "cannot write {YARN_LOCK}: {e} ({PACKAGE_JSON} restored)" )), @@ -750,25 +661,18 @@ async fn commit_pair( Ok(()) } -/// The single lock entry the rewrite replaces. -struct BerryTarget { - /// Verbatim key (no trailing colon, quotes kept). - key: String, - lines: Vec, - /// Already one of our `file:` entries (stale uuid or current). - is_ours: bool, -} - -/// Find the one replaceable entry for `name@version`, refusing fail-closed on -/// anything a bare-name resolutions entry would also move (other versions of -/// the name, non-npm protocols, ambiguous duplicates). +/// Find the one replaceable entry for `name@version` — `(index into blocks, +/// is_ours)`, where `is_ours` means the entry is already one of our `file:` +/// entries (stale uuid or current) — refusing fail-closed on anything a +/// bare-name resolutions entry would also move (other versions of the name, +/// non-npm protocols, ambiguous duplicates). fn scan_berry_target( blocks: &[LockBlock], name: &str, version: &str, -) -> Result, (&'static str, String)> { - let mut found: Vec = Vec::new(); - for block in blocks { +) -> Result, (&'static str, String)> { + let mut found: Vec<(usize, bool)> = Vec::new(); + for (idx, block) in blocks.iter().enumerate() { if block.key == "__metadata" { continue; } @@ -793,11 +697,7 @@ fn scan_berry_target( if parsed.iter().all(|(_, r)| r.starts_with("npm:")) { let v = berry_field(&block.lines, "version").unwrap_or(""); if v == version { - found.push(BerryTarget { - key: block.key.clone(), - lines: block.lines.clone(), - is_ours: false, - }); + found.push((idx, false)); } else { // SECURITY/CORRECTNESS: resolutions selectors are name-keyed; // ours would force-move this OTHER version too on the next @@ -816,11 +716,7 @@ fn scan_berry_target( .iter() .all(|(_, r)| parse_vendor_path(r).is_some_and(|p| p.eco == "npm")) { - found.push(BerryTarget { - key: block.key.clone(), - lines: block.lines.clone(), - is_ours: true, - }); + found.push((idx, true)); } else { return Err(( "vendor_override_conflict", @@ -846,26 +742,6 @@ fn scan_berry_target( } } -/// The exact entry yarn 4 emits for a resolutions-driven `file:` tarball -/// (spike B3, verbatim), with any carried-over sections (dependencies:, …) -/// in yarn's position between `resolution` and `checksum`. -fn build_entry_lines( - lock_key: &str, - version: &str, - resolution: &str, - checksum: &str, - carried: &[String], -) -> Vec { - let mut out = vec![format!("{lock_key}:")]; - out.push(format!(" version: {version}")); - out.push(format!(" resolution: \"{resolution}\"")); - out.extend(carried.iter().cloned()); - out.push(format!(" checksum: {checksum}")); - out.push(" languageName: node".to_string()); - out.push(" linkType: hard".to_string()); - out -} - /// Body sections of a lock entry that are NOT the five scalar fields we own /// — dependency sub-maps, bin:, conditions:, … — verbatim, in order. fn carried_sections(lines: &[String]) -> Vec { @@ -934,38 +810,15 @@ fn root_workspace_name(blocks: &[LockBlock]) -> Option { /// The `.yarnrc.yml` `compressionLevel` value, when set. A flat line scan is /// enough: yarn writes the knob as a top-level scalar (spike B4), and any -/// value we cannot positively read as `0` makes the caller refuse. -fn yarnrc_compression_level(rc: &str) -> Option<&str> { +/// value we cannot positively read as `0` makes the caller refuse. Shared +/// with the hosted-redirect rewriter, whose cache-checksum gate is identical. +pub(crate) fn yarnrc_compression_level(rc: &str) -> Option<&str> { rc.lines().find_map(|line| { let rest = line.strip_prefix("compressionLevel:")?; Some(rest.trim().trim_matches(['\'', '"'])) }) } -/// The manifest's indent unit (mirrors `npm_lock::detect_indent`); defaults -/// to npm's 2 spaces. -fn detect_indent(text: &str) -> String { - for line in text.lines() { - let trimmed = line.trim_start_matches([' ', '\t']); - if !trimmed.is_empty() && trimmed.len() < line.len() { - return line[..line.len() - trimmed.len()].to_string(); - } - } - " ".to_string() -} - -/// Pretty-print with the detected indent + trailing newline (mirrors -/// `npm_lock::serialize_lock`), so untouched keys stay byte-identical. -fn serialize_json(value: &Value, indent: &str) -> std::io::Result> { - use serde::Serialize; - let mut out = Vec::new(); - let formatter = serde_json::ser::PrettyFormatter::with_indent(indent.as_bytes()); - let mut ser = serde_json::Serializer::with_formatter(&mut out, formatter); - value.serialize(&mut ser).map_err(std::io::Error::other)?; - out.push(b'\n'); - Ok(out) -} - #[cfg(test)] mod tests { use super::*; @@ -1554,6 +1407,57 @@ __metadata: ); } + /// package.json and yarn.lock are user-owned files we merely edit: the + /// vendor pair commit and the revert restore must keep their permission + /// bits (a 0600 private file must not silently become umask-default 0644). + #[cfg(unix)] + #[tokio::test] + async fn pair_writes_preserve_file_modes() { + use std::os::unix::fs::PermissionsExt; + let fx = fixture().await; + tokio::fs::set_permissions(fx.pkg_path(), std::fs::Permissions::from_mode(0o600)) + .await + .unwrap(); + tokio::fs::set_permissions(fx.lock_path(), std::fs::Permissions::from_mode(0o640)) + .await + .unwrap(); + let mode = |path: PathBuf| async move { + tokio::fs::metadata(path) + .await + .unwrap() + .permissions() + .mode() + & 0o7777 + }; + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + assert_eq!( + mode(fx.pkg_path()).await, + 0o600, + "vendor must preserve package.json's mode" + ); + assert_eq!( + mode(fx.lock_path()).await, + 0o640, + "vendor must preserve yarn.lock's mode" + ); + + let outcome = revert_yarn_berry(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + mode(fx.pkg_path()).await, + 0o600, + "revert must preserve package.json's mode" + ); + assert_eq!( + mode(fx.lock_path()).await, + 0o640, + "revert must preserve yarn.lock's mode" + ); + } + #[tokio::test] async fn revert_round_trips_both_files_and_removes_the_artifact() { let fx = fixture().await; diff --git a/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs index f2531039..bad8d269 100644 --- a/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/yarn_classic_lock.rs @@ -21,18 +21,18 @@ //! blocks (comments, blank lines, other blocks, CRLF line endings) is //! preserved verbatim, so yarn's re-serialization produces no churn. -use std::collections::HashMap; use std::path::Path; use serde_json::Value; use crate::manifest::schema::PatchRecord; -use crate::patch::apply::{ApplyResult, PatchSources, VerifyResult, VerifyStatus}; +use crate::patch::apply::PatchSources; use crate::patch::copy_tree::remove_tree; -use crate::utils::fs::atomic_write_bytes; +use crate::utils::fs::atomic_write_bytes_preserving_mode; -use super::npm_common::{done_failure, guard_coordinates, refused, stage_patch_pack}; -use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; +use super::common::{already_patched_result, detect_eol, refused}; +use super::npm_common::{done_failure, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack}; +use super::path::parse_vendor_path; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; @@ -75,24 +75,9 @@ pub async fn vendor_yarn_classic( // ── 2. Lockfile ─────────────────────────────────────────────────────── let lock_path = project_root.join(YARN_LOCK); - let text = match tokio::fs::read_to_string(&lock_path).await { + let text = match read_yarn_lock(project_root).await { Ok(t) => t, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return refused( - "vendor_lockfile_missing", - format!( - "no {YARN_LOCK} at {} — vendoring rewires the lockfile, so one must \ - exist (run `yarn install` first)", - project_root.display() - ), - ); - } - Err(e) => { - return refused( - "vendor_lockfile_missing", - format!("cannot read {YARN_LOCK}: {e}"), - ); - } + Err(outcome) => return *outcome, }; // Defensive re-sniff: the flavor router already separates classic from // berry, but rewriting a berry lock with classic grammar would corrupt @@ -162,9 +147,8 @@ pub async fn vendor_yarn_classic( // ── 8. Lock rewrite: splice each candidate block, byte-preserving ───── let eol = detect_eol(&text); - let mut new_text = text.clone(); + let mut new_text = text; let mut wiring: Vec = Vec::new(); - let mut recomputed_deps = false; for key in &candidate_keys { let edit = { let blocks = scan_blocks(&new_text); @@ -204,12 +188,9 @@ pub async fn vendor_yarn_classic( if let Some((replaced, rec)) = edit { new_text = replaced; wiring.push(rec); - if staged_pkg_json.is_some() { - recomputed_deps = true; - } } } - if recomputed_deps { + if staged_pkg_json.is_some() && !wiring.is_empty() { warnings.push(VendorWarning::new( "vendor_dep_manifest_rewritten", format!( @@ -224,33 +205,19 @@ pub async fn vendor_yarn_classic( // Every block already points at this uuid with the packed hashes: // in sync. Touch nothing (the tarball re-pack above was // byte-identical by determinism) and synthesize AlreadyPatched. - let verified = record - .files - .keys() - .map(|f| already_patched_verify(f)) - .collect(); return VendorOutcome::Done { - result: synthesized_result(purl, &dest, verified, true, None), + result: already_patched_result(purl, &dest, &record.files), entry: None, warnings, }; } - if let Err(e) = atomic_write_bytes(&lock_path, new_text.as_bytes()).await { + if let Err(e) = atomic_write_bytes_preserving_mode(&lock_path, new_text.as_bytes()).await { return done_failure(purl, format!("cannot write {YARN_LOCK}: {e}")); } // ── 9. Marker + ledger entry ────────────────────────────────────────── - let mut vulnerabilities: Vec = record.vulnerabilities.keys().cloned().collect(); - vulnerabilities.sort(); - let marker = VendorMarker { - schema_version: 1, - purl: base_purl.clone(), - patch_uuid: record.uuid.clone(), - ecosystem: "npm".to_string(), - vulnerabilities, - vendored_at: vendored_at.to_string(), - }; + let marker = VendorMarker::new("npm", &base_purl, record, vendored_at); if let Err(e) = write_marker(&project_root.join(&uuid_dir_rel), &marker).await { warnings.push(VendorWarning::new( "vendor_marker_write_failed", @@ -294,14 +261,11 @@ pub async fn revert_yarn_classic( project_root: &Path, dry_run: bool, ) -> RevertOutcome { - // SECURITY: `entry.uuid` comes from the committed, tamper-able - // state.json and names the directory tree we are about to DELETE — - // validate fail-closed before any disk access. - let Some(uuid_dir_rel) = vendor_uuid_dir_rel("npm", &entry.uuid) else { - return RevertOutcome::failed(format!( - "refusing revert: `{}` is not a canonical patch uuid (tampered state.json?)", - entry.uuid - )); + // SECURITY: shared fail-closed guard on the tamper-able uuid, before any + // disk access. + let uuid_dir_rel = match guard_revert_uuid_dir(&entry.uuid) { + Ok(d) => d, + Err(outcome) => return outcome, }; if dry_run { return RevertOutcome::ok(); @@ -345,16 +309,18 @@ pub async fn revert_yarn_classic( if let Some(mut text) = text { let mut changed = false; for rec in records { - revert_one_block( + changed |= revert_recorded_block( &mut text, rec, &entry.uuid, - &mut changed, + KIND_LOCK_BLOCK, + "lock block", + |lines| classic_field(lines, "resolved"), &mut outcome.warnings, ); } if changed { - if let Err(e) = atomic_write_bytes(&lock_path, text.as_bytes()).await { + if let Err(e) = atomic_write_bytes_preserving_mode(&lock_path, text.as_bytes()).await { return RevertOutcome::failed(format!("cannot write {YARN_LOCK}: {e}")); } } @@ -369,50 +335,58 @@ pub async fn revert_yarn_classic( /// Apply one wiring record in reverse: restore `original` iff the live block /// is still ours (drift = a third party re-resolved it; leave theirs alone, -/// with a warning). -fn revert_one_block( +/// with a warning). Returns true when the block was restored. +/// +/// Shared by the classic and berry lock reverts, which differ only in the +/// wiring `kind` they own, the noun their warnings use (`lock block` vs +/// `lock entry`), and the field carrying the vendor path — `vendor_field` +/// reads it (classic `resolved` / berry `resolution`). +pub(super) fn revert_recorded_block( text: &mut String, rec: &WiringRecord, entry_uuid: &str, - changed: &mut bool, + expected_kind: &str, + noun: &str, + vendor_field: fn(&[String]) -> Option<&str>, warnings: &mut Vec, -) { +) -> bool { let Some(key) = rec.key.as_deref() else { warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", format!("wiring record in {} has no key; left alone", rec.file), )); - return; + return false; }; - if rec.kind != KIND_LOCK_BLOCK { + if rec.kind != expected_kind { // Forward compatibility: an unknown kind from a newer binary // degrades to a warning (see state.rs schema docs). warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", format!("unknown wiring kind `{}` for `{key}`; left alone", rec.kind), )); - return; + return false; } let edit = { let blocks = scan_blocks(text); let Some(block) = blocks.iter().find(|b| b.key == key) else { warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", - format!("lock block `{key}` no longer exists; nothing to restore"), + format!("{noun} `{key}` no longer exists; nothing to restore"), )); - return; + return false; }; - // Ownership gate: the live block's resolved must still point into - // OUR uuid dir — anything else means a third party re-resolved it. - let ours = classic_field(&block.lines, "resolved") + // Ownership gate: the live block's vendor field must still point + // into OUR uuid dir — anything else means a third party re-resolved + // it. + let ours = vendor_field(&block.lines) .and_then(parse_vendor_path) .is_some_and(|p| p.eco == "npm" && p.uuid == entry_uuid); if !ours { warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", - format!("lock block `{key}` was re-resolved since vendoring; left alone"), + format!("{noun} `{key}` was re-resolved since vendoring; left alone"), )); - return; + return false; } let Some(original) = rec.original.as_ref().and_then(json_to_lines) else { // The record rewrote one of our own earlier edits, so there is @@ -421,21 +395,20 @@ fn revert_one_block( warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", format!( - "lock block `{key}` has no recorded pre-vendor original; left as-is \ + "{noun} `{key}` has no recorded pre-vendor original; left as-is \ (re-run `yarn install` to re-resolve it from the registry)" ), )); - return; + return false; }; replace_block(text, block, &original, detect_eol(text)) }; *text = edit; - *changed = true; + true } // ─────────────────────────── block classification ─────────────────────────── -#[derive(Debug)] enum BlockClass { /// Rewritable instance of the target package. Candidate, @@ -573,8 +546,28 @@ fn is_tarball_path(path: &str) -> bool { // (pub(super): the berry backend reuses the same block grammar — key line at // column 0 ending `:`, indented body, blank-line separated) +/// Read the project's `yarn.lock` for a vendor run, refusing fail-closed +/// when it is missing or unreadable (vendoring rewires the lockfile, so one +/// must exist). Shared verbatim by the classic and berry backends. +pub(super) async fn read_yarn_lock(project_root: &Path) -> Result> { + match tokio::fs::read_to_string(project_root.join(YARN_LOCK)).await { + Ok(t) => Ok(t), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(Box::new(refused( + "vendor_lockfile_missing", + format!( + "no {YARN_LOCK} at {} — vendoring rewires the lockfile, so one must \ + exist (run `yarn install` first)", + project_root.display() + ), + ))), + Err(e) => Err(Box::new(refused( + "vendor_lockfile_missing", + format!("cannot read {YARN_LOCK}: {e}"), + ))), + } +} + /// One key-line block of a yarn lockfile (classic or berry). -#[derive(Debug)] pub(super) struct LockBlock { /// Byte offset of the key line's first byte. pub start: usize, @@ -637,16 +630,6 @@ fn is_body_line(s: &str) -> bool { s.starts_with(' ') || s.starts_with('\t') } -/// The file's dominant line terminator (new lines we write use it; bytes -/// outside edited blocks keep whatever they had). -pub(super) fn detect_eol(text: &str) -> &'static str { - if text.contains("\r\n") { - "\r\n" - } else { - "\n" - } -} - /// Splice `new_lines` over `block`'s byte range, preserving every byte /// outside it. pub(super) fn replace_block( @@ -746,7 +729,7 @@ pub(super) fn pattern_real_name(pattern: &str) -> Option<&str> { /// yarn v1's lockfile key quoting (stringify.js `shouldWrapKey`): wrap when /// the key would not parse bare. -pub(super) fn quote_yarn_key(key: &str) -> String { +fn quote_yarn_key(key: &str) -> String { let needs = key.is_empty() || key.starts_with("true") || key.starts_with("false") @@ -773,48 +756,16 @@ pub(super) fn json_to_lines(value: &Value) -> Option> { .collect() } -// ─────────────── shared synthesized-result helpers ─────────────── -// (mirrors npm_lock's private helpers; pub(super) so the berry backend can -// synthesize the same in-sync AlreadyPatched shape) - -pub(super) fn synthesized_result( - package_key: &str, - path: &Path, - files_verified: Vec, - success: bool, - error: Option, -) -> ApplyResult { - ApplyResult { - package_key: package_key.to_string(), - package_path: path.display().to_string(), - success, - files_verified, - files_patched: Vec::new(), - applied_via: HashMap::new(), - error, - sidecar: None, - } -} - -pub(super) fn already_patched_verify(file: &str) -> VerifyResult { - VerifyResult { - file: file.to_string(), - status: VerifyStatus::AlreadyPatched, - message: None, - current_hash: None, - expected_hash: None, - target_hash: None, - } -} - #[cfg(test)] mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; + use crate::patch::apply::{ApplyResult, VerifyStatus}; use base64::Engine as _; use serde_json::json; use sha1::Digest as _; + use std::collections::HashMap; use std::path::PathBuf; const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; @@ -1447,6 +1398,40 @@ left-pad@^1.3.0: assert!(!outcome.success, "tampered uuid must fail closed"); } + /// The lockfile is a user-owned file we merely edit: both the vendor + /// rewrite and the revert restore must keep its permission bits (a 0600 + /// private lock must not silently become umask-default 0644). + #[cfg(unix)] + #[tokio::test] + async fn lock_writes_preserve_file_mode() { + use std::os::unix::fs::PermissionsExt; + let fx = fixture_with_lock(Y2_BEFORE).await; + tokio::fs::set_permissions(fx.lock_path(), std::fs::Permissions::from_mode(0o600)) + .await + .unwrap(); + + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + let mode = tokio::fs::metadata(fx.lock_path()) + .await + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!(mode, 0o600, "vendor must preserve the lockfile's mode"); + + let outcome = revert_yarn_classic(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + let mode = tokio::fs::metadata(fx.lock_path()) + .await + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!(mode, 0o600, "revert must preserve the lockfile's mode"); + } + #[tokio::test] async fn crlf_lock_is_preserved_and_round_trips() { let crlf_before = Y2_BEFORE.replace('\n', "\r\n"); diff --git a/crates/socket-patch-core/src/pth_hook/detect.rs b/crates/socket-patch-core/src/pth_hook/detect.rs index cee2d1e8..9e72b24d 100644 --- a/crates/socket-patch-core/src/pth_hook/detect.rs +++ b/crates/socket-patch-core/src/pth_hook/detect.rs @@ -8,7 +8,7 @@ use std::path::Path; /// carrier). A single, familiar line. Classic Poetry can't express an extra as /// a bare key, so [`super::edit`] emits the equivalent /// `socket-patch = { extras = ["hook"] }` there instead. -pub const HOOK_DEP: &str = "socket-patch[hook]"; +pub(crate) const HOOK_DEP: &str = "socket-patch[hook]"; /// Substrings (space-insensitive, lower-cased) that mean the hook is already /// declared — the `socket-patch[hook]` extra, the standalone wheel, or the @@ -42,15 +42,21 @@ impl PythonPackageManager { } } - /// The lockfile-refresh command `(program, args)` for managers whose frozen - /// CI install reads a lockfile that must be regenerated after editing the - /// dependency list. `None` for managers that resolve dependencies directly - /// from the manifest at install time (pip, hatch). - pub fn lock_command(&self) -> Option<(&'static str, &'static [&'static str])> { + /// The lockfile-refresh invocations `(program, spellings)` for managers + /// whose frozen CI install reads a lockfile that must be regenerated + /// after editing the dependency list. Each arg-list is tried in order + /// until one succeeds: the first is the pin-preserving spelling where + /// the tool has a distinct one (`poetry lock --no-update` on Poetry 1.x — + /// bare `poetry lock` re-resolves the user's whole pinned set there; + /// `pdm lock --update-reuse`), the last is the bare `lock` accepted + /// everywhere (already pin-preserving on Poetry 2.x, where `--no-update` + /// was removed, and on uv). `None` for managers that resolve dependencies + /// directly from the manifest at install time (pip, hatch). + pub fn lock_commands(&self) -> Option<(&'static str, &'static [&'static [&'static str]])> { match self { - Self::Uv => Some(("uv", &["lock"])), - Self::Poetry => Some(("poetry", &["lock"])), - Self::Pdm => Some(("pdm", &["lock"])), + Self::Uv => Some(("uv", &[&["lock"]])), + Self::Poetry => Some(("poetry", &[&["lock", "--no-update"], &["lock"]])), + Self::Pdm => Some(("pdm", &[&["lock", "--update-reuse"], &["lock"]])), Self::Hatch | Self::Pip => None, } } @@ -91,7 +97,8 @@ pub async fn detect_python_pm(cwd: &Path) -> PythonPackageManager { } /// True if a `[prefix]` or `[prefix.*]` table header appears in the TOML text. -fn has_table(content: &str, prefix: &str) -> bool { +/// Also used by the pypi vendor flavor router (`patch::vendor::pypi`). +pub(crate) fn has_table(content: &str, prefix: &str) -> bool { content.lines().any(|line| { let l = line.trim(); let Some(rest) = l.strip_prefix('[') else { @@ -123,10 +130,7 @@ pub fn deps_contain_hook(text: &str) -> bool { // Drop a `#` comment first (requirements.txt and TOML both comment // with `#`): a commented-out `# socket-patch[hook]` declares nothing — // pip never installs it — and a marker mentioned inside a trailing - // comment must not read as configured. Same first-`#` rule as - // `edit::strip_requirement_comment`, so the `setup --check` / state - // probes (which call this on raw file content) agree with the editors - // (which pre-strip) on identical bytes. + // comment must not read as configured. let spec = match line.find('#') { Some(i) => &line[..i], None => line, @@ -136,15 +140,31 @@ pub fn deps_contain_hook(text: &str) -> bool { .chars() .filter(|c| !c.is_whitespace()) .collect(); - HOOK_MARKERS.iter().any(|m| normalized.contains(*m)) + if HOOK_MARKERS.iter().any(|m| normalized.contains(*m)) { + return true; + } + // PEP 503 makes `-`/`_`/`.` interchangeable in package names and PEP + // 508 lets the hook extra ride with others (`socket-patch[cli,hook]`), + // so pip installs the hook from spellings the markers above miss + // (`socket_patch[hook]`). Canonicalize and probe for the wheel name or + // a `socket-patch[...]` extras list containing `hook`. + let canon: String = normalized + .chars() + .map(|c| if c == '_' || c == '.' { '-' } else { c }) + .collect(); + if canon.contains("socket-patch-hook") { + return true; + } + canon.match_indices("socket-patch[").any(|(i, m)| { + let rest = &canon[i + m.len()..]; + match rest.find(']') { + Some(end) => rest[..end].split(',').any(|e| e == "hook"), + None => false, + } + }) }) } -/// True if a single PEP 508 dependency spec is the hook dependency. -pub fn spec_is_hook(spec: &str) -> bool { - deps_contain_hook(spec) -} - #[cfg(test)] mod tests { use super::*; @@ -158,6 +178,25 @@ mod tests { assert!(deps_contain_hook("socket_patch_hook")); } + #[test] + fn test_deps_contain_hook_pep503_and_combined_extras() { + // PEP 503: `-`, `_`, `.` are interchangeable in the name — pip + // installs the hook from all of these. + assert!(deps_contain_hook("socket_patch[hook]")); + assert!(deps_contain_hook("socket.patch[hook]==3.3.0")); + assert!(deps_contain_hook("Socket_Patch [hook]")); + assert!(deps_contain_hook("socket.patch_hook")); + // PEP 508: the hook extra combined with others still declares it. + assert!(deps_contain_hook("socket-patch[cli,hook]>=3.3.0")); + assert!(deps_contain_hook("socket-patch[ hook , cli ]")); + assert!(deps_contain_hook("socket_patch[cli,hook]")); + // Some other extra alone is NOT the hook, `hooky` is a different + // extra, and an unterminated bracket is not a spec. + assert!(!deps_contain_hook("socket_patch[cli]")); + assert!(!deps_contain_hook("socket-patch[hooky]")); + assert!(!deps_contain_hook("socket-patch[hook")); + } + #[test] fn test_deps_contain_hook_negative() { // A plain socket-patch dependency is NOT the hook. @@ -272,12 +311,22 @@ mod tests { } #[test] - fn test_lock_command() { + fn test_lock_commands() { + assert_eq!( + PythonPackageManager::Uv.lock_commands(), + Some(("uv", &[&["lock"][..]][..])) + ); + // Pin-preserving spelling first, bare `lock` fallback for versions + // that dropped the flag (Poetry 2.x). + assert_eq!( + PythonPackageManager::Poetry.lock_commands(), + Some(("poetry", &[&["lock", "--no-update"][..], &["lock"][..]][..])) + ); assert_eq!( - PythonPackageManager::Uv.lock_command(), - Some(("uv", &["lock"][..])) + PythonPackageManager::Pdm.lock_commands(), + Some(("pdm", &[&["lock", "--update-reuse"][..], &["lock"][..]][..])) ); - assert_eq!(PythonPackageManager::Pip.lock_command(), None); - assert_eq!(PythonPackageManager::Hatch.lock_command(), None); + assert_eq!(PythonPackageManager::Pip.lock_commands(), None); + assert_eq!(PythonPackageManager::Hatch.lock_commands(), None); } } diff --git a/crates/socket-patch-core/src/pth_hook/edit.rs b/crates/socket-patch-core/src/pth_hook/edit.rs index dfeb8349..fc297d1e 100644 --- a/crates/socket-patch-core/src/pth_hook/edit.rs +++ b/crates/socket-patch-core/src/pth_hook/edit.rs @@ -16,59 +16,9 @@ use std::path::Path; use tokio::fs; use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value}; -use super::detect::{deps_contain_hook, spec_is_hook, HOOK_DEP}; - -/// Atomically write `content` to `path`. -/// -/// A bare `fs::write` truncates the target before writing, so a crash, power -/// loss, or interrupted process mid-write would leave the user's hand-authored -/// `pyproject.toml` / `requirements.txt` (with its comments, formatting, and -/// other dependencies) truncated or empty — destroying the file we only meant -/// to add one dependency line to. Instead we write to a sibling stage file, -/// fsync it, then rename over the target (rename is atomic on the same -/// filesystem) so a reader ever sees either the old bytes or the complete new -/// bytes. Mirrors the hardened writer in `package_json::update`. -async fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let stem = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "manifest".to_string()); - let stage = parent.join(format!(".socket-stage-{}-{}", stem, uuid::Uuid::new_v4())); - - let mut file = tokio::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&stage) - .await?; - - use tokio::io::AsyncWriteExt; - if let Err(e) = file.write_all(content.as_bytes()).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - if let Err(e) = file.sync_all().await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - drop(file); - - if let Err(e) = tokio::fs::rename(&stage, path).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - - // The rename only updated the parent directory entry; fsync the directory - // so the rename itself survives a crash. Best-effort, Unix only. - #[cfg(unix)] - { - if let Ok(dir) = tokio::fs::File::open(parent).await { - let _ = dir.sync_all().await; - } - } - - Ok(()) -} +use super::detect::{deps_contain_hook, HOOK_DEP}; +use crate::patch::vendor::common::detect_eol; +use crate::utils::fs::atomic_write_bytes_preserving_mode; /// Which manifest format a path is. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -88,30 +38,53 @@ pub enum PthStatus { #[derive(Debug, Clone)] pub struct PthEditResult { pub path: String, - pub kind: ManifestKind, pub status: PthStatus, pub error: Option, } impl PthEditResult { - fn ok(path: &Path, kind: ManifestKind, status: PthStatus) -> Self { + fn ok(path: &Path, status: PthStatus) -> Self { Self { path: path.display().to_string(), - kind, status, error: None, } } - fn err(path: &Path, kind: ManifestKind, msg: impl Into) -> Self { + fn err(path: &Path, msg: impl Into) -> Self { Self { path: path.display().to_string(), - kind, status: PthStatus::Error, error: Some(msg.into()), } } } +/// Shared tail of add/remove: `None` means already in the desired state, +/// `Some(new_content)` is written atomically (unless `dry_run`). +async fn finish( + path: &Path, + dry_run: bool, + outcome: Result, String>, +) -> PthEditResult { + match outcome { + Ok(None) => PthEditResult::ok(path, PthStatus::AlreadyConfigured), + Ok(Some(new_content)) => { + if !dry_run { + // Mode-preserving: these are user-owned manifests we merely + // edit; the plain writer's fresh stage inode would reset a + // 0600 pyproject.toml / requirements.txt to umask defaults. + if let Err(e) = + atomic_write_bytes_preserving_mode(path, new_content.as_bytes()).await + { + return PthEditResult::err(path, e.to_string()); + } + } + PthEditResult::ok(path, PthStatus::Updated) + } + Err(e) => PthEditResult::err(path, e), + } +} + /// Add the hook dependency to a manifest. Idempotent. pub async fn add_hook_dependency(path: &Path, kind: ManifestKind, dry_run: bool) -> PthEditResult { let content = match fs::read_to_string(path).await { @@ -123,26 +96,14 @@ pub async fn add_hook_dependency(path: &Path, kind: ManifestKind, dry_run: bool) { String::new() } - Err(e) => return PthEditResult::err(path, kind, e.to_string()), + Err(e) => return PthEditResult::err(path, e.to_string()), }; let outcome = match kind { ManifestKind::Pyproject => pyproject_add(&content), - ManifestKind::Requirements => requirements_add(&content), + ManifestKind::Requirements => Ok(requirements_add(&content)), }; - - match outcome { - Ok(None) => PthEditResult::ok(path, kind, PthStatus::AlreadyConfigured), - Ok(Some(new_content)) => { - if !dry_run { - if let Err(e) = atomic_write(path, &new_content).await { - return PthEditResult::err(path, kind, e.to_string()); - } - } - PthEditResult::ok(path, kind, PthStatus::Updated) - } - Err(e) => PthEditResult::err(path, kind, e), - } + finish(path, dry_run, outcome).await } /// Remove the hook dependency from a manifest. Idempotent (already-absent -> @@ -156,82 +117,48 @@ pub async fn remove_hook_dependency( Ok(c) => c, // Nothing on disk → nothing to remove (idempotent no-op). Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return PthEditResult::ok(path, kind, PthStatus::AlreadyConfigured) + return PthEditResult::ok(path, PthStatus::AlreadyConfigured) } - Err(e) => return PthEditResult::err(path, kind, e.to_string()), + Err(e) => return PthEditResult::err(path, e.to_string()), }; let outcome = match kind { ManifestKind::Pyproject => pyproject_remove(&content), - ManifestKind::Requirements => requirements_remove(&content), + ManifestKind::Requirements => Ok(requirements_remove(&content)), }; - - match outcome { - Ok(None) => PthEditResult::ok(path, kind, PthStatus::AlreadyConfigured), - Ok(Some(new_content)) => { - if !dry_run { - if let Err(e) = atomic_write(path, &new_content).await { - return PthEditResult::err(path, kind, e.to_string()); - } - } - PthEditResult::ok(path, kind, PthStatus::Updated) - } - Err(e) => PthEditResult::err(path, kind, e), - } + finish(path, dry_run, outcome).await } // ── requirements.txt ──────────────────────────────────────────────────────── - -/// The file's dominant newline style, so edits don't rewrite CRLF as LF. -fn newline_of(content: &str) -> &'static str { - if content.contains("\r\n") { - "\r\n" - } else { - "\n" - } -} +// The dominant-newline probe (`detect_eol`) keeps CRLF files CRLF. /// Returns `Some(new_content)` if a line was appended, `None` if already there. -fn requirements_add(content: &str) -> Result, String> { - if content - .lines() - .any(|l| deps_contain_hook(strip_requirement_comment(l))) - { - return Ok(None); +fn requirements_add(content: &str) -> Option { + if deps_contain_hook(content) { + return None; } - let nl = newline_of(content); + let nl = detect_eol(content); let mut new = content.to_string(); if !new.is_empty() && !new.ends_with('\n') { new.push_str(nl); } new.push_str(HOOK_DEP); new.push_str(nl); - Ok(Some(new)) + Some(new) } /// Returns `Some(new_content)` if any hook line was removed, `None` otherwise. -fn requirements_remove(content: &str) -> Result, String> { - let kept: Vec<&str> = content - .lines() - .filter(|l| !deps_contain_hook(strip_requirement_comment(l))) - .collect(); +fn requirements_remove(content: &str) -> Option { + let kept: Vec<&str> = content.lines().filter(|l| !deps_contain_hook(l)).collect(); if kept.len() == content.lines().count() { - return Ok(None); + return None; } - let nl = newline_of(content); + let nl = detect_eol(content); let mut new = kept.join(nl); if !new.is_empty() { new.push_str(nl); } - Ok(Some(new)) -} - -/// Strip a trailing `# comment` so we match against the requirement spec only. -fn strip_requirement_comment(line: &str) -> &str { - match line.find('#') { - Some(i) => &line[..i], - None => line, - } + Some(new) } // ── pyproject.toml ─────────────────────────────────────────────────────────── @@ -262,11 +189,30 @@ fn pyproject_add(content: &str) -> Result, String> { .and_then(|t| t.get("poetry")) .and_then(Item::as_table) .is_some(); + // PEP 621 forbids a field that is both listed in `dynamic` and set + // statically, so a project with `dynamic = ["dependencies"]` (setuptools/ + // hatch dynamic metadata, or Poetry 2.x keeping its dependency surface in + // `[tool.poetry.dependencies]`) must not gain a static array — every + // backend would refuse to build the manifest. + let dynamic_deps = doc + .get("project") + .and_then(Item::as_table) + .and_then(|t| t.get("dynamic")) + .and_then(Item::as_array) + .map(|a| a.iter().any(|v| v.as_str() == Some("dependencies"))) + .unwrap_or(false); - let changed = if has_poetry && !real_pep621 { + let changed = if has_poetry && (!real_pep621 || dynamic_deps) { poetry_add(&mut doc)? - } else if real_pep621 { + } else if real_pep621 && !dynamic_deps { pep621_add(&mut doc)? + } else if dynamic_deps { + return Err( + "pyproject.toml declares `[project].dependencies` as dynamic; adding a static \ + dependencies array would make the manifest invalid — declare the hook in the \ + source the dynamic metadata is resolved from (or use requirements.txt) instead" + .to_string(), + ); } else { // Neither surface exists (e.g. a `[build-system]`-only or tool-config-only // pyproject.toml of a setup.py/setup.cfg project). Synthesizing a @@ -295,8 +241,9 @@ fn pyproject_remove(content: &str) -> Result, String> { } /// Ensure `parent[key]` is a table, creating it if absent. Errors if present -/// but a non-table. -fn ensure_table<'a>( +/// but a non-table. Also used by the vendor backends' TOML editing +/// (`patch::vendor::cargo_config`, `patch::vendor::pypi_uv`). +pub(crate) fn ensure_table<'a>( parent: &'a mut Table, key: &str, implicit: bool, @@ -324,7 +271,7 @@ fn pep621_add(doc: &mut DocumentMut) -> Result { .ok_or("`project.dependencies` is not an array")?; if deps .iter() - .any(|v| v.as_str().map(spec_is_hook).unwrap_or(false)) + .any(|v| v.as_str().map(deps_contain_hook).unwrap_or(false)) { return Ok(false); } @@ -343,7 +290,7 @@ fn pep621_remove(doc: &mut DocumentMut) -> bool { None => return false, }; let before = deps.len(); - deps.retain(|v| !v.as_str().map(spec_is_hook).unwrap_or(false)); + deps.retain(|v| !v.as_str().map(deps_contain_hook).unwrap_or(false)); deps.len() != before } @@ -355,11 +302,13 @@ fn poetry_add(doc: &mut DocumentMut) -> Result { // Classic Poetry can't express `socket-patch[hook]` as a key, so declare // the equivalent: `socket-patch` carrying the `hook` extra. Already wired - // if a bare `socket-patch-hook` key exists or the extra is already present. - if deps.contains_key("socket-patch-hook") { + // if a bare `socket-patch-hook` key exists or the extra is already present + // — matched canonically, since Poetry accepts any PEP 503 spelling. + if poetry_dep_key(deps, "socket-patch-hook").is_some() { return Ok(false); } - if let Some(item) = deps.get_mut("socket-patch") { + if let Some(key) = poetry_dep_key(deps, "socket-patch") { + let item = deps.get_mut(&key).expect("key came from this table"); if item_has_hook_extra(item) { return Ok(false); } @@ -374,7 +323,7 @@ fn poetry_add(doc: &mut DocumentMut) -> Result { extras.push("hook"); tbl.insert("extras", Item::Value(Value::Array(extras))); } else if let Some(version) = item.as_str().map(str::to_string) { - deps.insert("socket-patch", Item::Value(hook_inline_table(&version))); + deps.insert(&key, Item::Value(hook_inline_table(&version))); } else { // Any other shape (e.g. Poetry's multiple-constraints array of // tables) carries spec data a blanket replacement would destroy. @@ -404,19 +353,20 @@ fn poetry_remove(doc: &mut DocumentMut) -> bool { }; let mut changed = false; - // Drop a legacy bare `socket-patch-hook` key if present. - if deps.remove("socket-patch-hook").is_some() { + // Drop a legacy bare `socket-patch-hook` key (any PEP 503 spelling). + if let Some(key) = poetry_dep_key(deps, "socket-patch-hook") { + deps.remove(&key); changed = true; } // Strip the `hook` extra from a `socket-patch` dep table, leaving the rest // of the spec intact. - if let Some(tbl) = deps - .get_mut("socket-patch") + if let Some(tbl) = poetry_dep_key(deps, "socket-patch") + .and_then(|key| deps.get_mut(&key)) .and_then(Item::as_table_like_mut) { if let Some(extras) = tbl.get_mut("extras").and_then(Item::as_array_mut) { let before = extras.len(); - extras.retain(|v| v.as_str() != Some("hook")); + extras.retain(|v| !v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("hook"))); if extras.len() != before { changed = true; } @@ -428,6 +378,28 @@ fn poetry_remove(doc: &mut DocumentMut) -> bool { changed } +/// PEP 503 canonical form of a package name: `-`/`_`/`.` are interchangeable +/// and comparison is case-insensitive. Poetry accepts any spelling as a +/// dependency key, so the structural helpers must match keys canonically — +/// the textual probe ([`super::detect::deps_contain_hook`]) already does. +fn canonical_pypi_name(name: &str) -> String { + name.to_lowercase() + .chars() + .map(|c| if c == '_' || c == '.' { '-' } else { c }) + .collect() +} + +/// Find the key in a Poetry dependencies table whose canonical form is +/// `canonical`, returning the user's spelling so edits land on it in place +/// (inserting under the canonical name next to a variant-spelled key would +/// declare the dependency twice — Poetry rejects that). +fn poetry_dep_key(deps: &Table, canonical: &str) -> Option { + deps.iter() + .map(|(k, _)| k) + .find(|k| canonical_pypi_name(k) == canonical) + .map(str::to_string) +} + /// Build `{ version = "", extras = ["hook"] }`. fn hook_inline_table(version: &str) -> Value { let mut it = InlineTable::new(); @@ -439,12 +411,15 @@ fn hook_inline_table(version: &str) -> Value { } /// True if a dependency item (inline table or sub-table) already carries the -/// `hook` extra. +/// `hook` extra (case-insensitively — PEP 685 normalizes extras names). fn item_has_hook_extra(item: &Item) -> bool { item.as_table_like() .and_then(|t| t.get("extras")) .and_then(Item::as_array) - .map(|a| a.iter().any(|v| v.as_str() == Some("hook"))) + .map(|a| { + a.iter() + .any(|v| v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("hook"))) + }) .unwrap_or(false) } @@ -476,7 +451,7 @@ pub fn pyproject_contains_hook(content: &str) -> bool { .and_then(Item::as_array) .map(|deps| { deps.iter() - .any(|v| v.as_str().map(spec_is_hook).unwrap_or(false)) + .any(|v| v.as_str().map(deps_contain_hook).unwrap_or(false)) }) .unwrap_or(false); if in_pep621 { @@ -493,10 +468,10 @@ pub fn pyproject_contains_hook(content: &str) -> bool { .and_then(|p| p.get("dependencies")) .and_then(Item::as_table) { - if deps.contains_key("socket-patch-hook") { + if poetry_dep_key(deps, "socket-patch-hook").is_some() { return true; } - if let Some(item) = deps.get("socket-patch") { + if let Some(item) = poetry_dep_key(deps, "socket-patch").and_then(|key| deps.get(&key)) { if item_has_hook_extra(item) { return true; } @@ -514,7 +489,7 @@ mod tests { #[test] fn test_requirements_add() { - let out = requirements_add("requests==2.31.0\n").unwrap().unwrap(); + let out = requirements_add("requests==2.31.0\n").unwrap(); assert!(out.contains("requests==2.31.0")); assert!(out.contains("socket-patch[hook]")); assert!(out.ends_with('\n')); @@ -522,31 +497,27 @@ mod tests { #[test] fn test_requirements_add_no_trailing_newline() { - let out = requirements_add("requests").unwrap().unwrap(); + let out = requirements_add("requests").unwrap(); assert_eq!(out, "requests\nsocket-patch[hook]\n"); } #[test] fn test_requirements_add_idempotent() { // The extra, the standalone wheel, and a pinned variant are all recognized. - assert!(requirements_add("socket-patch[hook]\n").unwrap().is_none()); - assert!(requirements_add("socket-patch-hook\n").unwrap().is_none()); - assert!(requirements_add("socket-patch-hook==3.3.0\n") - .unwrap() - .is_none()); + assert!(requirements_add("socket-patch[hook]\n").is_none()); + assert!(requirements_add("socket-patch-hook\n").is_none()); + assert!(requirements_add("socket-patch-hook==3.3.0\n").is_none()); } #[test] fn test_requirements_remove() { - let out = requirements_remove("requests\nsocket-patch[hook]\n") - .unwrap() - .unwrap(); + let out = requirements_remove("requests\nsocket-patch[hook]\n").unwrap(); assert_eq!(out, "requests\n"); } #[test] fn test_requirements_remove_absent() { - assert!(requirements_remove("requests\n").unwrap().is_none()); + assert!(requirements_remove("requests\n").is_none()); } // ── pyproject PEP 621 ──────────────────────────────────────────── @@ -694,6 +665,56 @@ mod tests { assert!(pyproject_add(tool_only).is_err()); } + #[test] + fn test_pep621_dynamic_dependencies_refused() { + // setuptools/hatch dynamic-metadata pattern: `dependencies` is declared + // dynamic and resolved from an external source at build time. PEP 621 + // forbids a field that is both listed in `dynamic` and set statically, + // so inserting a static `dependencies` array makes every backend refuse + // to build. The edit must fail closed instead of bricking the build. + let toml = "[project]\nname = \"x\"\ndynamic = [\"dependencies\"]\n\n\ + [tool.setuptools.dynamic]\ndependencies = {file = [\"requirements.txt\"]}\n"; + assert!( + pyproject_add(toml).is_err(), + "must not add a static dependencies array next to dynamic = [\"dependencies\"]" + ); + } + + #[test] + fn test_poetry2_dynamic_dependencies_routes_to_poetry() { + // Poetry 2.x documented pattern: a PEP 621 `[project]` table with + // `dynamic = ["dependencies"]` while the real dependency surface stays + // in `[tool.poetry.dependencies]`. The hook must land in the poetry + // table — a static `[project].dependencies` array is both ignored by + // Poetry at install time and invalid per PEP 621. + let toml = "[project]\nname = \"x\"\ndynamic = [\"dependencies\"]\n\n\ + [tool.poetry.dependencies]\npython = \"^3.9\"\n"; + let out = pyproject_add(toml).unwrap().unwrap(); + let doc = out.parse::().unwrap(); + assert!( + item_has_hook_extra(&doc["tool"]["poetry"]["dependencies"]["socket-patch"]), + "hook must be wired via the poetry table:\n{out}" + ); + assert!( + doc.get("project") + .and_then(|p| p.get("dependencies")) + .is_none(), + "must not synthesize a static [project].dependencies:\n{out}" + ); + // Idempotent through the same route. + assert!(pyproject_add(&out).unwrap().is_none()); + } + + #[test] + fn test_pep621_other_dynamic_fields_still_edited_statically() { + // Only `dependencies` being dynamic blocks the static edit; dynamic + // version (the common setuptools-scm case) keeps the PEP 621 path. + let toml = + "[project]\nname = \"x\"\ndynamic = [\"version\"]\ndependencies = [\"requests\"]\n"; + let out = pyproject_add(toml).unwrap().unwrap(); + assert!(out.contains("socket-patch[hook]")); + } + #[test] fn test_poetry_add_multiconstraint_dep_not_clobbered() { // Poetry's multiple-constraints form declares one dep as an ARRAY of @@ -729,9 +750,9 @@ mod tests { #[test] fn test_requirements_preserves_crlf() { - let out = requirements_add("requests\r\n").unwrap().unwrap(); + let out = requirements_add("requests\r\n").unwrap(); assert_eq!(out, "requests\r\nsocket-patch[hook]\r\n"); - let removed = requirements_remove(&out).unwrap().unwrap(); + let removed = requirements_remove(&out).unwrap(); assert_eq!(removed, "requests\r\n"); } @@ -911,6 +932,136 @@ mod tests { ); } + // ── mode preservation (user-owned manifests keep their permission bits) ── + // + // The rename-based atomic write swaps in a fresh stage inode; without the + // mode-preserving variant a 0600 private manifest silently becomes 0644. + + #[cfg(unix)] + #[tokio::test] + async fn test_edit_preserves_file_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + + // A 0600 private pyproject.toml must stay private after add. + let py = dir.path().join("pyproject.toml"); + tokio::fs::write(&py, "[project]\nname = \"x\"\ndependencies = []\n") + .await + .unwrap(); + tokio::fs::set_permissions(&py, std::fs::Permissions::from_mode(0o600)) + .await + .unwrap(); + let res = add_hook_dependency(&py, ManifestKind::Pyproject, false).await; + assert_eq!(res.status, PthStatus::Updated); + let mode = tokio::fs::metadata(&py).await.unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "add must not reset pyproject.toml mode"); + + // A 0744 requirements.txt keeps its exec bit after remove (red under + // ANY umask: a 0666-created stage inode can never carry exec bits). + let req = dir.path().join("requirements.txt"); + tokio::fs::write(&req, "requests\nsocket-patch[hook]\n") + .await + .unwrap(); + tokio::fs::set_permissions(&req, std::fs::Permissions::from_mode(0o744)) + .await + .unwrap(); + let res = remove_hook_dependency(&req, ManifestKind::Requirements, false).await; + assert_eq!(res.status, PthStatus::Updated); + let mode = tokio::fs::metadata(&req) + .await + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o744, "remove must not reset requirements.txt mode"); + } + + // ── PEP 503/685 spellings in the Poetry TABLE forms ────────────── + // + // `-`/`_`/`.` are interchangeable in package names and names/extras are + // case-insensitive; Poetry installs the hook from any spelling, so the + // structural helpers must recognize them the way the textual probe + // (`deps_contain_hook`) already does. + + #[test] + fn test_poetry_add_pep503_variant_keys_idempotent() { + // A hook already declared under a variant spelling must be recognized, + // not shadowed by a second entry for the same canonical package. + let wheel = + "[tool.poetry]\nname = \"x\"\n\n[tool.poetry.dependencies]\nsocket_patch_hook = \"*\"\n"; + assert!(pyproject_add(wheel).unwrap().is_none()); + let extra = "[tool.poetry]\nname = \"x\"\n\n[tool.poetry.dependencies]\n\ + socket_patch = {version = \"^3.3.0\", extras = [\"hook\"]}\n"; + assert!(pyproject_add(extra).unwrap().is_none()); + // PEP 685: extras names are case-insensitive too. + let cased = "[tool.poetry]\nname = \"x\"\n\n[tool.poetry.dependencies]\n\ + socket-patch = {version = \"*\", extras = [\"Hook\"]}\n"; + assert!(pyproject_add(cased).unwrap().is_none()); + } + + #[test] + fn test_poetry_add_merges_into_pep503_variant_key() { + // An existing dep under a variant spelling gains the extra in place — + // not a duplicate `socket-patch` key canonicalizing to the same + // package, which Poetry rejects as a twice-declared dependency. + let toml = + "[tool.poetry]\nname = \"x\"\n\n[tool.poetry.dependencies]\nSocket_Patch = \"^3.3.0\"\n"; + let out = pyproject_add(toml).unwrap().unwrap(); + let doc = out.parse::().unwrap(); + let deps = doc["tool"]["poetry"]["dependencies"].as_table().unwrap(); + assert!( + deps.get("socket-patch").is_none(), + "must not add a duplicate key:\n{out}" + ); + let item = deps.get("Socket_Patch").expect("user's spelling kept"); + assert!( + item_has_hook_extra(item), + "extra merged under the user's spelling:\n{out}" + ); + assert_eq!( + item.as_table_like() + .and_then(|t| t.get("version")) + .and_then(Item::as_str), + Some("^3.3.0"), + "existing version must be preserved" + ); + } + + #[test] + fn test_poetry_remove_pep503_variant_keys() { + // remove must unwire the hook regardless of spelling — leaving it + // declared means the .pth carrier keeps installing after `remove`. + let wheel = "[tool.poetry.dependencies]\nsocket_patch_hook = \"*\"\n"; + let out = pyproject_remove(wheel) + .unwrap() + .expect("variant wheel key must be removed"); + assert!(!pyproject_contains_hook(&out)); + + let extra = + "[tool.poetry.dependencies]\n\"socket.patch\" = {version = \"*\", extras = [\"Hook\"]}\n"; + let out = pyproject_remove(extra) + .unwrap() + .expect("variant extras form must be stripped"); + assert!(!pyproject_contains_hook(&out)); + } + + #[test] + fn test_pyproject_contains_hook_pep503_poetry_forms() { + assert!(pyproject_contains_hook( + "[tool.poetry.dependencies]\nsocket_patch_hook = \"*\"\n" + )); + assert!(pyproject_contains_hook( + "[tool.poetry.dependencies]\nSocket_Patch = {version = \"*\", extras = [\"hook\"]}\n" + )); + assert!(pyproject_contains_hook( + "[tool.poetry.dependencies]\nsocket-patch = {version = \"*\", extras = [\"Hook\"]}\n" + )); + // A different package that merely shares the prefix is not the hook. + assert!(!pyproject_contains_hook( + "[tool.poetry.dependencies]\nsocket-patchwork = \"*\"\n" + )); + } + #[tokio::test] async fn test_dry_run_does_no_io_for_pyproject() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/pth_hook/mod.rs b/crates/socket-patch-core/src/pth_hook/mod.rs index 84802b4b..998d6235 100644 --- a/crates/socket-patch-core/src/pth_hook/mod.rs +++ b/crates/socket-patch-core/src/pth_hook/mod.rs @@ -18,9 +18,3 @@ pub mod detect; pub mod edit; - -pub use detect::{deps_contain_hook, detect_python_pm, PythonPackageManager, HOOK_DEP}; -pub use edit::{ - add_hook_dependency, pyproject_contains_hook, remove_hook_dependency, ManifestKind, - PthEditResult, PthStatus, -}; diff --git a/crates/socket-patch-core/src/utils/cleanup_blobs.rs b/crates/socket-patch-core/src/utils/cleanup_blobs.rs index 30cb9f29..9eebbbc4 100644 --- a/crates/socket-patch-core/src/utils/cleanup_blobs.rs +++ b/crates/socket-patch-core/src/utils/cleanup_blobs.rs @@ -5,7 +5,7 @@ use crate::manifest::operations::get_after_hash_blobs; use crate::manifest::schema::PatchManifest; /// Result of a blob cleanup operation. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Default)] pub struct CleanupResult { pub blobs_checked: usize, pub blobs_removed: usize, diff --git a/crates/socket-patch-core/src/utils/env_compat.rs b/crates/socket-patch-core/src/utils/env_compat.rs index 32cea686..c73118b1 100644 --- a/crates/socket-patch-core/src/utils/env_compat.rs +++ b/crates/socket-patch-core/src/utils/env_compat.rs @@ -32,7 +32,10 @@ static WARNED: Lazy>> = Lazy::new(|| Mutex::new(Hash /// /// Returns `None` when neither name is set (or both are set to an empty /// string, matching the prior call sites' filtering). -pub fn read_env_with_legacy(new_name: &'static str, legacy_name: &'static str) -> Option { +pub(crate) fn read_env_with_legacy( + new_name: &'static str, + legacy_name: &'static str, +) -> Option { if let Ok(v) = std::env::var(new_name) { if !v.is_empty() { return Some(v); @@ -47,11 +50,8 @@ pub fn read_env_with_legacy(new_name: &'static str, legacy_name: &'static str) - } } -/// Print a one-shot deprecation warning. Public so callers that read the -/// legacy name through other code paths (e.g. clap's `env =` attribute, -/// which reads only the new name) can still surface the deprecation when -/// they detect the legacy name was set. -pub fn warn_legacy_once(legacy_name: &'static str, new_name: &'static str) { +/// Print a one-shot deprecation warning for a legacy name. +fn warn_legacy_once(legacy_name: &'static str, new_name: &'static str) { let mut warned = match WARNED.lock() { Ok(g) => g, Err(poisoned) => poisoned.into_inner(), @@ -65,18 +65,22 @@ pub fn warn_legacy_once(legacy_name: &'static str, new_name: &'static str) { } } -/// Renamed env vars whose legacy `SOCKET_PATCH_*` names are still honored. -/// -/// First entry of each tuple is the new name (what clap and current code -/// read); second is the legacy name that gets a deprecation warning. -pub const LEGACY_ENV_RENAMES: &[(&str, &str)] = &[ - ("SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"), - ("SOCKET_DEBUG", "SOCKET_PATCH_DEBUG"), - ( - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_PATCH_TELEMETRY_DISABLED", - ), -]; +/// Check if debug mode is enabled via `SOCKET_DEBUG` (with the legacy +/// `SOCKET_PATCH_DEBUG` shim). +pub(crate) fn is_debug_enabled() -> bool { + matches!( + read_env_with_legacy("SOCKET_DEBUG", "SOCKET_PATCH_DEBUG").as_deref(), + Some("1" | "true") + ) +} + +/// The public patch-API proxy base URL: `SOCKET_PROXY_URL` (with the legacy +/// `SOCKET_PATCH_PROXY_URL` shim), defaulting to +/// [`DEFAULT_PATCH_API_PROXY_URL`](crate::constants::DEFAULT_PATCH_API_PROXY_URL). +pub(crate) fn proxy_url_from_env() -> String { + read_env_with_legacy("SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL") + .unwrap_or_else(|| crate::constants::DEFAULT_PATCH_API_PROXY_URL.to_string()) +} /// Promote legacy `SOCKET_PATCH_*` env vars to their new `SOCKET_*` names /// in-process. When the new name is unset and the legacy name is set, copy @@ -89,7 +93,14 @@ pub const LEGACY_ENV_RENAMES: &[(&str, &str)] = &[ /// The warning fires unconditionally — even under `--silent` / `--json` /// — so the transition signal isn't swallowed in CI logs. pub fn promote_legacy_env_vars() { - promote_renames(LEGACY_ENV_RENAMES); + promote_renames(&[ + ("SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"), + ("SOCKET_DEBUG", "SOCKET_PATCH_DEBUG"), + ( + "SOCKET_TELEMETRY_DISABLED", + "SOCKET_PATCH_TELEMETRY_DISABLED", + ), + ]); } /// Core of [`promote_legacy_env_vars`], parameterized over the rename table so @@ -97,18 +108,14 @@ pub fn promote_legacy_env_vars() { /// read concurrently by other tests in this binary). fn promote_renames(renames: &[(&'static str, &'static str)]) { for &(new_name, legacy_name) in renames { - let new_already_set = std::env::var(new_name) - .ok() - .filter(|v| !v.is_empty()) - .is_some(); + let new_already_set = matches!(std::env::var(new_name).as_deref(), Ok(v) if !v.is_empty()); if new_already_set { continue; } - if let Ok(value) = std::env::var(legacy_name) { - if !value.is_empty() { - warn_legacy_once(legacy_name, new_name); - std::env::set_var(new_name, value); - } + // New name is unset/empty, so any value returned here came from the + // legacy name (with the one-shot warning already emitted). + if let Some(value) = read_env_with_legacy(new_name, legacy_name) { + std::env::set_var(new_name, value); } } } diff --git a/crates/socket-patch-core/src/utils/fs.rs b/crates/socket-patch-core/src/utils/fs.rs index a33a3c65..020d0472 100644 --- a/crates/socket-patch-core/src/utils/fs.rs +++ b/crates/socket-patch-core/src/utils/fs.rs @@ -2,13 +2,15 @@ //! crate-wide atomic file writer ([`atomic_write_bytes`]). //! //! Each crawler walks one or more package directories and decides -//! whether each entry is a candidate package. The two operations that +//! whether each entry is a candidate package. The operations that //! all eight crawlers repeat are: //! //! - listing entries in a directory while tolerating permission / //! I/O errors (we treat an unreadable directory as "no entries"); //! - asking whether an entry is a directory while tolerating -//! `file_type()` failures (we treat a stat error as "not a dir"). +//! `file_type()` failures (we treat a stat error as "not a dir"); +//! - asking whether an arbitrary path is a directory while tolerating +//! stat errors ([`is_dir`], same "not a dir" fallback). //! //! Centralizing both keeps each crawler free of the //! `match read_dir { Ok(rd) => rd, Err(_) => return … }` boilerplate @@ -25,7 +27,7 @@ //! crawlers (pnpm's content-addressed store relies on resolving //! symlinks into `node_modules/.pnpm/*`). -use std::path::Path; +use std::path::{Path, PathBuf}; use std::fs::FileType; use tokio::fs::DirEntry; @@ -38,7 +40,7 @@ use tokio::fs::DirEntry; /// iteration stops. The crawlers treat all of these the same way: /// surface whatever the readable portion of the subtree yields, but /// don't abort the whole crawl. -pub async fn list_dir_entries(path: &Path) -> Vec { +pub(crate) async fn list_dir_entries(path: &Path) -> Vec { let mut entries = match tokio::fs::read_dir(path).await { Ok(rd) => rd, Err(_) => return Vec::new(), @@ -62,14 +64,76 @@ pub async fn list_dir_entries(path: &Path) -> Vec { /// would wrongly report `false`. To honor the documented /// symlink-following contract — which crawlers like deno/python/ruby /// rely on for symlinked package directories — we stat the resolved -/// `entry.path()` via `tokio::fs::metadata`, which does follow links. -pub async fn entry_is_dir(entry: &DirEntry) -> bool { - tokio::fs::metadata(entry.path()) +/// `entry.path()` via [`is_dir`], which does follow links. +pub(crate) async fn entry_is_dir(entry: &DirEntry) -> bool { + is_dir(&entry.path()).await +} + +/// Check whether `path` is a directory, following symlinks. +/// +/// Returns `false` if the stat fails (missing path, broken symlink, +/// permission error, etc.) — the crawlers probe candidate package +/// roots and treat "can't stat" the same as "not there". The +/// `Path`-taking counterpart of [`entry_is_dir`]; previously +/// copy-pasted into every crawler. +pub(crate) async fn is_dir(path: &Path) -> bool { + tokio::fs::metadata(path) .await .map(|m| m.is_dir()) .unwrap_or(false) } +/// Check whether `path` is a regular file, following symlinks. +/// +/// Returns `false` if the stat fails (missing path, broken symlink, +/// permission error, etc.) — the file-shaped sibling of [`is_dir`], +/// with the same "can't stat means not there" contract. +pub(crate) async fn is_file(path: &Path) -> bool { + tokio::fs::metadata(path) + .await + .map(|m| m.is_file()) + .unwrap_or(false) +} + +/// Open `path` read-only, requiring a regular file. +/// +/// Returns the open handle plus its `fstat` metadata. Deriving the +/// metadata from the open descriptor — rather than `stat`-ing the path +/// separately — means the size and any bytes subsequently read cannot +/// come from different inodes, even if the path is renamed/replaced +/// concurrently (the patch engine reads files an attacker may swap at +/// any moment). +/// +/// On Unix the open itself is non-blocking (`O_NONBLOCK`): a plain +/// `open(2)` of a FIFO with `O_RDONLY` waits for a writer that may +/// never come, which would hang the patch engine forever before the +/// regular-file guard below ever runs. `O_NONBLOCK` has no effect on +/// regular-file reads; the handle-based `is_file` check then rejects +/// FIFOs/devices/directories with `InvalidInput` instead of reading +/// them (on some platforms a directory reads as zero bytes, which +/// would otherwise be silently hashed as the empty blob). +pub(crate) async fn open_regular_file( + path: &Path, +) -> std::io::Result<(tokio::fs::File, std::fs::Metadata)> { + #[cfg(unix)] + let file = tokio::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(path) + .await?; + #[cfg(not(unix))] + let file = tokio::fs::File::open(path).await?; + + let metadata = file.metadata().await?; + if !metadata.is_file() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{} is not a regular file", path.display()), + )); + } + Ok((file, metadata)) +} + /// Return the raw `FileType` for `entry`, swallowing stat errors. /// /// Use this instead of `entry_is_dir` when the caller needs to @@ -78,10 +142,30 @@ pub async fn entry_is_dir(entry: &DirEntry) -> bool { /// be treated as scannable-but-non-recurseable). The returned /// `FileType` is the symlink-aware kind from `entry.file_type()`, /// not the resolved-target kind from `metadata()`. -pub async fn entry_file_type(entry: &DirEntry) -> Option { +pub(crate) async fn entry_file_type(entry: &DirEntry) -> Option { entry.file_type().await.ok() } +/// Resolve the user's home directory: `HOME`, then `USERPROFILE` +/// (Windows), then a literal `"~"` — a harmless non-existent path so +/// downstream joins probe nothing rather than panic. A set-but-empty +/// variable counts as unset: honoring `""` would turn every +/// `home_dir().join(…)` probe into a CWD-relative path, pointing the +/// crawlers at directories inside the user's project. The shared +/// fallback chain for every crawler that scans well-known per-user +/// package roots (`~/.cargo`, `~/.m2`, `~/.nuget`, …) and for +/// telemetry's home-dir redaction; previously copy-pasted into each. +/// The go/composer crawlers deliberately use a stricter +/// no-home-means-no-path chain instead. +pub(crate) fn home_dir() -> PathBuf { + let home = std::env::var("HOME") + .ok() + .filter(|h| !h.is_empty()) + .or_else(|| std::env::var("USERPROFILE").ok().filter(|h| !h.is_empty())) + .unwrap_or_else(|| "~".to_string()); + PathBuf::from(home) +} + /// Atomically commit `content` to `path` via stage + fsync + rename. /// /// The single shared implementation of the hardened-writer pattern used for @@ -92,7 +176,36 @@ pub async fn entry_file_type(entry: &DirEntry) -> Option { /// sibling file, fsync it, then rename over the target (atomic on the same /// filesystem), so a reader or recovering process only ever sees the complete /// old or the complete new bytes. -pub async fn atomic_write_bytes(path: &Path, content: &[u8]) -> std::io::Result<()> { +pub(crate) async fn atomic_write_bytes(path: &Path, content: &[u8]) -> std::io::Result<()> { + atomic_write_bytes_as(path, content, None).await +} + +/// [`atomic_write_bytes`], but the new inode keeps the destination's existing +/// permission bits (when the destination exists). +/// +/// The rename swaps in a fresh stage inode created with umask defaults, so the +/// plain writer resets a user-owned file's mode — a 0600 private package.json +/// silently becomes 0644, a 0664 group-writable one locks the group out. Use +/// this variant for files the *user* owns and we merely edit (package.json, +/// Gemfile, …), matching npm's write-file-atomic. The patch engine keeps the +/// plain writer: `restore_file_permissions` re-applies pre-patch mode + uid/gid +/// itself after the rename. +pub(crate) async fn atomic_write_bytes_preserving_mode( + path: &Path, + content: &[u8], +) -> std::io::Result<()> { + let perms = tokio::fs::metadata(path) + .await + .ok() + .map(|m| m.permissions()); + atomic_write_bytes_as(path, content, perms).await +} + +async fn atomic_write_bytes_as( + path: &Path, + content: &[u8], + perms: Option, +) -> std::io::Result<()> { let parent = path.parent().unwrap_or_else(|| Path::new(".")); let stem = path .file_name() @@ -115,6 +228,16 @@ pub async fn atomic_write_bytes(path: &Path, content: &[u8]) -> std::io::Result< let _ = tokio::fs::remove_file(&stage).await; return Err(e); } + // Set the preserved mode on the stage *before* the rename so the file + // never appears at the destination with the wrong bits, even briefly. + // The content is already written through the open handle, so a + // restrictive mode (0400, 0000) cannot fail the write. + if let Some(p) = perms { + if let Err(e) = file.set_permissions(p).await { + let _ = tokio::fs::remove_file(&stage).await; + return Err(e); + } + } drop(file); if let Err(e) = tokio::fs::rename(&stage, path).await { @@ -243,6 +366,22 @@ mod tests { } } + /// `is_dir` reports directories, and falls back to `false` for + /// files, missing paths, and (via `metadata`'s symlink-following) + /// resolves links to their target kind. + #[tokio::test] + async fn is_dir_dir_file_and_missing() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("d"); + tokio::fs::create_dir(&dir).await.unwrap(); + let file = tmp.path().join("f"); + tokio::fs::write(&file, b"x").await.unwrap(); + + assert!(is_dir(&dir).await); + assert!(!is_dir(&file).await); + assert!(!is_dir(&tmp.path().join("missing")).await); + } + /// Regression: `list_dir_entries` must hit the `read_dir` Err arm /// when handed a path that is a regular file (not a directory) and /// return an empty vec rather than panic. Crawlers routinely probe @@ -313,6 +452,62 @@ mod tests { } } + /// The preserving writer re-applies the destination's mode to the new + /// inode (0744's exec bit cannot come from a 0666-based create, so this + /// is red under any umask if preservation regresses), while a missing + /// destination is simply created with umask defaults. + #[cfg(unix)] + #[tokio::test] + async fn atomic_write_preserving_mode_keeps_dest_mode() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("f"); + tokio::fs::write(&path, b"old").await.unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o744)).unwrap(); + + atomic_write_bytes_preserving_mode(&path, b"new") + .await + .unwrap(); + assert_eq!(tokio::fs::read(&path).await.unwrap(), b"new"); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o744, "existing mode must survive the rename"); + + let fresh = tmp.path().join("fresh"); + atomic_write_bytes_preserving_mode(&fresh, b"x") + .await + .unwrap(); + assert_eq!(tokio::fs::read(&fresh).await.unwrap(), b"x"); + } + + /// Regression: a set-but-empty `HOME` (stripped CI/container/sudo + /// environments) must be treated as unset, exactly like the documented + /// no-home fallback. Honoring `""` made `home_dir()` return an empty + /// `PathBuf`, so every `home_dir().join(".cargo")`-style probe became a + /// CWD-relative path and the crawlers scanned directories inside the + /// user's project as if they were the per-user package roots. + #[test] + #[serial_test::serial] + fn home_dir_treats_empty_home_as_unset() { + let prev_home = std::env::var("HOME").ok(); + let prev_profile = std::env::var("USERPROFILE").ok(); + std::env::set_var("HOME", ""); + std::env::set_var("USERPROFILE", ""); + let home = home_dir(); + match prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match prev_profile { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } + assert_eq!( + home, + PathBuf::from("~"), + "empty HOME/USERPROFILE must fall back to the harmless `~` sentinel" + ); + } + /// `entry_file_type` is the symlink-aware counterpart: it reports /// the link itself (`is_symlink`), never the resolved target. #[cfg(unix)] diff --git a/crates/socket-patch-core/src/utils/fuzzy_match.rs b/crates/socket-patch-core/src/utils/fuzzy_match.rs index 93153582..5cb7555c 100644 --- a/crates/socket-patch-core/src/utils/fuzzy_match.rs +++ b/crates/socket-patch-core/src/utils/fuzzy_match.rs @@ -1,51 +1,25 @@ use crate::crawlers::types::CrawledPackage; -// --------------------------------------------------------------------------- -// MatchType enum -// --------------------------------------------------------------------------- - -/// Match type for sorting results by relevance. -/// -/// Lower numeric value = better match. The ordering is: -/// 1. Exact match on full name (including namespace) -/// 2. Exact match on package name only -/// 3. Prefix match on full name -/// 4. Prefix match on package name -/// 5. Contains match on full name -/// 6. Contains match on package name -/// -/// Internal to this module — `fuzzy_match_packages` is the only -/// external entry point and it returns plain `Vec` -/// (sorted), so callers never see the match-type tag. +/// Match type for sorting results by relevance; declaration order is the +/// ranking (earlier = better). Internal to this module — `fuzzy_match_packages` +/// is the only external entry point and it returns a plain sorted +/// `Vec`, so callers never see the match-type tag. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum MatchType { /// Exact match on full name (including namespace). - ExactFull = 0, + ExactFull, /// Exact match on package name only. - ExactName = 1, + ExactName, /// Query is a prefix of the full name. - PrefixFull = 2, + PrefixFull, /// Query is a prefix of the package name. - PrefixName = 3, + PrefixName, /// Query is contained in the full name. - ContainsFull = 4, + ContainsFull, /// Query is contained in the package name. - ContainsName = 5, + ContainsName, } -// --------------------------------------------------------------------------- -// Internal match result -// --------------------------------------------------------------------------- - -struct MatchResult { - package: CrawledPackage, - match_type: MatchType, -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - /// Get the full display name for a package (including namespace if present). fn get_full_name(pkg: &CrawledPackage) -> String { match &pkg.namespace { @@ -54,44 +28,26 @@ fn get_full_name(pkg: &CrawledPackage) -> String { } } -/// Determine the match type for a package against a query. -/// Returns `None` if there is no match. -fn get_match_type(pkg: &CrawledPackage, query: &str) -> Option { - let lower_query = query.to_lowercase(); - let full_name = get_full_name(pkg).to_lowercase(); - let name = pkg.name.to_lowercase(); - - // Check exact matches - if full_name == lower_query { - return Some(MatchType::ExactFull); - } - if name == lower_query { - return Some(MatchType::ExactName); +/// Determine the match type for a package against a query, or `None` if there +/// is no match. All inputs must already be lowercased. +fn get_match_type(full_name: &str, name: &str, query: &str) -> Option { + if full_name == query { + Some(MatchType::ExactFull) + } else if name == query { + Some(MatchType::ExactName) + } else if full_name.starts_with(query) { + Some(MatchType::PrefixFull) + } else if name.starts_with(query) { + Some(MatchType::PrefixName) + } else if full_name.contains(query) { + Some(MatchType::ContainsFull) + } else if name.contains(query) { + Some(MatchType::ContainsName) + } else { + None } - - // Check prefix matches - if full_name.starts_with(&lower_query) { - return Some(MatchType::PrefixFull); - } - if name.starts_with(&lower_query) { - return Some(MatchType::PrefixName); - } - - // Check contains matches - if full_name.contains(&lower_query) { - return Some(MatchType::ContainsFull); - } - if name.contains(&lower_query) { - return Some(MatchType::ContainsName); - } - - None } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - /// Fuzzy match packages against a query string. /// /// Matches are sorted by relevance: @@ -108,38 +64,28 @@ pub fn fuzzy_match_packages( packages: &[CrawledPackage], limit: usize, ) -> Vec { - let trimmed = query.trim(); - if trimmed.is_empty() { + let query = query.trim().to_lowercase(); + if query.is_empty() { return Vec::new(); } - let mut matches: Vec = Vec::new(); - - for pkg in packages { - if let Some(match_type) = get_match_type(pkg, trimmed) { - matches.push(MatchResult { - package: pkg.clone(), - match_type, - }); - } - } + let mut matches: Vec<(MatchType, String, CrawledPackage)> = packages + .iter() + .filter_map(|pkg| { + let full_name = get_full_name(pkg).to_lowercase(); + let match_type = get_match_type(&full_name, &pkg.name.to_lowercase(), &query)?; + Some((match_type, full_name, pkg.clone())) + }) + .collect(); - // Sort by match type (lower is better), then alphabetically by full name - matches.sort_by(|a, b| { - let type_cmp = a.match_type.cmp(&b.match_type); - if type_cmp != std::cmp::Ordering::Equal { - return type_cmp; - } - // Tie-break alphabetically by full name. Matching is case-insensitive, - // so the ordering must be too — otherwise byte order sorts uppercase - // ('Z' = 0x5A) before lowercase ('a' = 0x61), which is not alphabetical - // and can flip which package lands at `matches[0]`. - get_full_name(&a.package) - .to_lowercase() - .cmp(&get_full_name(&b.package).to_lowercase()) - }); + // Sort by match type (lower is better), then alphabetically by full name. + // Matching is case-insensitive, so the tie-break compares the lowercased + // full name too — otherwise byte order sorts uppercase ('Z' = 0x5A) before + // lowercase ('a' = 0x61), which is not alphabetical and can flip which + // package lands at `matches[0]`. + matches.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); - matches.into_iter().take(limit).map(|m| m.package).collect() + matches.into_iter().take(limit).map(|m| m.2).collect() } #[cfg(test)] diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index 789aee54..ce437bae 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -4,5 +4,6 @@ pub mod fs; pub mod fuzzy_match; pub mod process; pub mod purl; +pub(crate) mod serde; pub mod telemetry; pub mod uri; diff --git a/crates/socket-patch-core/src/utils/process.rs b/crates/socket-patch-core/src/utils/process.rs index 88475d2f..68d8302e 100644 --- a/crates/socket-patch-core/src/utils/process.rs +++ b/crates/socket-patch-core/src/utils/process.rs @@ -16,7 +16,7 @@ //! production callers either build the helper with the default //! runner or thread a singleton. -use std::process::{Command, Stdio}; +use std::process::Command; /// Run an external binary with the given args and return its /// stdout, trimmed, when the spawn succeeded AND the process exited @@ -26,27 +26,21 @@ use std::process::{Command, Stdio}; /// non-zero exit status, empty stdout after trim. Stderr is /// captured and discarded — the crawlers treat all failures as /// "no information", not as errors to surface. -pub trait CommandRunner: Send + Sync { +pub trait CommandRunner { fn run(&self, bin: &str, args: &[&str]) -> Option; } /// Default runner: spawns the real binary via `std::process::Command`. /// -/// Stdin is set to /dev/null so the child can't block waiting for +/// `output()` nulls stdin so the child can't block waiting for /// input. stdout is captured; stderr is captured and dropped (we /// don't surface CLI diagnostics — the helpers fall back to other /// discovery paths on any failure). -pub struct SystemCommandRunner; +pub(crate) struct SystemCommandRunner; impl CommandRunner for SystemCommandRunner { fn run(&self, bin: &str, args: &[&str]) -> Option { - let output = Command::new(bin) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .ok()?; + let output = Command::new(bin).args(args).output().ok()?; if !output.status.success() { return None; } diff --git a/crates/socket-patch-core/src/utils/purl.rs b/crates/socket-patch-core/src/utils/purl.rs index 8cb2d248..bb2b41ca 100644 --- a/crates/socket-patch-core/src/utils/purl.rs +++ b/crates/socket-patch-core/src/utils/purl.rs @@ -108,12 +108,13 @@ pub fn purl_eq(a: &str, b: &str) -> bool { normalize_purl(a) == normalize_purl(b) } -/// Parse a PyPI PURL to extract name and version. -/// -/// e.g., `"pkg:pypi/requests@2.28.0?artifact_id=abc"` -> `Some(("requests", "2.28.0"))` -pub fn parse_pypi_purl(purl: &str) -> Option<(&str, &str)> { - let base = strip_purl_qualifiers(purl); - let rest = base.strip_prefix("pkg:pypi/")?; +/// Shared split for `pkg:/@` purls: strip +/// `?qualifiers`/`#subpath` FIRST (a qualifier value can itself embed an +/// `@`, e.g. a `git@github.com` source URL), require `prefix`, then split +/// the version off at the LAST `@` — so the name/path keeps any internal +/// slashes and `@`s. +fn parse_name_version<'a>(purl: &'a str, prefix: &str) -> Option<(&'a str, &'a str)> { + let rest = strip_purl_qualifiers(purl).strip_prefix(prefix)?; let at_idx = rest.rfind('@')?; let name = &rest[..at_idx]; let version = &rest[at_idx + 1..]; @@ -123,77 +124,58 @@ pub fn parse_pypi_purl(purl: &str) -> Option<(&str, &str)> { Some((name, version)) } +/// [`parse_name_version`], then split the name at its FIRST `/` into a +/// `(namespace, name)` pair (maven groupId/artifactId, composer +/// vendor/name, jsr @scope/name). +fn parse_namespaced<'a>(purl: &'a str, prefix: &str) -> Option<((&'a str, &'a str), &'a str)> { + let (name_part, version) = parse_name_version(purl, prefix)?; + let (namespace, name) = name_part.split_once('/')?; + if namespace.is_empty() || name.is_empty() { + return None; + } + Some(((namespace, name), version)) +} + +/// Parse a PyPI PURL to extract name and version. +/// +/// e.g., `"pkg:pypi/requests@2.28.0?artifact_id=abc"` -> `Some(("requests", "2.28.0"))` +pub(crate) fn parse_pypi_purl(purl: &str) -> Option<(&str, &str)> { + parse_name_version(purl, "pkg:pypi/") +} + /// Parse a gem PURL to extract name and version. /// /// e.g., `"pkg:gem/rails@7.1.0"` -> `Some(("rails", "7.1.0"))` -pub fn parse_gem_purl(purl: &str) -> Option<(&str, &str)> { - let base = strip_purl_qualifiers(purl); - let rest = base.strip_prefix("pkg:gem/")?; - let at_idx = rest.rfind('@')?; - let name = &rest[..at_idx]; - let version = &rest[at_idx + 1..]; - if name.is_empty() || version.is_empty() { - return None; - } - Some((name, version)) +pub(crate) fn parse_gem_purl(purl: &str) -> Option<(&str, &str)> { + parse_name_version(purl, "pkg:gem/") } /// Build a gem PURL from components. -pub fn build_gem_purl(name: &str, version: &str) -> String { +pub(crate) fn build_gem_purl(name: &str, version: &str) -> String { format!("pkg:gem/{name}@{version}") } /// Parse a Maven PURL to extract groupId, artifactId, and version. /// /// e.g., `"pkg:maven/org.apache.commons/commons-lang3@3.12.0"` -> `Some(("org.apache.commons", "commons-lang3", "3.12.0"))` -#[cfg(feature = "maven")] -pub fn parse_maven_purl(purl: &str) -> Option<(&str, &str, &str)> { - let base = strip_purl_qualifiers(purl); - let rest = base.strip_prefix("pkg:maven/")?; - let at_idx = rest.rfind('@')?; - let name_part = &rest[..at_idx]; - let version = &rest[at_idx + 1..]; - - if name_part.is_empty() || version.is_empty() { - return None; - } - - // Split groupId/artifactId - let slash_idx = name_part.find('/')?; - let group_id = &name_part[..slash_idx]; - let artifact_id = &name_part[slash_idx + 1..]; - - if group_id.is_empty() || artifact_id.is_empty() { - return None; - } - +pub(crate) fn parse_maven_purl(purl: &str) -> Option<(&str, &str, &str)> { + let ((group_id, artifact_id), version) = parse_namespaced(purl, "pkg:maven/")?; Some((group_id, artifact_id, version)) } /// Build a Maven PURL from components. -#[cfg(feature = "maven")] -pub fn build_maven_purl(group_id: &str, artifact_id: &str, version: &str) -> String { +pub(crate) fn build_maven_purl(group_id: &str, artifact_id: &str, version: &str) -> String { format!("pkg:maven/{group_id}/{artifact_id}@{version}") } /// Parse a Go module PURL to extract module path and version. /// /// e.g., `"pkg:golang/github.com/gin-gonic/gin@v1.9.1"` -> `Some(("github.com/gin-gonic/gin", "v1.9.1"))` -#[cfg(feature = "golang")] pub fn parse_golang_purl(purl: &str) -> Option<(&str, &str)> { - let base = strip_purl_qualifiers(purl); - let rest = base.strip_prefix("pkg:golang/")?; - let at_idx = rest.rfind('@')?; - let module_path = &rest[..at_idx]; - let version = &rest[at_idx + 1..]; - if module_path.is_empty() || version.is_empty() { - return None; - } - Some((module_path, version)) + parse_name_version(purl, "pkg:golang/") } /// Build a Go module PURL from components. -#[cfg(feature = "golang")] pub fn build_golang_purl(module_path: &str, version: &str) -> String { format!("pkg:golang/{module_path}@{version}") } @@ -202,33 +184,12 @@ pub fn build_golang_purl(module_path: &str, version: &str) -> String { /// /// Composer packages always have a namespace (vendor). /// e.g., `"pkg:composer/monolog/monolog@3.5.0"` -> `Some((("monolog", "monolog"), "3.5.0"))` -#[cfg(feature = "composer")] -pub fn parse_composer_purl(purl: &str) -> Option<((&str, &str), &str)> { - let base = strip_purl_qualifiers(purl); - let rest = base.strip_prefix("pkg:composer/")?; - let at_idx = rest.rfind('@')?; - let name_part = &rest[..at_idx]; - let version = &rest[at_idx + 1..]; - - if name_part.is_empty() || version.is_empty() { - return None; - } - - // Split namespace/name - let slash_idx = name_part.find('/')?; - let namespace = &name_part[..slash_idx]; - let name = &name_part[slash_idx + 1..]; - - if namespace.is_empty() || name.is_empty() { - return None; - } - - Some(((namespace, name), version)) +pub(crate) fn parse_composer_purl(purl: &str) -> Option<((&str, &str), &str)> { + parse_namespaced(purl, "pkg:composer/") } /// Build a Composer PURL from components. -#[cfg(feature = "composer")] -pub fn build_composer_purl(namespace: &str, name: &str, version: &str) -> String { +pub(crate) fn build_composer_purl(namespace: &str, name: &str, version: &str) -> String { format!("pkg:composer/{namespace}/{name}@{version}") } @@ -245,32 +206,21 @@ pub fn build_composer_purl(namespace: &str, name: &str, version: &str) -> String /// have a `/` namespace structure. The leading `@` on /// the scope is preserved (matching npm's `@scope/name` convention). /// `((scope, name), version)` from a JSR purl, percent-decoded. -#[cfg(feature = "deno")] -pub type JsrPurlParts<'a> = ((Cow<'a, str>, Cow<'a, str>), Cow<'a, str>); +pub(crate) type JsrPurlParts<'a> = ((Cow<'a, str>, Cow<'a, str>), Cow<'a, str>); -#[cfg(feature = "deno")] -pub fn parse_jsr_purl(purl: &str) -> Option> { - let base = strip_purl_qualifiers(purl); - let rest = base.strip_prefix("pkg:jsr/")?; - let at_idx = rest.rfind('@')?; - let name_part = &rest[..at_idx]; - let version = &rest[at_idx + 1..]; +pub(crate) fn parse_jsr_purl(purl: &str) -> Option> { + let ((scope, name), version) = parse_namespaced(purl, "pkg:jsr/")?; - if name_part.is_empty() || version.is_empty() { - return None; - } - - let slash_idx = name_part.find('/')?; - // Decode AFTER splitting on `/`/`@` and BEFORE the shape checks below + // Decode AFTER splitting on `/`/`@` and BEFORE the shape check below // (and the caller's `is_safe_jsr_component` gate) — see // `percent_decode_purl_component`. The API serves `%40scope`. - let scope = percent_decode_purl_component(&name_part[..slash_idx]); - let name = percent_decode_purl_component(&name_part[slash_idx + 1..]); + let scope = percent_decode_purl_component(scope); + let name = percent_decode_purl_component(name); let version = percent_decode_purl_component(version); // Scope must be `@`. The bare `@` (length 1) is // invalid — there's no actual scope after the marker. - if name.is_empty() || !scope.starts_with('@') || scope.len() < 2 { + if !scope.starts_with('@') || scope.len() < 2 { return None; } @@ -278,52 +228,31 @@ pub fn parse_jsr_purl(purl: &str) -> Option> { } /// Build a JSR PURL from components. -#[cfg(feature = "deno")] -pub fn build_jsr_purl(scope: &str, name: &str, version: &str) -> String { +pub(crate) fn build_jsr_purl(scope: &str, name: &str, version: &str) -> String { format!("pkg:jsr/{scope}/{name}@{version}") } /// Parse a NuGet PURL to extract name and version. /// /// e.g., `"pkg:nuget/Newtonsoft.Json@13.0.3"` -> `Some(("Newtonsoft.Json", "13.0.3"))` -#[cfg(feature = "nuget")] -pub fn parse_nuget_purl(purl: &str) -> Option<(&str, &str)> { - let base = strip_purl_qualifiers(purl); - let rest = base.strip_prefix("pkg:nuget/")?; - let at_idx = rest.rfind('@')?; - let name = &rest[..at_idx]; - let version = &rest[at_idx + 1..]; - if name.is_empty() || version.is_empty() { - return None; - } - Some((name, version)) +pub(crate) fn parse_nuget_purl(purl: &str) -> Option<(&str, &str)> { + parse_name_version(purl, "pkg:nuget/") } /// Build a NuGet PURL from components. -#[cfg(feature = "nuget")] -pub fn build_nuget_purl(name: &str, version: &str) -> String { +pub(crate) fn build_nuget_purl(name: &str, version: &str) -> String { format!("pkg:nuget/{name}@{version}") } /// Parse a Cargo PURL to extract name and version. /// /// e.g., `"pkg:cargo/serde@1.0.200"` -> `Some(("serde", "1.0.200"))` -#[cfg(feature = "cargo")] -pub fn parse_cargo_purl(purl: &str) -> Option<(&str, &str)> { - let base = strip_purl_qualifiers(purl); - let rest = base.strip_prefix("pkg:cargo/")?; - let at_idx = rest.rfind('@')?; - let name = &rest[..at_idx]; - let version = &rest[at_idx + 1..]; - if name.is_empty() || version.is_empty() { - return None; - } - Some((name, version)) +pub(crate) fn parse_cargo_purl(purl: &str) -> Option<(&str, &str)> { + parse_name_version(purl, "pkg:cargo/") } /// Build a Cargo PURL from components. -#[cfg(feature = "cargo")] -pub fn build_cargo_purl(name: &str, version: &str) -> String { +pub(crate) fn build_cargo_purl(name: &str, version: &str) -> String { format!("pkg:cargo/{name}@{version}") } @@ -445,7 +374,6 @@ mod tests { assert!(!is_purl("CVE-2024-1234")); } - #[cfg(feature = "cargo")] #[test] fn test_parse_cargo_purl() { assert_eq!( @@ -461,7 +389,6 @@ mod tests { assert_eq!(parse_cargo_purl("pkg:cargo/serde@"), None); } - #[cfg(feature = "cargo")] #[test] fn test_build_cargo_purl() { assert_eq!( @@ -470,7 +397,6 @@ mod tests { ); } - #[cfg(feature = "cargo")] #[test] fn test_cargo_purl_round_trip() { let purl = build_cargo_purl("tokio", "1.38.0"); @@ -507,7 +433,6 @@ mod tests { assert_eq!(version, "1.16.5"); } - #[cfg(feature = "maven")] #[test] fn test_parse_maven_purl() { assert_eq!( @@ -530,7 +455,6 @@ mod tests { ); } - #[cfg(feature = "maven")] #[test] fn test_build_maven_purl() { assert_eq!( @@ -539,7 +463,6 @@ mod tests { ); } - #[cfg(feature = "maven")] #[test] fn test_maven_purl_round_trip() { let purl = build_maven_purl("com.google.guava", "guava", "32.1.3-jre"); @@ -549,7 +472,6 @@ mod tests { assert_eq!(version, "32.1.3-jre"); } - #[cfg(feature = "golang")] #[test] fn test_parse_golang_purl() { assert_eq!( @@ -565,7 +487,6 @@ mod tests { assert_eq!(parse_golang_purl("pkg:golang/github.com/foo/bar@"), None); } - #[cfg(feature = "golang")] #[test] fn test_build_golang_purl() { assert_eq!( @@ -574,7 +495,6 @@ mod tests { ); } - #[cfg(feature = "golang")] #[test] fn test_golang_purl_round_trip() { let purl = build_golang_purl("golang.org/x/text", "v0.14.0"); @@ -583,7 +503,6 @@ mod tests { assert_eq!(version, "v0.14.0"); } - #[cfg(feature = "composer")] #[test] fn test_parse_composer_purl() { assert_eq!( @@ -600,7 +519,6 @@ mod tests { assert_eq!(parse_composer_purl("pkg:composer/monolog/monolog@"), None); } - #[cfg(feature = "composer")] #[test] fn test_build_composer_purl() { assert_eq!( @@ -609,12 +527,10 @@ mod tests { ); } - #[cfg(feature = "deno")] fn jsr_parts(purl: &str) -> Option<(String, String, String)> { parse_jsr_purl(purl).map(|((s, n), v)| (s.into_owned(), n.into_owned(), v.into_owned())) } - #[cfg(feature = "deno")] #[test] fn test_parse_jsr_purl() { assert_eq!( @@ -635,7 +551,6 @@ mod tests { assert_eq!(jsr_parts("pkg:npm/@std/path@0.220.0"), None); } - #[cfg(feature = "deno")] #[test] fn test_build_jsr_purl() { assert_eq!( @@ -644,7 +559,6 @@ mod tests { ); } - #[cfg(feature = "deno")] #[test] fn test_jsr_purl_round_trip() { let purl = build_jsr_purl("@std", "path", "0.220.0"); @@ -654,7 +568,6 @@ mod tests { assert_eq!(version, "0.220.0"); } - #[cfg(feature = "composer")] #[test] fn test_composer_purl_round_trip() { let purl = build_composer_purl("symfony", "console", "6.4.1"); @@ -664,7 +577,6 @@ mod tests { assert_eq!(version, "6.4.1"); } - #[cfg(feature = "nuget")] #[test] fn test_parse_nuget_purl() { assert_eq!( @@ -680,7 +592,6 @@ mod tests { assert_eq!(parse_nuget_purl("pkg:nuget/Newtonsoft.Json@"), None); } - #[cfg(feature = "nuget")] #[test] fn test_build_nuget_purl() { assert_eq!( @@ -689,7 +600,6 @@ mod tests { ); } - #[cfg(feature = "nuget")] #[test] fn test_nuget_purl_round_trip() { let purl = build_nuget_purl("System.Text.Json", "8.0.0"); @@ -732,7 +642,6 @@ mod tests { ); } - #[cfg(feature = "maven")] #[test] fn test_parse_maven_qualifier_with_embedded_at() { // groupId/artifactId split must survive an `@` buried in a @@ -745,7 +654,6 @@ mod tests { ); } - #[cfg(feature = "composer")] #[test] fn test_parse_composer_qualifier_with_embedded_at() { assert_eq!( @@ -754,7 +662,6 @@ mod tests { ); } - #[cfg(feature = "golang")] #[test] fn test_parse_golang_keeps_full_module_path() { // The module path retains its internal slashes — only the @@ -765,7 +672,6 @@ mod tests { ); } - #[cfg(feature = "deno")] #[test] fn test_parse_jsr_with_trailing_qualifier() { // Scope `@` + version `@` + qualifier `@` all coexist; only the @@ -832,7 +738,6 @@ mod tests { ); } - #[cfg(feature = "golang")] #[test] fn test_parse_golang_subpath_stripped() { // Go subpaths point at a sub-package of the same module; the parsed @@ -932,7 +837,6 @@ mod tests { )); } - #[cfg(feature = "deno")] #[test] fn test_parse_jsr_purl_percent_encoded_scope() { let ((scope, name), version) = parse_jsr_purl("pkg:jsr/%40std/path@0.220.0").unwrap(); diff --git a/crates/socket-patch-core/src/utils/serde.rs b/crates/socket-patch-core/src/utils/serde.rs new file mode 100644 index 00000000..7f5f288c --- /dev/null +++ b/crates/socket-patch-core/src/utils/serde.rs @@ -0,0 +1,21 @@ +//! Shared serde helpers. + +use serde::{Serialize, Serializer}; +use std::collections::{BTreeMap, HashMap}; + +/// Serialize a `HashMap` with its keys in sorted order so the emitted JSON +/// is deterministic across runs. Used by every git-committed ledger the +/// tool writes (`.socket/manifest.json`, `.socket/vendor/state.json`): +/// `HashMap`'s randomized iteration order would otherwise re-shuffle the +/// keys on every write, producing spurious diffs and merge conflicts. This +/// mirrors the `BTreeMap` choice in `vex::schema`, made for the same +/// "easier diffing across runs" reason. The public field type stays +/// `HashMap` (so callers and deserialization are unaffected); only the +/// on-the-wire ordering is pinned. +pub fn serialize_sorted(map: &HashMap, serializer: S) -> Result +where + S: Serializer, + V: Serialize, +{ + map.iter().collect::>().serialize(serializer) +} diff --git a/crates/socket-patch-core/src/utils/telemetry.rs b/crates/socket-patch-core/src/utils/telemetry.rs index 5fa80880..afb7c079 100644 --- a/crates/socket-patch-core/src/utils/telemetry.rs +++ b/crates/socket-patch-core/src/utils/telemetry.rs @@ -3,8 +3,10 @@ use std::collections::HashMap; use once_cell::sync::Lazy; use uuid::Uuid; -use crate::constants::{DEFAULT_PATCH_API_PROXY_URL, DEFAULT_SOCKET_API_URL, USER_AGENT}; -use crate::utils::env_compat::read_env_with_legacy; +use crate::constants::{DEFAULT_SOCKET_API_URL, USER_AGENT}; +use crate::utils::env_compat::{is_debug_enabled, proxy_url_from_env, read_env_with_legacy}; +use crate::utils::fs::home_dir; +use crate::vex::time::unix_to_ymdhms; // --------------------------------------------------------------------------- // Session ID — generated once per process invocation @@ -26,7 +28,7 @@ const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Telemetry event types for the patch lifecycle. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PatchTelemetryEventType { +enum PatchTelemetryEventType { // Write-side: apply / remove / rollback PatchApplied, PatchApplyFailed, @@ -56,7 +58,7 @@ pub enum PatchTelemetryEventType { impl PatchTelemetryEventType { /// Return the wire-format string for this event type. - pub fn as_str(&self) -> &'static str { + fn as_str(&self) -> &'static str { match self { Self::PatchApplied => "patch_applied", Self::PatchApplyFailed => "patch_apply_failed", @@ -84,49 +86,32 @@ impl PatchTelemetryEventType { /// Telemetry context describing the execution environment. #[derive(Debug, Clone, serde::Serialize)] -pub struct PatchTelemetryContext { - pub version: String, - pub platform: String, - pub arch: String, - pub command: String, +struct PatchTelemetryContext { + version: String, + platform: String, + arch: String, + command: String, } /// Error details for telemetry events. #[derive(Debug, Clone, serde::Serialize)] -pub struct PatchTelemetryError { +struct PatchTelemetryError { #[serde(rename = "type")] - pub error_type: String, - pub message: Option, + error_type: String, + message: Option, } /// Telemetry event structure for patch operations. #[derive(Debug, Clone, serde::Serialize)] -pub struct PatchTelemetryEvent { - pub event_sender_created_at: String, - pub event_type: String, - pub context: PatchTelemetryContext, - pub session_id: String, +struct PatchTelemetryEvent { + event_sender_created_at: String, + event_type: String, + context: PatchTelemetryContext, + session_id: String, #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option>, + metadata: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// Options for tracking a patch event. -pub struct TrackPatchEventOptions { - /// The type of event being tracked. - pub event_type: PatchTelemetryEventType, - /// The CLI command being executed (e.g., "apply", "remove", "rollback"). - pub command: String, - /// Optional metadata to include with the event. - pub metadata: Option>, - /// Optional error information if the operation failed. - /// Tuple of (error_type, message). - pub error: Option<(String, String)>, - /// Optional API token for authenticated telemetry endpoint. - pub api_token: Option, - /// Optional organization slug for authenticated telemetry endpoint. - pub org_slug: Option, + error: Option, } // --------------------------------------------------------------------------- @@ -161,17 +146,6 @@ pub fn is_telemetry_disabled() -> bool { disabled_via_env || vitest || offline } -/// Check if debug mode is enabled. Reads `SOCKET_DEBUG` (with legacy -/// `SOCKET_PATCH_DEBUG` shim). -fn is_debug_enabled() -> bool { - matches!( - read_env_with_legacy("SOCKET_DEBUG", "SOCKET_PATCH_DEBUG") - .unwrap_or_default() - .as_str(), - "1" | "true" - ) -} - /// Log debug messages when debug mode is enabled. fn debug_log(message: &str) { if is_debug_enabled() { @@ -198,77 +172,57 @@ fn build_telemetry_context(command: &str) -> PatchTelemetryContext { /// Replaces the user's home directory path with `~` to avoid leaking /// sensitive file system information. pub fn sanitize_error_message(message: &str) -> String { - if let Some(home) = home_dir_string() { - if !home.is_empty() { - return message.replace(&home, "~"); - } + let home = home_dir(); + let home = home.to_string_lossy(); + // `home_dir()` falls back to a literal `"~"` when no home is set, and + // replacing `"~"` with `"~"` is a no-op. A set-but-empty HOME must be + // skipped explicitly — replacing `""` would splice `~` between every byte. + // Trailing separators are trimmed so a `HOME=/home/user/` redaction keeps + // the separator (`~/.cache`, not `~.cache`); a home that trims to nothing + // (`HOME=/`, common for unmapped-UID containers) is a filesystem root with + // no user-identifying prefix to redact — replacing it would splice `~` + // between every path segment in the message. + let home = home.trim_end_matches(['/', '\\']); + if home.is_empty() { + return message.to_string(); } - message.to_string() + message.replace(home, "~") } -/// Get the home directory as a string. -fn home_dir_string() -> Option { - std::env::var("HOME") - .ok() - .or_else(|| std::env::var("USERPROFILE").ok()) -} - -/// Build a telemetry event from the given options. -fn build_telemetry_event(options: &TrackPatchEventOptions) -> PatchTelemetryEvent { - let error = options - .error - .as_ref() - .map(|(error_type, message)| PatchTelemetryError { - error_type: error_type.clone(), - message: Some(sanitize_error_message(message)), - }); - +/// Build a telemetry event. `error` is an `(error_type, message)` pair; the +/// message is home-dir-sanitized before it leaves the process. +fn build_telemetry_event( + event_type: PatchTelemetryEventType, + command: &str, + metadata: Option>, + error: Option<(String, String)>, +) -> PatchTelemetryEvent { PatchTelemetryEvent { event_sender_created_at: chrono_now_iso(), - event_type: options.event_type.as_str().to_string(), - context: build_telemetry_context(&options.command), + event_type: event_type.as_str().to_string(), + context: build_telemetry_context(command), session_id: SESSION_ID.clone(), - metadata: options.metadata.clone(), - error, + metadata, + error: error.map(|(error_type, message)| PatchTelemetryError { + error_type, + message: Some(sanitize_error_message(&message)), + }), } } -/// Get the current time as an ISO 8601 string. +/// Get the current time as an ISO 8601 string with millisecond precision, +/// e.g. `2024-01-15T10:30:45.123Z`. The civil-date arithmetic is shared with +/// `vex::time` (`unix_to_ymdhms`); only the `.mmm` suffix differs from the +/// RFC 3339 string vex emits. fn chrono_now_iso() -> String { - let now = std::time::SystemTime::now(); - let duration = now + let duration = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default(); - let secs = duration.as_secs(); - - let days = secs / 86400; - let remaining = secs % 86400; - let hours = remaining / 3600; - let minutes = (remaining % 3600) / 60; - let seconds = remaining % 60; + let (year, month, day, hours, minutes, seconds) = unix_to_ymdhms(duration.as_secs()); let millis = duration.subsec_millis(); - - let (year, month, day) = days_to_ymd(days); - format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{millis:03}Z") } -/// Convert days since Unix epoch to (year, month, day). -fn days_to_ymd(days: u64) -> (u64, u64, u64) { - // Adapted from Howard Hinnant's civil_from_days algorithm - let z = days as i64 + 719468; - let era = if z >= 0 { z } else { z - 146096 } / 146097; - let doe = (z - era * 146097) as u64; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - (y as u64, m, d) -} - // --------------------------------------------------------------------------- // Send event // --------------------------------------------------------------------------- @@ -299,9 +253,7 @@ fn resolve_telemetry_endpoint(api_token: Option<&str>, org_slug: Option<&str>) - (format!("{api_url}/v0/orgs/{slug}/telemetry"), true) } _ => { - let proxy_url = read_env_with_legacy("SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL") - .filter(|u| !u.is_empty()) - .unwrap_or_else(|| DEFAULT_PATCH_API_PROXY_URL.to_string()); + let proxy_url = proxy_url_from_env(); let proxy_url = proxy_url.trim_end_matches('/'); (format!("{proxy_url}/patch/telemetry"), false) } @@ -359,57 +311,19 @@ async fn send_telemetry_event( } // --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/// Track a patch lifecycle event. -/// -/// This function is non-blocking and will never return errors. Telemetry -/// failures are logged in debug mode but do not affect CLI operation. -/// -/// If telemetry is disabled (via environment variables), the function returns -/// immediately. -pub async fn track_patch_event(options: TrackPatchEventOptions) { - if is_telemetry_disabled() { - debug_log("Telemetry is disabled, skipping event"); - return; - } - - let event = build_telemetry_event(&options); - send_telemetry_event( - &event, - options.api_token.as_deref(), - options.org_slug.as_deref(), - ) - .await; -} - -// --------------------------------------------------------------------------- -// Convenience functions +// Per-event tracker wrappers (the public API) // // These accept `Option<&str>` for api_token/org_slug to make call sites // convenient (callers typically have `Option` and call `.as_deref()`). // --------------------------------------------------------------------------- -/// Convert a `serde_json::json!({...})` object into the `HashMap` that -/// [`TrackPatchEventOptions::metadata`] expects, swallowing the conversion -/// to avoid `.unwrap()` noise at every call site. -fn metadata_from_json(value: serde_json::Value) -> Option> { - match value { - serde_json::Value::Object(map) => { - if map.is_empty() { - None - } else { - Some(map.into_iter().collect()) - } - } - _ => None, - } -} - /// Shared fire-and-forget helper for the per-event tracker wrappers below. -/// Centralizes the `String::from` plumbing for the four optional fields -/// that every tracker shares. +/// +/// Non-blocking and never returns errors: telemetry failures are logged in +/// debug mode but do not affect CLI operation. Returns immediately when +/// telemetry is disabled via environment variables. `metadata` is a +/// `serde_json::json!({...})` object; non-object / empty values are dropped +/// to avoid `.unwrap()` noise at every call site. async fn fire( event_type: PatchTelemetryEventType, command: &'static str, @@ -418,15 +332,18 @@ async fn fire( api_token: Option<&str>, org_slug: Option<&str>, ) { - track_patch_event(TrackPatchEventOptions { - event_type, - command: command.to_string(), - metadata: metadata_from_json(metadata), - error: error.map(|e| ("Error".to_string(), e.to_string())), - api_token: api_token.map(String::from), - org_slug: org_slug.map(String::from), - }) - .await; + if is_telemetry_disabled() { + debug_log("Telemetry is disabled, skipping event"); + return; + } + + let metadata = match metadata { + serde_json::Value::Object(map) if !map.is_empty() => Some(map.into_iter().collect()), + _ => None, + }; + let error = error.map(|e| ("Error".to_string(), e.to_string())); + let event = build_telemetry_event(event_type, command, metadata, error); + send_telemetry_event(&event, api_token, org_slug).await; } /// Track a successful patch application. @@ -583,8 +500,7 @@ pub async fn track_patch_rollback_failed( /// The argument count intentionally mirrors the metadata fields the /// dashboard needs — grouping them into a struct would force callers /// to build a config object for a single fire-and-forget call, which -/// is worse ergonomics for a tracker. `track_patch_event` is the -/// general path when you need that flexibility. +/// is worse ergonomics for a tracker. #[allow(clippy::too_many_arguments)] pub async fn track_patch_scanned( packages_scanned: usize, @@ -913,7 +829,9 @@ mod tests { #[test] fn test_sanitize_error_message() { - let home = home_dir_string().unwrap_or_else(|| "/home/testuser".to_string()); + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| "/home/testuser".to_string()); let msg = format!("Failed to read {home}/projects/secret/file.txt"); let sanitized = sanitize_error_message(&msg); assert!(sanitized.contains("~/projects/secret/file.txt")); @@ -1031,16 +949,8 @@ mod tests { #[test] fn test_build_telemetry_event_basic() { - let options = TrackPatchEventOptions { - event_type: PatchTelemetryEventType::PatchApplied, - command: "apply".to_string(), - metadata: None, - error: None, - api_token: None, - org_slug: None, - }; - - let event = build_telemetry_event(&options); + let event = + build_telemetry_event(PatchTelemetryEventType::PatchApplied, "apply", None, None); assert_eq!(event.event_type, "patch_applied"); assert_eq!(event.context.command, "apply"); assert!(!event.session_id.is_empty()); @@ -1057,16 +967,12 @@ mod tests { serde_json::Value::Number(5.into()), ); - let options = TrackPatchEventOptions { - event_type: PatchTelemetryEventType::PatchApplied, - command: "apply".to_string(), - metadata: Some(metadata), - error: None, - api_token: None, - org_slug: None, - }; - - let event = build_telemetry_event(&options); + let event = build_telemetry_event( + PatchTelemetryEventType::PatchApplied, + "apply", + Some(metadata), + None, + ); assert!(event.metadata.is_some()); let meta = event.metadata.unwrap(); assert_eq!( @@ -1077,16 +983,12 @@ mod tests { #[test] fn test_build_telemetry_event_with_error() { - let options = TrackPatchEventOptions { - event_type: PatchTelemetryEventType::PatchApplyFailed, - command: "apply".to_string(), - metadata: None, - error: Some(("IoError".to_string(), "file not found".to_string())), - api_token: None, - org_slug: None, - }; - - let event = build_telemetry_event(&options); + let event = build_telemetry_event( + PatchTelemetryEventType::PatchApplyFailed, + "apply", + None, + Some(("IoError".to_string(), "file not found".to_string())), + ); assert!(event.error.is_some()); let err = event.error.unwrap(); assert_eq!(err.error_type, "IoError"); @@ -1114,93 +1016,6 @@ mod tests { assert_eq!(ts.len(), 24); // YYYY-MM-DDTHH:MM:SS.mmmZ } - #[test] - fn test_days_to_ymd_epoch() { - let (y, m, d) = days_to_ymd(0); - assert_eq!((y, m, d), (1970, 1, 1)); - } - - #[test] - fn test_days_to_ymd_known_date() { - // 2024-01-01 is day 19723 - let (y, m, d) = days_to_ymd(19723); - assert_eq!((y, m, d), (2024, 1, 1)); - } - - /// Independent brute-force civil-date counter used to cross-check - /// `days_to_ymd` (Howard Hinnant's algorithm) without sharing any of its - /// arithmetic — so a regression in either is caught. - fn brute_days_to_ymd(days: u64) -> (u64, u64, u64) { - fn is_leap(y: u64) -> bool { - (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400) - } - let mut rem = days; - let mut y = 1970u64; - loop { - let year_len = if is_leap(y) { 366 } else { 365 }; - if rem < year_len { - break; - } - rem -= year_len; - y += 1; - } - let months = [ - 31, - if is_leap(y) { 29 } else { 28 }, - 31, - 30, - 31, - 30, - 31, - 31, - 30, - 31, - 30, - 31, - ]; - let mut m = 0usize; - while rem >= months[m] { - rem -= months[m]; - m += 1; - } - (y, (m + 1) as u64, rem + 1) - } - - /// Spot-check the trickiest civil-date edges: leap day, the day after, - /// non-leap century boundaries (1900/2100/2200/2300 — divisible by 100 but - /// not 400, so NOT leap) and leap centuries (2000/2400), plus year/month - /// rollovers. Each is computed by hand to anchor the value. - #[test] - fn test_days_to_ymd_edge_dates() { - // 2000-02-29 (leap century) and the day after. - assert_eq!(brute_days_to_ymd(11016), (2000, 2, 29)); // anchor the oracle - assert_eq!(days_to_ymd(11016), (2000, 2, 29)); - assert_eq!(days_to_ymd(11017), (2000, 3, 1)); - - // 2100-02-28 must NOT be followed by Feb 29 — 2100 is not a leap year. - let feb28_2100 = brute_days_to_ymd(47540); - assert_eq!(feb28_2100, (2100, 2, 28)); - assert_eq!(days_to_ymd(47540), (2100, 2, 28)); - assert_eq!(days_to_ymd(47541), (2100, 3, 1)); - - // Year/month rollover: Dec 31 -> Jan 1. - assert_eq!(days_to_ymd(19722), (2023, 12, 31)); - assert_eq!(days_to_ymd(19723), (2024, 1, 1)); - } - - /// Exhaustive cross-check against the independent counter across ~1265 - /// years, covering every leap rule and century boundary through 3235. - #[test] - fn test_days_to_ymd_matches_brute_force() { - for days in 0..462_000u64 { - assert_eq!( - days_to_ymd(days), - brute_days_to_ymd(days), - "mismatch at day {days}" - ); - } - } - /// The time-of-day split in `chrono_now_iso` must carve a within-day /// second offset into the right h/m/s buckets. We reconstruct the exact /// arithmetic for a known offset (23:59:59 on day 0 = epoch) by parsing diff --git a/crates/socket-patch-core/src/vex/build.rs b/crates/socket-patch-core/src/vex/build.rs index dbe8c211..22919652 100644 --- a/crates/socket-patch-core/src/vex/build.rs +++ b/crates/socket-patch-core/src/vex/build.rs @@ -11,7 +11,7 @@ //! the latter become aliases. When two patches fix the same vuln ID //! they merge into one statement with both PURLs as subcomponents. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use crate::manifest::schema::PatchManifest; use crate::vex::schema::{ @@ -42,39 +42,21 @@ pub struct BuildOptions { /// `applied` are silently dropped — see the design note in /// `vex::verify` for why we never emit `affected`. /// +/// PURLs in `vendored` (a subset of `applied`, from +/// `VerifyOutcome::vendored`) carry the impact-statement phrasing +/// "Patched via Socket patch `` (vendored)" so the attestation +/// records that the evidence is the committed `.socket/vendor/` +/// artifact, not the installed tree. PURLs in `redirected` (pointed at +/// Socket's hosted vendored patches by `scan --redirect`) carry +/// "(redirected)" instead. The two sets are disjoint in practice +/// (`--redirect` conflicts with `--vendor`); if a PURL somehow appears +/// in both, `vendored` wins. Status and justification are identical +/// across all three phrasings. +/// /// Returns `None` when no statements can be emitted (no applied /// patches matched the manifest). The CLI converts `None` into a /// non-zero exit code per the agreed contract. pub fn build_document( - manifest: &PatchManifest, - applied: &[String], - opts: &BuildOptions, -) -> Option { - build_document_with_vendored(manifest, applied, &[], opts) -} - -/// [`build_document`] with vendored-patch awareness: PURLs in `vendored` -/// (a subset of `applied`, from `VerifyOutcome::vendored`) carry the -/// impact-statement phrasing "Patched via Socket patch `` (vendored)" -/// so the attestation records that the evidence is the committed -/// `.socket/vendor/` artifact, not the installed tree. Status and -/// justification are identical to the non-vendored form. -pub fn build_document_with_vendored( - manifest: &PatchManifest, - applied: &[String], - vendored: &[String], - opts: &BuildOptions, -) -> Option { - build_document_with_provenance(manifest, applied, vendored, &[], opts) -} - -/// [`build_document_with_vendored`] extended with a `redirected` set (PURLs -/// pointed at Socket's hosted vendored patches by `scan --redirect`). A -/// redirected PURL carries the phrasing "Patched via Socket patch `` -/// (redirected)". `vendored` and `redirected` are disjoint in practice -/// (`--redirect` conflicts with `--vendor`); if a PURL somehow appears in -/// both, `vendored` wins. Status and justification are unchanged. -pub fn build_document_with_provenance( manifest: &PatchManifest, applied: &[String], vendored: &[String], @@ -82,15 +64,14 @@ pub fn build_document_with_provenance( opts: &BuildOptions, ) -> Option { let timestamp = now_rfc3339(); - let applied_set: std::collections::HashSet<&str> = applied.iter().map(|s| s.as_str()).collect(); - let vendored_set: std::collections::HashSet<&str> = - vendored.iter().map(|s| s.as_str()).collect(); - let redirected_set: std::collections::HashSet<&str> = - redirected.iter().map(|s| s.as_str()).collect(); + let applied_set: HashSet<&str> = applied.iter().map(String::as_str).collect(); + let vendored_set: HashSet<&str> = vendored.iter().map(String::as_str).collect(); + let redirected_set: HashSet<&str> = redirected.iter().map(String::as_str).collect(); // vuln-id -> (aliases, impact-statement parts, subcomponent PURLs) - // BTreeMap keeps statement order deterministic by vuln id, which - // helps reproducibility for downstream diffs. + // BTreeMap/BTreeSet keep every output field sorted, which keeps + // statement order deterministic and helps reproducibility for + // downstream diffs. let mut grouped: BTreeMap = BTreeMap::new(); for (purl, record) in &manifest.patches { @@ -99,15 +80,11 @@ pub fn build_document_with_provenance( } for (vuln_id, info) in &record.vulnerabilities { let entry = grouped.entry(vuln_id.clone()).or_default(); - for cve in &info.cves { - if !entry.aliases.contains(cve) { - entry.aliases.push(cve.clone()); - } - } + entry.aliases.extend(info.cves.iter().cloned()); entry.subcomponents.insert(purl.clone()); entry .impact_parts - .push(if vendored_set.contains(purl.as_str()) { + .insert(if vendored_set.contains(purl.as_str()) { format!("Patched via Socket patch {} (vendored)", record.uuid) } else if redirected_set.contains(purl.as_str()) { format!("Patched via Socket patch {} (redirected)", record.uuid) @@ -123,12 +100,8 @@ pub fn build_document_with_provenance( let mut statements = Vec::with_capacity(grouped.len()); for (vuln_id, group) in grouped { - let mut aliases = group.aliases; - aliases.sort(); - - let mut subcomponent_ids: Vec = group.subcomponents.into_iter().collect(); - subcomponent_ids.sort(); - let subcomponents = subcomponent_ids + let subcomponents = group + .subcomponents .into_iter() .map(|id| Subcomponent { id, @@ -137,25 +110,28 @@ pub fn build_document_with_provenance( }) .collect(); - let mut parts = group.impact_parts; - parts.sort(); - parts.dedup(); - // The `parts.is_empty()` branch is unreachable from the - // public API: the loop above pushes one entry per applied + // The empty-parts branch is unreachable from the public + // API: the loop above inserts one entry per applied // (purl, vuln) pair, so every group present in `grouped` // has ≥1 entry. The defensive `None` arm stays in case a // future refactor decouples grouping from impact tracking. - let impact_statement = if parts.is_empty() { + let impact_statement = if group.impact_parts.is_empty() { None } else { - Some(parts.join("; ")) + Some( + group + .impact_parts + .into_iter() + .collect::>() + .join("; "), + ) }; statements.push(Statement { id: None, vulnerability: Vulnerability { name: vuln_id, - aliases, + aliases: group.aliases.into_iter().collect(), }, timestamp: Some(timestamp.clone()), last_updated: None, @@ -188,9 +164,9 @@ pub fn build_document_with_provenance( #[derive(Default)] struct VulnGroup { - aliases: Vec, - subcomponents: std::collections::HashSet, - impact_parts: Vec, + aliases: BTreeSet, + subcomponents: BTreeSet, + impact_parts: BTreeSet, } #[cfg(test)] @@ -240,10 +216,16 @@ mod tests { } } + /// [`build_document`] with no vendored/redirected PURLs and the + /// default [`opts`]. + fn build_plain(manifest: &PatchManifest, applied: &[String]) -> Option { + build_document(manifest, applied, &[], &[], &opts()) + } + #[test] fn empty_applied_returns_none() { let manifest = PatchManifest::new(); - assert!(build_document(&manifest, &[], &opts()).is_none()); + assert!(build_plain(&manifest, &[]).is_none()); } #[test] @@ -254,7 +236,7 @@ mod tests { record("u1", vec![("GHSA-aaaa", vec!["CVE-2024-1"])]), ); // applied is empty → no statements → None. - assert!(build_document(&manifest, &[], &opts()).is_none()); + assert!(build_plain(&manifest, &[]).is_none()); } #[test] @@ -264,8 +246,7 @@ mod tests { "pkg:npm/lodash@4.0.0".to_string(), record("u1", vec![("GHSA-aaaa", vec!["CVE-2024-1"])]), ); - let doc = - build_document(&manifest, &["pkg:npm/lodash@4.0.0".to_string()], &opts()).unwrap(); + let doc = build_plain(&manifest, &["pkg:npm/lodash@4.0.0".to_string()]).unwrap(); assert_eq!(doc.statements.len(), 1); let st = &doc.statements[0]; @@ -290,7 +271,7 @@ mod tests { "pkg:npm/x@1.0.0".to_string(), record("u1", vec![("GHSA-bbbb", vec!["CVE-2024-2", "CVE-2024-3"])]), ); - let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts()).unwrap(); + let doc = build_plain(&manifest, &["pkg:npm/x@1.0.0".to_string()]).unwrap(); let aliases = &doc.statements[0].vulnerability.aliases; assert_eq!(aliases.len(), 2); // Sorted for determinism. @@ -310,10 +291,9 @@ mod tests { record("u2", vec![("GHSA-cccc", vec!["CVE-A"])]), ); - let doc = build_document( + let doc = build_plain( &manifest, &["pkg:npm/x@1.0.0".to_string(), "pkg:npm/y@2.0.0".to_string()], - &opts(), ) .unwrap(); @@ -340,7 +320,7 @@ mod tests { ), ); - let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts()).unwrap(); + let doc = build_plain(&manifest, &["pkg:npm/x@1.0.0".to_string()]).unwrap(); assert_eq!(doc.statements.len(), 2); // BTreeMap order → sorted by vuln id. assert_eq!(doc.statements[0].vulnerability.name, "GHSA-aaaa"); @@ -354,7 +334,7 @@ mod tests { "pkg:npm/x@1.0.0".to_string(), record("u1", vec![("GHSA-aaaa", vec![])]), ); - let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts()).unwrap(); + let doc = build_plain(&manifest, &["pkg:npm/x@1.0.0".to_string()]).unwrap(); assert_eq!(doc.context, OPENVEX_CONTEXT_V0_2_0); assert_eq!(doc.id, "urn:uuid:test"); assert_eq!(doc.author, "Socket"); @@ -374,13 +354,12 @@ mod tests { record("u1", vec![("GHSA-aaaa", vec!["CVE-1"])]), ); - let doc = build_document( + let doc = build_plain( &manifest, &[ "pkg:npm/in-manifest@1.0.0".to_string(), "pkg:npm/ghost@9.9.9".to_string(), // not in manifest ], - &opts(), ) .unwrap(); @@ -404,13 +383,12 @@ mod tests { .patches .insert("pkg:npm/no-vuln@2.0.0".to_string(), record("u2", vec![])); - let doc = build_document( + let doc = build_plain( &manifest, &[ "pkg:npm/with-vuln@1.0.0".to_string(), "pkg:npm/no-vuln@2.0.0".to_string(), ], - &opts(), ) .unwrap(); @@ -429,7 +407,7 @@ mod tests { "pkg:npm/x@1.0.0".to_string(), record("u1", vec![("GHSA-no-cves", vec![])]), ); - let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts()).unwrap(); + let doc = build_plain(&manifest, &["pkg:npm/x@1.0.0".to_string()]).unwrap(); assert_eq!(doc.statements[0].vulnerability.aliases.len(), 0); // Serialize and verify the JSON omits the `aliases` key. @@ -461,10 +439,9 @@ mod tests { ), ); - let doc = build_document( + let doc = build_plain( &manifest, &["pkg:npm/x@1.0.0".to_string(), "pkg:npm/y@2.0.0".to_string()], - &opts(), ) .unwrap(); @@ -498,10 +475,9 @@ mod tests { record("shared-uuid", vec![("GHSA-shared", vec!["CVE-1"])]), ); - let doc = build_document( + let doc = build_plain( &manifest, &["pkg:npm/x@1.0.0".to_string(), "pkg:npm/x@1.0.1".to_string()], - &opts(), ) .unwrap(); let imp = doc.statements[0].impact_statement.as_ref().unwrap(); @@ -529,7 +505,8 @@ mod tests { author: "Socket".to_string(), tooling: None, }; - let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts).unwrap(); + let doc = + build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &[], &[], &opts).unwrap(); assert!(doc.tooling.is_none()); let v = serde_json::to_value(&doc).unwrap(); @@ -551,7 +528,8 @@ mod tests { author: String::new(), tooling: None, }; - let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts).unwrap(); + let doc = + build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &[], &[], &opts).unwrap(); assert_eq!(doc.author, ""); } @@ -579,8 +557,8 @@ mod tests { let applied = vec!["pkg:npm/x@1.0.0".to_string(), "pkg:npm/y@2.0.0".to_string()]; - let a = build_document(&manifest, &applied, &opts()).unwrap(); - let b = build_document(&manifest, &applied, &opts()).unwrap(); + let a = build_plain(&manifest, &applied).unwrap(); + let b = build_plain(&manifest, &applied).unwrap(); // Sanity-strip the per-run timestamp before comparing. let strip = |mut d: Document| -> Document { @@ -606,7 +584,7 @@ mod tests { vec![("GHSA-a", vec!["CVE-1"]), ("GHSA-b", vec!["CVE-2"])], ), ); - let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts()).unwrap(); + let doc = build_plain(&manifest, &["pkg:npm/x@1.0.0".to_string()]).unwrap(); for st in &doc.statements { assert_eq!(st.timestamp.as_deref(), Some(doc.timestamp.as_str())); } @@ -626,10 +604,9 @@ mod tests { manifest .patches .insert("pkg:npm/b@2.0.0".to_string(), record("u2", vec![])); - let doc = build_document( + let doc = build_plain( &manifest, &["pkg:npm/a@1.0.0".to_string(), "pkg:npm/b@2.0.0".to_string()], - &opts(), ); assert!( doc.is_none(), @@ -694,8 +671,8 @@ mod tests { "pkg:npm/aaa@1.0.0".to_string(), "pkg:npm/zzz@9.0.0".to_string(), ]; - let da = strip(build_document(&a, &applied, &opts()).unwrap()); - let db = strip(build_document(&b, &applied, &opts()).unwrap()); + let da = strip(build_plain(&a, &applied).unwrap()); + let db = strip(build_plain(&b, &applied).unwrap()); assert_eq!(da, db, "output must not depend on manifest insertion order"); } @@ -717,14 +694,13 @@ mod tests { record("u-m", vec![("GHSA-shared", vec![])]), ); - let doc = build_document( + let doc = build_plain( &manifest, &[ "pkg:npm/zzz@1.0.0".to_string(), "pkg:npm/aaa@1.0.0".to_string(), "pkg:npm/mmm@1.0.0".to_string(), ], - &opts(), ) .unwrap(); @@ -735,7 +711,7 @@ mod tests { assert_eq!(subs[2].id, "pkg:npm/zzz@1.0.0"); } - // ── Vendored-patch phrasing (`build_document_with_vendored`) ── + // ── Vendored/redirected-patch phrasing ──────────────────────── /// A vendored PURL's impact statement carries the "(vendored)" suffix; /// status/justification stay identical to the non-vendored form. @@ -747,7 +723,7 @@ mod tests { record("u-vend", vec![("GHSA-vvvv", vec!["CVE-2024-7"])]), ); let applied = vec!["pkg:cargo/serde@1.0.0".to_string()]; - let doc = build_document_with_vendored(&manifest, &applied, &applied, &opts()).unwrap(); + let doc = build_document(&manifest, &applied, &applied, &[], &opts()).unwrap(); let st = &doc.statements[0]; assert_eq!( st.impact_statement.as_deref(), @@ -771,8 +747,7 @@ mod tests { record("u-rdir", vec![("GHSA-rrrr", vec!["CVE-2024-8"])]), ); let applied = vec!["pkg:npm/left-pad@1.3.0".to_string()]; - let doc = - build_document_with_provenance(&manifest, &applied, &[], &applied, &opts()).unwrap(); + let doc = build_document(&manifest, &applied, &[], &applied, &opts()).unwrap(); let st = &doc.statements[0]; assert_eq!( st.impact_statement.as_deref(), @@ -796,39 +771,28 @@ mod tests { record("u-both", vec![("GHSA-both", vec!["CVE-2024-9"])]), ); let applied = vec!["pkg:cargo/serde@1.0.0".to_string()]; - let doc = build_document_with_provenance(&manifest, &applied, &applied, &applied, &opts()) - .unwrap(); + let doc = build_document(&manifest, &applied, &applied, &applied, &opts()).unwrap(); assert_eq!( doc.statements[0].impact_statement.as_deref(), Some("Patched via Socket patch u-both (vendored)") ); } - /// `build_document` is exactly `build_document_with_vendored(.., &[], ..)` - /// — no "(vendored)" phrasing without a vendored set. + /// Empty `vendored`/`redirected` sets → the plain phrasing, with + /// no "(vendored)" or "(redirected)" suffix. #[test] - fn build_document_is_empty_vendored_wrapper() { + fn empty_provenance_sets_produce_plain_phrasing() { let mut manifest = PatchManifest::new(); manifest.patches.insert( "pkg:npm/x@1.0.0".to_string(), record("u1", vec![("GHSA-aaaa", vec!["CVE-1"])]), ); let applied = vec!["pkg:npm/x@1.0.0".to_string()]; - let strip = |mut d: Document| -> Document { - d.timestamp = String::new(); - for s in d.statements.iter_mut() { - s.timestamp = None; - } - d - }; - let a = strip(build_document(&manifest, &applied, &opts()).unwrap()); - let b = strip(build_document_with_vendored(&manifest, &applied, &[], &opts()).unwrap()); - assert_eq!(a, b); - assert!(!a.statements[0] - .impact_statement - .as_deref() - .unwrap() - .contains("(vendored)")); + let doc = build_document(&manifest, &applied, &[], &[], &opts()).unwrap(); + assert_eq!( + doc.statements[0].impact_statement.as_deref(), + Some("Patched via Socket patch u1") + ); } /// Same patch UUID across a vendored and a non-vendored PURL sharing a @@ -847,7 +811,7 @@ mod tests { ); let applied = vec!["pkg:npm/x@1.0.0".to_string(), "pkg:npm/x@1.0.1".to_string()]; let vendored = vec!["pkg:npm/x@1.0.1".to_string()]; - let doc = build_document_with_vendored(&manifest, &applied, &vendored, &opts()).unwrap(); + let doc = build_document(&manifest, &applied, &vendored, &[], &opts()).unwrap(); let imp = doc.statements[0].impact_statement.as_ref().unwrap(); assert!( imp.contains("Patched via Socket patch shared-uuid (vendored)"), @@ -880,7 +844,7 @@ mod tests { record("shared-uuid", vec![("GHSA-shared", vec!["CVE-1"])]), ); let applied = vec!["pkg:npm/x@1.0.0".to_string(), "pkg:npm/x@1.0.1".to_string()]; - let doc = build_document_with_vendored(&manifest, &applied, &applied, &opts()).unwrap(); + let doc = build_document(&manifest, &applied, &applied, &[], &opts()).unwrap(); let imp = doc.statements[0].impact_statement.as_ref().unwrap(); assert_eq!( imp.matches("shared-uuid").count(), diff --git a/crates/socket-patch-core/src/vex/conformance_tests.rs b/crates/socket-patch-core/src/vex/conformance_tests.rs index 8f25cbb8..7e33eef3 100644 --- a/crates/socket-patch-core/src/vex/conformance_tests.rs +++ b/crates/socket-patch-core/src/vex/conformance_tests.rs @@ -72,6 +72,8 @@ fn sample_doc() -> Document { "pkg:npm/lodash@4.17.20".to_string(), "pkg:npm/minimist@1.2.0".to_string(), ], + &[], + &[], &options(), ) .expect("build sample doc") @@ -104,6 +106,8 @@ fn merged_doc() -> Document { "pkg:npm/aaa@1.0.0".to_string(), "pkg:npm/bbb@2.0.0".to_string(), ], + &[], + &[], &options(), ) .expect("build merged doc") diff --git a/crates/socket-patch-core/src/vex/mod.rs b/crates/socket-patch-core/src/vex/mod.rs index abc79e8a..eac35c24 100644 --- a/crates/socket-patch-core/src/vex/mod.rs +++ b/crates/socket-patch-core/src/vex/mod.rs @@ -20,9 +20,7 @@ pub mod schema; pub mod time; pub mod verify; -pub use build::{ - build_document, build_document_with_provenance, build_document_with_vendored, BuildOptions, -}; +pub use build::{build_document, BuildOptions}; pub use product::{detect_product, DetectResult}; pub use schema::{ Document, Justification, Product, Statement, Status, Subcomponent, Vulnerability, diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index d39d23db..057737c3 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -19,6 +19,15 @@ use std::path::Path; +// npm/Node strip a BOM from package.json and cargo accepts one in Cargo.toml, +// but serde_json and the line scanner both reject it — without this, manifests +// the user's own toolchain accepts yield no PURL. +use crate::package_json::detect::strip_bom; + +/// Version-extracting parser for one manifest flavor, keyed by file name in +/// the priority table inside [`detect_product`]. +type ManifestParser = fn(&str) -> Option; + /// Outcome of [`detect_product`]. #[derive(Debug, Clone, Default)] pub struct DetectResult { @@ -38,50 +47,33 @@ pub async fn detect_product(cwd: &Path) -> DetectResult { return result; } - let pkg_json = cwd.join("package.json"); - let pyproject = cwd.join("pyproject.toml"); - let cargo = cwd.join("Cargo.toml"); - - let pkg_json_exists = tokio::fs::metadata(&pkg_json).await.is_ok(); - let pyproject_exists = tokio::fs::metadata(&pyproject).await.is_ok(); - let cargo_exists = tokio::fs::metadata(&cargo).await.is_ok(); - - // Names of every manifest present, in priority order — used for the - // "detected (...)" portion of the multi-manifest warning. + // 2. Package manifests, in priority order. `present` collects every + // manifest on disk for the "detected (...)" portion of the + // multi-manifest warning; `selected` records the manifest ACTUALLY + // used — not merely the highest-priority one present, because that + // one may fail to parse (invalid JSON, missing version, workspace + // inheritance) and fall through to a lower-priority manifest. The + // warning must name what we used, otherwise it misreports the source. + let manifests: [(&str, ManifestParser); 3] = [ + ("package.json", parse_package_json), + ("pyproject.toml", parse_pyproject), + ("Cargo.toml", parse_cargo_toml), + ]; let mut present = Vec::new(); - if pkg_json_exists { - present.push("package.json"); - } - if pyproject_exists { - present.push("pyproject.toml"); - } - if cargo_exists { - present.push("Cargo.toml"); - } - - // Read manifests in priority order, taking the first that yields a - // usable PURL. `selected` records the manifest ACTUALLY used — not - // merely the highest-priority one present, because that one may fail - // to parse (invalid JSON, missing version, workspace inheritance) and - // fall through to a lower-priority manifest. The warning must name - // what we used, otherwise it misreports the source. let mut selected: Option<&str> = None; - if pkg_json_exists { - if let Some(purl) = read_package_json(&pkg_json).await { - result.purl = Some(purl); - selected = Some("package.json"); - } - } - if result.purl.is_none() && pyproject_exists { - if let Some(purl) = read_pyproject(&pyproject).await { - result.purl = Some(purl); - selected = Some("pyproject.toml"); + for (name, parse) in manifests { + let path = cwd.join(name); + if tokio::fs::metadata(&path).await.is_err() { + continue; } - } - if result.purl.is_none() && cargo_exists { - if let Some(purl) = read_cargo_toml(&cargo).await { - result.purl = Some(purl); - selected = Some("Cargo.toml"); + present.push(name); + if result.purl.is_none() { + if let Ok(content) = tokio::fs::read_to_string(&path).await { + if let Some(purl) = parse(&content) { + result.purl = Some(purl); + selected = Some(name); + } + } } } @@ -100,17 +92,8 @@ pub async fn detect_product(cwd: &Path) -> DetectResult { result } -/// Strip a leading UTF-8 BOM. npm/Node strip one from package.json and -/// cargo accepts one in Cargo.toml, but serde_json and the line scanner -/// both reject it — without this, manifests the user's own toolchain -/// accepts yield no PURL. Mirrors `package_json/detect.rs`. -fn strip_bom(content: &str) -> &str { - content.strip_prefix('\u{feff}').unwrap_or(content) -} - -async fn read_package_json(path: &Path) -> Option { - let content = tokio::fs::read_to_string(path).await.ok()?; - let v: serde_json::Value = serde_json::from_str(strip_bom(&content)).ok()?; +fn parse_package_json(content: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(strip_bom(content)).ok()?; let name = v.get("name")?.as_str()?; let version = v.get("version")?.as_str()?; if name.is_empty() || version.is_empty() { @@ -121,21 +104,19 @@ async fn read_package_json(path: &Path) -> Option { Some(format!("pkg:npm/{name}@{version}")) } -async fn read_pyproject(path: &Path) -> Option { - let content = tokio::fs::read_to_string(path).await.ok()?; +fn parse_pyproject(content: &str) -> Option { // No BOM strip here, unlike npm/cargo: tomllib (and pip's vendored // tomli) reject a BOM'd pyproject.toml outright, so such a file is // not a buildable Python project and must keep yielding None. // PEP 621 `[project]` takes precedence (newer projects favor it), // then fall back to Poetry's `[tool.poetry]` for legacy layouts. - let (name, version) = scan_toml_section(&content, "project") - .or_else(|| scan_toml_section(&content, "tool.poetry"))?; + let (name, version) = scan_toml_section(content, "project") + .or_else(|| scan_toml_section(content, "tool.poetry"))?; Some(format!("pkg:pypi/{name}@{version}")) } -async fn read_cargo_toml(path: &Path) -> Option { - let content = tokio::fs::read_to_string(path).await.ok()?; - let (name, version) = scan_toml_section(strip_bom(&content), "package")?; +fn parse_cargo_toml(content: &str) -> Option { + let (name, version) = scan_toml_section(strip_bom(content), "package")?; Some(format!("pkg:cargo/{name}@{version}")) } @@ -153,22 +134,22 @@ fn scan_toml_section(content: &str, section: &str) -> Option<(String, String)> { let mut in_section = false; let mut name: Option = None; let mut version: Option = None; - let header = format!("[{section}]"); for raw in content.lines() { let line = raw.trim(); if line.is_empty() || line.starts_with('#') { continue; } - if line.starts_with('[') { - // A header may carry a trailing comment (`[package] # x`) — - // valid TOML that cargo and tomllib both accept. Anything + if let Some(rest) = line.strip_prefix('[') { + // A header may carry a trailing comment (`[package] # x`) + // and whitespace inside the brackets (`[ package ]`) — + // both valid TOML that cargo and tomllib accept. Anything // else after the closing bracket means a different (or // malformed) section. - in_section = match line.strip_prefix(header.as_str()) { - Some(rest) => { - let rest = rest.trim_start(); - rest.is_empty() || rest.starts_with('#') + in_section = match rest.split_once(']') { + Some((inner, after)) => { + let after = after.trim_start(); + (after.is_empty() || after.starts_with('#')) && inner.trim() == section } None => false, }; @@ -222,11 +203,7 @@ async fn find_git_config(start: &Path) -> Option { }; loop { let candidate = cursor.join(".git").join("config"); - if tokio::fs::metadata(&candidate) - .await - .map(|m| m.is_file()) - .unwrap_or(false) - { + if crate::utils::fs::is_file(&candidate).await { return Some(candidate); } match cursor.parent() { @@ -272,15 +249,38 @@ fn scan_remote_origin_url(content: &str) -> Option { if key.trim() != "url" { continue; } - let value = value.trim(); + let value = parse_git_config_value(value); if value.is_empty() { return None; } - return Some(value.to_string()); + return Some(value); } None } +/// Reduce the raw right-hand side of a git config `key = value` line +/// to the value git itself reports (verified against `git config -f`): +/// `#`/`;` begin a comment outside double quotes — no preceding +/// whitespace required — `"` quotes a segment verbatim (comment chars +/// inside stay literal), and `\` escapes the next character (passed +/// through literally; `\n`-style control escapes never occur in urls). +/// Trailing unquoted whitespace is stripped. +fn parse_git_config_value(raw: &str) -> String { + let mut out = String::new(); + let mut in_quotes = false; + let mut chars = raw.trim_start().chars(); + while let Some(c) = chars.next() { + match c { + '"' => in_quotes = !in_quotes, + '\\' => out.extend(chars.next()), + '#' | ';' if !in_quotes => break, + _ => out.push(c), + } + } + out.truncate(out.trim_end().len()); + out +} + /// Convert a git remote URL to a PURL when possible, else return the /// URL itself (OpenVEX `@id` accepts any URI). /// @@ -365,11 +365,12 @@ fn parse_toml_string_kv(line: &str, key: &str) -> Option { if lhs.trim() != key { return None; } - let rhs = rhs[1..].trim(); // drop the leading '=' and surrounding ws - // The value must open with a string delimiter; match it to its twin. - // `'` is a literal string (no escapes), `"` a basic string — for our - // purposes (names/versions, which never contain escaped quotes) the - // first matching delimiter terminates the value in both cases. + // Drop the leading '=' and surrounding whitespace. The value must + // open with a string delimiter; match it to its twin. `'` is a + // literal string (no escapes), `"` a basic string — for our purposes + // (names/versions, which never contain escaped quotes) the first + // matching delimiter terminates the value in both cases. + let rhs = rhs[1..].trim(); let quote = rhs.chars().next().filter(|c| *c == '"' || *c == '\'')?; let stripped = &rhs[quote.len_utf8()..]; let end = stripped.find(quote)?; @@ -1395,6 +1396,129 @@ mod tests { assert!(scan_remote_origin_url(cfg).is_none()); } + // ── Regression: whitespace inside TOML table headers ────────── + // TOML permits whitespace around the key in a table header + // (`[ package ]`, `[project ]`) — tomllib and cargo both accept + // it. The exact `strip_prefix("[package]")` match treated such a + // header as a foreign section, so a manifest the user's own + // toolchain accepts yielded no PURL. Mirrors the cargo crawler's + // `parse_table_header`, which already trims inside the brackets. + + #[test] + fn scan_toml_section_header_with_inner_whitespace() { + let toml = "[ package ]\nname = \"x\"\nversion = \"1.0\"\n"; + let (n, v) = scan_toml_section(toml, "package").unwrap(); + assert_eq!(n, "x"); + assert_eq!(v, "1.0"); + } + + /// Spaced header carrying a trailing comment — both relaxations + /// compose. + #[test] + fn scan_toml_section_spaced_header_with_trailing_comment() { + let toml = "[ package ] # metadata\nname = \"x\"\nversion = \"1.0\"\n"; + let (n, v) = scan_toml_section(toml, "package").unwrap(); + assert_eq!(n, "x"); + assert_eq!(v, "1.0"); + } + + /// A spaced FOREIGN header must still close the current section. + #[test] + fn scan_toml_spaced_foreign_header_still_closes_section() { + let toml = "[package]\nname = \"x\"\n[ dependencies ]\nversion = \"9.9\"\n"; + assert!(scan_toml_section(toml, "package").is_none()); + } + + /// Junk (non-comment) after the closing bracket is still rejected + /// as a malformed/foreign header — the relaxation is inside the + /// brackets only. + #[test] + fn scan_toml_header_with_trailing_junk_still_rejected() { + let toml = "[package] junk\nname = \"x\"\nversion = \"1.0\"\n"; + assert!(scan_toml_section(toml, "package").is_none()); + } + + #[tokio::test] + async fn detect_cargo_toml_spaced_header() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write( + dir.path().join("Cargo.toml"), + "[ package ]\nname = \"spaced-rust\"\nversion = \"1.0.0\"\n", + ) + .await + .unwrap(); + let r = detect_product(dir.path()).await; + assert_eq!(r.purl.as_deref(), Some("pkg:cargo/spaced-rust@1.0.0")); + } + + #[tokio::test] + async fn detect_pyproject_spaced_header() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write( + dir.path().join("pyproject.toml"), + "[project ]\nname = \"spaced-py\"\nversion = \"0.4.0\"\n", + ) + .await + .unwrap(); + let r = detect_product(dir.path()).await; + assert_eq!(r.purl.as_deref(), Some("pkg:pypi/spaced-py@0.4.0")); + } + + // ── Regression: git config VALUE comments and quoting ───────── + // git strips a `;`/`#` comment from an unquoted value — with or + // without preceding whitespace — and unquotes `"..."` segments + // (comment chars inside quotes stay literal). Verified against + // `git config -f`. Returning the raw text produced a WRONG + // product identity, e.g. `pkg:github/foo/bar.git ; mirror` from + // `url = git@github.com:foo/bar.git ; mirror`. + + #[test] + fn scan_origin_url_strips_trailing_semicolon_comment() { + let cfg = "[remote \"origin\"]\n\turl = git@github.com:foo/bar.git ; mirror note\n"; + assert_eq!( + scan_remote_origin_url(cfg).as_deref(), + Some("git@github.com:foo/bar.git") + ); + } + + /// git starts the comment at `#` even with NO whitespace before + /// it: `url = https://host/a#frag` resolves to `https://host/a`. + #[test] + fn scan_origin_url_strips_hash_comment_without_space() { + let cfg = "[remote \"origin\"]\n\turl = https://host/a#frag\n"; + assert_eq!(scan_remote_origin_url(cfg).as_deref(), Some("https://host/a")); + } + + /// A double-quoted value is unquoted, matching git. + #[test] + fn scan_origin_url_unquotes_double_quoted_value() { + let cfg = "[remote \"origin\"]\n\turl = \"git@github.com:foo/bar.git\"\n"; + assert_eq!( + scan_remote_origin_url(cfg).as_deref(), + Some("git@github.com:foo/bar.git") + ); + } + + /// Comment characters INSIDE a quoted segment are literal value + /// bytes, not comment starts — `"https://host/a#frag"` keeps its + /// fragment. + #[test] + fn scan_origin_url_comment_char_inside_quotes_is_literal() { + let cfg = "[remote \"origin\"]\n\turl = \"https://host/a#frag\"\n"; + assert_eq!( + scan_remote_origin_url(cfg).as_deref(), + Some("https://host/a#frag") + ); + } + + /// A value that is ONLY a comment (`url = ; x`) is an empty url — + /// same fall-through as the existing `url = ` case. + #[test] + fn scan_origin_url_value_that_is_only_comment_is_none() { + let cfg = "[remote \"origin\"]\n\turl = ; commented out\n"; + assert!(scan_remote_origin_url(cfg).is_none()); + } + #[tokio::test] async fn multi_manifest_all_unparseable_emits_no_warning() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vex/time.rs b/crates/socket-patch-core/src/vex/time.rs index 150ab812..e3ffd281 100644 --- a/crates/socket-patch-core/src/vex/time.rs +++ b/crates/socket-patch-core/src/vex/time.rs @@ -19,7 +19,7 @@ pub fn now_rfc3339() -> String { /// /// Pulled out as its own function so the formatting can be unit-tested /// against fixed timestamps without mocking the system clock. -pub fn format_unix_secs_rfc3339(secs: u64) -> String { +fn format_unix_secs_rfc3339(secs: u64) -> String { let (year, month, day, hour, minute, second) = unix_to_ymdhms(secs); format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") } @@ -30,7 +30,10 @@ pub fn format_unix_secs_rfc3339(secs: u64) -> String { /// . /// Adapted to operate on a non-negative second count — socket-patch only /// ever stamps "now", so pre-1970 inputs are out of scope. -fn unix_to_ymdhms(secs: u64) -> (i32, u32, u32, u32, u32, u32) { +/// +/// Also the date backbone of `utils::telemetry`'s millisecond-precision +/// timestamps, so this is the single civil-date implementation in the crate. +pub(crate) fn unix_to_ymdhms(secs: u64) -> (i32, u32, u32, u32, u32, u32) { let days = (secs / 86_400) as i64; let secs_of_day = (secs % 86_400) as u32; let hour = secs_of_day / 3600; @@ -344,6 +347,60 @@ mod tests { } } + /// Independent brute-force civil-date counter used to cross-check + /// `unix_to_ymdhms` (Howard Hinnant's algorithm) without sharing any of + /// its arithmetic — so a regression in either is caught. + fn brute_days_to_ymd(days: u64) -> (u64, u64, u64) { + fn is_leap(y: u64) -> bool { + (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400) + } + let mut rem = days; + let mut y = 1970u64; + loop { + let year_len = if is_leap(y) { 366 } else { 365 }; + if rem < year_len { + break; + } + rem -= year_len; + y += 1; + } + let months = [ + 31, + if is_leap(y) { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + let mut m = 0usize; + while rem >= months[m] { + rem -= months[m]; + m += 1; + } + (y, (m + 1) as u64, rem + 1) + } + + /// Exhaustive cross-check against the independent counter across ~1265 + /// years, covering every leap rule and century boundary through 3235. + #[test] + fn unix_to_ymdhms_matches_brute_force() { + for days in 0..462_000u64 { + let (y, m, d, h, mi, s) = unix_to_ymdhms(days * 86_400); + assert_eq!((h, mi, s), (0, 0, 0), "midnight expected at day {days}"); + assert_eq!( + (y as u64, m as u64, d as u64), + brute_days_to_ymd(days), + "mismatch at day {days}" + ); + } + } + /// `now_rfc3339` must produce a string that round-trips through /// our own `format_unix_secs_rfc3339` — i.e. the year/month/day /// fields are within plausible ranges (years 1970..3000, months diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index c77b0c32..f4e23f0e 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -15,9 +15,9 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use crate::manifest::schema::PatchManifest; +use crate::manifest::schema::{PatchManifest, PatchRecord}; use crate::patch::apply::{verify_file_patch, VerifyStatus}; -use crate::patch::vendor::state::VendorEntry; +use crate::patch::vendor::state::{lookup_entry, VendorEntry}; use crate::patch::vendor::verify::verify_vendored_patch_record; /// One entry per manifest PURL that did NOT pass verification. The @@ -99,49 +99,25 @@ pub async fn applied_patches_with_vendor( let mut out = VerifyOutcome::default(); for (purl, record) in &manifest.patches { - if let Some(ctx) = vendor { - let entry = ctx - .entries - .get(purl) - .or_else(|| ctx.entries.values().find(|e| e.base_purl == *purl)); - if let Some(entry) = entry { - match verify_vendored_patch_record(&ctx.project_root, entry, record).await { - Ok(()) => { - out.applied.push(purl.clone()); - out.vendored.push(purl.clone()); - } - Err(reason) => out.failed.push(FailedPatch { - purl: purl.clone(), - reason, - }), - } - continue; - } - if let Some(copy_dir) = ctx.go_patches.get(purl) { - match verify_patch_record(copy_dir, record).await { - Ok(()) => out.applied.push(purl.clone()), - Err(reason) => out.failed.push(FailedPatch { - purl: purl.clone(), - reason, - }), - } - continue; - } - } - - let pkg_path = match package_paths.get(purl) { - Some(p) => p, - None => { - out.failed.push(FailedPatch { - purl: purl.clone(), - reason: "package_not_found".to_string(), - }); - continue; - } + let vendor_entry = + vendor.and_then(|ctx| lookup_entry(&ctx.entries, purl).map(|e| (ctx, e))); + let result = if let Some((ctx, entry)) = vendor_entry { + verify_vendored_patch_record(&ctx.project_root, entry, record).await + } else if let Some(copy_dir) = vendor.and_then(|ctx| ctx.go_patches.get(purl)) { + verify_patch_record(copy_dir, record).await + } else if let Some(pkg_path) = package_paths.get(purl) { + verify_patch_record(pkg_path, record).await + } else { + Err("package_not_found".to_string()) }; - match verify_patch_record(pkg_path, record).await { - Ok(()) => out.applied.push(purl.clone()), + match result { + Ok(()) => { + out.applied.push(purl.clone()); + if vendor_entry.is_some() { + out.vendored.push(purl.clone()); + } + } Err(reason) => out.failed.push(FailedPatch { purl: purl.clone(), reason, @@ -161,10 +137,7 @@ pub async fn applied_patches_with_vendor( /// zero-file record offers nothing to hash, so — per the module's /// "omit when unconfirmed" contract — it is reported as `no_files` and /// dropped from the VEX document rather than vacuously attested. -async fn verify_patch_record( - pkg_path: &Path, - record: &crate::manifest::schema::PatchRecord, -) -> Result<(), String> { +async fn verify_patch_record(pkg_path: &Path, record: &PatchRecord) -> Result<(), String> { if record.files.is_empty() { return Err("no_files".to_string()); } diff --git a/crates/socket-patch-core/tests/crawler_cargo_e2e.rs b/crates/socket-patch-core/tests/crawler_cargo_e2e.rs index 9b7fab9d..318c959a 100644 --- a/crates/socket-patch-core/tests/crawler_cargo_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_cargo_e2e.rs @@ -1,7 +1,5 @@ //! Integration coverage for `crawlers::cargo_crawler`. -#![cfg(feature = "cargo")] - use std::path::Path; use socket_patch_core::crawlers::cargo_crawler::parse_cargo_toml_name_version; @@ -15,7 +13,6 @@ fn options_at(root: &Path) -> CrawlerOptions { cwd: root.to_path_buf(), global: false, global_prefix: None, - batch_size: 100, } } @@ -132,7 +129,6 @@ async fn cargo_home_fallback_to_home_dot_cargo() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_crate_source_paths(&opts).await.unwrap(); @@ -141,6 +137,8 @@ async fn cargo_home_fallback_to_home_dot_cargo() { } if let Some(v) = prev_home { std::env::set_var("HOME", v); + } else { + std::env::remove_var("HOME"); } // Exactly the one staged index dir — proves the fallback resolved to @@ -241,7 +239,6 @@ async fn crawl_all_via_registry_layout() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; // Exact contents, not just a `>= 2` floor: a regression that drops a @@ -277,7 +274,6 @@ async fn crawl_all_empty_src_returns_empty() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert!(result.is_empty()); @@ -293,7 +289,6 @@ async fn get_crate_source_paths_with_global_prefix_passthrough() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let paths = crawler.get_crate_source_paths(&opts).await.unwrap(); assert_eq!(paths, vec![tmp.path().to_path_buf()]); @@ -379,7 +374,6 @@ async fn crawl_all_falls_back_to_dir_name_when_workspace_version() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert_eq!(result.len(), 1); @@ -399,7 +393,6 @@ async fn crawl_all_skips_dir_without_cargo_toml() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert!(result.is_empty(), "dir without Cargo.toml must be skipped"); @@ -446,26 +439,31 @@ fn parse_cargo_toml_version_workspace_returns_none() { assert_eq!(parse_cargo_toml_name_version(toml), None); } -/// `verify_crate_at_path` with a dir-name-only match (workspace -/// version) but a mismatched purl name — must return false. Exercises -/// the `parsed_name == name && parsed_version == version` false arm -/// (cargo_crawler.rs:344-346). +/// `verify_crate_at_path` with a dir-name-only parse (workspace +/// version) whose result mismatches the requested coordinates — must +/// return false. The vendor probe for `pkg:cargo/sha-1@0.10.6` is the +/// staged `sha-1/` dir itself: its manifest bails (workspace version) +/// and its dir name parses as ("sha", "1") ≠ ("sha-1", "0.10.6"), so +/// the version cannot be confirmed and the crate must be rejected. +/// Exercises the `n == name && v == version` false arm +/// (cargo_crawler.rs:349). #[tokio::test] async fn find_by_purls_verify_fallback_dir_name_mismatch_returns_empty() { let tmp = tempfile::tempdir().unwrap(); - let pkg = tmp.path().join("real-crate-1.0.0"); + let pkg = tmp.path().join("sha-1"); tokio::fs::create_dir(&pkg).await.unwrap(); tokio::fs::write( pkg.join("Cargo.toml"), - "[package]\nname = \"real-crate\"\nversion.workspace = true\n", + "[package]\nname = \"sha-1\"\nversion.workspace = true\n", ) .await .unwrap(); let crawler = CargoCrawler; - // Ask for a name that doesn't match the dir layout. + // Registry probe `sha-1-0.10.6/` misses; vendor probe `sha-1/` hits + // the staged dir and must fail verification. let result = crawler - .find_by_purls(tmp.path(), &["pkg:cargo/other-crate@1.0.0".to_string()]) + .find_by_purls(tmp.path(), &["pkg:cargo/sha-1@0.10.6".to_string()]) .await .unwrap(); assert!(result.is_empty(), "dir-name mismatch must reject"); @@ -493,7 +491,6 @@ async fn crawl_all_skips_hidden_dirs() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect(); @@ -526,7 +523,6 @@ async fn crawl_all_dedups_same_purl() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert_eq!( @@ -588,7 +584,6 @@ async fn crawl_all_skips_top_level_files() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert_eq!(result.len(), 1); @@ -614,7 +609,6 @@ async fn crawl_all_skips_crate_with_unparseable_toml_and_no_version_dir_name() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert!( @@ -649,7 +643,6 @@ async fn crawl_all_handles_unreadable_src_path() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(unreadable.clone()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; common::chmod_readable(&unreadable); @@ -658,21 +651,25 @@ async fn crawl_all_handles_unreadable_src_path() { } /// `verify_crate_at_path` returns false when neither the Cargo.toml -/// parses NOR the dir-name parses — exercises the `else { false }` -/// arm at line 345-346. +/// parses NOR the dir-name parses. The vendor probe for +/// `pkg:cargo/foo@1.0.0` is the staged `foo/` dir itself: its +/// Cargo.toml is unparseable and `foo` has no `-` boundary, so +/// both parsers fail and the crate must be rejected — exercises the +/// dir-name-fallback `is_some_and` short-circuit on `None` +/// (cargo_crawler.rs:346-349). #[tokio::test] async fn find_by_purls_verify_fails_when_both_parsers_fail() { let tmp = tempfile::tempdir().unwrap(); - let bad = tmp.path().join("not-cargo-like-at-all"); + let bad = tmp.path().join("foo"); tokio::fs::create_dir(&bad).await.unwrap(); tokio::fs::write(bad.join("Cargo.toml"), b"this is not toml") .await .unwrap(); let crawler = CargoCrawler; - // The strict registry dir for `pkg:cargo/foo@1.0.0` is - // `tmp/foo-1.0.0/` (doesn't exist). The vendor dir `tmp/foo/` - // also doesn't exist. So neither layout matches and we get empty. + // Registry probe `tmp/foo-1.0.0/` misses; vendor probe `tmp/foo/` + // hits the staged dir, whose broken manifest and version-less dir + // name must both fail to verify. let result = crawler .find_by_purls(tmp.path(), &["pkg:cargo/foo@1.0.0".to_string()]) .await diff --git a/crates/socket-patch-core/tests/crawler_composer_e2e.rs b/crates/socket-patch-core/tests/crawler_composer_e2e.rs index 212cd924..2b597548 100644 --- a/crates/socket-patch-core/tests/crawler_composer_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_composer_e2e.rs @@ -3,8 +3,6 @@ //! find_by_purls happy path, crawl_all via installed.json parsing, //! malformed installed.json variants. -#![cfg(feature = "composer")] - use std::path::Path; use socket_patch_core::crawlers::composer_crawler::parse_composer_home_output; @@ -30,7 +28,6 @@ fn options_at(root: &Path) -> CrawlerOptions { cwd: root.to_path_buf(), global: false, global_prefix: None, - batch_size: 100, } } @@ -179,7 +176,6 @@ async fn crawl_all_via_installed_json_returns_packages() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().join("vendor")), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert_eq!(result.len(), 1); @@ -218,7 +214,6 @@ async fn crawl_all_with_corrupt_installed_json_returns_empty() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(vendor.clone()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert!(result.is_empty(), "corrupt JSON must yield empty crawl"); @@ -250,7 +245,6 @@ async fn get_vendor_paths_with_global_prefix_passthrough() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let paths = crawler.get_vendor_paths(&opts).await.unwrap(); assert_eq!(paths, vec![tmp.path().to_path_buf()]); @@ -353,7 +347,6 @@ async fn get_vendor_paths_global_via_composer_home_env() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_vendor_paths(&opts).await.unwrap(); @@ -395,7 +388,6 @@ async fn get_vendor_paths_global_via_home_dot_composer_fallback() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_vendor_paths(&opts).await.unwrap(); @@ -448,7 +440,6 @@ async fn get_vendor_paths_global_via_home_xdg_config_composer_fallback() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_vendor_paths(&opts).await.unwrap(); @@ -498,7 +489,6 @@ async fn get_vendor_paths_global_no_composer_no_home_layout_returns_empty() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_vendor_paths(&opts).await.unwrap(); @@ -522,6 +512,67 @@ async fn get_vendor_paths_global_no_composer_no_home_layout_returns_empty() { ); } +/// A set-but-empty `HOME` (stripped CI/container/sudo environments) must +/// be treated as unset, not honored: `PathBuf::from("")` turns the +/// `.composer` / `.config/composer` platform-default probes into +/// CWD-relative paths, so a `.composer/vendor/` directory inside the +/// user's project gets scanned as if it were the global composer home. +/// Twin of the `utils::fs::home_dir` empty-HOME fix. +#[tokio::test] +#[serial_test::serial] +async fn get_vendor_paths_global_empty_home_not_cwd_relative() { + let tmp = tempfile::tempdir().unwrap(); + // Plant a project-local .composer/vendor inside what will be the CWD. + tokio::fs::create_dir_all(tmp.path().join(".composer").join("vendor")) + .await + .unwrap(); + let empty_path = tempfile::tempdir().unwrap(); + + let prev_composer = std::env::var("COMPOSER_HOME").ok(); + let prev_home = std::env::var("HOME").ok(); + let prev_profile = std::env::var("USERPROFILE").ok(); + let prev_path = std::env::var("PATH").ok(); + let prev_cwd = std::env::current_dir().unwrap(); + std::env::remove_var("COMPOSER_HOME"); + std::env::set_var("HOME", ""); + std::env::set_var("USERPROFILE", ""); + std::env::set_var("PATH", empty_path.path()); + std::env::set_current_dir(tmp.path()).unwrap(); + + let crawler = ComposerCrawler; + let opts = CrawlerOptions { + cwd: tmp.path().to_path_buf(), + global: true, + global_prefix: None, + }; + let paths = crawler.get_vendor_paths(&opts).await.unwrap(); + + std::env::set_current_dir(prev_cwd).unwrap(); + if let Some(v) = prev_composer { + std::env::set_var("COMPOSER_HOME", v); + } + if let Some(v) = prev_home { + std::env::set_var("HOME", v); + } else { + std::env::remove_var("HOME"); + } + if let Some(v) = prev_profile { + std::env::set_var("USERPROFILE", v); + } else { + std::env::remove_var("USERPROFILE"); + } + if let Some(v) = prev_path { + std::env::set_var("PATH", v); + } else { + std::env::remove_var("PATH"); + } + + assert!( + paths.is_empty(), + "empty HOME must not resolve to CWD-relative .composer probes; got {paths:?}" + ); +} + #[path = "common/mod.rs"] mod common; @@ -604,7 +655,6 @@ async fn crawl_all_dedups_across_vendor_paths() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(custom_vendor), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert_eq!( diff --git a/crates/socket-patch-core/tests/crawler_deno_e2e.rs b/crates/socket-patch-core/tests/crawler_deno_e2e.rs index f3ef73e6..de46fd85 100644 --- a/crates/socket-patch-core/tests/crawler_deno_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_deno_e2e.rs @@ -2,8 +2,6 @@ //! docker e2e suite doesn't drive (project-marker gates, env-var //! resolution, malformed cache layouts, etc.). -#![cfg(feature = "deno")] - use std::path::Path; use serial_test::serial; @@ -17,7 +15,6 @@ fn options_at(root: &Path) -> CrawlerOptions { cwd: root.to_path_buf(), global: false, global_prefix: None, - batch_size: 100, } } @@ -154,7 +151,6 @@ async fn crawl_all_enumerates_jsr_packages() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; let purls: Vec<&str> = result.iter().map(|p| p.purl.as_str()).collect(); @@ -196,7 +192,6 @@ async fn crawl_all_global_via_deno_dir_env_scans_cache() { cwd: tempfile::tempdir().unwrap().path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert_eq!(result.len(), 1, "got {:?}", result); @@ -223,7 +218,6 @@ async fn crawl_all_does_not_recurse_below_version_layer() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert_eq!( @@ -250,7 +244,6 @@ async fn crawl_all_skips_dirs_not_starting_with_at() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; // Exactly the one legitimate package — not the bogus `notascope/foo`. @@ -281,7 +274,6 @@ async fn get_jsr_cache_paths_global_prefix_passthrough() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let paths = crawler.get_jsr_cache_paths(&opts).await.unwrap(); assert_eq!(paths, vec![tmp.path().to_path_buf()]); @@ -301,7 +293,6 @@ async fn get_jsr_cache_paths_global_via_deno_dir_env() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_jsr_cache_paths(&opts).await.unwrap(); @@ -322,7 +313,6 @@ async fn get_jsr_cache_paths_global_deno_dir_missing_cache_returns_empty() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_jsr_cache_paths(&opts).await.unwrap(); assert!( diff --git a/crates/socket-patch-core/tests/crawler_go_e2e.rs b/crates/socket-patch-core/tests/crawler_go_e2e.rs index b99157e6..6d6b46c7 100644 --- a/crates/socket-patch-core/tests/crawler_go_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_go_e2e.rs @@ -1,7 +1,5 @@ //! Integration coverage for `crawlers::go_crawler`. -#![cfg(feature = "golang")] - use std::path::Path; use serial_test::serial; @@ -18,7 +16,6 @@ fn options_at(root: &Path) -> CrawlerOptions { cwd: root.to_path_buf(), global: false, global_prefix: None, - batch_size: 100, } } @@ -142,7 +139,6 @@ async fn get_module_cache_paths_with_global_prefix_passthrough() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let paths = crawler.get_module_cache_paths(&opts).await.unwrap(); assert_eq!(paths, vec![tmp.path().to_path_buf()]); @@ -218,7 +214,6 @@ async fn crawl_all_handles_unreadable_cache_path() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(cache.clone()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; common::chmod_readable(&cache); @@ -334,7 +329,6 @@ async fn crawl_all_finds_nested_versioned_module() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert_eq!(result.len(), 1); @@ -368,7 +362,6 @@ async fn crawl_all_skips_cache_metadata_dir() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert_eq!( @@ -428,6 +421,57 @@ async fn get_module_cache_paths_home_go_pkg_mod_fallback() { ); } +/// A set-but-EMPTY `HOME` must count as unset, matching the empty guard on +/// `GOMODCACHE` and the empty-entry filter on `GOPATH`: honoring `""` makes +/// the last-resort fallback return the RELATIVE path `go/pkg/mod`, pointing +/// every crawl and lookup at `/go/pkg/mod` inside the user's project +/// instead of a real module cache. +#[tokio::test] +#[serial] +async fn get_module_cache_paths_empty_home_returns_no_paths() { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write( + tmp.path().join("go.mod"), + b"module example.com/test\n\ngo 1.21\n", + ) + .await + .unwrap(); + let prev_gomod = std::env::var("GOMODCACHE").ok(); + let prev_gopath = std::env::var("GOPATH").ok(); + let prev_home = std::env::var("HOME").ok(); + let prev_profile = std::env::var("USERPROFILE").ok(); + std::env::remove_var("GOMODCACHE"); + std::env::remove_var("GOPATH"); + std::env::remove_var("USERPROFILE"); + std::env::set_var("HOME", ""); + + let crawler = GoCrawler; + let paths = crawler + .get_module_cache_paths(&options_at(tmp.path())) + .await + .unwrap(); + + if let Some(v) = prev_gomod { + std::env::set_var("GOMODCACHE", v); + } + if let Some(v) = prev_gopath { + std::env::set_var("GOPATH", v); + } + if let Some(v) = prev_home { + std::env::set_var("HOME", v); + } else { + std::env::remove_var("HOME"); + } + if let Some(v) = prev_profile { + std::env::set_var("USERPROFILE", v); + } + + assert!( + paths.is_empty(), + "empty HOME must not yield a CWD-relative go/pkg/mod cache path; got {paths:?}" + ); +} + #[tokio::test] #[serial] async fn get_module_cache_paths_gopath_fallback_when_gomodcache_unset() { diff --git a/crates/socket-patch-core/tests/crawler_maven_e2e.rs b/crates/socket-patch-core/tests/crawler_maven_e2e.rs index 7f63ae01..f5586872 100644 --- a/crates/socket-patch-core/tests/crawler_maven_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_maven_e2e.rs @@ -3,8 +3,6 @@ //! detection, gradle marker detection, m2_repo_path env-var //! resolution, walkdir-based scanning. -#![cfg(feature = "maven")] - use std::path::Path; use serial_test::serial; @@ -17,7 +15,6 @@ fn options_at(root: &Path) -> CrawlerOptions { cwd: root.to_path_buf(), global: false, global_prefix: None, - batch_size: 100, } } @@ -265,7 +262,6 @@ async fn get_maven_repo_paths_global_mode_with_maven_repo_local() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_maven_repo_paths(&opts).await.unwrap(); @@ -297,7 +293,6 @@ async fn get_maven_repo_paths_global_mode_no_m2_returns_empty() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_maven_repo_paths(&opts).await.unwrap(); @@ -320,8 +315,7 @@ async fn get_maven_repo_paths_global_mode_no_m2_returns_empty() { } /// `find_by_purls` for a version directory that contains a non-`.pom` -/// file but no `.pom` — exercise the `has_pom_file` return-false arm -/// (line 405) via verify_maven_at_path. +/// file but no `.pom` — exercise the `has_pom_file` return-false arm. #[tokio::test] async fn find_by_purls_version_dir_without_pom_returns_empty() { let tmp = tempfile::tempdir().unwrap(); @@ -431,7 +425,6 @@ async fn crawl_all_discovers_packages_in_repo() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; // `>= 2` would pass on garbage/duplicate packages — assert the exact @@ -468,7 +461,6 @@ async fn crawl_all_with_empty_repo_returns_empty() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert!(result.is_empty()); @@ -484,7 +476,6 @@ async fn get_maven_repo_paths_with_global_prefix_returns_only_prefix() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let paths = crawler.get_maven_repo_paths(&opts).await.unwrap(); assert_eq!(paths, vec![tmp.path().to_path_buf()]); diff --git a/crates/socket-patch-core/tests/crawler_monorepo_gaps.rs b/crates/socket-patch-core/tests/crawler_monorepo_gaps.rs index 67cc220b..c0fe5d8e 100644 --- a/crates/socket-patch-core/tests/crawler_monorepo_gaps.rs +++ b/crates/socket-patch-core/tests/crawler_monorepo_gaps.rs @@ -30,7 +30,6 @@ fn local_opts_at(root: &Path) -> CrawlerOptions { cwd: root.to_path_buf(), global: false, global_prefix: None, - batch_size: 100, } } diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index 86d7098c..fd7e49a1 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -20,7 +20,6 @@ fn options_at(root: &Path) -> CrawlerOptions { cwd: root.to_path_buf(), global: false, global_prefix: None, - batch_size: 100, } } @@ -173,7 +172,6 @@ async fn get_node_modules_paths_global_prefix_passthrough() { cwd: tmp.path().to_path_buf(), global: false, global_prefix: Some(custom.clone()), - batch_size: 100, }; let paths = crawler.get_node_modules_paths(&opts).await.unwrap(); assert_eq!(paths, vec![custom]); @@ -191,7 +189,6 @@ async fn get_node_modules_paths_global_mode_no_prefix() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; // Just must not panic — the actual list depends on the host. let _paths = crawler.get_node_modules_paths(&opts).await.unwrap(); @@ -1008,3 +1005,237 @@ async fn crawl_all_does_not_recurse_through_symlinked_nested_package() { "crawler must not recurse through the symlink into the store" ); } + +// ── regression pins: metadata identity + nested lookup ───────── + +/// Regression: npm (and Node's own loader) strip a leading UTF-8 BOM from +/// `package.json`, so a published package may legitimately ship one +/// (Windows-authored packages do). `serde_json::from_str` rejects the BOM, +/// which made the crawler silently skip the package — a vulnerable install +/// invisible to `scan` and unpatchable by `apply`. Same class as the +/// `strip_bom` fixes in `package_json/detect.rs`. +#[tokio::test] +async fn read_package_json_tolerates_utf8_bom() { + let tmp = tempfile::tempdir().unwrap(); + let nm = tmp.path().join("node_modules"); + let pkg_dir = nm.join("bommed"); + tokio::fs::create_dir_all(&pkg_dir).await.unwrap(); + tokio::fs::write( + pkg_dir.join("package.json"), + "\u{feff}{\"name\":\"bommed\",\"version\":\"1.0.0\"}", + ) + .await + .unwrap(); + + let result = read_package_json(&pkg_dir.join("package.json")).await; + assert_eq!( + result, + Some(("bommed".to_string(), "1.0.0".to_string())), + "a BOM'd package.json is npm-valid and must parse" + ); + + // The production symptom: the package must be visible to scan… + let crawler = NpmCrawler; + let crawled = crawler.crawl_all(&options_at(tmp.path())).await; + assert_eq!( + crawled.len(), + 1, + "BOM'd package must be discovered by crawl_all; got {crawled:?}" + ); + + // …and resolvable by apply's lookup. + let found = crawler + .find_by_purls(&nm, &["pkg:npm/bommed@1.0.0".to_string()]) + .await + .unwrap(); + assert!( + found.contains_key("pkg:npm/bommed@1.0.0"), + "BOM'd package must resolve in find_by_purls; got {found:?}" + ); +} + +/// Regression: `find_by_purls` verified only the *version* of the +/// `package.json` it probed, never the *name*. An npm alias install +/// (`npm i foo@npm:bar@1.0.0`) puts package `bar` in `node_modules/foo`; +/// a patch for `foo@1.0.0` would then be "resolved" to bar's directory and +/// applied to a completely different package's files (with the default +/// mismatch policy applying the full patched blob of `foo` over `bar`). +/// The probe must require the on-disk name to match the PURL identity. +#[tokio::test] +async fn find_by_purls_rejects_alias_dir_with_matching_version() { + let tmp = tempfile::tempdir().unwrap(); + let nm = tmp.path().join("node_modules"); + + // `npm i foo@npm:bar@1.0.0` layout: dir name ≠ package.json name. + let alias_dir = nm.join("foo"); + tokio::fs::create_dir_all(&alias_dir).await.unwrap(); + tokio::fs::write( + alias_dir.join("package.json"), + r#"{"name":"bar","version":"1.0.0"}"#, + ) + .await + .unwrap(); + + let crawler = NpmCrawler; + let result = crawler + .find_by_purls(&nm, &["pkg:npm/foo@1.0.0".to_string()]) + .await + .unwrap(); + assert!( + result.is_empty(), + "an aliased dir holding a different package must not be identified \ + as the PURL target; got {result:?}" + ); + + // Scoped twin: @s/x aliasing some other package. + let scoped_alias = nm.join("@s").join("x"); + tokio::fs::create_dir_all(&scoped_alias).await.unwrap(); + tokio::fs::write( + scoped_alias.join("package.json"), + r#"{"name":"@other/pkg","version":"2.0.0"}"#, + ) + .await + .unwrap(); + let result = crawler + .find_by_purls(&nm, &["pkg:npm/@s/x@2.0.0".to_string()]) + .await + .unwrap(); + assert!( + result.is_empty(), + "scoped alias must not be misidentified; got {result:?}" + ); +} + +/// Regression: CLI_CONTRACT promises "deeply nested transitive dependencies +/// are fully supported … `apply` is path-agnostic … patched identically to a +/// direct one", and `crawl_all` (scan) discovers them at unbounded depth — +/// but `find_by_purls` (apply's resolver) probed only the tree root, so a +/// version that exists *only* nested (root holds a different major, the +/// classic hoisting-conflict layout) was scannable yet unpatchable: apply +/// reported "No packages found that match available patches". +#[tokio::test] +async fn find_by_purls_resolves_nested_only_install() { + let tmp = tempfile::tempdir().unwrap(); + let nm = tmp.path().join("node_modules"); + + // Root: a@1.0.0 and the shadowing b@3.0.0. The patched b@2.0.0 lives + // only at a/node_modules/b (npm's layout when siblings conflict). + stage_npm_pkg(&nm, "a", "1.0.0").await; + stage_npm_pkg(&nm, "b", "3.0.0").await; + let a_nm = nm.join("a").join("node_modules"); + stage_npm_pkg(&a_nm, "b", "2.0.0").await; + // Depth 3: a → b → c. + let b_nm = a_nm.join("b").join("node_modules"); + stage_npm_pkg(&b_nm, "c", "5.0.0").await; + // Nested scoped package. + stage_npm_pkg(&a_nm, "@s/d", "1.0.0").await; + + let crawler = NpmCrawler; + let purls = vec![ + "pkg:npm/b@2.0.0".to_string(), + "pkg:npm/c@5.0.0".to_string(), + "pkg:npm/@s/d@1.0.0".to_string(), + ]; + let result = crawler.find_by_purls(&nm, &purls).await.unwrap(); + + let b = result + .get("pkg:npm/b@2.0.0") + .expect("nested-only b@2.0.0 must resolve (root b@3.0.0 shadows it)"); + assert_eq!(b.path, a_nm.join("b"), "must point at the nested copy"); + let c = result + .get("pkg:npm/c@5.0.0") + .expect("depth-3 transitive c@5.0.0 must resolve"); + assert_eq!(c.path, b_nm.join("c")); + let d = result + .get("pkg:npm/@s/d@1.0.0") + .expect("nested scoped @s/d@1.0.0 must resolve"); + assert_eq!(d.path, a_nm.join("@s").join("d")); +} + +/// Regression: a FIFO planted at a `package.json` path must be skipped +/// promptly, never opened blockingly. `tokio::fs::read_to_string` performs a +/// plain `open(2)`, which on a FIFO waits for a writer that never comes — so +/// one special file inside `node_modules` (a malicious package's postinstall +/// can create one; npm itself never extracts FIFOs) wedged `scan` +/// (crawl_all) and `apply` (find_by_purls) indefinitely, with no error and +/// no timeout. Same class as the `open_regular_file` guards in +/// `patch/file_hash.rs`, the cargo sidecar, and the vendor harvest/verify +/// readers. +#[cfg(unix)] +#[tokio::test] +async fn read_package_json_rejects_fifo_without_hanging() { + let tmp = tempfile::tempdir().unwrap(); + let nm = tmp.path().join("node_modules"); + let fifo_pkg = nm.join("fifo-pkg"); + tokio::fs::create_dir_all(&fifo_pkg).await.unwrap(); + let fifo = fifo_pkg.join("package.json"); + let status = std::process::Command::new("mkfifo") + .arg(&fifo) + .status() + .expect("mkfifo must be runnable"); + assert!(status.success(), "mkfifo failed"); + // A sibling real package proves the tree stays crawlable around the FIFO. + stage_npm_pkg(&nm, "real-pkg", "1.0.0").await; + + // On timeout the open is wedged in a `spawn_blocking` thread that the + // runtime waits for on shutdown; connect a writer to release it so the + // test can FAIL instead of hanging the whole suite. + let release_and_panic = |what: &str| -> ! { + let _ = std::fs::OpenOptions::new().write(true).open(&fifo); + panic!("{what} must complete promptly with a FIFO package.json in the tree"); + }; + let deadline = std::time::Duration::from_secs(5); + + let Ok(direct) = tokio::time::timeout(deadline, read_package_json(&fifo)).await else { + release_and_panic("read_package_json"); + }; + assert_eq!(direct, None, "a FIFO is not a valid package.json"); + + let crawler = NpmCrawler; + let Ok(crawled) = tokio::time::timeout(deadline, crawler.crawl_all(&options_at(tmp.path()))).await + else { + release_and_panic("crawl_all (scan)"); + }; + let names: Vec<&str> = crawled.iter().map(|p| p.name.as_str()).collect(); + assert_eq!( + names, + vec!["real-pkg"], + "the sibling real package must still be discovered, the FIFO skipped" + ); + + let Ok(found) = tokio::time::timeout( + deadline, + crawler.find_by_purls(&nm, &["pkg:npm/fifo-pkg@1.0.0".to_string()]), + ) + .await + else { + release_and_panic("find_by_purls (apply's resolver)"); + }; + assert!( + found.unwrap().is_empty(), + "the FIFO-backed purl must resolve to nothing" + ); +} + +/// When the same `name@version` exists at the root *and* nested, the root +/// copy must win (shallowest-first), preserving the pre-existing behavior +/// for everything resolvable at the root. +#[tokio::test] +async fn find_by_purls_prefers_root_copy_over_nested_duplicate() { + let tmp = tempfile::tempdir().unwrap(); + let nm = tmp.path().join("node_modules"); + stage_npm_pkg(&nm, "a", "1.0.0").await; + stage_npm_pkg(&nm, "dup", "1.0.0").await; + stage_npm_pkg(&nm.join("a").join("node_modules"), "dup", "1.0.0").await; + + let crawler = NpmCrawler; + let result = crawler + .find_by_purls(&nm, &["pkg:npm/dup@1.0.0".to_string()]) + .await + .unwrap(); + assert_eq!( + result.get("pkg:npm/dup@1.0.0").map(|p| p.path.clone()), + Some(nm.join("dup")), + "root copy must be preferred over the nested duplicate" + ); +} diff --git a/crates/socket-patch-core/tests/crawler_nuget_e2e.rs b/crates/socket-patch-core/tests/crawler_nuget_e2e.rs index 06cdf91d..f7cd0379 100644 --- a/crates/socket-patch-core/tests/crawler_nuget_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_nuget_e2e.rs @@ -6,8 +6,6 @@ //! hidden-dir skip, `get_nuget_package_paths` discovery branches — //! goes uncovered without these tests. -#![cfg(feature = "nuget")] - use std::path::Path; use serial_test::serial; @@ -22,7 +20,6 @@ fn options_at(root: &Path) -> CrawlerOptions { cwd: root.to_path_buf(), global: false, global_prefix: None, - batch_size: 100, } } @@ -176,7 +173,6 @@ async fn crawl_all_discovers_global_cache_layout() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert_eq!(result.len(), 2); @@ -218,7 +214,6 @@ async fn crawl_all_discovers_legacy_layout() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; // Legacy layout preserves the original folder casing in the name/version, @@ -261,7 +256,6 @@ async fn crawl_all_skips_hidden_directories() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; // Only the real package should show up. @@ -287,7 +281,6 @@ async fn get_nuget_package_paths_with_global_prefix_returns_only_prefix() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let paths = crawler.get_nuget_package_paths(&opts).await.unwrap(); assert_eq!(paths, vec![tmp.path().to_path_buf()]); @@ -492,7 +485,6 @@ async fn crawl_all_handles_unreadable_pkg_path() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(pkg.clone()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; common::chmod_readable(&pkg); @@ -534,7 +526,6 @@ async fn crawl_all_handles_unreadable_version_dir() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; common::chmod_readable(&pkg_name_dir); @@ -566,7 +557,6 @@ async fn crawl_all_skips_files_at_top_level() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect(); @@ -587,7 +577,6 @@ async fn crawl_all_missing_pkg_path_returns_empty() { global: true, // Point global_prefix at a non-existent dir. global_prefix: Some(tmp.path().join("does-not-exist")), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert!(result.is_empty()); @@ -619,7 +608,6 @@ async fn get_nuget_package_paths_global_mode_returns_nuget_home() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_nuget_package_paths(&opts).await.unwrap(); @@ -651,7 +639,6 @@ async fn get_nuget_package_paths_global_mode_missing_home_returns_empty() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_nuget_package_paths(&opts).await.unwrap(); @@ -805,7 +792,7 @@ async fn get_nuget_package_paths_discovers_assets_json_in_subproject() { } /// Empty `packageFolders` object in assets.json must not surface any -/// paths (line 447-448 `if result.is_empty()` arm). +/// paths (`parse_project_assets_package_folders` yields an empty vec). #[tokio::test] #[serial] async fn get_nuget_package_paths_assets_json_empty_packagefolders_yields_no_paths() { @@ -815,6 +802,15 @@ async fn get_nuget_package_paths_assets_json_empty_packagefolders_yields_no_path tokio::fs::write(obj.join("project.assets.json"), br#"{"packageFolders":{}}"#) .await .unwrap(); + // A .NET marker so the local-mode gate passes and the assets parse + // actually runs — without it the gate returns early and the + // assertion holds vacuously. + tokio::fs::write( + tmp.path().join("MyProj.csproj"), + r#""#, + ) + .await + .unwrap(); let prev = std::env::var("NUGET_PACKAGES").ok(); let prev_home = std::env::var("HOME").ok(); @@ -851,6 +847,15 @@ async fn get_nuget_package_paths_assets_json_malformed_skipped() { tokio::fs::write(obj.join("project.assets.json"), b"this is not json") .await .unwrap(); + // A .NET marker so the local-mode gate passes and the assets parse + // actually runs — without it the gate returns early and the + // assertion holds vacuously. + tokio::fs::write( + tmp.path().join("MyProj.csproj"), + r#""#, + ) + .await + .unwrap(); let prev = std::env::var("NUGET_PACKAGES").ok(); let prev_home = std::env::var("HOME").ok(); diff --git a/crates/socket-patch-core/tests/crawler_python_e2e.rs b/crates/socket-patch-core/tests/crawler_python_e2e.rs index 867f3ad5..3ac85604 100644 --- a/crates/socket-patch-core/tests/crawler_python_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_python_e2e.rs @@ -358,6 +358,40 @@ async fn get_global_python_site_packages_discovers_uv_tools_macos() { ); } +/// uv follows XDG conventions on macOS too: `uv tool dir` resolves to +/// `~/.local/share/uv/tools` (verified against a real uv install), NOT +/// `~/Library/Application Support/uv/tools`. Scanning only the Application +/// Support path makes every `uv tool install`ed package invisible to +/// global discovery on macOS. +#[cfg(target_os = "macos")] +#[tokio::test] +#[serial] +async fn get_global_python_site_packages_discovers_uv_tools_xdg_on_macos() { + let tmp = tempfile::tempdir().unwrap(); + let sp = tmp + .path() + .join(".local") + .join("share") + .join("uv") + .join("tools") + .join("black") + .join("lib") + .join("python3.11") + .join("site-packages"); + tokio::fs::create_dir_all(&sp).await.unwrap(); + + let prev_home = std::env::var("HOME").ok(); + std::env::set_var("HOME", tmp.path()); + let result = get_global_python_site_packages().await; + if let Some(v) = prev_home { + std::env::set_var("HOME", v); + } + assert!( + result.iter().any(|p| p == &sp), + "XDG uv tools layout must surface on macOS; got {result:?}" + ); +} + /// `uv tool install ` on Linux installs into /// `~/.local/share/uv/tools//lib/python3.X/site-packages/`. #[cfg(all(not(target_os = "macos"), not(windows)))] @@ -475,7 +509,6 @@ async fn get_site_packages_paths_falls_back_via_pyproject_marker() { cwd: project.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let result = crawler.get_site_packages_paths(&opts).await.unwrap(); if let Some(v) = prev_home { @@ -551,7 +584,6 @@ async fn get_site_packages_paths_falls_back_via_uv_lock_marker() { cwd: project.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let result = crawler.get_site_packages_paths(&opts).await.unwrap(); if let Some(v) = prev_home { @@ -573,6 +605,61 @@ async fn get_site_packages_paths_falls_back_via_uv_lock_marker() { let _ = (result, staged); } +/// A pipenv-managed project ships `Pipfile`/`Pipfile.lock` and commonly has +/// NO pyproject.toml / setup.py / requirements.txt — the marker list must +/// include it or a fresh clone (pipenv keeps its venvs out-of-tree under +/// `~/.local/share/virtualenvs`) returns zero packages via the no-marker +/// early-out. The vendor layer already treats `Pipfile.lock` as a +/// first-class pypi flavor; discovery must agree. +#[tokio::test] +#[serial] +async fn get_site_packages_paths_falls_back_via_pipfile_marker() { + let project = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + tokio::fs::write( + project.path().join("Pipfile"), + b"[packages]\nrequests = \"*\"\n", + ) + .await + .unwrap(); + + // Stage an anaconda3 layout under the stubbed HOME — scanned by global + // discovery on every platform, so this test needs no per-OS forks. + let staged = home + .path() + .join("anaconda3") + .join("lib") + .join("python3.11") + .join("site-packages"); + tokio::fs::create_dir_all(&staged).await.unwrap(); + + let prev_virtual_env = std::env::var("VIRTUAL_ENV").ok(); + std::env::remove_var("VIRTUAL_ENV"); + let prev_home = std::env::var("HOME").ok(); + std::env::set_var("HOME", home.path()); + let crawler = PythonCrawler; + let opts = CrawlerOptions { + cwd: project.path().to_path_buf(), + global: false, + global_prefix: None, + }; + let result = crawler.get_site_packages_paths(&opts).await.unwrap(); + if let Some(v) = prev_home { + std::env::set_var("HOME", v); + } + if let Some(v) = prev_virtual_env { + std::env::set_var("VIRTUAL_ENV", v); + } + + #[cfg(not(windows))] + assert!( + result.iter().any(|p| p == &staged), + "Pipfile marker must trigger global fallback; got {result:?}" + ); + #[cfg(windows)] + let _ = (result, staged); +} + /// Without any Python-project marker AND without a venv, local-mode /// discovery returns an empty Vec — no false positives from scanning /// a non-Python project. @@ -585,7 +672,6 @@ async fn get_site_packages_paths_no_marker_no_venv_returns_empty() { cwd: project.path().to_path_buf(), global: false, global_prefix: None, - batch_size: 100, }; let prev_virtual_env = std::env::var("VIRTUAL_ENV").ok(); std::env::remove_var("VIRTUAL_ENV"); @@ -695,7 +781,6 @@ async fn crawl_all_handles_unreadable_site_packages() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(site_packages.clone()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; common::chmod_readable(&site_packages); @@ -804,6 +889,36 @@ async fn find_by_purls_strips_subpath() { assert_eq!(pkg.path, tmp.path()); } +/// The patches API serves purls in canonical percent-encoded form (see +/// `percent_decode_purl_component`): a PEP 440 local/epoch version carries +/// `+`/`!`, which arrive as `%2B`/`%21`. The lookup key must be built from +/// the DECODED coordinates or the installed package silently fails to match +/// — reported "not installed", patch skipped. Twin of the npm crawler's +/// percent-decode handling. +#[tokio::test] +async fn find_by_purls_percent_decodes_encoded_version() { + let tmp = tempfile::tempdir().unwrap(); + stage_dist_info(tmp.path(), "torch", "2.1.0+cpu").await; + + let crawler = PythonCrawler; + let result = crawler + .find_by_purls(tmp.path(), &["pkg:pypi/torch@2.1.0%2Bcpu".to_string()]) + .await + .unwrap(); + assert_eq!( + result.len(), + 1, + "%-encoded version must decode before lookup; got {result:?}" + ); + // Keyed by the ORIGINAL (encoded) PURL, like the qualifier/subpath tests. + let pkg = result + .get("pkg:pypi/torch@2.1.0%2Bcpu") + .expect("result must be keyed by the original encoded PURL"); + assert_eq!(pkg.name, "torch"); + assert_eq!(pkg.version, "2.1.0+cpu"); + assert_eq!(pkg.purl, "pkg:pypi/torch@2.1.0%2Bcpu"); +} + #[tokio::test] async fn find_by_purls_empty_purls_returns_empty() { let tmp = tempfile::tempdir().unwrap(); @@ -870,7 +985,6 @@ async fn crawl_all_via_site_packages_finds_dist_info_packages() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert_eq!( @@ -914,7 +1028,6 @@ async fn crawl_all_with_unparseable_dist_info_skips() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert!( @@ -936,7 +1049,6 @@ async fn get_site_packages_paths_with_global_prefix_passthrough() { cwd: tmp.path().to_path_buf(), global: false, global_prefix: Some(custom.clone()), - batch_size: 100, }; let paths = crawler.get_site_packages_paths(&opts).await.unwrap(); assert_eq!(paths, vec![custom]); diff --git a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs index 64e92c9f..876457a8 100644 --- a/crates/socket-patch-core/tests/crawler_ruby_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_ruby_e2e.rs @@ -32,7 +32,6 @@ fn options_at(root: &Path) -> CrawlerOptions { cwd: root.to_path_buf(), global: false, global_prefix: None, - batch_size: 100, } } @@ -182,7 +181,6 @@ async fn crawl_all_discovers_gems_in_path() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; assert_eq!(result.len(), 2); @@ -217,7 +215,6 @@ async fn get_gem_paths_with_global_prefix_returns_only_prefix() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(tmp.path().to_path_buf()), - batch_size: 100, }; let paths = crawler.get_gem_paths(&opts).await.unwrap(); assert_eq!(paths, vec![tmp.path().to_path_buf()]); @@ -363,7 +360,6 @@ async fn global_gem_discovery_via_home_dotgem_layout() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_gem_paths(&opts).await.unwrap(); if let Some(v) = prev { @@ -399,7 +395,6 @@ async fn crawl_all_handles_unreadable_gem_dir() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: Some(gem_dir.clone()), - batch_size: 100, }; let result = crawler.crawl_all(&opts).await; common::chmod_readable(&gem_dir); @@ -471,7 +466,6 @@ async fn global_gem_discovery_no_binary_no_home_layout_returns_empty() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_gem_paths(&opts).await.unwrap(); @@ -517,7 +511,6 @@ async fn global_gem_discovery_via_rvm_layout() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_gem_paths(&opts).await.unwrap(); if let Some(v) = prev { @@ -554,7 +547,6 @@ async fn global_gem_discovery_via_rbenv_layout() { cwd: tmp.path().to_path_buf(), global: true, global_prefix: None, - batch_size: 100, }; let paths = crawler.get_gem_paths(&opts).await.unwrap(); if let Some(v) = prev { diff --git a/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs b/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs index 0e63ea34..d3556db2 100644 --- a/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs +++ b/crates/socket-patch-core/tests/crawlers_empty_paths_e2e.rs @@ -15,22 +15,17 @@ //! input, not by a crawler that can never find anything. use socket_patch_core::crawlers::types::CrawlerOptions; -#[cfg(feature = "cargo")] use socket_patch_core::crawlers::CargoCrawler; -#[cfg(feature = "golang")] use socket_patch_core::crawlers::GoCrawler; -#[cfg(feature = "maven")] use socket_patch_core::crawlers::MavenCrawler; -#[cfg(feature = "nuget")] use socket_patch_core::crawlers::NuGetCrawler; use socket_patch_core::crawlers::{NpmCrawler, PythonCrawler, RubyCrawler}; /// `CrawlerOptions::default()` should populate cwd from -/// `std::env::current_dir`, default `global` to false, leave -/// `global_prefix` unset, and set `batch_size` to the documented 100. -/// Covers types.rs:143-150 (the `Default` impl, which the apply-CLI -/// tests never exercise because callers always build options -/// explicitly). +/// `std::env::current_dir`, default `global` to false, and leave +/// `global_prefix` unset. Covers the `Default` impl in types.rs, which +/// the apply-CLI tests never exercise because callers always build +/// options explicitly. #[test] fn crawler_options_default_populates_fields() { let opts = CrawlerOptions::default(); @@ -46,7 +41,6 @@ fn crawler_options_default_populates_fields() { opts.global_prefix.is_none(), "global_prefix must default to None" ); - assert_eq!(opts.batch_size, 100, "batch_size must default to 100"); } fn options_at(root: &std::path::Path) -> CrawlerOptions { @@ -54,7 +48,6 @@ fn options_at(root: &std::path::Path) -> CrawlerOptions { cwd: root.to_path_buf(), global: false, global_prefix: None, - batch_size: 100, } } @@ -287,7 +280,6 @@ async fn ruby_crawler_crawl_all_empty_returns_empty() { // cargo // --------------------------------------------------------------------------- -#[cfg(feature = "cargo")] #[tokio::test] async fn cargo_crawler_find_by_purls_empty_returns_empty() { let tmp = tempfile::tempdir().unwrap(); @@ -316,7 +308,6 @@ async fn cargo_crawler_find_by_purls_empty_returns_empty() { assert!(result.is_empty(), "empty PURL list → empty result"); } -#[cfg(feature = "cargo")] #[tokio::test] async fn cargo_crawler_crawl_all_empty_returns_empty() { let crawler = CargoCrawler; @@ -355,7 +346,6 @@ async fn cargo_crawler_crawl_all_empty_returns_empty() { // golang // --------------------------------------------------------------------------- -#[cfg(feature = "golang")] #[tokio::test] async fn go_crawler_find_by_purls_empty_returns_empty() { let tmp = tempfile::tempdir().unwrap(); @@ -391,7 +381,6 @@ async fn go_crawler_find_by_purls_empty_returns_empty() { // maven // --------------------------------------------------------------------------- -#[cfg(feature = "maven")] #[tokio::test] async fn maven_crawler_find_by_purls_empty_returns_empty() { let tmp = tempfile::tempdir().unwrap(); @@ -435,7 +424,6 @@ async fn maven_crawler_find_by_purls_empty_returns_empty() { // nuget // --------------------------------------------------------------------------- -#[cfg(feature = "nuget")] #[tokio::test] async fn nuget_crawler_find_by_purls_empty_returns_empty() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/tests/redirect_golden.rs b/crates/socket-patch-core/tests/redirect_golden.rs index 63e8bb67..4fdb6a27 100644 --- a/crates/socket-patch-core/tests/redirect_golden.rs +++ b/crates/socket-patch-core/tests/redirect_golden.rs @@ -13,7 +13,7 @@ //! (nuget.config, maven pom). Any shared fixture not yet ported is skipped //! here (logged) rather than silently ignored. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -86,7 +86,7 @@ fn redirect_golden_fixtures_match() { root.display() ); - let mut asserted = 0; + let mut asserted: BTreeSet = BTreeSet::new(); for case in &cases { let rel = rel_key(&root, case); // rel = "//"; eco_flavor = "/". @@ -147,11 +147,17 @@ fn redirect_golden_fixtures_match() { let again = rewrite_registry_redirect(&files, &overrides); assert_eq!(again.files, result.files, "{rel}: non-deterministic"); - asserted += 1; + asserted.insert(eco_flavor.to_string()); } + // Per-flavor, not a case count: a case total stays comfortably above the + // flavor count, so counting cases lets an entire implemented eco/flavor + // lose its fixtures (bad rebase, fixture-sync failure) without failing. + let missing: Vec<&&str> = RUST_IMPLEMENTED + .iter() + .filter(|f| !asserted.contains(**f)) + .collect(); assert!( - asserted >= RUST_IMPLEMENTED.len(), - "expected to assert all {} implemented ecosystems, got {asserted}", - RUST_IMPLEMENTED.len() + missing.is_empty(), + "implemented eco/flavors with no golden case asserted: {missing:?}" ); } diff --git a/crates/socket-patch-core/tests/rollback_new_file_e2e.rs b/crates/socket-patch-core/tests/rollback_new_file_e2e.rs index 00a52ce7..04009e30 100644 --- a/crates/socket-patch-core/tests/rollback_new_file_e2e.rs +++ b/crates/socket-patch-core/tests/rollback_new_file_e2e.rs @@ -188,6 +188,68 @@ async fn verify_existing_file_rollback_missing_blob() { assert_eq!(result.expected_hash, None); } +/// New-file rollback fail-closed: the patch-added path is occupied by +/// something unhashable (here: a directory). The hash error must surface +/// as a blocking status carrying the real error — not be swallowed into +/// an empty-string "hash" that gets misreported as "modified after +/// patching" with a fabricated `current_hash: Some("")`. +#[tokio::test] +async fn verify_new_file_rollback_unhashable_entry_fails_closed() { + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path(); + let blobs = tmp.path().join("blobs"); + std::fs::create_dir(&blobs).unwrap(); + + // A directory sits where the patch-added file should be. + std::fs::create_dir(pkg.join("added.txt")).unwrap(); + + let file_info = PatchFileInfo { + before_hash: String::new(), + after_hash: git_sha256(b"content the patch added"), + }; + let result = verify_file_rollback(pkg, "package/added.txt", &file_info, &blobs).await; + // Same convention as the pre-existing-file branch and this branch's own + // stat-failure arm: unverifiable state → NotFound + the underlying error. + assert_eq!(result.status, VerifyRollbackStatus::NotFound); + let msg = result.message.as_deref().unwrap_or(""); + assert!( + msg.starts_with("Failed to hash file:"), + "must surface the hash error, not claim the file was modified: {msg:?}" + ); + // No hash was computed — a fabricated Some("") must never be reported. + assert_eq!(result.current_hash, None); + assert_eq!(result.expected_hash, None); + assert_eq!(result.target_hash, None); +} + +/// Fail-open twin of the test above: when a malformed manifest carries an +/// empty `after_hash` alongside the empty `before_hash`, a swallowed hash +/// error ("") compares equal to the empty `after_hash` — verify reported +/// `Ready` and cleared an entry it could not read for deletion. An +/// unverifiable entry must never verify `Ready`. +#[tokio::test] +async fn verify_new_file_rollback_unhashable_entry_empty_after_hash_not_ready() { + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path(); + let blobs = tmp.path().join("blobs"); + std::fs::create_dir(&blobs).unwrap(); + + std::fs::create_dir(pkg.join("added.txt")).unwrap(); + + let file_info = PatchFileInfo { + before_hash: String::new(), + after_hash: String::new(), + }; + let result = verify_file_rollback(pkg, "package/added.txt", &file_info, &blobs).await; + assert_ne!( + result.status, + VerifyRollbackStatus::Ready, + "an entry that cannot be hashed must never be cleared for deletion" + ); + assert_eq!(result.status, VerifyRollbackStatus::NotFound); + assert_eq!(result.current_hash, None); +} + // Marker so `Path` import isn't unused on platforms that gate // helper code differently. #[allow(dead_code)] diff --git a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs index b680d364..76aa379a 100644 --- a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs +++ b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs @@ -238,7 +238,7 @@ fn with_home(home: &str, f: impl FnOnce() -> T) -> T { .iter() .map(|k| (*k, std::env::var(k).ok())) .collect(); - // home_dir_string() reads HOME first, then USERPROFILE. Clear USERPROFILE + // The home lookup reads HOME first, then USERPROFILE. Clear USERPROFILE // so HOME is unambiguously the source on every platform. std::env::remove_var("USERPROFILE"); std::env::set_var("HOME", home); @@ -297,6 +297,29 @@ fn sanitize_error_message_replaces_home_with_tilde() { }); } +#[test] +#[serial] +fn sanitize_error_message_root_home_leaves_message_unchanged() { + // Containers running as an unmapped UID commonly get HOME=/ (and some + // init systems hand root HOME=/). A "/" home carries no user-identifying + // information, so there is nothing to redact — and naively replacing it + // would rewrite EVERY path separator in the message + // ("/etc/hosts" -> "~etc~hosts"), garbling the telemetry payload. Same + // splice-corruption class as the empty-HOME guard already in the impl. + with_home("/", || { + let msg = "failed to read /etc/hosts and /tmp/socket/blob.bin"; + assert_eq!(sanitize_error_message(msg), msg); + }); + // Trailing-slash homes must still redact, and must not eat the + // separator that follows the home prefix. + with_home("/home/socket-sentinel/", || { + assert_eq!( + sanitize_error_message("error at /home/socket-sentinel/.cache/x"), + "error at ~/.cache/x" + ); + }); +} + #[test] #[serial] fn sanitize_error_message_falls_back_to_userprofile() { diff --git a/scripts/burn-down-tests.ts b/scripts/burn-down-tests.ts index 17bf5ef8..3a9535eb 100644 --- a/scripts/burn-down-tests.ts +++ b/scripts/burn-down-tests.ts @@ -31,7 +31,7 @@ * * Options: * --features cargo features for the suite + single-test runs - * (default: cargo,golang,maven,composer,nuget,deno; + * (default: none — every ecosystem is unconditional; * intentionally NOT --all-features, which would pull * in the infra-gated docker-e2e / setup-e2e suites). * --test-cmd Override the full-suite enumeration command @@ -82,7 +82,7 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(SCRIPT_DIR, ".."); const CLAUDE_BIN = process.env.CLAUDE_BIN || "claude"; -const DEFAULT_FEATURES = "cargo,golang,maven,composer,nuget,deno"; +const DEFAULT_FEATURES = ""; // --------------------------------------------------------------------------- // Types diff --git a/scripts/harden-tests.config.ts b/scripts/harden-tests.config.ts index d8020b8f..fe24dfaf 100644 --- a/scripts/harden-tests.config.ts +++ b/scripts/harden-tests.config.ts @@ -114,7 +114,7 @@ export default function render(ctx: FileCtx): string { `3. Edit the file to close those loopholes.`, `4. Build and run just this file's tests to confirm they still pass against the`, ` current code, e.g.:`, - ` cargo test -p ${ctx.crate}${ctx.crate === "socket-patch-cli" ? " --features cargo" : ""} --test ${ctx.stem}`, + ` cargo test -p ${ctx.crate} --test ${ctx.stem}`, ` (for inline/unit tests run the crate's lib tests; adapt the invocation as`, ` needed and report exactly what you ran).`, ]; diff --git a/tests/docker/Dockerfile.base b/tests/docker/Dockerfile.base index 1c579282..0630725f 100644 --- a/tests/docker/Dockerfile.base +++ b/tests/docker/Dockerfile.base @@ -21,9 +21,9 @@ WORKDIR /src COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ COPY crates ./crates -# Build all features so every ecosystem dispatch path compiles into the -# binary. `--locked` enforces Cargo.lock is honored exactly. -RUN cargo build --release --workspace --all-features --locked --bin socket-patch \ +# Every ecosystem is unconditionally compiled in. `--locked` enforces +# Cargo.lock is honored exactly. +RUN cargo build --release --workspace --locked --bin socket-patch \ && cp target/release/socket-patch /out-socket-patch # ---------------------------------------------------------------------- diff --git a/tests/setup_matrix/run-case.sh b/tests/setup_matrix/run-case.sh index c6448818..913d10b3 100755 --- a/tests/setup_matrix/run-case.sh +++ b/tests/setup_matrix/run-case.sh @@ -681,7 +681,8 @@ export XDG_CACHE_HOME="$WORKDIR/.cache" # observation so the lifecycle hook acts on a pristine package. This verifies # behavior end-to-end rather than reading package.json: # * patch: NOT applied before setup → applied after setup → NOT applied after remove -# * check: fails before setup → passes after setup → fails after remove +# * check: fails before setup → passes after the post-setup install (hook +# present AND patches applied on disk) → fails after remove # # Every other case (run_setup=0, or non-npm-family ecosystems) keeps the simple # single-install flow, preserving the existing aspirational expect_applied @@ -705,20 +706,25 @@ if is_npm_family && [ "$SM_RUN_SETUP" = 1 ]; then "$SP_BIN" setup --check --json; CHECK_BEFORE_SETUP_EXIT=$? log "check-before-setup exit=$CHECK_BEFORE_SETUP_EXIT" - # (3) setup, then check must report "configured" (zero). + # (3) setup must succeed. (Its `--check` is probed AFTER the next install: + # since contract property 4 shipped, check also verifies on-disk patch + # consistency, and right after `setup` the pre-hook install from (1) is + # legitimately still unpatched — check would fail there by design.) log "running: socket-patch setup --yes" "$SP_BIN" setup --yes --json; SETUP_EXIT=$? log "setup exit=$SETUP_EXIT" [ -f package.json ] && { log "package.json scripts after setup:"; grep -A6 '"scripts"' package.json || true; } - "$SP_BIN" setup --check --json; CHECK_AFTER_SETUP_EXIT=$? - log "check-after-setup exit=$CHECK_AFTER_SETUP_EXIT" - # (4) AFTER setup: clean reinstall → the hook fires → MAIN applied result. + # (4) AFTER setup: clean reinstall → the hook fires → MAIN applied result; + # NOW check must report "configured" (zero): hooks present AND patches + # applied on disk — the contract's correctly-patched state. reset_modules log "[after-setup] install for pm=$SM_PM (layout=$SM_LAYOUT)" do_install; INSTALL_EXIT=$? log "[after-setup] install exit=$INSTALL_EXIT" verify_applied # sets the canonical APPLIED / PRIMARY_PRESENT / TARGET + "$SP_BIN" setup --check --json; CHECK_AFTER_SETUP_EXIT=$? + log "check-after-setup exit=$CHECK_AFTER_SETUP_EXIT" # (5) remove, then check must report "needs configuration" (non-zero) again. log "running: socket-patch setup --remove --yes"