diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index a2995755b..e77f57c08 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -172,7 +172,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 it is redirected in the same commit. 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 kept vendored, 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 covered the same way: 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 kept vendored with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is kept vendored with `redirect_poetry_lock_unsupported`. **Staged takeover (v5.0)**: the takeover is atomic. Each purl's vendored revert is staged in memory (the run's group commit, the same journaled commit vendored mode uses), the hosted rewrite plans against the reverted project, and a staged purl the rewrite does not pin is retracted: every staged revert is undone, that purl keeps its vendored wiring, ledger entry and artifact byte-identical, and the rest are staged and rewritten again. A retracted purl is skipped (`redirect.skipped[].reason`) with the cause: its existing skip reason (unavailable wheel metadata, …), the rewriter warning that names the package (`redirect_yarn_berry_missing_checksum`, `redirect_pypi_platform_wheel`, …), `redirect_requirements_takeover_unreachable`, the rewrite's lock-level refusal (`redirect_yarn_berry_mixed_line_endings`, `redirect_bun_lock_unsupported`, a Gradle planner refusal, …) or `redirect_takeover_not_pinned`; that warning is reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored` naming the package. Exit 0: the package stays vendored and patched, never unpatched in both modes. The reverts, the hosted pins and the vendored ledger then reach the disk in one commit, and the reverted artifacts are deleted only after it: a refusal or write failure before the commit writes nothing, a failed commit puts back the files it replaced, and a commit interrupted after its journal was written is finished by the next command that takes the apply lock. `--dry-run` runs the same steps and drops the staged state instead of committing, so its `redirected` count and warnings are the wet run's. A hosted run with no takeover stages its writes the same way but commits them without the journal (it writes nothing under `.socket/`): a file that fails to be replaced puts back the ones already replaced, so a failed run leaves no lock half-redirected. 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` before its first wet write (the staged takeover 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"`, `error: {code: "lock_held" | "lock_io", message}` (v5.0: the same object every command uses; no top-level `error.code`), and `redirect: {mode: "hosted"}` retained. **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_bundle_lockfile_unsupported` (gem: bundler 4's custom lockfile — the `BUNDLE_LOCKFILE` environment variable, else `BUNDLE_LOCKFILE:` in the bundler app config, else in the global config — names a lock other than the loaded pair's own `Gemfile.lock` / `gems.locked`, so no gem is redirected or attested rather than pinning a lock bundler ignores), `redirect_gem_twin_manifest_ambiguous` (gem: a `Gemfile` + `gems.rb` twin under default discovery; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, and a lock's `BUNDLED WITH` records which bundler wrote it, not which one installs it, so neither pair is wired or attested — remove the unused spelling or set `BUNDLE_GEMFILE` to the one in use), `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), `redirect_gem_no_lockfile` (gem: the project has a `Gemfile` / `gems.rb` but no `Gemfile.lock` / `gems.locked`, so nothing records which version it resolves and the crawled version may be another project's copy in the shared gem home; the gem is skipped with nothing written, never pinned over the user's constraint or appended as a new dependency, and the detail asks for `bundle lock` (or `bundle install`) and a re-run). 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 of a yarn-berry entry is checked against these project gates (mixed `yarn.lock` / `package.json` line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) on the project as it is before any revert, because the revert re-renders `package.json` in its majority ending — wet and `--dry-run` alike. A refused purl keeps its vendored wiring, ledger entry and artifact byte-identical, is skipped with the gate's code (reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored`) and is never announced as `redirect_takeover_reverted_vendored`. +`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 it is redirected in the same commit. 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 kept vendored, 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 covered the same way: 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 kept vendored with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is kept vendored with `redirect_poetry_lock_unsupported`. **Staged takeover (v5.0)**: the takeover is atomic. Each purl's vendored revert is staged in memory (the run's group commit, the same journaled commit vendored mode uses), the hosted rewrite plans against the reverted project, and a staged purl the rewrite does not pin is retracted: every staged revert is undone, that purl keeps its vendored wiring, ledger entry and artifact byte-identical, and the rest are staged and rewritten again. A retracted purl is skipped (`redirect.skipped[].reason`) with the cause: its existing skip reason (unavailable wheel metadata, …), the rewriter warning that names the package (`redirect_yarn_berry_missing_checksum`, `redirect_pypi_platform_wheel`, …), `redirect_requirements_takeover_unreachable`, the rewrite's lock-level refusal (`redirect_yarn_berry_mixed_line_endings`, `redirect_bun_lock_unsupported`, a Gradle planner refusal, …) or `redirect_takeover_not_pinned`; that warning is reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored` naming the package. Exit 0: the package stays vendored and patched, never unpatched in both modes. The reverts, the hosted pins and the vendored ledger then reach the disk in one commit, and the reverted artifacts are deleted only after it: a refusal or write failure before the commit writes nothing, a failed commit puts back the files it replaced, and a commit interrupted after its journal was written is finished by the next command that takes the apply lock. `--dry-run` runs the same steps and drops the staged state instead of committing, so its `redirected` count and warnings are the wet run's. A hosted run with no takeover stages its writes the same way but commits them without the journal (it writes nothing under `.socket/`): a file that fails to be replaced puts back the ones already replaced, so a failed run leaves no lock half-redirected. 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` before its first wet write (the staged takeover 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"`, `error: {code: "lock_held" | "lock_io", message}` (v5.0: the same object every command uses; no top-level `error.code`), and `redirect: {mode: "hosted"}` retained. **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_bundle_lockfile_unsupported` (gem: bundler 4's custom lockfile — the `BUNDLE_LOCKFILE` environment variable, else `BUNDLE_LOCKFILE:` in the bundler app config, else in the global config — names a lock other than the loaded pair's own `Gemfile.lock` / `gems.locked`, so no gem is redirected or attested rather than pinning a lock bundler ignores), `redirect_gem_twin_manifest_ambiguous` (gem: a `Gemfile` + `gems.rb` twin under default discovery; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, and a lock's `BUNDLED WITH` records which bundler wrote it, not which one installs it, so neither pair is wired or attested — remove the unused spelling or set `BUNDLE_GEMFILE` to the one in use), `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; a leading UTF-8 BOM, which dotnet reads past, is not corrupt and is kept on rewrite), `redirect_nuget_lock_other_version` (the lock also resolves the patched id at another version in some target framework: the exact-id `packageSourceMapping` would route that framework to a feed that serves only the patched version, so the dep is skipped with nothing written; only lock entries at the patched version are ever re-pinned, and `resolved` is never rewritten), `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), `redirect_gem_no_lockfile` (gem: the project has a `Gemfile` / `gems.rb` but no `Gemfile.lock` / `gems.locked`, so nothing records which version it resolves and the crawled version may be another project's copy in the shared gem home; the gem is skipped with nothing written, never pinned over the user's constraint or appended as a new dependency, and the detail asks for `bundle lock` (or `bundle install`) and a re-run). 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 of a yarn-berry entry is checked against these project gates (mixed `yarn.lock` / `package.json` line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) on the project as it is before any revert, because the revert re-renders `package.json` in its majority ending — wet and `--dry-run` alike. A refused purl keeps its vendored wiring, ledger entry and artifact byte-identical, is skipped with the gate's code (reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored`) and is never announced as `redirect_takeover_reverted_vendored`. **Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported in `redirect.skipped[]` as `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a yarn `npm:` alias entry left on the registry with `redirect_yarn_classic_alias_skipped` / `redirect_yarn_berry_alias_skipped` while the package's direct entry is pinned, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. @@ -755,7 +755,7 @@ to **six flavors**. | pypi / pdm (pdm.lock) | (rebuilt wheel) | lock-only: the `[[package]]` gains the local-file `path` + `files[]` hash. pyproject + `content_hash` untouched. Non-fixture `[metadata] strategy` / hash-less locks refused | `pdm sync` (+ `pdm install --check`), cold cache | | pypi / pipenv (Pipfile.lock) | (rebuilt wheel) | lock-only: the `default`/`develop` entry → `{file, hashes:[sha256-of-our-wheel]}`. Pipfile + `_meta.hash` untouched. Emits `vendor_integrity_unverified` — pipenv does not hash-check file entries; the committed wheel bytes are the protection | `pipenv install --deploy` (+ `pipenv verify`), cold cache | | pypi / requirements.txt (pip / `uv pip`) | (rebuilt wheel) | pin line → `./` (markers carried over; transitive deps appended), plus `--hash=sha256:` only when the requirements tree is already in pip's hash-checking mode (any `--hash` or `--require-hashes`) | `pip install -r` / `uv pip install -r` **run from the project root** (both resolve bare paths against the CWD) | -| nuget | deterministically rebuilt `.nupkg` at `..nupkg` (the uuid dir IS a NuGet folder feed; the stale embedded signature is dropped — unsigned is accepted under NuGet's default validation) | `nuget.config` source + `packageSourceMapping` for the id (creating the mapping from scratch ALSO fans a `` out to every pre-existing source — mapping is exclusive, NU1100 otherwise) **+** `packages.lock.json` `contentHash` → `base64(sha512(nupkg))` when the lock exists (`vendor_nuget_no_lockfile` warning otherwise) | `dotnet restore --locked-mode`, cold cache, `--network none` (tampered nupkg fails NU1403) | +| nuget | deterministically rebuilt `.nupkg` at `..nupkg` (the uuid dir IS a NuGet folder feed; the stale embedded signature is dropped — unsigned is accepted under NuGet's default validation) | `nuget.config` source + `packageSourceMapping` for the id (creating the mapping from scratch ALSO fans a `` out to every pre-existing source — mapping is exclusive, NU1100 otherwise) **+** `packages.lock.json` `contentHash` → `base64(sha512(nupkg))` for the entries at the patched version when the lock exists (`vendor_nuget_no_lockfile` warning otherwise; a lock that also resolves the id at another version is refused with `vendor_nuget_lock_other_version`, nothing written) | `dotnet restore --locked-mode`, cold cache, `--network none` (tampered nupkg fails NU1403) | | maven | the patched `.jar` + the upstream pom (only its `` suffixed; transitives survive) + `.sha1` sidecars + an ownership marker under `.socket/vendor/maven2///-socket./` | every pom root, single-module (a reactor of one) or multi-module: the pinned `` + `` pin, `.mvn/maven.config` (`maven.repo.local.tail`) and the `socket-patch-vendor` fallback file repository (`checksumPolicy=fail`); Gradle, sbt and scala-cli roots go to the same JVM backend (ledger ecosystem `jvm`). Pre-v5 `maven_pom_repository` entries (`` to `.socket/vendor/maven/`) are revert-only: vendoring their root is refused (`vendor_jvm_shape_unsupported`, `legacy_maven_root`) | `mvn` build on a fresh checkout with a warm local repository and behind `mirrorOf external:*` (host capstone `e2e_vendor_maven_build` across the Maven matrix) | Ecosystems with no vendor backend (jsr) refuse per-purl with diff --git a/crates/socket-patch-core/src/formats/nuget/lock.rs b/crates/socket-patch-core/src/formats/nuget/lock.rs new file mode 100644 index 000000000..7ce813272 --- /dev/null +++ b/crates/socket-patch-core/src/formats/nuget/lock.rs @@ -0,0 +1,179 @@ +//! `packages.lock.json`: the one reader every NuGet lock walker shares — +//! the vendored pin, the hosted redirect, the hosted upstream restore and +//! VEX discovery. +//! +//! A lock is `{"dependencies": {"": {"": {"resolved": …, +//! "contentHash": …}}}}`. NuGet matches ids case-insensitively, and one +//! multi-targeting project can resolve the same id at a different version +//! per framework, so a walker that pins a patched ` ` must +//! match the id AND the version: an entry at another version is a +//! different package that the patched bytes must never replace (#593). +//! +//! dotnet restores a lock that starts with a UTF-8 BOM (one saved by a +//! Windows editor or Windows PowerShell 5.1), so the parse reads past it +//! (#623). Writers keep it: the vendored and upstream-restore edits are +//! string surgery on the hash value, and the hosted rewrite re-renders in +//! the original layout. + +use serde_json::{Map, Value}; + +use crate::vendor::nuget_feed::normalize_nuget_version; + +/// The lock NuGet writes beside a project under its default name. +pub(crate) const PACKAGES_LOCK: &str = "packages.lock.json"; + +/// One `dependencies..` entry that restores from a source: its raw +/// id key, `resolved` and `contentHash` strings. +#[derive(Debug, Clone, Copy)] +pub(crate) struct NugetLockEntry<'a> { + pub(crate) id: &'a str, + pub(crate) resolved: &'a str, + pub(crate) content_hash: Option<&'a str>, +} + +/// Parse a lock's text as dotnet does, reading past a leading UTF-8 BOM. +pub(crate) fn parse_lock(text: &str) -> serde_json::Result { + serde_json::from_str(crate::formats::text::strip_bom(text)) +} + +/// Every entry of a parsed lock, target framework by target framework, in +/// document-key order. Frameworks that are not objects and entries without +/// a string `resolved` (`type: "Project"` references, which nothing +/// restores from a source) are skipped; strings are raw (callers trim / +/// normalize / compare ids as they need). +pub(crate) fn nuget_lock_entries(doc: &Value) -> impl Iterator> { + doc.get("dependencies") + .and_then(Value::as_object) + .into_iter() + .flat_map(|frameworks| frameworks.values()) + .filter_map(Value::as_object) + .flatten() + .filter_map(|(id, entry)| { + Some(NugetLockEntry { + id, + resolved: entry.get("resolved").and_then(Value::as_str)?, + content_hash: entry.get("contentHash").and_then(Value::as_str), + }) + }) +} + +/// The entries of package `id` (case-insensitive) whose `resolved` +/// normalizes to `version_norm` ([`normalize_nuget_version`]): the entries +/// a patch of `id version_norm` replaces. +pub(crate) fn locked_at<'a>( + doc: &'a Value, + id: &'a str, + version_norm: &'a str, +) -> impl Iterator> { + nuget_lock_entries(doc).filter(move |e| { + e.id.eq_ignore_ascii_case(id) && normalize_nuget_version(e.resolved) == version_norm + }) +} + +/// [`locked_at`] for editing: `(id key, entry object)` of every matching +/// entry, framework by framework. +pub(crate) fn locked_at_mut<'a>( + doc: &'a mut Value, + id: &'a str, + version_norm: &'a str, +) -> Vec<(&'a str, &'a mut Map)> { + doc.get_mut("dependencies") + .and_then(Value::as_object_mut) + .into_iter() + .flat_map(|frameworks| frameworks.values_mut()) + .filter_map(Value::as_object_mut) + .flat_map(|fw| fw.iter_mut()) + .filter(|(key, _)| key.eq_ignore_ascii_case(id)) + .filter_map(|(key, entry)| Some((key.as_str(), entry.as_object_mut()?))) + .filter(|(_, entry)| { + entry + .get("resolved") + .and_then(Value::as_str) + .is_some_and(|r| normalize_nuget_version(r) == version_norm) + }) + .collect() +} + +/// The other versions the lock resolves `id` at, normalized, sorted and +/// deduplicated. Both writers route the WHOLE id to a feed that serves only +/// the patched version (`packageSourceMapping` patterns name ids, never +/// versions), so a framework that resolves the id at another version could +/// no longer restore it: the writers refuse such a lock rather than break +/// that framework or re-pin it to the patched version. +pub(crate) fn other_versions(doc: &Value, id: &str, version_norm: &str) -> Vec { + let mut out: Vec = nuget_lock_entries(doc) + .filter(|e| e.id.eq_ignore_ascii_case(id)) + .map(|e| normalize_nuget_version(e.resolved)) + .filter(|v| v != version_norm) + .collect(); + out.sort(); + out.dedup(); + out +} + +/// The refusal detail both writers give for [`other_versions`]. +pub(crate) fn other_versions_detail( + lock_rel: &str, + id: &str, + version_norm: &str, + others: &[String], +) -> String { + format!( + "{lock_rel} also resolves {id} at {}; the patch for {version_norm} would route every \ + version of {id} to a feed that serves only {version_norm}, so those target frameworks \ + could not restore. Align {id} on {version_norm} in every target framework (or patch \ + each version), restore, and re-run", + others.join(", ") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MULTI: &str = r#"{ + "version": 1, + "dependencies": { + "net6.0": { + "Newtonsoft.Json": { "type": "Direct", "requested": "[12.0.3, )", "resolved": "12.0.3", "contentHash": "OLD12==" } + }, + "net8.0": { + "newtonsoft.json": { "type": "Direct", "requested": "[13.0.3, )", "resolved": "13.0.3", "contentHash": "OLD13==" }, + "App.Lib": { "type": "Project" } + } + } +}"#; + + #[test] + fn bom_lock_parses_like_dotnet_reads_it() { + let bom = format!("\u{feff}{MULTI}"); + assert!(serde_json::from_str::(&bom).is_err()); + assert_eq!(parse_lock(&bom).unwrap(), parse_lock(MULTI).unwrap()); + } + + #[test] + fn locked_at_matches_id_and_version() { + let doc = parse_lock(MULTI).unwrap(); + let hits: Vec<_> = locked_at(&doc, "Newtonsoft.Json", "13.0.3").collect(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].content_hash, Some("OLD13==")); + let mut doc = doc; + let hits = locked_at_mut(&mut doc, "NEWTONSOFT.JSON", "13.0.3"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].0, "newtonsoft.json"); + } + + #[test] + fn other_versions_names_every_other_resolution() { + let doc = parse_lock(MULTI).unwrap(); + assert_eq!( + other_versions(&doc, "newtonsoft.json", "13.0.3"), + ["12.0.3"] + ); + assert!(other_versions(&doc, "newtonsoft.json", "12.0.3") == ["13.0.3"]); + assert!(other_versions(&doc, "App.Lib", "1.0.0").is_empty()); + // A spelling of the same version is not another version. + let doc = parse_lock(&MULTI.replace("\"12.0.3\"", "\"13.0.3.0\"")).unwrap(); + assert!(other_versions(&doc, "Newtonsoft.Json", "13.0.3").is_empty()); + } +} diff --git a/crates/socket-patch-core/src/formats/nuget/mod.rs b/crates/socket-patch-core/src/formats/nuget/mod.rs index 1499a27d2..f3617fb90 100644 --- a/crates/socket-patch-core/src/formats/nuget/mod.rs +++ b/crates/socket-patch-core/src/formats/nuget/mod.rs @@ -10,6 +10,8 @@ //! and DOCTYPEs are skipped; an unterminated tag or comment, an unquoted //! attribute or a mismatched close tag makes the whole file `None`. +pub(crate) mod lock; + use std::collections::BTreeSet; use std::ops::Range; diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index f9ed8e284..8f4e44805 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -477,6 +477,7 @@ pub(crate) fn full_name(dep: &DepOverride) -> String { /// Canonical JSON serialization matching TS `JSON.stringify(v, null, 2) + '\n'` /// (2-space pretty via serde_json, key order preserved by `preserve_order`, /// `/` unescaped). +#[cfg(test)] fn serialize_json(value: &Value) -> String { // A `Value` into an in-memory buffer cannot fail; swallowing an `Err` // into an empty string would truncate the user's lockfile to "\n". @@ -5759,6 +5760,8 @@ fn nuget_xml_attribute(value: &str) -> String { .replace('\r', " ") } +use crate::formats::nuget::lock::PACKAGES_LOCK; + fn rewrite_nuget( files: &BTreeMap, overrides: &[DepOverride], @@ -5794,9 +5797,12 @@ fn rewrite_nuget( // contentHash (NU1403 on restore) and the ledger claimed the redirect. // Warn once and skip the whole nuget redirect before anything is planned // (the npm twin does the same). An ABSENT lock is fine — config-only. - let mut lock: Option = match files.get("packages.lock.json") { + // Read past a UTF-8 BOM the way dotnet does (#623); the write below + // keeps it. + let lock_text = files.get(PACKAGES_LOCK); + let mut lock: Option = match lock_text { None => None, - Some(text) => match serde_json::from_str::(text) { + Some(text) => match crate::formats::nuget::lock::parse_lock(text) { Ok(parsed) => Some(parsed), Err(_) => { result.warnings.push(RewriteWarning { @@ -5835,6 +5841,34 @@ fn rewrite_nuget( .clone() .unwrap_or_else(|| dep.name.to_lowercase()); + let version_norm = crate::vendor::nuget_feed::normalize_nuget_version( + ov.identifiers + .nuget_version_norm + .as_deref() + .unwrap_or(&dep.version), + ); + // The mapping routes every version of the id to the Socket source, + // which serves only the patched one: a target framework that locks + // another version could no longer restore it, and re-pinning that + // entry would silently swap in the patched version (#593). Refused + // before anything is wired. + if let Some(lock_val) = &lock { + let others = + crate::formats::nuget::lock::other_versions(lock_val, &id_lower, &version_norm); + if !others.is_empty() { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_lock_other_version".into(), + detail: crate::formats::nuget::lock::other_versions_detail( + PACKAGES_LOCK, + &dep.name, + &version_norm, + &others, + ), + }); + continue; + } + } + let unwritable = || RewriteWarning { code: "redirect_nuget_config_unwritable".into(), detail: format!( @@ -5878,55 +5912,34 @@ fn rewrite_nuget( }); } + // Only the entries at the patched version: another version of the + // id is a different package (#593). if let Some(lock_val) = lock.as_mut() { - if let Some(deps) = lock_val - .get_mut("dependencies") - .and_then(Value::as_object_mut) + for (id, obj) in + crate::formats::nuget::lock::locked_at_mut(lock_val, &id_lower, &version_norm) { - for framework in deps.values_mut() { - if let Some(fw) = framework.as_object_mut() { - for (id, entry) in fw.iter_mut() { - if id.to_lowercase() == id_lower { - if let Some(obj) = entry.as_object_mut() { - let resolved = ov - .identifiers - .nuget_version_norm - .clone() - .unwrap_or_else(|| dep.version.clone()); - // Already redirected (re-run): no edit. - if obj.get("resolved").and_then(Value::as_str) - == Some(resolved.as_str()) - && obj.get("contentHash").and_then(Value::as_str) - == Some(content_hash.as_str()) - { - continue; - } - let original = json!({ - "resolved": obj.get("resolved").cloned().unwrap_or(Value::Null), - "contentHash": obj.get("contentHash").cloned().unwrap_or(Value::Null), - }); - obj.insert("resolved".into(), Value::String(resolved.clone())); - obj.insert( - "contentHash".into(), - Value::String(content_hash.clone()), - ); - lock_changed = true; - result.edits.push(FileEdit { - path: "packages.lock.json".into(), - kind: "redirect_nuget_lock".into(), - action: "rewritten".into(), - key: Some(id.clone()), - original: Some(original), - new: Some(json!({ - "resolved": resolved, - "contentHash": content_hash, - })), - }); - } - } - } - } + // Already redirected (re-run): no edit. + if obj.get("contentHash").and_then(Value::as_str) == Some(content_hash.as_str()) { + continue; } + let resolved = obj.get("resolved").cloned().unwrap_or(Value::Null); + let original = json!({ + "resolved": resolved, + "contentHash": obj.get("contentHash").cloned().unwrap_or(Value::Null), + }); + obj.insert("contentHash".into(), Value::String(content_hash.clone())); + lock_changed = true; + result.edits.push(FileEdit { + path: PACKAGES_LOCK.into(), + kind: "redirect_nuget_lock".into(), + action: "rewritten".into(), + key: Some(id.to_string()), + original: Some(original), + new: Some(json!({ + "resolved": resolved, + "contentHash": content_hash, + })), + }); } } } @@ -5935,10 +5948,12 @@ fn rewrite_nuget( result.files.insert(config_path.into(), config); } if lock_changed { - if let Some(lock_val) = lock { - result - .files - .insert("packages.lock.json".into(), serialize_json(&lock_val)); + if let (Some(lock_val), Some(original)) = (lock, lock_text) { + // In the lock's own layout: its BOM, indent and line endings. + result.files.insert( + PACKAGES_LOCK.into(), + serialize_json_like(&lock_val, original), + ); } } } @@ -22773,6 +22788,102 @@ packages: ); } + /// #623: dotnet restores a lock that starts with a UTF-8 BOM, so hosted + /// wires and re-pins it like any other lock (no `unparseable` skip), + /// and the rewritten lock keeps its BOM and its CRLF layout. + #[test] + fn nuget_bom_lock_is_redirected_and_keeps_its_bom() { + let lock = "\u{feff}{\r\n \"version\": 1,\r\n \"dependencies\": {\r\n \"net8.0\": {\r\n \"Newtonsoft.Json\": {\r\n \"type\": \"Direct\",\r\n \"requested\": \"[13.0.3, )\",\r\n \"resolved\": \"13.0.3\",\r\n \"contentHash\": \"ORIGINALHASH==\"\r\n }\r\n }\r\n }\r\n}"; + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert("packages.lock.json".to_string(), lock.to_string()); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.files.contains_key("nuget.config"), "{:?}", r.files.keys()); + let out = r.files.get("packages.lock.json").expect("lock re-pinned"); + assert_eq!( + *out, + lock.replace("ORIGINALHASH==", "PATCHED=="), + "only the hash changes; BOM and CRLF layout kept" + ); + } + + /// #593: a multi-targeting lock resolving the patched id at another + /// version in some framework is refused whole: the exact-id mapping + /// would route that framework to a feed that serves only the patched + /// version, and re-pinning its entry would swap in the patched + /// version's bytes. Nothing is written. + #[test] + fn nuget_lock_with_the_id_at_another_version_is_refused() { + let lock = r#"{ + "version": 1, + "dependencies": { + "net6.0": { + "Newtonsoft.Json": { "type": "Direct", "requested": "[12.0.3, )", "resolved": "12.0.3", "contentHash": "OLD12==" } + }, + "net8.0": { + "Newtonsoft.Json": { "type": "Direct", "requested": "[13.0.3, )", "resolved": "13.0.3", "contentHash": "OLD13==" } + } + } +} +"#; + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert("packages.lock.json".to_string(), lock.to_string()); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "nothing may land: files={:?} edits={:?}", + r.files.keys(), + r.edits + ); + assert_eq!(warning_codes(&r), vec!["redirect_nuget_lock_other_version"]); + assert!( + r.warnings[0].detail.contains("12.0.3"), + "{:?}", + r.warnings[0] + ); + } + + /// #593: only the entries at the patched version are re-pinned, and + /// `resolved` is never rewritten (a `13.0.3.0` spelling stays). + #[test] + fn nuget_lock_repins_only_entries_at_the_patched_version() { + let lock = r#"{ + "version": 1, + "dependencies": { + "net6.0": { + "Newtonsoft.Json": { "type": "Direct", "requested": "[13.0.3, )", "resolved": "13.0.3.0", "contentHash": "OLD13==" }, + "Other.Pkg": { "type": "Direct", "requested": "[1.0.0, )", "resolved": "1.0.0", "contentHash": "OTHER==" } + }, + "net8.0": { + "newtonsoft.json": { "type": "Transitive", "resolved": "13.0.3", "contentHash": "OLD13==" } + } + } +} +"#; + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert("packages.lock.json".to_string(), lock.to_string()); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let out = r.files.get("packages.lock.json").expect("lock re-pinned"); + assert_eq!( + serde_json::from_str::(out).unwrap(), + serde_json::from_str::(&lock.replace("OLD13==", "PATCHED==")).unwrap() + ); + let locks: Vec<&FileEdit> = r + .edits + .iter() + .filter(|e| e.kind == "redirect_nuget_lock") + .collect(); + assert_eq!(locks.len(), 2, "{locks:?}"); + assert_eq!( + locks[0].new, + Some(json!({"resolved": "13.0.3.0", "contentHash": "PATCHED=="})) + ); + } + /// The re-run probe reads the parsed `` keys, so a /// hand-normalized spelling of the socket source (single quotes, spaces /// around `=`) is recognized as already wired instead of being added a diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs index 37d370c33..31fd19ea8 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs @@ -262,7 +262,10 @@ pub(crate) async fn restore( continue; } }; - let mut lock: Option = match lock_text.as_deref().map(serde_json::from_str) { + let mut lock: Option = match lock_text + .as_deref() + .map(crate::formats::nuget::lock::parse_lock) + { None => None, Some(Ok(v)) => Some(v), Some(Err(_)) => { @@ -279,17 +282,15 @@ pub(crate) async fn restore( let Some(lock) = lock.as_mut() else { continue; }; - let entries: Vec<&mut serde_json::Map> = lock - .get_mut("dependencies") - .and_then(Value::as_object_mut) - .into_iter() - .flat_map(|fws| fws.values_mut()) - .filter_map(Value::as_object_mut) - .flat_map(|fw| fw.iter_mut()) - .filter(|(k, _)| k.eq_ignore_ascii_case(id)) - .filter_map(|(_, e)| e.as_object_mut()) - .filter(|e| e.contains_key("contentHash")) - .collect(); + // Only the entries at the pinned version: another version of + // the id was never re-pinned (#593). + let norm = normalize_nuget_version(version); + let entries: Vec<&mut serde_json::Map> = + crate::formats::nuget::lock::locked_at_mut(lock, id, &norm) + .into_iter() + .map(|(_, e)| e) + .filter(|e| e.contains_key("contentHash")) + .collect(); if entries.is_empty() { continue; } @@ -297,7 +298,6 @@ pub(crate) async fn restore( result.refuse(&pin.uuid, why); continue; } - let norm = normalize_nuget_version(version); let hash = match ctx.client.nuget_content_hash(id, &norm).await { Ok(h) => h, Err(why) => { @@ -306,13 +306,6 @@ pub(crate) async fn restore( } }; for entry in entries { - let keeps_resolved = entry - .get("resolved") - .and_then(Value::as_str) - .is_some_and(|r| normalize_nuget_version(r).eq_ignore_ascii_case(&norm)); - if !keeps_resolved { - entry.insert("resolved".into(), Value::String(norm.clone())); - } entry.insert("contentHash".into(), Value::String(hash.clone())); } } @@ -335,9 +328,13 @@ pub(crate) async fn restore( } view.write(rel, text); if let (Some(lock), Some(before)) = (lock, lock_text) { - let after = super::super::serialize_json(&lock); - if serde_json::from_str::(&before).ok().as_ref() != Some(&lock) { - view.write(&lock_rel, after); + // In the lock's own layout (BOM, indent, line endings). + if crate::formats::nuget::lock::parse_lock(&before) + .ok() + .as_ref() + != Some(&lock) + { + view.write(&lock_rel, super::super::serialize_json_like(&lock, &before)); } } result diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 13c335489..30c27f52d 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -28,7 +28,7 @@ use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, Vendo /// Project-relative lockfile this backend pins (optional — NuGet only writes /// it when `RestorePackagesWithLockFile`/`--use-lock-file` is set). -const PACKAGES_LOCK: &str = "packages.lock.json"; +use crate::formats::nuget::lock::{locked_at, PACKAGES_LOCK}; /// Wiring-record discriminators. `nuget_config_source` carries the WHOLE-FILE /// pre/post `nuget.config` snapshot (the authoritative revert record); @@ -106,48 +106,6 @@ pub(crate) fn nupkg_leaf(id_lower: &str, version: &str) -> String { format!("{id_lower}.{}.nupkg", normalize_nuget_version(version)) } -/// One `packages.lock.json` `dependencies..` entry that restores -/// from a source: its raw id key, `resolved` and `contentHash` strings. -#[derive(Debug, Clone, Copy)] -pub(crate) struct NugetLockEntry<'a> { - pub(crate) id: &'a str, - pub(crate) resolved: &'a str, - pub(crate) content_hash: Option<&'a str>, -} - -/// Every entry of a parsed `packages.lock.json`, target framework by target -/// framework, in document-key order. Frameworks that are not objects and -/// entries without a string `resolved` (`type: "Project"` references, which -/// nothing restores from a source) are skipped; strings are raw (callers -/// trim / normalize / compare ids as they need). -pub(crate) fn nuget_lock_entries(doc: &Value) -> impl Iterator> { - doc.get("dependencies") - .and_then(Value::as_object) - .into_iter() - .flat_map(|frameworks| frameworks.values()) - .filter_map(Value::as_object) - .flatten() - .filter_map(|(id, entry)| { - Some(NugetLockEntry { - id, - resolved: entry.get("resolved").and_then(Value::as_str)?, - content_hash: entry.get("contentHash").and_then(Value::as_str), - }) - }) -} - -/// The lock entries of package `id` (case-insensitive) whose `resolved` -/// normalizes to `version_norm` — the entries the vendored nupkg replaces. -fn locked_at<'a>( - doc: &'a Value, - id: &'a str, - version_norm: &'a str, -) -> impl Iterator> { - nuget_lock_entries(doc).filter(move |e| { - e.id.eq_ignore_ascii_case(id) && normalize_nuget_version(e.resolved) == version_norm - }) -} - /// Everything [`vendor_nuget`] decides before it can first ask the patch /// service: the coordinate guards, the no-op of an empty patch, the /// nuget.config and packages.lock.json reads, and whether the feed already @@ -258,6 +216,25 @@ async fn nuget_prelude( .as_deref() .and_then(crate::formats::nuget::parse_config) .is_some_and(|parsed| parsed.sources.iter().any(|(key, _)| *key == source_key)); + // The mapping routes every version of the id to this feed, which serves + // only the patched one: a framework that locks another version could no + // longer restore (NU1102). Refused before anything is wired (#593). + if !config_wired { + if let Some(Ok(doc)) = lock_text.as_deref().map(lock_value) { + let others = crate::formats::nuget::lock::other_versions(&doc, name, &version_norm); + if !others.is_empty() { + return Err(refused( + "vendor_nuget_lock_other_version", + crate::formats::nuget::lock::other_versions_detail( + PACKAGES_LOCK, + name, + &version_norm, + &others, + ), + )); + } + } + } let in_sync = config_wired && { // One guarded read of the committed nupkg serves both the member-hash // check and the lock's content-hash pin. @@ -1191,7 +1168,9 @@ static LOCK_VALUE_MEMO: ParseMemo = ParseMemo::new(); /// [`PACKAGES_LOCK`] as JSON, reusing the run's parse while `text` is the /// text it came from. fn lock_value(text: &str) -> Result, serde_json::Error> { - LOCK_VALUE_MEMO.parse(text.as_bytes(), || serde_json::from_str::(text)) + LOCK_VALUE_MEMO.parse(text.as_bytes(), || { + crate::formats::nuget::lock::parse_lock(text) + }) } /// Rewrite `contentHash` to `new_hash` for every framework entry of `id` @@ -1971,6 +1950,72 @@ mod tests { Some(out) } + /// #593: a lock that also resolves the patched id at another version + /// (a multi-targeting project) is refused before anything is written: + /// the exact-id mapping would send that framework to a feed that only + /// serves the patched version (NU1102). + #[tokio::test] + async fn lock_with_the_id_at_another_version_is_refused_untouched() { + let (dir, blobs, installed, record) = fixture(false, None).await; + let root = dir.path(); + let lock = lock_json("ORIGINALcachedhash==").replacen( + "\"resolved\": \"13.0.3\"", + "\"resolved\": \"12.0.3\"", + 1, + ); + tokio::fs::write(root.join(PACKAGES_LOCK), &lock) + .await + .unwrap(); + let (code, detail) = + unwrap_refused(run_vendor(root, &blobs, &installed, &record, false).await); + assert_eq!(code, "vendor_nuget_lock_other_version"); + assert!(detail.contains("12.0.3"), "{detail}"); + assert_eq!( + tokio::fs::read_to_string(root.join(PACKAGES_LOCK)) + .await + .unwrap(), + lock + ); + assert!(!root.join("nuget.config").exists()); + assert!(!root.join(".socket").exists()); + } + + /// #623: dotnet restores a BOM'd lock, so vendor pins it (the BOM kept) + /// and revert restores it byte-identically. + #[tokio::test] + async fn bom_lock_is_pinned_and_reverted_byte_identically() { + let (dir, blobs, installed, record) = fixture(false, None).await; + let root = dir.path(); + let lock = format!("\u{feff}{}", lock_json("ORIGINALcachedhash==")); + tokio::fs::write(root.join(PACKAGES_LOCK), &lock) + .await + .unwrap(); + let (result, entry, warnings) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + assert!( + !warnings.iter().any(|w| w.code.contains("lock")), + "{warnings:?}" + ); + let pinned = tokio::fs::read_to_string(root.join(PACKAGES_LOCK)) + .await + .unwrap(); + let nupkg = tokio::fs::read(root.join(copy_rel())).await.unwrap(); + assert_eq!( + pinned, + lock.replace("ORIGINALcachedhash==", &sha512_base64_of(&nupkg)) + ); + let entry = entry.expect("ledger entry"); + let reverted = revert_nuget(&entry, root, false).await; + assert!(reverted.success, "{:?}", reverted.error); + assert_eq!( + tokio::fs::read_to_string(root.join(PACKAGES_LOCK)) + .await + .unwrap(), + lock + ); + } + #[tokio::test] async fn happy_path_wires_config_lock_and_artifact() { let (dir, blobs, installed, record) = fixture(true, None).await; diff --git a/crates/socket-patch-core/src/vex/discover/nuget.rs b/crates/socket-patch-core/src/vex/discover/nuget.rs index 225e97f36..6a01a1fed 100644 --- a/crates/socket-patch-core/src/vex/discover/nuget.rs +++ b/crates/socket-patch-core/src/vex/discover/nuget.rs @@ -73,10 +73,11 @@ use super::{ Discovery, PatchedRef, UnlockedPin, WiringMode, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; +use crate::formats::nuget::lock::nuget_lock_entries; use crate::formats::nuget::{parse_config, NugetConfig}; use crate::vendor::lock_inventory::LockIntegrity; use crate::vendor::nuget_config::{same_file, CONFIG_NAMES}; -use crate::vendor::nuget_feed::{is_plain_nuget_token, nuget_lock_entries, nupkg_leaf}; +use crate::vendor::nuget_feed::{is_plain_nuget_token, nupkg_leaf}; use crate::vendor::path::VENDOR_DIR; /// The lock NuGet writes beside the project (default name).