diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 05bc0c003..0b7c4bbbf 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -85,7 +85,7 @@ 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 | +| `apply` | `--check` | — | Read-only audit that every in-scope (`--ecosystems`) manifest patch is in place, for CI / GitHub-App auditing (v5.0: previously Go-only, which passed on any unpatched non-Go tree). Local Go patches: the committed `.socket/go-patches/` copies and `go.mod` `replace` directives match the manifest (`go_redirect_drift`). Every other patch: each installed copy hashes to the record's `afterHash` — the `vex` verifier over the `vex` copy lookup; the copies are narrowed by `apply`'s own rules: a release variant (a qualified purl such as `?artifact_id=` / `?platform=`) is judged only on the copies holding its distribution, matched against every variant of its base as `apply` matches them, and an installed copy that holds none of them is drift of the base purl (`no_matching_variant`, the copy `apply` fails with "no matching variant found"; a Gradle / Ivy cache dir is exempt, as in `apply`); for gem, once a bundle-store copy exists the `gem env` fallback-home copies are not judged (`apply` treats them as best-effort). Drift is a `failed` event per patch with `errorCode` `not_applied` (still unpatched), `hash_mismatch` (neither the original nor the patched bytes), `file_not_found` or `no_matching_variant`, status `partialFailure`, exit 1, and the human `Error: Patches are OUT OF SYNC:` report (printed even under `--silent`). In sync is exit 0: `Patches are in sync (N checked).` and, under `--json`, a `skipped` event per verified patch (`errorCode: already_patched`). A patch with no installed copy is skipped as `apply` skips it (`package_not_installed`; the human line adds `M not installed, skipped`). Vendor-owned patches are excluded (`vendor --check` audits them). Lock-free, fetch-free, offline-safe; it never writes. An unreadable manifest is drift (`manifest_unreadable`, exit 1) | | `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) | | `vendor` | `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore recorded original lockfile fragments + remove `.socket/vendor/` artifacts. Works without a manifest. A package vendored over a hosted pin returns to its upstream registry entry, never to hosted (see "Takeover reconciliation") | | `vendor` | `--check` | — | Offline, read-only artifact and wiring audit; exits 1 on drift. Conflicts with `--revert`. | @@ -161,7 +161,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is refused BEFORE its revert, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are gated the same way, from the ledger entry and the lock on disk: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is refused with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is refused with `redirect_poetry_lock_unsupported`. A taken-over package whose wiring was reverted but that was then not pinned to hosted now installs the unpatched registry release in both modes. Causes include a refused lock, unavailable hosted wheel metadata, or a vendored ledger update that failed after the revert (refused with `redirect_vendored_revert_failed`). It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. That warning also prints under `--silent`. Human output prints no `Migrated …` progress line for the package and no "keep the hosted patches" next steps. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). +`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is refused BEFORE its revert, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are gated the same way, from the ledger entry and the lock on disk: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is refused with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is refused with `redirect_poetry_lock_unsupported`. A taken-over package whose wiring was reverted but that was then not pinned to hosted now installs the unpatched registry release in both modes. Causes include a refused lock, unavailable hosted wheel metadata, or a vendored ledger update that failed after the revert (refused with `redirect_vendored_revert_failed`). It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. That warning also prints under `--silent`. Human output prints no `Migrated …` progress line for the package and no "keep the hosted patches" next steps. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, patches, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). `patches` (additive, v5.0) is the per-purl outcome of every selected patch, sorted by purl: `{purl, uuid, action}` with `action` `pinned` (`would_pin` under `--dry-run`; `redirected` counts these), `skipped` (`errorCode` = the `skipped[]` reason, `error` = its detail when it has one), or `unpinned` (`errorCode: redirect_unconfirmed` — the patch was granted but no lockfile entry pinning it could be rewritten; the human output's `Not hosted : …` line). An `unpinned` or `skipped` row does not change `status` or the exit code (the hosted exit policy is an open decision, #704). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). @@ -1267,6 +1267,8 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_unwired` | `skipped` | vex: a hosted record (this run's, or a pre-v5 redirect ledger's) whose hosted patch no lockfile wires any more (and no manifest entry owns the purl). Applies under `--no-verify` too. | | `wiring_conflict` | `skipped` | vex (manifest-less): the lockfiles wire one package to two or more different patches (e.g. a stale sibling lock); which one the build installs is undecidable, so none is attested. | | `hash_mismatch` / `not_applied` / `file_not_found` / `package_not_found` / `no_files` / `vendor_*` | `skipped` | vex: verification omissions — the installed copy (agent / hosted) or the committed artifact (`vendor_hash_mismatch`, `vendor_artifact_missing`, `vendor_artifact_unreadable`, `vendor_inventory_mismatch`, `vendor_uuid_mismatch`, `vendor_path_unsafe`) does not carry the patched bytes, or nothing is installed. `vendor_manifest_unverifiable`: a vendored vlt directory verified without its vendor ledger (from `vlt-lock.json` alone) holds a `package.json` with its devDependencies stripped, and the patched `package.json` blob is not in `.socket/blobs`, so it cannot be checked. A lockfile-pinned hosted reference with nothing installed attests instead of `package_not_found` (see "Manifest-less VEX"). | +| `not_applied` / `hash_mismatch` / `file_not_found` / `no_matching_variant` | `failed` | `apply --check` (v5.0): an installed copy of the patch does not verify (still unpatched; neither the original nor the patched bytes; a patched file missing; a copy of a release-variant base that holds none of the manifest's variants, keyed by the base purl). Exit 1, status `partialFailure`. | +| `redirect_unconfirmed` | `redirect.patches[]` `unpinned` row | hosted `scan` / `get` (v5.0, additive): the patch was granted but no lockfile entry pinning it could be rewritten. The status and exit code are unchanged for now, pending the open hosted exit-policy decision (#704); `--silent` hides the human line. | | `lockfile_unreadable` / `lockfile_unparseable` / `patched_ref_invalid` / `patched_ref_unattributable` | run-level `warnings[]` | vex (every form): lockfile-discovery diagnostics — see "Manifest-less VEX (lockfile discovery)". Never flip the exit on their own. | | `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager will still install UNPATCHED bytes; names the wired winner + the ignored locks. | | `vendor_yarn_berry_unsupported` | `failed` | vendor (npm): yarn-berry Plug'n'Play layout; use its native `yarn patch` workflow. | @@ -1655,7 +1657,7 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) |---|---| | `0` | Success | | `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, an invalid or ambiguous socket.yml on `scan` (v5.0), etc.) | -| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg, an unknown subcommand such as the removed `setup`) 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`) and `--mode hosted\|vendored` with `--global`/`--global-prefix` (same enforcement point; `get` refuses the same combination); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, a `scan` PATH outside the repository root and a malformed `SOCKET_MIN_SEVERITY` or `SOCKET_MAX_NEW_PATCHES` (v5.0), `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). v5.0: `get`'s self-enforced conflicts exit `2` too (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--mode hosted\|vendored --save-only`, a malformed identifier for a forced `--id`/`--cve`/`--ghsa`) — previously `1` (MAJOR). The never-implemented `get --one-off` / `rollback --one-off` (and `SOCKET_ONE_OFF`) are removed in v5.0; `--one-off` is now an ordinary unknown-flag clap error. | +| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg, an unknown subcommand such as the removed `setup`) 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`) and `--mode hosted\|vendored` with `--global`/`--global-prefix` (same enforcement point; `get` refuses the same combination); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); on every project command (all but `--update`), a `--cwd` / `SOCKET_CWD` or `--global-prefix` / `SOCKET_GLOBAL_PREFIX` that is not an existing directory, and a non-default `--manifest-path` / `SOCKET_MANIFEST_PATH` that names a directory or sits in a project directory that does not exist (v5.0; checked once before the command runs, whether or not the command uses the flag — previously these read as an empty project and exited 0, and `remove` / `rollback` exited 1; a missing manifest FILE in an existing project stays legal, and `get` / `scan`, which create the manifest, still create a missing manifest directory); `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, a `scan` PATH outside the repository root and a malformed `SOCKET_MIN_SEVERITY` or `SOCKET_MAX_NEW_PATCHES` (v5.0), `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). v5.0: `get`'s self-enforced conflicts exit `2` too (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--mode hosted\|vendored --save-only`, a malformed identifier for a forced `--id`/`--cve`/`--ghsa`) — previously `1` (MAJOR). The never-implemented `get --one-off` / `rollback --one-off` (and `SOCKET_ONE_OFF`) are removed in v5.0; `--one-off` is now an ordinary unknown-flag clap error. | `list` returns **`0`** for every project it can read, empty or not (**v5.0, BREAKING**: a project with no manifest and no ledger record — normal for hosted mode, which writes no manifest — used to exit `1` with `manifest_not_found`; it is now an empty list: `No patches in this project. Run \`socket-patch scan\`.` on stdout, and under `--json` the success envelope with `events: []`). Only an unreadable or invalid manifest (`manifest_unreadable` / `manifest_invalid`) exits `1`. Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index a2ecfe542..5020b05c1 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -95,7 +95,11 @@ pub(crate) const GLOBAL_OPTIONS: &str = "Global options"; // // **Every** global flag is parseable on **every** subcommand. Commands that // don't use a given flag ignore it silently — e.g. `list --global` parses -// fine and the `global` field is unused at runtime. +// fine and the `global` field is unused at runtime. The one exception is +// the path flags (`--cwd`, `--global-prefix`, `--manifest-path`): `main` +// validates them on every project command, used or not +// ([`GlobalArgs::validate_paths`]), because a path that names nothing must +// never read as an empty project. // // (Plain `//` comments: clap turns a doc comment here into the `--help` // description of any subcommand that has none of its own.) @@ -383,6 +387,76 @@ pub struct GlobalArgs { } impl GlobalArgs { + /// Reject path flags that name nothing: `--cwd` and `--global-prefix` + /// (flag or env) must be existing directories, and a `--manifest-path` + /// other than the default must sit in an existing project directory and + /// must not itself be a directory. `main` maps `Err` to the usage exit + /// (2), the same exit a hosted/vendored `scan` PATH that is not a + /// directory gets. + /// + /// Without this a typo in `--cwd` / `SOCKET_CWD` read as an empty + /// project: `apply`, `list`, `scan`, `get` and the `vendor --check` CI + /// gate all exited 0 having checked nothing. + /// + /// A missing manifest FILE stays legal: hosted and vendored projects + /// have none, and `get` / `scan --mode agent` create it. Only its + /// project directory has to exist — except for the commands that + /// create the manifest (`creates_manifest`: `get`, `scan`), which make + /// a missing directory as they always have; writing it is no vacuous + /// pass. + pub fn validate_paths(&self, creates_manifest: bool) -> Result<(), String> { + let not_dir = |flag: &str, env: &str, path: &Path, what: &str| { + format!("{flag} (or {env}) `{}` {what}", path.display()) + }; + if !self.cwd.is_dir() { + let what = if self.cwd.exists() { + "is not a directory" + } else { + "does not exist" + }; + return Err(not_dir("--cwd", "SOCKET_CWD", &self.cwd, what)); + } + if let Some(prefix) = &self.global_prefix { + if !prefix.is_dir() { + let what = if prefix.exists() { + "is not a directory" + } else { + "does not exist" + }; + return Err(not_dir( + "--global-prefix", + "SOCKET_GLOBAL_PREFIX", + prefix, + what, + )); + } + } + if self.manifest_path != DEFAULT_PATCH_MANIFEST_PATH { + let manifest = self.resolved_manifest_path(); + if manifest.is_dir() { + return Err(not_dir( + "--manifest-path", + "SOCKET_MANIFEST_PATH", + &manifest, + "is a directory, not a manifest file", + )); + } + let root = self.project_root(); + if !creates_manifest && !root.is_dir() { + return Err(not_dir( + "--manifest-path", + "SOCKET_MANIFEST_PATH", + &manifest, + &format!( + "is in a project directory that does not exist ({})", + root.display() + ), + )); + } + } + Ok(()) + } + /// The crawler options this run's `--cwd` / `--global` / /// `--global-prefix` select. pub(crate) fn crawler_options(&self) -> socket_patch_core::crawlers::CrawlerOptions { diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 1cd8d8185..b66d37335 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -365,9 +365,11 @@ pub struct ApplyArgs { )] pub force: bool, - /// Read-only: verify that the committed Go `replace`-redirects match the - /// manifest (for CI / GitHub-App auditing), exiting non-zero on drift. - /// Lock-free and offline-safe — it does not crawl, fetch, or mutate. + /// Read-only: verify that every manifest patch is in place (each + /// installed copy hashes to the patched bytes; Go: the committed + /// `replace`-redirects match the manifest), exiting non-zero on drift. + /// For CI / GitHub-App auditing. Lock-free and offline-safe: it never + /// fetches or writes. Vendored patches are `vendor --check`'s job. #[arg( long = "check", default_value_t = false, @@ -504,10 +506,21 @@ 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. +/// Read-only verification that the manifest's patches are in place, for CI +/// / GitHub-App auditing. Lock-free, fetch-free, offline-safe, and it never +/// writes. Exits 0 when in sync, 1 on drift. +/// +/// Two audits, over the in-scope (`--ecosystems`) manifest entries that are +/// not vendor-owned (`vendor --check` audits those): +/// +/// * local Go patches: the committed `.socket/go-patches/` copies and +/// `go.mod` `replace` directives ([`verify_go_redirect_state`]); +/// * every other patch: each installed copy must hash to the record's +/// `afterHash` — the same verifier `vex` attests with +/// ([`applied_patches_with_copies`](socket_patch_core::vex::applied_patches_with_copies)), +/// over the same copy lookup. A release variant (a qualified purl) is +/// judged only on the copies holding its distribution, as `apply` patches +/// it. A package with no installed copy is skipped, as `apply` skips it. async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { let manifest = match read_manifest(manifest_path).await { Ok(Some(m)) => m, @@ -518,7 +531,7 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { Ok(None) => return 0, Err(e) => { let msg = format!( - "Patch redirect check could not read the manifest ({e}); \ + "Patch check could not read the manifest ({e}); \ treating it as drift (fail-closed)." ); if args.common.json { @@ -535,29 +548,33 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { }; // (purl_or_name, reason_code, detail) for each drift. - let mut drifts: Vec<(String, &'static str, String)> = Vec::new(); + let mut drifts: Vec<(String, String, String)> = Vec::new(); let mut checked: usize = 0; + // The `apply` scope: `--ecosystems`, minus vendor-owned purls (matched + // exactly as `apply` matches them; the ledger owns the PROJECT's + // copies only, so a global check verifies every in-scope copy). + let manifest_purls: Vec = manifest.patches.keys().cloned().collect(); + let in_scope: HashSet = + partition_purls(&manifest_purls, args.common.ecosystems.as_deref()) + .into_values() + .flatten() + .collect(); + let vendored = if crate::commands::project_state_in_scope(&args.common) { + socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await + } else { + Default::default() + }; + let owned_by_apply = |purl: &str| in_scope.contains(purl) && !purl_keys_cover(&vendored, purl); + { use socket_patch_core::patch::redirect::golang_local::Drift as GoDrift; if eco_in_local_scope(&args.common, Ecosystem::Golang) { - // Vendored modules are excluded: their replace directives point at - // `.socket/vendor/golang/` (the verify engine skips Vendor-owned - // entries) and their state is audited by `vendor`, not `--check`. - let vendored = socket_patch_core::vendor::load_state(&args.common.cwd) - .await - .map(|s| { - s.entries - .iter() - .flat_map(|(k, e)| [k.clone(), e.base_purl.clone()]) - .collect::>() - }) - .unwrap_or_default(); let desired: HashSet = manifest .patches .keys() .filter(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Golang)) - .filter(|p| !vendored.contains(*p)) + .filter(|p| !purl_keys_cover(&vendored, p)) .cloned() .collect(); checked += desired.len(); @@ -571,17 +588,56 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { | GoDrift::ResolvedVersionMismatch { purl, .. } => purl.clone(), GoDrift::OrphanReplace { module } => module.clone(), }; - drifts.push((id, "go_redirect_drift", d.to_string())); + drifts.push((id, "go_redirect_drift".to_string(), d.to_string())); } } } } + // Installed-tree patches: everything `apply` patches in place. + let mut tree = manifest.clone(); + tree.patches + .retain(|purl, _| owned_by_apply(purl) && !is_local_go(purl, &args.common)); + // The same PnP gate `apply` runs: a PnP tree hides every npm copy, so + // without it each npm patch would read as not installed and pass calm. + if matches!( + detect_npm_pkg_manager(&args.common.cwd), + NpmPkgManager::YarnBerryPnP + ) && eco_in_local_scope(&args.common, Ecosystem::Npm) + && manifest_targets_npm(&tree) + { + return refuse_yarn_pnp(args); + } + let mut in_sync: Vec = Vec::new(); + let mut not_installed: Vec = Vec::new(); + if !tree.patches.is_empty() { + let outcome = verify_installed_tree(&args.common, &tree).await; + in_sync = outcome.applied; + for failed in outcome.failed { + match failed.reason.as_str() { + "package_not_found" => not_installed.push(failed.purl), + // A zero-file record (`no_files`) is drift, not in sync: + // nothing was hashed, and `apply` / `get` count such a + // record as failed, so `--check` must not attest it. + reason => { + let detail = format!("{}: {}", failed.purl, describe_check_failure(reason)); + drifts.push((failed.purl, reason.to_string(), detail)); + } + } + } + in_sync.sort(); + not_installed.sort(); + checked += in_sync.len(); + } + drifts.sort(); + if drifts.is_empty() { if args.common.json { - println!("{}", Envelope::new(Command::Apply).to_pretty_json()); + let mut env = Envelope::new(Command::Apply); + record_check_skips(&mut env, &in_sync, ¬_installed); + println!("{}", env.to_pretty_json()); } else if !args.common.silent { - println!("{}", format_check_in_sync(checked)); + println!("{}", format_check_in_sync(checked, not_installed.len())); } 0 } else { @@ -590,15 +646,16 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { for (id, code, detail) in &drifts { env.record( PatchEvent::new(PatchAction::Failed, id.clone()) - .with_reason(*code, detail.clone()), + .with_reason(code.clone(), detail.clone()), ); } + record_check_skips(&mut env, &in_sync, ¬_installed); env.mark_partial_failure(); println!("{}", env.to_pretty_json()); } else { // Drift IS the error the exit code signals — it prints even // under --silent ("errors only", never "nothing"). - eprintln!("Error: Patch redirects are OUT OF SYNC:"); + eprintln!("Error: Patches are OUT OF SYNC:"); for (_, _, detail) in &drifts { eprintln!(" {detail}"); } @@ -608,15 +665,186 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { } } -/// The `apply --check` success line. `--check` audits Go redirects only, -/// so a project with none says so instead of a vacuous "in sync". -fn format_check_in_sync(checked: usize) -> String { - if checked == 0 { - "No Go patch redirects to check.".to_string() +/// The installed-tree half of `apply --check`: every copy of each purl in +/// `tree`, judged by the `vex` verifier over the `vex` copy lookup (Maven: +/// the copies a build consumes), narrowed by `apply`'s own copy rules: +/// +/// * Release variants (a base whose manifest keys are qualified, e.g. +/// `?artifact_id=` / `?platform=`): each copy is matched against EVERY +/// variant of its base, as `apply` matches it, and judged only for the +/// variants it holds. A copy that holds none of them is a +/// `no_matching_variant` drift of the base — `apply` fails that copy +/// ("no matching variant found"), so `--check` must not read it as +/// "not installed". Gradle / Ivy cache dirs are exempt: `apply` treats a +/// cache dir no variant matches as not an install of the record. +/// * Gem: once a bundle-store copy exists, `gem env` fallback-home copies +/// (rvm `@global`, system gem dirs) are dropped — `apply` treats them as +/// best-effort once the store copy is patched, and an unpatched store +/// copy is drift on its own. Copies under a containment-refused +/// `.bundle/config` `BUNDLE_PATH` root are kept: Bundler loads them. +async fn verify_installed_tree( + common: &GlobalArgs, + tree: &PatchManifest, +) -> socket_patch_core::vex::VerifyOutcome { + use socket_patch_core::crawlers::gradle_cache::expands; + use socket_patch_core::patch::apply::select_installed_variants; + use socket_patch_core::vex::FailedPatch; + let purls: Vec = tree.patches.keys().cloned().collect(); + let found = + crate::ecosystem_dispatch::find_manifest_package_copies_reusing(&purls, common, true, None) + .await; + let mut copies = crate::commands::vex::vex_copy_sets(common, tree, &found).await; + + // Gem copy classes, decided exactly as `apply` decides them. + let (gem_stores, refused_root): (Vec, Option) = if !common.global + && common.global_prefix.is_none() + && purls + .iter() + .any(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Gem)) + { + let discovery = RubyCrawler::discover_bundle_stores(&common.cwd).await; + (discovery.stores, discovery.skipped_config_root) + } else { + (Vec::new(), None) + }; + if !gem_stores.is_empty() { + for (purl, paths) in copies.iter_mut() { + if Ecosystem::from_purl(purl) != Some(Ecosystem::Gem) { + continue; + } + let in_store = |p: &PathBuf| gem_stores.iter().any(|s| p.starts_with(s)); + // Only `gem env` fallback homes are dropped: a copy under the + // containment-refused `.bundle/config` root is the one Bundler + // loads, so it stays verified even when a store copy matches. + let in_refused_root = + |p: &PathBuf| refused_root.as_ref().is_some_and(|r| p.starts_with(r)); + if paths.iter().any(in_store) { + paths.retain(|p| in_store(p) || in_refused_root(p)); + } + } + } + + // Release variants, grouped per base purl over all of its keys. + let mut groups: BTreeMap> = BTreeMap::new(); + for purl in &purls { + groups + .entry(strip_purl_qualifiers(purl).to_string()) + .or_default() + .push(purl.clone()); + } + let mut unmatched: Vec = Vec::new(); + let mut mismatched_keys: std::collections::BTreeSet = Default::default(); + for (base, mut keys) in groups { + if keys.len() == 1 && keys[0] == base { + // An unqualified singleton names no distribution: `apply` + // runs it through the mismatch policy, the verifier judges it. + continue; + } + keys.sort(); + let variants: Vec<(&str, &HashMap)> = keys + .iter() + .filter_map(|k| tree.patches.get(k).map(|r| (k.as_str(), &r.files))) + .collect(); + let mut group_copies: Vec = keys + .iter() + .flat_map(|k| copies.get(k).cloned().unwrap_or_default()) + .collect(); + group_copies.sort(); + group_copies.dedup(); + let mut kept: HashMap<&str, Vec> = HashMap::new(); + let mut stray: Vec = Vec::new(); + for path in group_copies { + let matched = select_installed_variants(&path, &variants).await; + if matched.is_empty() { + if !expands(&path) { + stray.push(path); + } + continue; + } + for idx in matched { + kept.entry(variants[idx].0).or_default().push(path.clone()); + } + } + for key in &keys { + let paths = kept.remove(key.as_str()).unwrap_or_default(); + if !stray.is_empty() && paths.is_empty() { + mismatched_keys.insert(key.clone()); + } + copies.insert(key.clone(), paths); + } + if !stray.is_empty() { + unmatched.push(FailedPatch { + purl: base, + reason: "no_matching_variant".to_string(), + }); + } + } + + let mut outcome = + socket_patch_core::vex::applied_patches_with_copies(tree, &copies, None).await; + // A key left copy-less only because its release's installed copy + // matched no variant is that `no_matching_variant` failure, not a + // second "not installed" skip. + outcome + .failed + .retain(|f| !(f.reason == "package_not_found" && mismatched_keys.contains(&f.purl))); + outcome.failed.extend(unmatched); + outcome +} + +/// The `apply --check` drift text for a verifier routing tag. +fn describe_check_failure(reason: &str) -> &'static str { + match reason { + "not_applied" => "patch not applied (an installed copy is still unpatched)", + "hash_mismatch" => "an installed copy matches neither the original nor the patched bytes", + "file_not_found" => "a patched file is missing from an installed copy", + "no_files" => "the patch record lists no files to verify", + "no_matching_variant" => { + "an installed copy matches none of the manifest's release variants \ + (no matching variant found)" + } + _ => "an installed copy does not verify", + } +} + +/// The `skipped` events of an `apply --check --json` envelope: in-sync +/// patches as `already_patched` (apply's own tag for them) and patches with +/// no installed copy as `package_not_installed`. +fn record_check_skips(env: &mut Envelope, in_sync: &[String], not_installed: &[String]) { + for purl in in_sync { + env.record( + PatchEvent::new(PatchAction::Skipped, purl.clone()) + .with_reason("already_patched", "every installed copy is patched"), + ); + } + for purl in not_installed { + env.record( + PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason( + "package_not_installed", + "No installed package matches this PURL", + ), + ); + } +} + +/// The `apply --check` success line: how many patches were checked, and how +/// many were skipped for having no installed copy, so a check over an +/// uninstalled tree never reads as a vacuous "in sync". +fn format_check_in_sync(checked: usize, not_installed: usize) -> String { + let skipped = if not_installed == 0 { + String::new() + } else { + format!( + "; {} not installed, skipped", + plural(not_installed, "patch", "patches") + ) + }; + if checked == 0 && not_installed == 0 { + "No patches to check.".to_string() } else { format!( - "Patch redirects are in sync ({} checked).", - plural(checked, "redirect", "redirects") + "Patches are in sync ({} checked{skipped}).", + plural(checked, "patch", "patches") ) } } @@ -4221,14 +4449,20 @@ mod tests { #[test] fn check_in_sync_line() { - assert_eq!(format_check_in_sync(0), "No Go patch redirects to check."); + assert_eq!(format_check_in_sync(0, 0), "No patches to check."); + assert_eq!( + format_check_in_sync(1, 0), + "Patches are in sync (1 patch checked)." + ); assert_eq!( - format_check_in_sync(1), - "Patch redirects are in sync (1 redirect checked)." + format_check_in_sync(3, 0), + "Patches are in sync (3 patches checked)." ); + // A check that skipped uninstalled packages says so, never a bare + // vacuous "in sync". assert_eq!( - format_check_in_sync(3), - "Patch redirects are in sync (3 redirects checked)." + format_check_in_sync(0, 2), + "Patches are in sync (0 patches checked; 2 patches not installed, skipped)." ); } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 2d7b53012..23e6f021a 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1407,18 +1407,26 @@ pub(crate) async fn run_redirect_selected( warnings.extend(takeover_warnings.iter().cloned()); warnings.extend(prune_warnings.iter().cloned()); + // Granted, but nothing in the project pins it (no lock entry, + // unreadable lock, ...): reported per purl (`redirect.patches[]` + // `unpinned` rows; the human "Not hosted" lines) so it never vanishes + // silently. (A skipped uuid — e.g. unavailable wheel metadata — is + // already listed with its reason.) + let unconfirmed = socket_patch_core::hosted::engine::unconfirmed_candidates( + &candidates, + &confirmed, + &skipped, + ); if common.json { // Nest the redirect result under `redirect` inside the classic scan // object (built by `run`, threaded in via `scan_result`), mirroring // vendored mode's nested `vendor` block, so the hosted `--json` // envelope keeps the same top-level scan keys as every other scan. let redirect = redirect_json_block( - confirmed.len(), + &confirmed, + &unconfirmed, done.rewritten.clone(), - skipped - .iter() - .map(socket_patch_core::hosted::render::skipped_json) - .collect(), + &skipped, warnings, common.dry_run, ); @@ -1500,23 +1508,11 @@ pub(crate) async fn run_redirect_selected( .filter(|s| s.reason != super::rollout::ROLLOUT_DEFERRED) .map(|s| (s.purl.clone(), s.reason.clone())) .collect(); - // Granted, but nothing in the project pins it (no lock entry, - // unreadable lock, ...): listed so it never vanishes silently. - // (A skipped uuid — e.g. unavailable wheel metadata — is already - // listed with its reason.) - let unconfirmed: Vec = candidates - .iter() - .filter(|c| { - !confirmed - .iter() - .any(|(cp, cu)| *cp == c.purl && *cu == c.dep.patch_uuid) - }) - .filter(|c| !skipped.iter().any(|s| s.uuid == c.dep.patch_uuid)) - .map(|c| c.purl.clone()) - .collect(); + let unconfirmed_purls: Vec = + unconfirmed.iter().map(|(purl, _)| purl.clone()).collect(); for line in format_unredirected( &skipped_pairs, - &unconfirmed, + &unconfirmed_purls, confirmed.is_empty(), // Only the lockfile rewriters' own warnings explain a // missing lock entry; unrelated guidance (pnpm trust, VEX, @@ -3296,9 +3292,10 @@ mod tests { // spelling of the block (`run`'s zero-discovery arm uses the same // helper). let redirect = redirect_json_block( - 1, + &[("pkg:npm/minimist@1.2.2".to_string(), "abc-123".to_string())], + &[], vec!["package-lock.json".to_string()], - Vec::new(), + &[], vec![prune_ignored_warning()], false, ); diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 66df3be83..e242d6435 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -2063,9 +2063,10 @@ async fn run_scan( warnings.push(hosted::prune_ignored_warning()); } result["redirect"] = hosted::redirect_json_block( - 0, - Vec::new(), + &[], + &[], Vec::new(), + &[], warnings, args.common.dry_run, ); diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index b2ce4cd02..658ac5494 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -938,7 +938,7 @@ async fn generate_vex( /// install of it and is dropped, as apply does. One holding only some of /// them is kept: the keys it lacks verify as not found, so the statement /// is withheld while the build loads the held (unpatched) jar. -async fn vex_copy_sets( +pub(crate) async fn vex_copy_sets( common: &GlobalArgs, manifest: &PatchManifest, copies: &HashMap>, diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index 32e01e9eb..002b369c6 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -152,6 +152,20 @@ impl Commands { Commands::HostedBundle(a) => &a.common, } } + + /// Validate the run's path flags ([`args::GlobalArgs::validate_paths`]) + /// for every command that reads the project. `self-update` and the + /// internal `hosted-bundle` harness (stdin in, stdout out) never touch + /// `--cwd`, so an ambient `SOCKET_CWD` must not fail them. `get` and + /// `scan` create the manifest, so a `--manifest-path` into a directory + /// that does not exist yet stays legal for them. + pub fn validate_paths(&self) -> Result<(), String> { + match self { + Commands::SelfUpdate(_) | Commands::HostedBundle(_) => Ok(()), + Commands::Get(_) | Commands::Scan(_) => self.global_args().validate_paths(true), + other => other.global_args().validate_paths(false), + } + } } /// Global options every subcommand's short help (`-h`) still lists; the diff --git a/crates/socket-patch-cli/src/main.rs b/crates/socket-patch-cli/src/main.rs index 418403a12..13de81a3d 100644 --- a/crates/socket-patch-cli/src/main.rs +++ b/crates/socket-patch-cli/src/main.rs @@ -75,6 +75,14 @@ async fn main() { std::process::exit(2); } + // A `--cwd`, `--global-prefix` or `--manifest-path` that names nothing + // is a usage error, checked once here before any command runs: read as + // an empty project it made every verifier pass vacuously. + if let Err(message) = cli.command.validate_paths() { + eprintln!("Error: {message}"); + std::process::exit(2); + } + // Human-output policy (core advisories and prompt notes go quiet under // --silent/--json) is fixed once, before any command code runs. socket_patch_cli::ui::init(cli.command.global_args()); diff --git a/crates/socket-patch-cli/tests/apply/check_verifies_installed_tree.rs b/crates/socket-patch-cli/tests/apply/check_verifies_installed_tree.rs new file mode 100644 index 000000000..da0f9a8d7 --- /dev/null +++ b/crates/socket-patch-cli/tests/apply/check_verifies_installed_tree.rs @@ -0,0 +1,344 @@ +//! `apply --check` verifies every manifest patch, not only Go redirects. +//! +//! Before, `--check` audited the committed Go `replace`-redirects and +//! nothing else, so on an npm (or any non-Go) agent project whose installed +//! files were unpatched it printed "No Go patch redirects to check." and +//! exited 0 — a permanent false green for the obvious "are we patched?" CI +//! gate (audit B27). It now runs the `vex` verifier over every installed +//! copy of each in-scope, non-vendored manifest patch. + +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::common; +use common::{git_sha256, parse_json_envelope, run_with_env}; + +const PURL: &str = "pkg:npm/check-target@1.0.0"; +const ORIGINAL: &[u8] = b"module.exports = 'vulnerable';\n"; +const PATCHED: &[u8] = b"module.exports = 'patched';\n"; + +fn run_check(cwd: &Path, extra: &[&str]) -> (i32, String, String) { + let mut argv = vec!["apply", "--check", "--offline"]; + argv.extend_from_slice(extra); + run_with_env(cwd, &argv, &[("SOCKET_TELEMETRY_DISABLED", "1")]) +} + +/// An npm agent project: `.socket/manifest.json` patching +/// `node_modules/check-target/index.js` from [`ORIGINAL`] to [`PATCHED`]; +/// the installed copy holds `installed` (`None`: not installed). +fn project(installed: Option<&[u8]>) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write( + root.join("package.json"), + r#"{ "name": "check-root", "version": "0.0.0" }"#, + ) + .unwrap(); + std::fs::create_dir_all(root.join(".socket")).unwrap(); + let manifest = json!({ "patches": { PURL: { + "uuid": "27272727-2727-4272-8272-272727272727", + "exportedAt": "2024-01-01T00:00:00Z", + "files": { "index.js": { + "beforeHash": git_sha256(ORIGINAL), + "afterHash": git_sha256(PATCHED), + }}, + "vulnerabilities": {}, + "description": "apply --check fixture", + "license": "MIT", + "tier": "free", + }}}); + std::fs::write( + root.join(".socket/manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + if let Some(bytes) = installed { + let pkg = root.join("node_modules/check-target"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + r#"{ "name": "check-target", "version": "1.0.0" }"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), bytes).unwrap(); + } + tmp +} + +fn events(env: &Value) -> Vec { + env["events"].as_array().cloned().unwrap_or_default() +} + +/// The regression: an unpatched installed copy is drift (exit 1), in both +/// output modes, and `--check` writes nothing. +#[test] +fn check_fails_on_an_unpatched_npm_package() { + let tmp = project(Some(ORIGINAL)); + let index = tmp.path().join("node_modules/check-target/index.js"); + + let (code, stdout, stderr) = run_check(tmp.path(), &[]); + assert_eq!( + code, 1, + "an unpatched tree is drift\nstdout={stdout}\nstderr={stderr}" + ); + assert!(stderr.contains("OUT OF SYNC"), "{stderr}"); + assert!( + stderr.contains(&format!("{PURL}: patch not applied")), + "the drift names the package: {stderr}" + ); + assert_eq!( + std::fs::read(&index).unwrap(), + ORIGINAL, + "--check never writes" + ); + + let (code, stdout, stderr) = run_check(tmp.path(), &["--json"]); + assert_eq!(code, 1, "stderr={stderr}"); + let env = parse_json_envelope(stdout.trim()); + assert_eq!(env["status"], "partialFailure", "{env}"); + let failed: Vec = events(&env) + .into_iter() + .filter(|e| e["action"] == "failed") + .collect(); + assert_eq!(failed.len(), 1, "{env}"); + assert_eq!(failed[0]["purl"], PURL, "{env}"); + assert_eq!(failed[0]["errorCode"], "not_applied", "{env}"); +} + +/// Bytes that match neither hash are drift too (`apply` would overwrite +/// them), with their own code. +#[test] +fn check_fails_on_a_tampered_npm_package() { + let tmp = project(Some(b"something else entirely\n")); + let (code, stdout, stderr) = run_check(tmp.path(), &["--json"]); + assert_eq!(code, 1, "stderr={stderr}"); + let env = parse_json_envelope(stdout.trim()); + assert!( + events(&env) + .iter() + .any(|e| e["action"] == "failed" && e["errorCode"] == "hash_mismatch"), + "{env}" + ); +} + +/// A record with an empty `files` map offers nothing to hash, so an +/// installed copy of it is drift (`no_files`), never `already_patched`: +/// `apply` and `get` count such a record as failed, and `--check` exit 0 +/// is the remediation attestation. +#[test] +fn check_fails_on_a_zero_file_record() { + let tmp = project(Some(ORIGINAL)); + let path = tmp.path().join(".socket/manifest.json"); + let mut manifest: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + manifest["patches"][PURL]["files"] = json!({}); + std::fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); + + let (code, stdout, stderr) = run_check(tmp.path(), &["--json"]); + assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); + let env = parse_json_envelope(stdout.trim()); + let evs = events(&env); + assert!( + evs.iter() + .any(|e| e["action"] == "failed" && e["purl"] == PURL && e["errorCode"] == "no_files"), + "{env}" + ); + assert!( + !evs.iter().any(|e| e["errorCode"] == "already_patched"), + "{env}" + ); + + let (code, _stdout, stderr) = run_check(tmp.path(), &[]); + assert_eq!(code, 1, "{stderr}"); + assert!(stderr.contains("OUT OF SYNC"), "{stderr}"); +} + +/// Anti-vacuous half: the same project with the patch in place is in sync, +/// and says how many patches it checked. +#[test] +fn check_passes_on_a_patched_npm_package() { + let tmp = project(Some(PATCHED)); + let (code, stdout, stderr) = run_check(tmp.path(), &[]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.contains("Patches are in sync (1 patch checked)."), + "{stdout}" + ); + + let (code, stdout, stderr) = run_check(tmp.path(), &["--json"]); + assert_eq!(code, 0, "stderr={stderr}"); + let env = parse_json_envelope(stdout.trim()); + assert_eq!(env["status"], "success", "{env}"); + let events = events(&env); + assert_eq!(events.len(), 1, "{env}"); + assert_eq!(events[0]["action"], "skipped", "{env}"); + assert_eq!(events[0]["errorCode"], "already_patched", "{env}"); +} + +/// A package with no installed copy is skipped — `apply` skips it too — +/// and the success line says so instead of a bare "in sync". +#[test] +fn check_skips_an_uninstalled_package_and_says_so() { + let tmp = project(None); + let (code, stdout, stderr) = run_check(tmp.path(), &[]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.contains("1 patch not installed, skipped"), + "{stdout}" + ); + + let (code, stdout, _) = run_check(tmp.path(), &["--json"]); + assert_eq!(code, 0); + let env = parse_json_envelope(stdout.trim()); + assert!( + events(&env) + .iter() + .any(|e| e["action"] == "skipped" && e["errorCode"] == "package_not_installed"), + "{env}" + ); +} + +/// `--ecosystems` scopes the check exactly as it scopes `apply`. +#[test] +fn check_honors_the_ecosystems_filter() { + let tmp = project(Some(ORIGINAL)); + let (code, stdout, stderr) = run_check(tmp.path(), &["--ecosystems", "pypi"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert!(stdout.contains("No patches to check."), "{stdout}"); +} + +const GEM_BASE: &str = "pkg:gem/nokogiri@1.16.5"; +const GEM_LINUX: &str = "pkg:gem/nokogiri@1.16.5?platform=x86_64-linux"; +const GEM_ORIGINAL: &[u8] = b"module Nokogiri\n VERSION = '1.16.5'\nend\n"; +const GEM_PATCHED: &[u8] = b"module Nokogiri\n VERSION = '1.16.5'\nend\n# SOCKET-PATCH\n"; + +/// A gem project whose manifest keys the patch by a QUALIFIED purl (a +/// release variant, the normal form for gem and PyPI patches); the +/// installed `lib/nokogiri.rb` holds `installed`. +fn gem_project(installed: &[u8]) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join("Gemfile"), b"source 'https://rubygems.org'\n").unwrap(); + let file = root.join("vendor/bundle/ruby/3.4.0/gems/nokogiri-1.16.5/lib/nokogiri.rb"); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, installed).unwrap(); + std::fs::create_dir_all(root.join("vendor/bundle/ruby/3.4.0/specifications")).unwrap(); + std::fs::create_dir_all(root.join(".socket/blobs")).unwrap(); + let manifest = json!({ "patches": { GEM_LINUX: { + "uuid": "41414141-4141-4141-8141-414141414141", + "exportedAt": "2024-01-01T00:00:00Z", + "files": { "lib/nokogiri.rb": { + "beforeHash": git_sha256(GEM_ORIGINAL), + "afterHash": git_sha256(GEM_PATCHED), + }}, + "vulnerabilities": {}, + "description": "apply --check gem variant fixture", + "license": "MIT", + "tier": "free", + }}}); + std::fs::write( + root.join(".socket/manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write( + root.join(".socket/blobs").join(git_sha256(GEM_PATCHED)), + GEM_PATCHED, + ) + .unwrap(); + tmp +} + +/// The release-variant false green: an installed copy of a qualified +/// purl whose bytes match neither of the variant's hashes is drift +/// (`no_matching_variant`, exit 1) — `apply` fails the same copy with +/// "no matching variant found" — never "not installed, skipped". +#[test] +fn check_fails_on_a_qualified_gem_matching_no_variant() { + let tmp = gem_project(b"# foreign bytes\n"); + let (code, stdout, stderr) = run_check(tmp.path(), &["--json", "--ecosystems", "gem"]); + assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); + let env = parse_json_envelope(stdout.trim()); + assert!( + events(&env).iter().any(|e| e["action"] == "failed" + && e["purl"] == GEM_BASE + && e["errorCode"] == "no_matching_variant"), + "{env}" + ); + // The installed mismatch is not also reported as not installed. + assert!( + !events(&env) + .iter() + .any(|e| e["errorCode"] == "package_not_installed"), + "{env}" + ); + + // Parity: `apply` exits 1 on the same tree. + let (code, stdout, stderr) = run_with_env( + tmp.path(), + &["apply", "--offline", "--ecosystems", "gem"], + &[("SOCKET_TELEMETRY_DISABLED", "1")], + ); + assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); +} + +/// The same qualified variant judged on its own copy: unpatched is +/// `not_applied` drift, patched is in sync. +#[test] +fn check_judges_a_qualified_gem_on_its_own_copy() { + let tmp = gem_project(GEM_ORIGINAL); + let (code, stdout, stderr) = run_check(tmp.path(), &["--json", "--ecosystems", "gem"]); + assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); + let env = parse_json_envelope(stdout.trim()); + assert!( + events(&env).iter().any(|e| e["action"] == "failed" + && e["purl"] == GEM_LINUX + && e["errorCode"] == "not_applied"), + "{env}" + ); + + let tmp = gem_project(GEM_PATCHED); + let (code, stdout, stderr) = run_check(tmp.path(), &["--ecosystems", "gem"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.contains("Patches are in sync (1 patch checked)."), + "{stdout}" + ); +} + +/// A `.bundle/config` `BUNDLE_PATH` the containment guard refuses (it +/// resolves outside the project) is still where Bundler loads gems from. +/// A patched decoy copy under the in-project `vendor/bundle` store must +/// not hide the unpatched copy in that refused root: `--check` is drift. +#[test] +fn check_fails_on_an_unpatched_copy_in_a_refused_bundle_path() { + let tmp = gem_project(GEM_PATCHED); + let outside = tempfile::tempdir().unwrap(); + let loaded = outside + .path() + .join("ruby/3.4.0/gems/nokogiri-1.16.5/lib/nokogiri.rb"); + std::fs::create_dir_all(loaded.parent().unwrap()).unwrap(); + std::fs::write(&loaded, GEM_ORIGINAL).unwrap(); + std::fs::create_dir_all(outside.path().join("ruby/3.4.0/specifications")).unwrap(); + std::fs::create_dir_all(tmp.path().join(".bundle")).unwrap(); + std::fs::write( + tmp.path().join(".bundle/config"), + format!( + "---\nBUNDLE_PATH: {}\n", + serde_json::to_string(&outside.path().to_string_lossy()).unwrap() + ), + ) + .unwrap(); + + let (code, stdout, stderr) = run_check(tmp.path(), &["--json", "--ecosystems", "gem"]); + assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); + let env = parse_json_envelope(stdout.trim()); + assert!( + events(&env).iter().any(|e| e["action"] == "failed" + && e["purl"] == GEM_LINUX + && e["errorCode"] == "not_applied"), + "{env}" + ); + // The refused root is only read: the loaded copy stays unpatched. + assert_eq!(std::fs::read(&loaded).unwrap(), GEM_ORIGINAL); +} diff --git a/crates/socket-patch-cli/tests/apply/covgap_commands_apply.rs b/crates/socket-patch-cli/tests/apply/covgap_commands_apply.rs index 035a48da9..c838970b9 100644 --- a/crates/socket-patch-cli/tests/apply/covgap_commands_apply.rs +++ b/crates/socket-patch-cli/tests/apply/covgap_commands_apply.rs @@ -873,6 +873,18 @@ fn corrupt_manifest_under_pnp_layout_reports_manifest_error_not_refusal() { stderr.contains("Plug'n'Play layout is not supported"), "the refusal must fire when the manifest IS readable and targets npm; stderr={stderr}" ); + + // (c) `--check` refuses the same tree instead of reading every hidden + // npm copy as not installed and exiting 0. + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--check"], &[]); + assert_eq!( + code, 1, + "PnP + npm patch must refuse --check; stdout={stdout} stderr={stderr}" + ); + assert!( + stderr.contains("Plug'n'Play layout is not supported"), + "--check must run apply's PnP refusal; stderr={stderr}" + ); } // ═══════════ 6. gem fallback-home skip on human stderr ═══════════ diff --git a/crates/socket-patch-cli/tests/apply/in_process_gem_fallback_home.rs b/crates/socket-patch-cli/tests/apply/in_process_gem_fallback_home.rs index bb61ebd56..fcd626e5e 100644 --- a/crates/socket-patch-cli/tests/apply/in_process_gem_fallback_home.rs +++ b/crates/socket-patch-cli/tests/apply/in_process_gem_fallback_home.rs @@ -230,6 +230,23 @@ fn mismatched_fallback_home_copy_is_nonfatal_when_store_patched() { ); } +/// `apply --check` uses the same copy classes: once the store copy is +/// patched, a fallback-home copy no variant matches is no drift — `apply` +/// left it alone and exited 0, so the check must not send CI back to an +/// `apply` that will never act on it. +#[test] +fn check_ignores_a_mismatched_fallback_home_copy_when_store_patched() { + let fx = build_fixture(true, b"totally different bytes\n", QUALIFIED_PURL); + let (code, _, stderr) = run_apply(&fx, &["--json"]); + assert_eq!(code, 0, "stderr:\n{stderr}"); + + let (code, stdout, stderr) = run_apply(&fx, &["--check"]); + assert_eq!( + code, 0, + "apply exited 0 on this tree, so --check must too.\nstdout={stdout}\nstderr={stderr}" + ); +} + /// (b) Parity pin: with NO bundle-store copy, the fallback-home copy IS /// the primary install — a mismatch there is a loud failure (exit 1), as /// in plain apply. @@ -247,6 +264,26 @@ fn fallback_only_mismatch_keeps_loud_failure_parity() { find_skip_event(&env).is_none(), "no best-effort skip without a patched store copy.\nenvelope: {env}" ); + + // `apply --check` on the same tree fails too: the copy holds no + // release variant of the manifest's base, which is drift, never + // "not installed, skipped". + let (code, stdout, stderr) = run_apply(&fx, &["--check", "--json"]); + assert_eq!( + code, 1, + "--check must match apply's exit.\nstdout={stdout}\nstderr={stderr}" + ); + let env = parse_env(&stdout); + assert!( + env["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "failed" + && e["purl"] == BASE_PURL + && e["errorCode"] == "no_matching_variant"), + "envelope: {env}" + ); } /// (c) Both copies healthy: both patched (the multi-copy guarantee is @@ -320,4 +357,12 @@ fn failing_fallback_home_copy_write_is_nonfatal_when_store_patched() { failed_events, 0, "no Failed event for a best-effort home copy.\nenvelope: {env}" ); + + // `apply --check` agrees: the diverged home copy is best-effort, so + // the patched store copy is what it judges. + let (code, stdout, stderr) = run_apply(&fx, &["--check"]); + assert_eq!( + code, 0, + "apply exited 0 on this tree, so --check must too.\nstdout={stdout}\nstderr={stderr}" + ); } diff --git a/crates/socket-patch-cli/tests/apply/main.rs b/crates/socket-patch-cli/tests/apply/main.rs index 20dc853e8..afd82271f 100644 --- a/crates/socket-patch-cli/tests/apply/main.rs +++ b/crates/socket-patch-cli/tests/apply/main.rs @@ -11,6 +11,7 @@ mod vlt_vendored; mod apply_invariants; mod apply_network; +mod check_verifies_installed_tree; mod cli_gem_variant_mismatch_policy; mod covgap_commands_apply; mod e2e_safety_advisories; diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs index 9a850490d..93cbaa6e9 100644 --- a/crates/socket-patch-cli/tests/cli_parse_list.rs +++ b/crates/socket-patch-cli/tests/cli_parse_list.rs @@ -380,17 +380,14 @@ fn missing_manifest_under_valid_cwd_is_not_an_error_via_binary() { } #[test] -fn manifest_path_is_existing_directory_reports_unreadable_via_binary() { - // A genuine I/O error reaching an *existing* path must be - // `manifest_unreadable`, never `manifest_not_found`. Here the manifest path - // points at a directory, so the read fails with a non-absence I/O error - // (Unix `IsADirectory` / Windows `PermissionDenied`) — present, but - // unreadable. (We use a directory rather than a `/manifest` - // path because the latter is `ENOTDIR` on Unix but a NotFound-class error - // on Windows, where traversing through a file is legitimately "path not - // found"; a directory yields a non-NotFound error on every platform.) - // A stat failure must not be folded into `manifest_not_found`: - // `read_manifest`'s I/O error classifies it. +fn manifest_path_is_existing_directory_is_a_usage_error_via_binary() { + // A `--manifest-path` naming a directory is never a manifest: v5.0 + // rejects it before the command runs, as a usage error (exit 2, the + // `Error:` line naming the flag), on every project command + // (`GlobalArgs::validate_paths`). Before, `list` reached `read_manifest` + // and failed with `manifest_unreadable` (exit 1); the I/O + // classification itself (a non-absence error is never + // `manifest_not_found`) is pinned on `read_manifest` in core. let tmp = tempfile::tempdir().unwrap(); let manifest_path = tmp.path().join("manifest-is-a-dir"); std::fs::create_dir(&manifest_path).unwrap(); @@ -399,14 +396,11 @@ fn manifest_path_is_existing_directory_reports_unreadable_via_binary() { tmp.path(), &["--json", "--manifest-path", manifest_path.to_str().unwrap()], ); - let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON envelope"); - assert_eq!(out.status.code(), Some(1), "I/O error must exit 1"); - assert_eq!(v["status"], "error"); - assert_eq!( - v["error"]["code"], "manifest_unreadable", - "a non-absence I/O error must be manifest_unreadable, not \ - manifest_not_found, got envelope: {v}" + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(2), "stderr={stderr}"); + assert!( + stderr.contains("--manifest-path") && stderr.contains("is a directory"), + "{stderr}" ); } diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index eff55ee79..44d22d342 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -533,6 +533,7 @@ fn scan_json_empty_cwd_emits_updates_key() { "redirected": 0, "rewrittenFiles": [], "skipped": [], + "patches": [], "warnings": [], "dryRun": false }, diff --git a/crates/socket-patch-cli/tests/cli_path_flags_validated.rs b/crates/socket-patch-cli/tests/cli_path_flags_validated.rs new file mode 100644 index 000000000..dc835e9b5 --- /dev/null +++ b/crates/socket-patch-cli/tests/cli_path_flags_validated.rs @@ -0,0 +1,220 @@ +//! `--cwd`, `--global-prefix` and `--manifest-path` that name nothing are +//! usage errors (exit 2) on every project command, checked once in `main` +//! before the command runs. +//! +//! Before, nothing validated them: a typo in `--cwd` / `SOCKET_CWD` read as +//! an empty project, so `list`, `apply`, `apply --check`, `vendor`, +//! `vendor --check`, `scan` and `get` all exited 0 having checked nothing — +//! a CI gate that passes on a directory that does not exist (audit B10). +//! The positional `scan` PATH already exits 2 when it is not a directory; +//! the flags now match it. + +mod common; + +use std::path::Path; + +/// Every project command, with the args it needs to get past clap. All are +/// offline so no case can reach the network before the check. +const COMMANDS: &[&[&str]] = &[ + &["list"], + &["apply"], + &["apply", "--check"], + &["vendor"], + &["vendor", "--check"], + &["vendor", "--revert", "--yes"], + &["scan"], + &["scan", "--mode", "agent"], + &["get", "lodash", "--yes"], + &["vex"], + &["rollback", "--yes"], + &["remove", "pkg:npm/lodash@4.17.20", "--yes"], + &["repair"], +]; + +/// `get` and `scan` create the manifest (and its directory), so a +/// `--manifest-path` into a directory that does not exist yet is legal for +/// them. +fn creates_manifest(command: &[&str]) -> bool { + matches!(command.first(), Some(&"get") | Some(&"scan")) +} + +fn run(cwd: &Path, command: &[&str], extra: &[&str]) -> (i32, String, String) { + let mut args: Vec<&str> = command.to_vec(); + args.extend_from_slice(extra); + args.push("--offline"); + common::run(cwd, &args) +} + +/// A nonexistent `--cwd` fails every command with exit 2 and a message +/// naming the flag. +#[test] +fn nonexistent_cwd_is_a_usage_error_on_every_command() { + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("no-such-dir"); + let missing = missing.to_str().unwrap(); + for command in COMMANDS { + for json in [false, true] { + let mut extra = vec!["--cwd", missing]; + if json { + extra.push("--json"); + } + let (code, stdout, stderr) = run(tmp.path(), command, &extra); + assert_eq!( + code, 2, + "{command:?} {extra:?} must be a usage error\nstdout={stdout}\nstderr={stderr}" + ); + assert!( + stderr.contains("--cwd") && stderr.contains("does not exist"), + "{command:?}: the error names the flag: {stderr}" + ); + assert!( + stdout.is_empty(), + "{command:?}: nothing on stdout: {stdout}" + ); + } + } +} + +/// The env spelling (`SOCKET_CWD`) is validated the same way, and a +/// `--cwd` naming a FILE is "not a directory". +#[test] +fn socket_cwd_env_and_file_cwd_are_usage_errors() { + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("gone"); + let (code, _stdout, stderr) = common::run_with_env( + tmp.path(), + &["list"], + &[("SOCKET_CWD", missing.to_str().unwrap())], + ); + assert_eq!(code, 2, "SOCKET_CWD typo must fail: {stderr}"); + assert!(stderr.contains("SOCKET_CWD"), "{stderr}"); + + let file = tmp.path().join("package.json"); + std::fs::write(&file, "{}").unwrap(); + let (code, _stdout, stderr) = run( + tmp.path(), + &["apply", "--check"], + &["--cwd", file.to_str().unwrap()], + ); + assert_eq!(code, 2, "a file --cwd must fail: {stderr}"); + assert!(stderr.contains("is not a directory"), "{stderr}"); +} + +/// A nonexistent `--global-prefix` fails every command with exit 2 (it +/// used to report "No global packages found." and exit 0). +#[test] +fn nonexistent_global_prefix_is_a_usage_error_on_every_command() { + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("no-such-prefix"); + for command in COMMANDS { + let (code, stdout, stderr) = run( + tmp.path(), + command, + &["--global-prefix", missing.to_str().unwrap()], + ); + assert_eq!( + code, 2, + "{command:?} must be a usage error\nstdout={stdout}\nstderr={stderr}" + ); + assert!(stderr.contains("--global-prefix"), "{command:?}: {stderr}"); + } +} + +/// A `--manifest-path` in a directory that does not exist fails every +/// command with exit 2 (`list` used to say "No patches" and exit 0); one +/// naming a directory fails too. +#[test] +fn manifest_path_in_a_missing_directory_is_a_usage_error_on_every_command() { + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("no-such-dir/m.json"); + for command in COMMANDS { + if creates_manifest(command) { + continue; + } + let (code, stdout, stderr) = run( + tmp.path(), + command, + &["--manifest-path", missing.to_str().unwrap()], + ); + assert_eq!( + code, 2, + "{command:?} must be a usage error\nstdout={stdout}\nstderr={stderr}" + ); + assert!(stderr.contains("--manifest-path"), "{command:?}: {stderr}"); + } + let (code, _stdout, stderr) = run( + tmp.path(), + &["list"], + &["--manifest-path", tmp.path().to_str().unwrap()], + ); + assert_eq!(code, 2, "a directory is no manifest file: {stderr}"); + assert!(stderr.contains("is a directory"), "{stderr}"); +} + +/// Anti-vacuous controls: real directories still run, and a missing +/// manifest FILE in an existing project stays legal — hosted and vendored +/// projects have none, and `get` / `scan --mode agent` create it. +#[test] +fn existing_paths_and_a_missing_manifest_file_still_run() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("project"); + let prefix = tmp.path().join("prefix"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&prefix).unwrap(); + + let (code, _stdout, stderr) = run(tmp.path(), &["list"], &["--cwd", project.to_str().unwrap()]); + assert_eq!(code, 0, "an existing --cwd runs: {stderr}"); + + let (code, _stdout, stderr) = run( + tmp.path(), + &["apply"], + &["--global-prefix", prefix.to_str().unwrap()], + ); + assert_eq!(code, 0, "an existing --global-prefix runs: {stderr}"); + + for manifest in [ + project.join("custom.json"), + // `.socket/` itself need not exist yet: its project does. + project.join(".socket/manifest.json"), + ] { + let (code, _stdout, stderr) = run( + tmp.path(), + &["list"], + &["--manifest-path", manifest.to_str().unwrap()], + ); + assert_eq!( + code, 0, + "a missing manifest file in an existing project stays legal: {stderr}" + ); + } +} + +/// The commands that create the manifest keep creating its directory: a +/// fresh checkout's `get --manifest-path state/patches.json` is no usage +/// error, and neither is `scan`. A directory in that position still is. +#[test] +fn manifest_creating_commands_accept_a_manifest_path_in_a_new_directory() { + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("state/patches.json"); + for command in COMMANDS.iter().filter(|c| creates_manifest(c)) { + let (code, stdout, stderr) = run( + tmp.path(), + command, + &["--manifest-path", missing.to_str().unwrap()], + ); + assert_ne!( + code, 2, + "{command:?} creates the manifest\nstdout={stdout}\nstderr={stderr}" + ); + assert!(!stderr.contains("--manifest-path"), "{command:?}: {stderr}"); + let (code, _stdout, stderr) = run( + tmp.path(), + command, + &["--manifest-path", tmp.path().to_str().unwrap()], + ); + assert_eq!( + code, 2, + "{command:?}: a directory is no manifest file: {stderr}" + ); + } +} diff --git a/crates/socket-patch-cli/tests/get/get_modes_e2e.rs b/crates/socket-patch-cli/tests/get/get_modes_e2e.rs index cada79646..d2f6f83d0 100644 --- a/crates/socket-patch-cli/tests/get/get_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/get/get_modes_e2e.rs @@ -269,6 +269,7 @@ async fn get_uuid_hosted_json_envelope_nests_redirect() { "redirected": 1, "rewrittenFiles": ["package-lock.json"], "skipped": [], + "patches": [{ "purl": PURL1, "uuid": UUID1, "action": "pinned" }], "warnings": [{ "code": "redirect_npm_allow_remote", "detail": allow_remote }], "dryRun": false, }, @@ -629,6 +630,7 @@ async fn get_hosted_dry_run_json_envelope() { "redirected": 1, "rewrittenFiles": ["package-lock.json"], "skipped": [], + "patches": [{ "purl": PURL1, "uuid": UUID1, "action": "would_pin" }], "warnings": [{ "code": "redirect_npm_allow_remote", "detail": allow_remote }], "dryRun": true, }, diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 74990badf..536de40b5 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -5080,3 +5080,63 @@ async fn cargo_hosted_scan_from_workspace_member_refuses() { assert!(!member.join(".cargo").exists(), "no member registry block"); assert!(!member.join(".socket").exists()); } + +/// `redirect.patches[]` reports every selected patch per purl (audit B12): +/// a pinned dep is a `pinned` row, so a consumer no longer has to infer +/// which patches the `redirected` count covers. +#[tokio::test] +#[serial] +async fn hosted_json_reports_a_pinned_row_per_patch() { + 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()); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!( + env["redirect"]["patches"], + serde_json::json!([{ "purl": PURL, "uuid": UUID, "action": "pinned" }]), + "{env:#}" + ); +} + +/// A granted patch that no lockfile entry pins (here: no lockfile at all) +/// used to vanish from `--json` — it is neither redirected nor skipped, and +/// only the human output named it ("Not hosted"). It is now an `unpinned` +/// row with `errorCode: redirect_unconfirmed`. The exit code is unchanged +/// (0): the hosted exit policy is an open maintainer decision (#704). +#[tokio::test] +#[serial] +async fn hosted_json_reports_an_unpinned_row_for_a_granted_patch_nothing_pins() { + 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(); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + let rows = env["redirect"]["patches"].as_array().expect("patches[]"); + assert_eq!(rows.len(), 1, "{env:#}"); + assert_eq!(rows[0]["purl"], PURL, "{env:#}"); + assert_eq!(rows[0]["uuid"], UUID, "{env:#}"); + assert_eq!(rows[0]["action"], "unpinned", "{env:#}"); + assert_eq!(rows[0]["errorCode"], "redirect_unconfirmed", "{env:#}"); +} diff --git a/crates/socket-patch-cli/tests/scan_rollout_e2e.rs b/crates/socket-patch-cli/tests/scan_rollout_e2e.rs index 6af41211c..795f139b2 100644 --- a/crates/socket-patch-cli/tests/scan_rollout_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_rollout_e2e.rs @@ -353,6 +353,12 @@ async fn hosted_cap_rolls_nine_packages_forward_three_per_run() { let mut block = block.clone(); let obj = block.as_object_mut().unwrap(); obj.remove("dryRun"); + // `patches[]` rows switch tense too: `would_pin` / `pinned`. + for row in obj["patches"].as_array_mut().unwrap() { + if row["action"] == "would_pin" { + row["action"] = json!("pinned"); + } + } let codes: Vec = obj["warnings"] .as_array() .unwrap() diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 7b4d180fe..bd1fe1910 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -92,6 +92,29 @@ pub struct Candidate { pub dep: DepOverride, } +/// `(purl, uuid)` of each candidate that was granted but that nothing in +/// the project's final files pins: not in `confirmed` (purl and uuid) and +/// not already reported in `skipped` (by uuid — a skip carries its own +/// reason). Candidate order. The disk and memory paths both report these +/// as the `unpinned` rows of the `redirect` block, and the disk path's +/// human output lists them as "Not hosted". +pub fn unconfirmed_candidates( + candidates: &[Candidate], + confirmed: &[(String, String)], + skipped: &[SkippedPatch], +) -> Vec<(String, String)> { + candidates + .iter() + .filter(|c| { + !confirmed + .iter() + .any(|(purl, uuid)| *purl == c.purl && *uuid == c.dep.patch_uuid) + }) + .filter(|c| !skipped.iter().any(|s| s.uuid == c.dep.patch_uuid)) + .map(|c| (c.purl.clone(), c.dep.patch_uuid.clone())) + .collect() +} + /// A selected patch that was not redirected, and why (the `skipped[]` /// entries of the `redirect` block). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/socket-patch-core/src/hosted/memory/mod.rs b/crates/socket-patch-core/src/hosted/memory/mod.rs index 06d814f9e..8c5f50223 100644 --- a/crates/socket-patch-core/src/hosted/memory/mod.rs +++ b/crates/socket-patch-core/src/hosted/memory/mod.rs @@ -1020,9 +1020,10 @@ async fn engine( let redirect = match &state.error { Some(_) => serde_json::json!({ "mode": "hosted" }), None => crate::hosted::render::redirect_json_block( - 0, - Vec::new(), + &[], + &[], Vec::new(), + &[], Vec::new(), options.dry_run, ), @@ -1199,6 +1200,7 @@ fn finish_root( skipped, pre_warnings, done, + unconfirmed, } = done; let crate::hosted::engine::Rewritten { rewrite, @@ -1299,14 +1301,11 @@ fn finish_root( warnings.extend(npm_warnings); warnings.extend(pre_warnings); let redirect_warnings = crate::hosted::render::rewrite_warnings_json(&warnings); - let skipped_values: Vec = skipped - .iter() - .map(crate::hosted::render::skipped_json) - .collect(); let redirect = crate::hosted::render::redirect_json_block( - confirmed.len(), + &confirmed, + &unconfirmed, rewritten, - skipped_values, + &skipped, redirect_warnings, dry_run, ); @@ -1367,6 +1366,7 @@ mod tests { project: MemoryProject::new(), skipped: Vec::new(), pre_warnings: Vec::new(), + unconfirmed: Vec::new(), done: crate::hosted::engine::Rewritten { files: BTreeMap::new(), symlinked_reads: Vec::new(), diff --git a/crates/socket-patch-core/src/hosted/memory/stages.rs b/crates/socket-patch-core/src/hosted/memory/stages.rs index 161bd9835..2ed9b7fc9 100644 --- a/crates/socket-patch-core/src/hosted/memory/stages.rs +++ b/crates/socket-patch-core/src/hosted/memory/stages.rs @@ -228,6 +228,8 @@ pub(crate) struct Rewritten { pub(crate) skipped: Vec, pub(crate) pre_warnings: Vec, pub(crate) done: engine::Rewritten, + /// Granted candidates nothing pins ([`engine::unconfirmed_candidates`]). + pub(crate) unconfirmed: Vec<(String, String)>, } /// A refused rewrite: its refusal and the skips recorded before the @@ -342,11 +344,13 @@ pub(crate) async fn rewrite( skipped: skipped_before, }); } + let unconfirmed = engine::unconfirmed_candidates(&candidates, &done.confirmed, &skipped); Ok(Rewritten { project, skipped, pre_warnings, done, + unconfirmed, }) } diff --git a/crates/socket-patch-core/src/hosted/render.rs b/crates/socket-patch-core/src/hosted/render.rs index 51ccd0809..5131ec0e2 100644 --- a/crates/socket-patch-core/src/hosted/render.rs +++ b/crates/socket-patch-core/src/hosted/render.rs @@ -21,23 +21,61 @@ pub fn rewrite_warnings_json(warnings: &[RewriteWarning]) -> Vec, - skipped: Vec, + skipped: &[SkippedPatch], warnings: Vec, dry_run: bool, ) -> serde_json::Value { + let pinned = if dry_run { "would_pin" } else { "pinned" }; + let mut patches: Vec = confirmed + .iter() + .map(|(purl, uuid)| serde_json::json!({ "purl": purl, "uuid": uuid, "action": pinned })) + .chain(skipped.iter().map(|s| { + let mut row = serde_json::json!({ + "purl": s.purl, "uuid": s.uuid, "action": "skipped", "errorCode": s.reason, + }); + if let Some(detail) = &s.detail { + row["error"] = serde_json::json!(detail); + } + row + })) + .chain(unconfirmed.iter().map(|(purl, uuid)| { + serde_json::json!({ + "purl": purl, "uuid": uuid, "action": "unpinned", + "errorCode": REDIRECT_UNCONFIRMED, + "error": "no lockfile entry pinning it could be rewritten", + }) + })) + .collect(); + patches.sort_by(|a, b| { + (a["purl"].as_str(), a["uuid"].as_str()).cmp(&(b["purl"].as_str(), b["uuid"].as_str())) + }); serde_json::json!({ "mode": "hosted", - "redirected": redirected, + "redirected": confirmed.len(), "rewrittenFiles": rewritten, - "skipped": skipped, + "skipped": skipped.iter().map(skipped_json).collect::>(), + "patches": patches, "warnings": warnings, "dryRun": dry_run, }) } + +/// The `errorCode` of an `unpinned` `redirect.patches[]` row: the patch was +/// granted, but no lockfile entry pinning it could be rewritten. +pub const REDIRECT_UNCONFIRMED: &str = "redirect_unconfirmed";