diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b9921c653..ed33433bc 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -218,7 +218,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **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. -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 (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), 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` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), 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 the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **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 reactor root (a `` / `` declaration, a profile's included) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), 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). +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` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `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 (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), 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` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), 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 the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **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 reactor root (a `` / `` declaration, a profile's included) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), 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). **Hosted sbt (v5.0, additive)**: an sbt build root (`project/build.properties` naming an `sbt.version`, 0.13.18 or later) is wired through ONE generated root file, `socket-patch.sbt` — no user file is edited. It pins every granted Maven patch build-wide (a `ThisBuild` `dependencyOverrides +=` of the Socket-only `-socket.` version plus a `file:` resolver over `.socket/sbt-hosted/maven2/`, moved ahead of the default repositories on sbt 0.13 / 1.x so an unreachable one never blocks it offline), downloads the pinned pom and jar there on the first sbt load (sha256-checked, gitignored by the file itself), and installs a load-time verifier that fails `update` when any project resolves another version or a pinned artifact whose bytes are not pinned. Edits: `redirect_sbt_pin` (added), `redirect_sbt_pin_updated` (an existing row replaced: same GA and base under a new uuid, or the same uuid with new served values; `original` names the previous uuid and version), `redirect_sbt_pin_rechecked` (an existing row re-verified after the build's dependencies changed: its dependency digest is recorded anew, `original`/`new` are `{deps}`). The load-time verifier also fails `update` when a project declares a pinned GA at a version newer than the pin's base (the build-wide override would otherwise force it back down). A new pin is gated on sbt's own resolution records under `target/` (never the machine-wide cache): run-level stops wire nothing, warn once and exit 0 — `redirect_sbt_no_resolution_evidence` (none; run `sbt update` first; always the in-memory engine's answer), `redirect_sbt_resolution_incomplete` (a declared project left no evidence, or the project definitions cannot be read statically), `redirect_sbt_resolution_stale` (a build source is newer than some project's evidence: each project is dated by its own newest record, so a partial `sbt /update` does not vouch for the others). Per-patch refusals (never confirmed): `redirect_sbt_missing_override` (no `maven2` override or no suffixed version), `redirect_sbt_integrity_missing` (jar or pom sha256 missing), `redirect_sbt_unsafe_value` (a value unsafe in a Scala literal, or an index URL not naming the uuid), `redirect_sbt_version_conflict` (some project resolves another version, or a build source declares the GA newer than the patch's base), `redirect_sbt_override_conflict` (two patches for one GA in a run, or another base already pinned), `redirect_sbt_vendored_conflict` (the GA is pinned by `socket-patch-vendor.sbt`, or that file cannot be parsed — then every Maven patch), `redirect_sbt_owned_file_modified` / `redirect_sbt_owned_file_foreign` (`socket-patch.sbt` edited, or not socket-patch's — every Maven patch), `redirect_sbt_owned_file_unreadable` (a whole-run refusal: `socket-patch.sbt` is on disk but cannot be read as UTF-8 text, so writing it would replace it; nothing is written), `redirect_sbt_unsupported_version`, `redirect_sbt_build_root_unknown` (sbt files but no versioned build root — every Maven patch), `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` (a build source reassigns `dependencyOverrides` / `resolvers` with `:=`, `~=` or `--=`), `redirect_sbt_dependency_lock_present` (a `build.sbt.lock`), `redirect_sbt_scala_runtime_unsupported` (`org.scala-lang`), `redirect_sbt_classifier_unsupported`; a GA no library configuration resolves is skipped silently (`redirect_sbt_meta_build_only` when only the meta-build resolves it). Advisories: `redirect_sbt_version_untested` (sbt 2.1+, still wired), `redirect_sbt_override_build_repos` (`sbt.override.build.repos=true`), `redirect_maven_pom_ignored_sbt_build` (a `pom.xml` beside the sbt build, which sbt never reads; the Maven rewriter still edits it for the Maven build). A re-run keeps an existing row and re-checks it. When the build's dependency digest changed since the pin, evidence resolved after the change (fresh, newer than the generated file) re-verifies it and the row's digest is refreshed (`redirect_sbt_pin_rechecked`); the uuid is NOT confirmed on `redirect_sbt_pin_declared_newer` (a build source now declares the GA newer than the pin's base; the row stays, sbt's load-time verifier fails the build, and the remedy is `socket-patch rollback` or declaring the base again), `redirect_sbt_pin_unverifiable` (the digest changed and the evidence predates the change, or the digest cannot be computed: run `sbt update`, then re-run socket-patch), `redirect_sbt_override_shadowed` (the evidence still resolves the base version) or `redirect_sbt_resolved_elsewhere` (the pinned version resolves from outside the pin repository from a file whose sha256 is not the pinned jar's; a copy holding the pinned bytes, such as the Ivy cache a second checkout reads, is fine — at most 64 pinned artifact files of up to 256 MiB are hashed, anything else counts as elsewhere), and also when a build source now reassigns `dependencyOverrides` / `resolvers` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_dependency_lock_present` codes; the row stays and sbt's load-time verifier fails the build). For a pure sbt root (no `pom.xml` / Gradle script beside it), maven confirmation is decided only by the sbt rewriter's report; on a mixed root a uuid the sbt rewriter refused is still confirmed by the Maven rewriter's own `pom.xml` pin (the generated sbt files never prove a pin by substring). **Mill and scala-cli** are guidance only: per Maven patch `redirect_mill_manual_snippet` / `redirect_scala_cli_manual_snippet` carry a paste-able snippet (repository + forced suffixed version), nothing is written or confirmed, and a pure Mill / scala-cli root gets no `redirect_maven_no_pom`; there, a Maven patch the server sent without a `maven2` registry override gets `redirect_maven_missing_override` instead of a snippet (with a `pom.xml` beside the Mill / scala-cli files the pom rewriter reports it). `rollback` / `remove` restore `socket-patch.sbt` offline (the rows removed, the file deleted with its last pin; the gitignored downloads are left). Manifest-less VEX reads every strictly parsed pin as a hosted reference but grants it the lockfile basis only when the local evidence shows every recorded version of the GA is the pinned one and every recorded artifact hashes to a pinned sha256 (else `sbt_resolution_unverified`). @@ -808,7 +808,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))` 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) | +| 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 in every lock a project under the root restores into — the root `packages.lock.json`, member projects' locks, `packages..lock.json`, a literal `NuGetLockFilePath` (v5.0 #353/#514; an unevaluable `NuGetLockFilePath` is refused with `vendor_nuget_lock_path_unresolved`) — (`vendor_nuget_no_lockfile` warning when there is none; 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 @@ -1025,7 +1025,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem * **gem** — `Gemfile.lock` / `gems.locked` + `Gemfile` / `gems.rb`: the spec moves back into the upstream `GEM` section (or the Socket remote leaves a merged section), the `source "" do … end` block is undone, the `CHECKSUMS` entry is re-pinned from the rubygems.org compact index (`SOCKET_RUBYGEMS_URL`) and the `DEPENDENCIES` pin loses its `!`. The declaration's original constraint is not recorded, so it comes back as the exact pin `gem "", ""`. A transitive gem (one the manifest never declared) gets an appended block with a blank line before it; the restore removes that block, its blank line and the `DEPENDENCIES` entry, so the pair comes back byte for byte. An appended block with no blank line before it (written by a release before this one) can't be told apart from an in-place rewrite, so it still comes back as the exact pin. Refused: an ambiguous upstream section, an upstream remote other than rubygems.org. The manifest pair can't make a committed bundler cache upstream: a `-.gem` left in Bundler's cache dir that isn't the upstream archive is named by `upstream_gem_stale_cache`, never deleted. * **composer** — `composer.lock`: `dist` and the deleted `source` block from packagist's composer v2 metadata (`SOCKET_PACKAGIST_URL`). Refused unless the entry is packagist-sourced and packagist still serves the lock's `dist.reference` for the version. * **maven** — `pom.xml` (the `-socket.` version suffix, the added `` / `` entry) and the `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` lines hosted mode writes: **no network**, so it restores under `--offline` too. `.mvn` files holding anything else keep the resolver lines (`maven_trusted_checksums_left`). - * **nuget** — `nuget.config` loses the `socket-patch-` source and its exact-id mapping; every `packages.lock.json` entry of the id gets nuget.org's `contentHash` back (`SOCKET_NUGET_URL`). Refused when the restored config would not resolve the id from nuget.org alone. A config hosted mode created from scratch is kept (`nuget_default_config_left`). + * **nuget** — `nuget.config` loses the `socket-patch-` source and its exact-id mapping; every lock entry of the id at the pinned version, in every lock the root config governs (member projects' and `packages..lock.json` included), gets nuget.org's `contentHash` back (`SOCKET_NUGET_URL`). Refused when the restored config would not resolve the id from nuget.org alone. A config hosted mode created from scratch is kept (`nuget_default_config_left`). * Any other file wiring a pin refuses it (`socket-patch cannot re-derive the upstream entry in `). * **Refusals.** `--offline` refuses every pin whose restore needs a registry lookup (all but maven), as does a registry that does not answer or no longer describes the entry. A refused pin writes nothing; its message is `cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` — for a `bun.lock` / `bun.lockb` followed by `, then run `bun install --force` (a plain `bun install` keeps the patched copy)`, since Bun's hoisted linker does not re-extract a package whose entry returns to the registry copy of the same `name@version` (#764) — human `Error: Cannot restore …` on stderr (even under `--silent`), JSON `hosted.failed[{purl, error}]`, and `partial_failure` exit 1 (`remove`: the `hosted_revert_failed` error). A write failure after every pin resolved is one `hosted.failed` entry with the pseudo-purl `files`. * **Output.** Human `Restored to its upstream registry entry` / `Would restore to its upstream registry entry` (`--dry-run`). vlt: the stale installed copies of restored nodes are removed afterwards, as before (`--no-vlt-install-cleanup` keeps them). diff --git a/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs b/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs index 3238fdd3c..c6df01926 100644 --- a/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs +++ b/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs @@ -967,3 +967,107 @@ fn nuget_vendored_dotnet_restore_then_manifestless_vex() { None, ); } + +// ── solution layout: member and named locks (#353, #514) ────────────── + +/// A solution layout: `nuget.config` at the root, one project under +/// `src/App/` with its own `packages.lock.json`, and one under `src/Named/` +/// whose lock is the per-project `packages.named.lock.json`. Vendoring from +/// the root must pin BOTH locks, so a fresh checkout restores each project +/// (`--locked-mode`, cold cache) with the patched bytes instead of NU1403. +#[test] +#[ignore = "real .NET SDK + nuget.org: run with --ignored (CI e2e matrix pins each SDK major)"] +fn nuget_vendored_solution_member_and_named_locks_restore() { + let sb = Sandbox::new(); + let Some(dn) = Dotnet::probe("vendored-solution", &sb) else { + return; + }; + let sdk = dn.version.clone(); + let root = sb.dir("solution"); + let store_fx = sb.dir("store-solution"); + std::fs::write(root.join("nuget.config"), REGISTRY_CONFIG).unwrap(); + let projects = [("src/App", "app"), ("src/Named", "named")]; + for (dir, name) in projects { + let d = root.join(dir); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join(format!("{name}.csproj")), dn.csproj()).unwrap(); + dn.restore_ok(&sb, &d, &store_fx, &[], "member restore from nuget.org"); + } + // NuGet reads (and writes) the per-project name once it exists. + std::fs::rename( + root.join("src/Named/packages.lock.json"), + root.join("src/Named/packages.named.lock.json"), + ) + .unwrap(); + assert!(!root.join("packages.lock.json").exists()); + + let pristine = std::fs::read(pkg_dir(&store_fx).join(FILE_KEY)).unwrap(); + let mut patched = pristine.clone(); + patched.extend_from_slice(MARKER); + let upstream = std::fs::read(pkg_dir(&store_fx).join(NUPKG_NAME)).unwrap(); + let nupkg = patched_nupkg(&upstream, &patched); + let backend = Backend::start(VENDORED_UUID, &pristine, &patched, Some(&nupkg)); + let uri = backend.uri(); + + let (code, env, stderr) = socket_patch( + &root, + &store_fx, + &[ + "scan", + "--mode", + "vendored", + "--vendor-source", + "service", + "--json", + "--yes", + "--api-url", + &uri, + "--org", + ORG, + "--api-token", + "fake-token", + ], + ); + assert_eq!(code, Some(0), "SDK {sdk}: {env:#}\n{stderr}"); + assert!( + !env.to_string().contains("vendor_nuget_no_lockfile"), + "the member locks were found: {env:#}" + ); + let artifact = root.join(format!(".socket/vendor/nuget/{VENDORED_UUID}/{NUPKG_NAME}")); + let pin = content_hash(&std::fs::read(&artifact).unwrap()); + for lock in [ + "src/App/packages.lock.json", + "src/Named/packages.named.lock.json", + ] { + let text = std::fs::read_to_string(root.join(lock)).unwrap(); + assert!(text.contains(&pin), "SDK {sdk}: {lock} re-pinned: {text}"); + } + + // Fresh checkout of the committable files, cold cache, each project. + let checkout = sb.dir("solution-checkout"); + std::fs::copy(root.join("nuget.config"), checkout.join("nuget.config")).unwrap(); + copy_tree(&root.join(".socket"), &checkout.join(".socket")); + strip_manifest(&checkout); + let blobs = checkout.join(".socket/blobs"); + if blobs.exists() { + std::fs::remove_dir_all(blobs).unwrap(); + } + for (dir, name) in projects { + let (from, to) = (root.join(dir), checkout.join(dir)); + std::fs::create_dir_all(&to).unwrap(); + for entry in std::fs::read_dir(&from).unwrap() { + let entry = entry.unwrap(); + let file = entry.file_name().to_string_lossy().into_owned(); + if file.ends_with(".csproj") || file.ends_with(".lock.json") { + std::fs::copy(entry.path(), to.join(&file)).unwrap(); + } + } + let store = sb.dir(&format!("store-checkout-{name}")); + dn.restore_ok(&sb, &to, &store, &["--locked-mode"], "member fresh restore"); + assert_eq!( + std::fs::read(pkg_dir(&store).join(FILE_KEY)).unwrap(), + patched, + "SDK {sdk}: {dir} restored the PATCHED {FILE_KEY}" + ); + } +} diff --git a/crates/socket-patch-core/src/formats/nuget/lock.rs b/crates/socket-patch-core/src/formats/nuget/lock.rs index 7ce813272..2dd1b3dea 100644 --- a/crates/socket-patch-core/src/formats/nuget/lock.rs +++ b/crates/socket-patch-core/src/formats/nuget/lock.rs @@ -127,6 +127,179 @@ pub(crate) fn other_versions_detail( ) } +/// MSBuild project files NuGet restores `PackageReference`s for. +const PROJECT_EXTENSIONS: [&str; 3] = [".csproj", ".fsproj", ".vbproj"]; + +/// Whether `name` (a basename) is an MSBuild project file. +pub(crate) fn is_project_file(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + PROJECT_EXTENSIONS + .iter() + .any(|ext| lower.len() > ext.len() && lower.ends_with(ext)) +} + +/// The locks a project tree restores into, as root-relative `/` paths +/// (#353, #514). Every project under the root inherits the root +/// `nuget.config`, so the Socket source and mapping wired there reach every +/// one of them, and each lock they restore must be pinned with it: +/// +/// * the root `packages.lock.json`, when present (a project file NuGet +/// restores from the root, or a lock with no project beside it); +/// * per project, the lock NuGet reads: a literal `NuGetLockFilePath` +/// (relative to the project), else `packages..lock.json` +/// beside it (spaces in the name become `_`) when that file exists, else +/// `packages.lock.json` beside it. +/// +/// Only existing locks are returned. A `NuGetLockFilePath` this reader cannot +/// evaluate (an MSBuild property or item reference, a `Condition`, an +/// absolute path or one leaving the root) is returned in `unresolved` as +/// `(project, detail)`: the writers refuse rather than leave a lock they +/// cannot find on its upstream hash. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct GovernedLocks { + pub(crate) locks: Vec, + pub(crate) unresolved: Vec<(String, String)>, +} + +/// [`GovernedLocks`] of `projects` (`(root-relative project path, text)`), +/// asking `exists` whether a root-relative file exists. +pub(crate) fn governed_locks( + projects: &[(String, String)], + exists: impl Fn(&str) -> bool, +) -> GovernedLocks { + let mut out = GovernedLocks::default(); + if exists(PACKAGES_LOCK) { + out.locks.push(PACKAGES_LOCK.to_string()); + } + for (project, text) in projects { + let (dir, file) = match project.rsplit_once('/') { + Some((dir, file)) => (dir, file), + None => ("", project.as_str()), + }; + let join = |leaf: &str| { + if dir.is_empty() { + leaf.to_string() + } else { + format!("{dir}/{leaf}") + } + }; + let lock = match lock_file_path_property(text) { + Some(Err(detail)) => { + out.unresolved.push((project.clone(), detail)); + continue; + } + Some(Ok(value)) => { + match resolve_relative(dir, &value) { + Some(rel) => rel, + None => { + out.unresolved.push(( + project.clone(), + format!("NuGetLockFilePath `{value}` is absolute or leaves the project root"), + )); + continue; + } + } + } + None => { + let stem = &file[..file.rfind('.').unwrap_or(file.len())]; + let named = join(&format!("packages.{}.lock.json", stem.replace(' ', "_"))); + if exists(&named) { + named + } else { + join(PACKAGES_LOCK) + } + } + }; + if exists(&lock) && !out.locks.contains(&lock) { + out.locks.push(lock); + } + } + out +} + +/// The project's literal `NuGetLockFilePath`: `None` when it sets none, +/// `Some(Err)` when it sets one this reader cannot evaluate. +fn lock_file_path_property(text: &str) -> Option> { + const OPEN: &str = "> = None; + let mut rest = text.as_str(); + while let Some(at) = rest.find(OPEN) { + let after = &rest[at + OPEN.len()..]; + // `` is another property. + if !after.starts_with(['>', ' ', '\t', '\r', '\n', '/']) { + rest = after; + continue; + } + let Some(gt) = after.find('>') else { + return Some(Err("an unterminated NuGetLockFilePath element".to_string())); + }; + let attrs = after[..gt].trim(); + if attrs.ends_with('/') { + // ``: an empty value, NuGet's default. + value = None; + rest = &after[gt + 1..]; + continue; + } + let Some(end) = after[gt + 1..].find(CLOSE) else { + return Some(Err("an unterminated NuGetLockFilePath element".to_string())); + }; + let raw = after[gt + 1..gt + 1 + end].trim(); + rest = &after[gt + 1 + end + CLOSE.len()..]; + if raw.is_empty() && attrs.is_empty() { + value = None; + continue; + } + value = Some(if !attrs.is_empty() { + Err(format!( + "NuGetLockFilePath `{raw}` is conditional ({attrs}); its value depends on the build" + )) + } else if raw.contains("$(") || raw.contains("@(") || raw.contains("%(") { + Err(format!( + "NuGetLockFilePath `{raw}` references MSBuild properties or items" + )) + } else { + Ok(raw.to_string()) + }); + } + value +} + +fn strip_xml_comments(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(at) = rest.find("") { + Some(end) => rest = &rest[at + 4 + end + 3..], + None => return out, + } + } + out.push_str(rest); + out +} + +/// `value` (a project-relative path, either separator) resolved against +/// the root-relative `dir`; `None` when it is absolute or leaves the root. +fn resolve_relative(dir: &str, value: &str) -> Option { + let value = value.replace('\\', "/"); + if value.starts_with('/') || value.as_bytes().get(1) == Some(&b':') { + return None; + } + let mut parts: Vec<&str> = dir.split('/').filter(|p| !p.is_empty()).collect(); + for seg in value.split('/') { + match seg { + "" | "." => {} + ".." => { + parts.pop()?; + } + seg => parts.push(seg), + } + } + (!parts.is_empty()).then(|| parts.join("/")) +} + #[cfg(test)] mod tests { use super::*; @@ -176,4 +349,77 @@ mod tests { 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()); } + + fn exists_in(files: &'static [&'static str]) -> impl Fn(&str) -> bool { + move |p| files.contains(&p) + } + + #[test] + fn member_and_named_locks_are_governed() { + let projects = vec![ + ("src/App/App.csproj".to_string(), "".to_string()), + ( + "src/Lib/My Lib.fsproj".to_string(), + "".to_string(), + ), + ("tests/T/T.vbproj".to_string(), "".to_string()), + ]; + let got = governed_locks( + &projects, + exists_in(&[ + "packages.lock.json", + "src/App/packages.lock.json", + "src/Lib/packages.My_Lib.lock.json", + "src/Lib/packages.lock.json", + ]), + ); + assert_eq!( + got.locks, + [ + "packages.lock.json", + "src/App/packages.lock.json", + "src/Lib/packages.My_Lib.lock.json" + ] + ); + assert!(got.unresolved.is_empty()); + } + + #[test] + fn lock_file_path_property_is_honored_or_refused() { + let project = |body: &str| { + vec![( + "src/App/App.csproj".to_string(), + format!("{body}"), + )] + }; + let got = governed_locks( + &project("..\\locks/app.lock.json"), + exists_in(&["src/locks/app.lock.json", "src/App/packages.lock.json"]), + ); + assert_eq!(got.locks, ["src/locks/app.lock.json"]); + // A commented-out property is not set. + let got = governed_locks( + &project(""), + exists_in(&["src/App/packages.lock.json"]), + ); + assert_eq!(got.locks, ["src/App/packages.lock.json"]); + for body in [ + "$(MSBuildProjectDirectory)/l.json", + "l.json", + "../../../outside.json", + "/abs/l.json", + ] { + let got = governed_locks(&project(body), exists_in(&[])); + assert!(got.locks.is_empty(), "{body}"); + assert_eq!(got.unresolved.len(), 1, "{body}"); + } + } + + #[test] + fn project_files_are_recognized_by_extension() { + assert!(is_project_file("App.csproj")); + assert!(is_project_file("App.FSPROJ")); + assert!(!is_project_file(".csproj")); + assert!(!is_project_file("App.sln")); + } } diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index b9cab7ce2..c1371e3d5 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -633,6 +633,36 @@ pub async fn read_candidate_files( } } + // NuGet: the root config routes every project under the root, so each + // project's lock is pinned with it (#353, #514). The walk's answer rides + // a synthetic key (the lock paths, an unevaluable NuGetLockFilePath, or + // why the tree could not be listed) and every lock is read. The project + // files themselves stay out of the candidate texts. NuGet is disk-only + // (the in-memory engine refuses it). + if candidates.iter().any(|c| c.dep.ecosystem == "nuget") + && !matches!(view, ProjectView::Memory(_)) + { + let mut lines: Vec = Vec::new(); + match crate::vendor::nuget_config::governed_locks_in(view).await { + Ok(governed) => { + for (project, detail) in &governed.unresolved { + lines.push(format!("unresolved\t{project}\t{detail}")); + } + for rel in governed.locks { + if !out.files.contains_key(&rel) { + out.read(view, unreadable, &rel).await; + } + lines.push(format!("lock\t{rel}")); + } + } + Err(why) => lines.push(format!("error\t{why}")), + } + out.files.insert( + crate::patch::redirect::NUGET_LOCKS_KEY.to_string(), + lines.join("\n"), + ); + } + for path in view.python_lock_paths() { if let Some(script) = crate::utils::python_lock::script_of_lock(&path) { out.read(view, unreadable, script).await; diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 2faee8377..12eb35c07 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -5912,7 +5912,13 @@ fn nuget_xml_attribute(value: &str) -> String { .replace('\r', " ") } -use crate::formats::nuget::lock::PACKAGES_LOCK; +/// The synthetic candidate key carrying the locks the engine found every +/// project under the root restoring into (#353, #514), one per line: +/// `lock\t`, `unresolved\t\t` for a `NuGetLockFilePath` +/// it cannot evaluate, or `error\t` when it could not list the tree. +/// Absent (the in-memory engine, unit tests), the root `packages.lock.json` +/// is the one lock. Never a path (see [`sbt::SYNTHETIC_KEY_PREFIX`]). +pub const NUGET_LOCKS_KEY: &str = ""; fn rewrite_nuget( files: &BTreeMap, @@ -5951,21 +5957,64 @@ fn rewrite_nuget( // (the npm twin does the same). An ABSENT lock is fine — config-only. // 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 crate::formats::nuget::lock::parse_lock(text) { - Ok(parsed) => Some(parsed), + // + // Every lock a project under the root restores into: the root config + // routes them all, so each is pinned with it (#353, #514). The engine + // reads the project files and their locks; the same pure discovery + // re-derives which keys are locks here. + let lock_rels: Vec = match files.get(NUGET_LOCKS_KEY) { + None => vec![crate::formats::nuget::lock::PACKAGES_LOCK.to_string()], + Some(found) => { + let mut rels = Vec::new(); + for line in found.lines() { + let mut fields = line.splitn(3, '\t'); + match (fields.next(), fields.next(), fields.next()) { + (Some("lock"), Some(rel), None) => rels.push(rel.to_string()), + (Some("unresolved"), Some(project), Some(detail)) => { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_lock_path_unresolved".into(), + detail: format!( + "{project}: {detail}; the lock it restores into cannot be \ + pinned, so nuget redirect is skipped (set a literal \ + NuGetLockFilePath, or remove it)" + ), + }); + return; + } + (Some("error"), Some(why), _) => { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_lock_unreadable".into(), + detail: format!( + "cannot list the project's NuGet locks ({why}); nuget redirect \ + skipped" + ), + }); + return; + } + _ => {} + } + } + rels + } + }; + // Read past a UTF-8 BOM the way dotnet does (#623); the write below + // keeps it. + let mut locks: Vec<(String, &String, Value, bool)> = Vec::new(); + for rel in lock_rels { + let Some(text) = files.get(&rel) else { + continue; + }; + match crate::formats::nuget::lock::parse_lock(text) { + Ok(parsed) => locks.push((rel, text, parsed, false)), Err(_) => { result.warnings.push(RewriteWarning { code: "redirect_nuget_lock_unparseable".into(), - detail: "packages.lock.json is not valid JSON; nuget redirect skipped".into(), + detail: format!("{rel} is not valid JSON; nuget redirect skipped"), }); return; } - }, - }; - let mut lock_changed = false; + } + } for dep in &nuget { let Some(ov) = registry_override_of_kind(dep, "nuget-v3") else { @@ -6004,21 +6053,24 @@ fn rewrite_nuget( // 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 other_version = locks.iter().find_map(|(rel, _, lock_val, _)| { 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; - } + (!others.is_empty()).then(|| { + crate::formats::nuget::lock::other_versions_detail( + rel, + &dep.name, + &version_norm, + &others, + ) + }) + }); + if let Some(detail) = other_version { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_lock_other_version".into(), + detail, + }); + continue; } let unwritable = || RewriteWarning { @@ -6066,7 +6118,7 @@ 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() { + for (rel, _, lock_val, lock_changed) in locks.iter_mut() { for (id, obj) in crate::formats::nuget::lock::locked_at_mut(lock_val, &id_lower, &version_norm) { @@ -6080,9 +6132,9 @@ fn rewrite_nuget( "contentHash": obj.get("contentHash").cloned().unwrap_or(Value::Null), }); obj.insert("contentHash".into(), Value::String(content_hash.clone())); - lock_changed = true; + *lock_changed = true; result.edits.push(FileEdit { - path: PACKAGES_LOCK.into(), + path: rel.clone(), kind: "redirect_nuget_lock".into(), action: "rewritten".into(), key: Some(id.to_string()), @@ -6099,13 +6151,12 @@ fn rewrite_nuget( if config_changed { result.files.insert(config_path.into(), config); } - if lock_changed { - if let (Some(lock_val), Some(original)) = (lock, lock_text) { + for (rel, original, lock_val, changed) in locks { + if changed { // In the lock's own layout: its BOM, indent and line endings. - result.files.insert( - PACKAGES_LOCK.into(), - serialize_json_like(&lock_val, original), - ); + result + .files + .insert(rel, serialize_json_like(&lock_val, original)); } } } @@ -23233,6 +23284,83 @@ packages: ); } + fn simple_lock(hash: &str) -> String { + format!( + "{{\n \"version\": 1,\n \"dependencies\": {{\n \"net8.0\": {{\n \"Newtonsoft.Json\": {{\n \"type\": \"Direct\",\n \"requested\": \"[13.0.3, )\",\n \"resolved\": \"13.0.3\",\n \"contentHash\": \"{hash}\"\n }}\n }}\n }}\n}}\n" + ) + } + + /// #353 / #514: member-project locks and named locks the root config + /// governs (the engine's walk, carried by [`NUGET_LOCKS_KEY`]) are + /// re-pinned under their own paths with the config. + #[test] + fn nuget_member_and_named_locks_are_repinned() { + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert( + "src/App/packages.lock.json".to_string(), + simple_lock("ORIGINALHASH=="), + ); + files.insert( + "src/Lib/packages.Lib.lock.json".to_string(), + simple_lock("ORIGINALHASH=="), + ); + files.insert( + NUGET_LOCKS_KEY.to_string(), + "lock\tsrc/App/packages.lock.json\nlock\tsrc/Lib/packages.Lib.lock.json".to_string(), + ); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + for rel in [ + "src/App/packages.lock.json", + "src/Lib/packages.Lib.lock.json", + ] { + assert_eq!( + r.files.get(rel).map(String::as_str), + Some(simple_lock("PATCHED==").as_str()), + "{rel}" + ); + } + let lock_paths: Vec<&str> = r + .edits + .iter() + .filter(|e| e.kind == "redirect_nuget_lock") + .map(|e| e.path.as_str()) + .collect(); + assert_eq!( + lock_paths, + [ + "src/App/packages.lock.json", + "src/Lib/packages.Lib.lock.json" + ] + ); + // Without the walk (in memory, unit tests) only the root lock is one: + // a member lock is never mistaken for one. + files.remove(NUGET_LOCKS_KEY); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(!r.files.contains_key("src/App/packages.lock.json")); + } + + /// #514: an unresolvable `NuGetLockFilePath`, or a project tree the + /// engine could not list, skips the nuget redirect with nothing written. + #[test] + fn nuget_unknowable_locks_skip_the_redirect() { + for (walk, code) in [ + ( + "lock\tpackages.lock.json\nunresolved\tapp.csproj\tNuGetLockFilePath `$(X)/l.json` references MSBuild properties or items", + "redirect_nuget_lock_path_unresolved", + ), + ("error\tunreadable src", "redirect_nuget_lock_unreadable"), + ] { + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert(NUGET_LOCKS_KEY.to_string(), walk.to_string()); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); + assert_eq!(warning_codes(&r), vec![code]); + } + } + /// #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 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 8ce8030eb..bfaf8350a 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs @@ -211,9 +211,9 @@ pub(crate) async fn restore( Some((d, l)) => (format!("{d}/"), l), None => (String::new(), rel.as_str()), }; - if leaf == PACKAGES_LOCK { - // Restored together with the config that wires it; a pin no - // config claims is refused by the driver. + if !crate::patch::redirect::NUGET_CONFIG_FILE_NAMES.contains(&leaf) { + // A lock is restored together with the config that wires it; a + // pin no config claims is refused by the driver. continue; } let Some(original) = read_or_refuse(view, rel, &pins, &mut result).await else { @@ -253,44 +253,68 @@ pub(crate) async fn restore( continue; }; - let lock_rel = format!("{dir}{PACKAGES_LOCK}"); - let lock_text = match view.read(&lock_rel).await { - Ok(t) => t, - Err(e) => { - refuse_all_in(&pins, rel, &mut result, e); - continue; + // The locks the config governs: at the root, every lock a project + // under it restores into (#353, #514); a nested config, its own + // directory's default lock. + let lock_rels: Vec = if dir.is_empty() { + match crate::vendor::nuget_config::governed_locks_on_disk(view.root()) { + Ok(governed) => { + if let Some((project, detail)) = governed.unresolved.first() { + refuse_all_in( + &pins, + rel, + &mut result, + format!("{project}: {detail}; its lock cannot be restored"), + ); + continue; + } + governed.locks + } + Err(why) => { + refuse_all_in(&pins, rel, &mut result, why); + continue; + } } + } else { + vec![format!("{dir}{PACKAGES_LOCK}")] }; - 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(_)) => { - refuse_all_in( - &pins, - rel, - &mut result, - format!("{lock_rel} is not valid JSON"), - ); - continue; + let mut locks: Vec<(String, String, Value)> = Vec::new(); + let mut unreadable = false; + for lock_rel in lock_rels { + match view.read(&lock_rel).await { + Ok(None) => {} + Ok(Some(text)) => match crate::formats::nuget::lock::parse_lock(&text) { + Ok(value) => locks.push((lock_rel, text, value)), + Err(_) => { + refuse_all_in( + &pins, + rel, + &mut result, + format!("{lock_rel} is not valid JSON"), + ); + unreadable = true; + break; + } + }, + Err(e) => { + refuse_all_in(&pins, rel, &mut result, e); + unreadable = true; + break; + } } - }; + } + if unreadable { + continue; + } for (pin, id, version) in &restored { - let Some(lock) = lock.as_mut() else { - continue; - }; // 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() { + let pinned = locks.iter().any(|(_, _, lock)| { + crate::formats::nuget::lock::locked_at(lock, id, &norm) + .any(|e| e.content_hash.is_some()) + }); + if !pinned { continue; } if let Err(why) = check_upstream_feed(&cfg, id, rel) { @@ -304,8 +328,12 @@ pub(crate) async fn restore( continue; } }; - for entry in entries { - entry.insert("contentHash".into(), Value::String(hash.clone())); + for (_, _, lock) in locks.iter_mut() { + for (_, entry) in crate::formats::nuget::lock::locked_at_mut(lock, id, &norm) { + if entry.contains_key("contentHash") { + entry.insert("contentHash".into(), Value::String(hash.clone())); + } + } } } // A refusal in this file reruns the pass without that pin; write @@ -326,7 +354,7 @@ pub(crate) async fn restore( )); } view.write(rel, text); - if let (Some(lock), Some(before)) = (lock, lock_text) { + for (lock_rel, before, lock) in locks { // In the lock's own layout (BOM, indent, line endings). if crate::formats::nuget::lock::parse_lock(&before) .ok() @@ -515,6 +543,42 @@ mod tests { assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); } + /// #353: the root config governs a member project's lock, so the + /// unwind restores it with the config. + #[tokio::test] + #[serial_test::serial] + async fn a_member_project_lock_is_restored_with_the_root_config() { + let server = nuget_org().await; + std::env::set_var("SOCKET_NUGET_URL", server.uri()); + let tmp = tempfile::tempdir().unwrap(); + let app = tmp.path().join("src/App"); + std::fs::create_dir_all(&app).unwrap(); + std::fs::write(tmp.path().join("nuget.config"), hosted_config(USER_MAPPING)).unwrap(); + std::fs::write(app.join("App.csproj"), "").unwrap(); + std::fs::write(app.join(PACKAGES_LOCK), lock(PATCHED)).unwrap(); + let pins = [HostedPin { + purl: "pkg:nuget/Newtonsoft.Json@13.0.3".into(), + uuid: UUID.into(), + files: vec!["nuget.config".into()], + }]; + let outcome = restore_upstream(tmp.path(), &pins, &RestoreOptions::default()).await; + std::env::remove_var("SOCKET_NUGET_URL"); + assert_eq!( + outcome.pins[0].status, + PinStatus::Restored, + "{:?}", + outcome.pins + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("nuget.config")).unwrap(), + USER_MAPPING + ); + assert_eq!( + std::fs::read_to_string(app.join(PACKAGES_LOCK)).unwrap(), + lock(UPSTREAM) + ); + } + #[tokio::test] #[serial_test::serial] async fn a_config_created_from_scratch_is_kept_and_warned() { diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index 44addc7a4..8723561c1 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -625,14 +625,32 @@ impl<'a> DiskSnapshot<'a> { } /// The UTF-8-named entries of directory `dir` on disk, sorted by name. -async fn list_disk_dir(dir: &Path) -> io::Result> { +/// `strict` fails the listing instead of skipping what it cannot see: a +/// non-UTF-8 name, a `file_type()` error, or a read error mid-listing. +async fn list_disk_dir(dir: &Path, strict: bool) -> io::Result> { let mut read = tokio::fs::read_dir(dir).await?; let mut out = Vec::new(); - while let Ok(Some(entry)) = read.next_entry().await { + loop { + let entry = match read.next_entry().await { + Ok(Some(entry)) => entry, + Ok(None) => break, + Err(e) if strict => return Err(e), + Err(_) => break, + }; let Some(name) = entry.file_name().to_str().map(str::to_string) else { + if strict { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("non-UTF-8 name {:?}", entry.file_name()), + )); + } continue; }; - let is_dir = entry.file_type().await.is_ok_and(|t| t.is_dir()); + let is_dir = match entry.file_type().await { + Ok(t) => t.is_dir(), + Err(e) if strict => return Err(e), + Err(_) => false, + }; out.push(DirEntryInfo { name, is_dir }); } out.sort_by(|a, b| a.name.cmp(&b.name)); @@ -882,14 +900,25 @@ impl<'a> ProjectView<'a> { /// The UTF-8-named entries of directory `rel`, sorted by name. pub async fn list_dir(&self, rel: &str) -> io::Result> { + self.list_dir_with(rel, false).await + } + + /// [`Self::list_dir`] for a walk that must see the whole tree: a + /// non-UTF-8 name, an unreadable entry type or a read error mid-listing + /// fails the listing instead of being skipped. + pub async fn list_dir_strict(&self, rel: &str) -> io::Result> { + self.list_dir_with(rel, true).await + } + + async fn list_dir_with(&self, rel: &str, strict: bool) -> io::Result> { if let ProjectView::Snapshot(snap) = self { snap.touch_listing(rel); } match self { - ProjectView::Disk(root) => list_disk_dir(&root.join(rel)).await, + ProjectView::Disk(root) => list_disk_dir(&root.join(rel), strict).await, ProjectView::Snapshot(snap) => { let created = snap.overlaid_children(rel); - let mut out = match list_disk_dir(&snap.root.join(rel)).await { + let mut out = match list_disk_dir(&snap.root.join(rel), strict).await { Ok(out) => out, Err(e) if e.kind() == io::ErrorKind::NotFound && !created.is_empty() => { Vec::new() diff --git a/crates/socket-patch-core/src/vendor/nuget_config.rs b/crates/socket-patch-core/src/vendor/nuget_config.rs index 460530741..747ea219a 100644 --- a/crates/socket-patch-core/src/vendor/nuget_config.rs +++ b/crates/socket-patch-core/src/vendor/nuget_config.rs @@ -47,6 +47,179 @@ fn regular_file(path: &std::path::Path) -> bool { std::fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_file()) } +// ── project walk ── + +/// Directories the project walk never enters: build output, the restore's +/// `obj/`, a legacy `packages/` folder and JS dependencies (hidden ones — +/// `.git`, `.socket` — are skipped too). The NuGet crawler's restore scope +/// skips the same set. +const SKIPPED_DIRS: [&str; 4] = ["bin", "obj", "packages", "node_modules"]; + +/// Directories the walk lists before giving up. +const WALK_DIR_BUDGET: usize = 10_000; + +/// A project file larger than this is not an MSBuild project anyone wrote. +const MAX_PROJECT_BYTES: u64 = 4 * 1024 * 1024; + +/// Every MSBuild project file under `root` (root-relative, `/`-separated, +/// sorted) with its text. Symlinked directories are not followed. `Err` +/// when the walk cannot see the whole tree (an unreadable directory or +/// project file, or more than [`WALK_DIR_BUDGET`] directories): a lock the +/// walk missed would keep its upstream hash under the wired mapping. +pub(crate) fn project_files(root: &std::path::Path) -> Result, String> { + let mut out = Vec::new(); + let mut pending = vec![String::new()]; + let mut listed = 0usize; + while let Some(rel) = pending.pop() { + listed += 1; + if listed > WALK_DIR_BUDGET { + return Err(format!( + "more than {WALK_DIR_BUDGET} directories under the project root" + )); + } + let dir = if rel.is_empty() { + root.to_path_buf() + } else { + root.join(&rel) + }; + let entries = + std::fs::read_dir(&dir).map_err(|e| format!("unreadable {}: {e}", dir.display()))?; + for entry in entries { + let entry = entry.map_err(|e| format!("unreadable {}: {e}", dir.display()))?; + let raw = entry.file_name(); + let Some(name) = raw.to_str().map(str::to_string) else { + // A project (or a directory that may hold one) this walk + // cannot name is a lock it would miss: fail closed. + let lossy = raw.to_string_lossy(); + if crate::formats::nuget::lock::is_project_file(&lossy) + || entry.file_type().is_ok_and(|k| k.is_dir()) + { + return Err(format!( + "{} has a name that is not UTF-8", + dir.join(&raw).display() + )); + } + continue; + }; + let child = if rel.is_empty() { + name.clone() + } else { + format!("{rel}/{name}") + }; + let kind = entry + .file_type() + .map_err(|e| format!("unreadable {}: {e}", entry.path().display()))?; + // A symlinked directory is not entered (it may lead outside the + // tree, or back into it); a symlinked project file is read + // through the link, like MSBuild opens it. + let is_project = crate::formats::nuget::lock::is_project_file(&name) + && (kind.is_file() + || (kind.is_symlink() + && std::fs::metadata(entry.path()).is_ok_and(|m| m.is_file()))); + if kind.is_dir() { + if !name.starts_with('.') && !SKIPPED_DIRS.contains(&name.as_str()) { + pending.push(child); + } + } else if is_project { + let path = entry.path(); + if std::fs::metadata(&path).is_ok_and(|m| m.len() > MAX_PROJECT_BYTES) { + return Err(format!("{} is too large to read", path.display())); + } + let text = crate::utils::fs::read_regular_to_string_sync(&path) + .map_err(|e| format!("unreadable {}: {e}", path.display()))?; + out.push((child, text)); + } + } + } + out.sort(); + Ok(out) +} + +/// [`crate::formats::nuget::lock::governed_locks`] of the project tree on +/// disk under `root`. +pub(crate) fn governed_locks_on_disk( + root: &std::path::Path, +) -> Result { + let projects = project_files(root)?; + Ok(crate::formats::nuget::lock::governed_locks( + &projects, + |rel| lock_present(root, rel), + )) +} + +/// [`governed_locks_on_disk`] through a [`ProjectView`]: every listing, read +/// and probe goes through the view, so a recording [`DiskSnapshot`] read +/// cache fingerprints them like its own reads (asking it for its raw root +/// would end its recording, and with it the re-scan's reuse of the +/// discovery it guards). Same walk rules as [`project_files`]. +/// +/// [`ProjectView`]: crate::vendor::lock_inventory::ProjectView +/// [`DiskSnapshot`]: crate::vendor::lock_inventory::DiskSnapshot +pub(crate) async fn governed_locks_in( + view: &crate::vendor::lock_inventory::ProjectView<'_>, +) -> Result { + use crate::formats::nuget::lock::{governed_locks, is_project_file}; + let mut projects: Vec<(String, String)> = Vec::new(); + let mut pending = vec![String::new()]; + let mut listed = 0usize; + while let Some(rel) = pending.pop() { + listed += 1; + if listed > WALK_DIR_BUDGET { + return Err(format!( + "more than {WALK_DIR_BUDGET} directories under the project root" + )); + } + let entries = view.list_dir_strict(&rel).await.map_err(|e| { + format!( + "unreadable {}: {e}", + if rel.is_empty() { "." } else { &rel } + ) + })?; + for entry in entries { + let child = if rel.is_empty() { + entry.name.clone() + } else { + format!("{rel}/{}", entry.name) + }; + if entry.is_dir { + if !entry.name.starts_with('.') && !SKIPPED_DIRS.contains(&entry.name.as_str()) { + pending.push(child); + } + } else if is_project_file(&entry.name) { + let text = view + .read_text(&child) + .await + .map_err(|e| format!("unreadable {child}: {e}"))?; + if text.len() as u64 > MAX_PROJECT_BYTES { + return Err(format!("{child} is too large to read")); + } + projects.push((child, text)); + } + } + } + projects.sort(); + // Which lock paths the discovery asks about, then their answers. + let asked = std::cell::RefCell::new(Vec::::new()); + governed_locks(&projects, |rel| { + asked.borrow_mut().push(rel.to_string()); + false + }); + let mut present = std::collections::BTreeSet::new(); + for rel in asked.into_inner() { + if !present.contains(&rel) && view.exists_no_follow(&rel).await { + present.insert(rel); + } + } + Ok(governed_locks(&projects, |rel| present.contains(rel))) +} + +/// Whether something other than a directory sits at `root/rel` (`lstat`): +/// a FIFO or link under a lock name is then read, and refused, by the +/// FIFO-safe reader rather than taken for an absent lock. +pub(crate) fn lock_present(root: &std::path::Path, rel: &str) -> bool { + std::fs::symlink_metadata(root.join(rel)).is_ok_and(|m| !m.is_dir()) +} + #[cfg(test)] mod tests { use super::same_file; @@ -79,4 +252,56 @@ mod tests { std::fs::create_dir(&dir).unwrap(); assert_eq!(same_file(&dir, &dir).await, cfg!(unix)); } + + /// The walk reads a symlinked project file, never enters build output + /// or hidden dirs, and finds projects at any depth. + #[cfg(unix)] + #[test] + fn project_walk_follows_project_links_and_skips_output() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for dir in ["src/App/obj", "src/Lib", ".git", "shared"] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + } + std::fs::write(root.join("src/App/App.csproj"), "").unwrap(); + std::fs::write(root.join("src/App/obj/Gen.csproj"), "").unwrap(); + std::fs::write(root.join(".git/X.csproj"), "").unwrap(); + std::fs::write(root.join("shared/Lib.csproj"), "lib").unwrap(); + std::os::unix::fs::symlink( + root.join("shared/Lib.csproj"), + root.join("src/Lib/Lib.csproj"), + ) + .unwrap(); + let found: Vec = super::project_files(root) + .unwrap() + .into_iter() + .map(|(rel, _)| rel) + .collect(); + assert_eq!( + found, + [ + "shared/Lib.csproj", + "src/App/App.csproj", + "src/Lib/Lib.csproj" + ] + ); + } + + /// The view walk fails closed on a directory name it cannot spell, like + /// the disk walk: a member project under it would otherwise drop out of + /// the governed locks. (Linux: APFS refuses non-UTF-8 names.) + #[cfg(target_os = "linux")] + #[tokio::test] + async fn view_walk_fails_closed_on_a_non_utf8_dir() { + use std::os::unix::ffi::OsStrExt; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let odd = root.join(std::ffi::OsStr::from_bytes(b"m\xffember")); + std::fs::create_dir_all(&odd).unwrap(); + std::fs::write(odd.join("M.csproj"), "").unwrap(); + std::fs::write(root.join("App.csproj"), "").unwrap(); + assert!(super::project_files(root).is_err()); + let view = crate::vendor::lock_inventory::ProjectView::Disk(root); + assert!(super::governed_locks_in(&view).await.is_err()); + } } diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 97c374685..620973346 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -7,7 +7,7 @@ use serde_json::Value; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::remove_tree; -use crate::patch::path_safety::is_safe_single_segment; +use crate::patch::path_safety::{is_safe_multi_segment, is_safe_single_segment}; use crate::utils::fs::{ atomic_write_artifact, atomic_write_bytes_preserving_mode, read_regular_to_string, }; @@ -122,8 +122,8 @@ struct NugetPrelude { source_key: String, config_path: Option, config_text: Option, - lock_path: PathBuf, - lock_text: Option, + /// Every lock the projects under the root restore into (#353, #514). + locks: Vec, /// nuget.config already carries this uuid's source. config_wired: bool, /// ...and the committed nupkg plus the lock pin are in sync (the hot @@ -197,17 +197,40 @@ async fn nuget_prelude( }, None => None, }; - let lock_path = project_root.join(PACKAGES_LOCK); - let lock_text: Option = match read_regular_to_string(&lock_path).await { - Ok(t) => Some(t), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + // Every lock a project under the root restores into: the root config + // routes all of them, so each must be pinned with it (#353, #514). + let governed = match super::nuget_config::governed_locks_on_disk(project_root) { + Ok(governed) => governed, Err(e) => { return Err(refused( "vendor_nuget_lock_unreadable", - format!("unreadable {}: {e}", lock_path.display()), + format!("cannot list the project's NuGet locks: {e}"), )); } }; + if let Some((project, detail)) = governed.unresolved.first() { + return Err(refused( + "vendor_nuget_lock_path_unresolved", + format!( + "{project}: {detail}; the lock it restores into cannot be pinned, so {name} is \ + not vendored (set a literal NuGetLockFilePath, or remove it)" + ), + )); + } + let mut locks: Vec = Vec::with_capacity(governed.locks.len()); + for rel in governed.locks { + let path = project_root.join(&rel); + match read_regular_to_string(&path).await { + Ok(text) => locks.push(LockFile { rel, path, text }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(refused( + "vendor_nuget_lock_unreadable", + format!("unreadable {}: {e}", path.display()), + )); + } + } + } // The idempotent hot path's test (see `vendor_nuget`): a live // `` source under our key. A commented-out one — or the @@ -220,13 +243,16 @@ async fn nuget_prelude( // 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) { + for lock in &locks { + let Ok(doc) = lock_value(&lock.text) else { + continue; + }; 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, + &lock.rel, name, &version_norm, &others, @@ -244,17 +270,22 @@ async fn nuget_prelude( .is_some_and(|bytes| zip_bytes_match_after_hashes(bytes, &record.files)); // Only worth computing when the artifact itself is in sync (a stale // nupkg rebuilds regardless of what the lock pins). - let lock_ok = match (&lock_text, &nupkg_bytes) { - (None, _) => true, - (Some(text), Some(bytes)) if nupkg_ok => { + let lock_ok = match &nupkg_bytes { + _ if locks.is_empty() => true, + Some(bytes) if nupkg_ok => { let expected = sha512_base64_of(bytes); // Pinned at our bytes, or no matching resolved entry at // all — the same absence `edit_lock` tolerates with a // warning on the first run. Treating absence as stale // would misreport "missing or stale; rebuilt" on every // rerun with nothing to actually pin. - lock_pinned(text, name, &version_norm, &expected) - || matches!(edit_lock(text, name, &version_norm, &expected), Ok(None)) + locks.iter().all(|lock| { + lock_pinned(&lock.text, name, &version_norm, &expected) + || matches!( + edit_lock(&lock.text, name, &version_norm, &expected), + Ok(None) + ) + }) } _ => false, }; @@ -271,8 +302,7 @@ async fn nuget_prelude( source_key, config_path, config_text, - lock_path, - lock_text, + locks, config_wired, in_sync, }) @@ -331,8 +361,7 @@ pub async fn vendor_nuget( source_key, config_path, config_text, - lock_path, - lock_text, + locks, config_wired, in_sync, } = match nuget_prelude(purl, project_root, record).await { @@ -395,32 +424,39 @@ pub async fn vendor_nuget( // pre-vendor contentHash from the entry being replaced and // re-attaches the untouched config records. let mut wiring: Vec = Vec::new(); - if let Some(text) = &lock_text { - let new_hash = sha512_base64_of(&bytes); - match edit_lock(text, name, &version_norm, &new_hash) { + let new_hash = sha512_base64_of(&bytes); + // Locks already re-pinned, put back if a later one fails: the + // projects must agree on one nupkg (the rebuilt artifact stays, + // the config routes to it). + let mut written: Vec<(&LockFile, &str)> = Vec::new(); + for lock in &locks { + match edit_lock(&lock.text, name, &version_norm, &new_hash) { Ok(Some(edit)) => { LOCK_VALUE_MEMO.invalidate(); if let Err(e) = - atomic_write_bytes_preserving_mode(&lock_path, edit.text.as_bytes()) + atomic_write_bytes_preserving_mode(&lock.path, edit.text.as_bytes()) .await { + unwind_locks(&written).await; result.success = false; - result.error = Some(format!("failed to rewrite {PACKAGES_LOCK}: {e}")); + result.error = Some(format!("failed to rewrite {}: {e}", lock.rel)); return done(result, None, warnings); } + written.push((lock, lock.text.as_str())); wiring.push(WiringRecord { - file: PACKAGES_LOCK.to_string(), + file: lock.rel.clone(), kind: LOCK_WIRING_KIND.to_string(), action: WiringAction::Rewritten, key: Some(name.to_string()), original: None, - new: Some(Value::String(new_hash)), + new: Some(Value::String(new_hash.clone())), }); } Ok(None) => {} Err(detail) => { + unwind_locks(&written).await; result.success = false; - result.error = Some(detail); + result.error = Some(format!("{}: {detail}", lock.rel)); return done(result, None, warnings); } } @@ -518,21 +554,26 @@ pub async fn vendor_nuget( } // ── packages.lock.json pinning (a failure here unwinds the config) ──── - let mut lock_record: Option = None; - if let Some(text) = &lock_text { - match edit_lock(text, name, &version_norm, &new_hash) { + let mut lock_records: Vec = Vec::new(); + // The locks already re-pinned, with their pre-vendor text, so a later + // failure puts every one of them back with the config. + let mut written: Vec<(&LockFile, &str)> = Vec::new(); + for lock in &locks { + match edit_lock(&lock.text, name, &version_norm, &new_hash) { Ok(Some(edit)) => { LOCK_VALUE_MEMO.invalidate(); if let Err(e) = - atomic_write_bytes_preserving_mode(&lock_path, edit.text.as_bytes()).await + atomic_write_bytes_preserving_mode(&lock.path, edit.text.as_bytes()).await { + unwind_locks(&written).await; unwind_config(&config_target, config_text.as_deref(), &uuid_dir).await; result.success = false; - result.error = Some(format!("failed to write {PACKAGES_LOCK}: {e}")); + result.error = Some(format!("failed to write {}: {e}", lock.rel)); return done(result, None, warnings); } - lock_record = Some(WiringRecord { - file: PACKAGES_LOCK.to_string(), + written.push((lock, lock.text.as_str())); + lock_records.push(WiringRecord { + file: lock.rel.clone(), kind: LOCK_WIRING_KIND.to_string(), action: WiringAction::Rewritten, key: Some(name.to_string()), @@ -547,24 +588,28 @@ pub async fn vendor_nuget( warnings.push(VendorWarning::new( "vendor_nuget_lock_entry_absent", format!( - "{PACKAGES_LOCK} has no resolved entry for {name} {version_norm}; the \ - vendored feed still serves it but its contentHash is not pinned" + "{} has no resolved entry for {name} {version_norm}; the vendored feed \ + still serves it but its contentHash is not pinned there", + lock.rel ), )); } Err(detail) => { + unwind_locks(&written).await; unwind_config(&config_target, config_text.as_deref(), &uuid_dir).await; result.success = false; - result.error = Some(detail); + result.error = Some(format!("{}: {detail}", lock.rel)); return done(result, None, warnings); } } - } else { + } + if locks.is_empty() { warnings.push(VendorWarning::new( "vendor_nuget_no_lockfile", format!( - "no {PACKAGES_LOCK} (RestorePackagesWithLockFile is off); the vendored feed \ - forces {name} from the patched copy but its contentHash is not pinned" + "no project under the root restores into a {PACKAGES_LOCK} (or a \ + packages..lock.json); the vendored feed serves {name} from the patched \ + copy but its contentHash is not pinned" ), )); } @@ -608,9 +653,7 @@ pub async fn vendor_nuget( // Application order: config source, config mapping, then the lock pin. // Revert runs them in reverse (lock → mapping → source). let mut wiring = vec![source_record, mapping_record]; - if let Some(rec) = lock_record { - wiring.push(rec); - } + wiring.extend(lock_records); let entry = nuget_entry(base_purl, record, copy_rel, &nupkg_bytes, wiring); @@ -691,9 +734,15 @@ pub async fn revert_nuget_opts( // record, then the authoritative config restore. for w in entry.wiring.iter().rev() { let restored = match w.kind.as_str() { - LOCK_WIRING_KIND => { - revert_lock_record(&project_root.join(PACKAGES_LOCK), w, dry_run).await - } + // SECURITY: state.json is committed and tamper-able; the lock + // path is joined under the root and written through, so only a + // plain relative path is accepted (a `../`, an absolute path + // would make the restore an arbitrary file write). + LOCK_WIRING_KIND if !is_safe_multi_segment(&w.file) => Err(format!( + "refusing revert: unsafe wiring file path {:?}", + w.file + )), + LOCK_WIRING_KIND => revert_lock_record(&project_root.join(&w.file), w, dry_run).await, // Audit-only: the whole-file config restore lives on the source // record, so there is nothing to undo here. CONFIG_MAPPING_WIRING_KIND => Ok(true), @@ -1265,6 +1314,21 @@ async fn revert_lock_record( Ok(true) } +/// One project lock: its root-relative path, absolute path and text. +struct LockFile { + rel: String, + path: PathBuf, + text: String, +} + +/// Put back the locks a failed vendor already re-pinned. +async fn unwind_locks(written: &[(&LockFile, &str)]) { + for (lock, original) in written { + let _ = atomic_write_bytes_preserving_mode(&lock.path, original.as_bytes()).await; + } + LOCK_VALUE_MEMO.invalidate(); +} + /// Restore the config to its pre-vendor state (or delete a created file) after /// a later wiring step failed, then remove the partial uuid dir. async fn unwind_config(config_target: &Path, original: Option<&str>, uuid_dir: &Path) { @@ -1970,6 +2034,155 @@ mod tests { assert!(!root.join(".socket").exists()); } + /// #353: a solution layout keeps each project's lock beside it. The + /// root nuget.config routes every project, so the member lock is pinned + /// (and recorded under its own path) and revert restores it. + #[tokio::test] + async fn member_project_lock_is_pinned_and_reverted() { + let (dir, blobs, installed, record) = fixture(false, None).await; + let root = dir.path(); + let app = root.join("src/App"); + tokio::fs::create_dir_all(&app).await.unwrap(); + tokio::fs::write( + app.join("App.csproj"), + "", + ) + .await + .unwrap(); + let lock = lock_json("ORIGINALcachedhash=="); + tokio::fs::write(app.join(PACKAGES_LOCK), &lock) + .await + .unwrap(); + // Build output is never walked. + tokio::fs::create_dir_all(app.join("obj")).await.unwrap(); + tokio::fs::write(app.join("obj/Stray.csproj"), "") + .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 == "vendor_nuget_no_lockfile"), + "{warnings:?}" + ); + let nupkg = tokio::fs::read(root.join(copy_rel())).await.unwrap(); + let pinned = tokio::fs::read_to_string(app.join(PACKAGES_LOCK)) + .await + .unwrap(); + assert_eq!( + pinned, + lock.replace("ORIGINALcachedhash==", &sha512_base64_of(&nupkg)) + ); + let entry = entry.unwrap(); + let files: Vec<&str> = entry + .wiring + .iter() + .filter(|w| w.kind == LOCK_WIRING_KIND) + .map(|w| w.file.as_str()) + .collect(); + assert_eq!(files, ["src/App/packages.lock.json"]); + + // The re-run is the in-sync hot path. + let (_r, rerun_entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, false).await); + assert!( + rerun_entry.is_none(), + "already vendored: nothing re-recorded" + ); + + let reverted = revert_nuget(&entry, root, false).await; + assert!(reverted.success, "{:?}", reverted.error); + assert_eq!( + tokio::fs::read_to_string(app.join(PACKAGES_LOCK)) + .await + .unwrap(), + lock + ); + } + + /// #514: `packages..lock.json` is the lock NuGet reads when it + /// exists; it is pinned (the plain name beside it is not NuGet's). + #[tokio::test] + async fn named_project_lock_is_pinned() { + let (dir, blobs, installed, record) = fixture(false, None).await; + let root = dir.path(); + tokio::fs::write(root.join("app.csproj"), "") + .await + .unwrap(); + let lock = lock_json("ORIGINALcachedhash=="); + tokio::fs::write(root.join("packages.app.lock.json"), &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 == "vendor_nuget_no_lockfile"), + "{warnings:?}" + ); + let pinned = tokio::fs::read_to_string(root.join("packages.app.lock.json")) + .await + .unwrap(); + assert!(!pinned.contains("ORIGINALcachedhash=="), "{pinned}"); + let entry = entry.unwrap(); + assert!(entry + .wiring + .iter() + .any(|w| w.kind == LOCK_WIRING_KIND && w.file == "packages.app.lock.json")); + } + + /// #514: a `NuGetLockFilePath` this reader cannot evaluate is refused + /// before anything is written, rather than left on its upstream hash. + #[tokio::test] + async fn unresolvable_lock_file_path_is_refused() { + let (dir, blobs, installed, record) = fixture(false, None).await; + let root = dir.path(); + tokio::fs::write( + root.join("app.csproj"), + "$(BaseDir)app.lock.json", + ) + .await + .unwrap(); + let (code, detail) = + unwrap_refused(run_vendor(root, &blobs, &installed, &record, false).await); + assert_eq!(code, "vendor_nuget_lock_path_unresolved"); + assert!(detail.contains("app.csproj"), "{detail}"); + assert!(!root.join("nuget.config").exists()); + assert!(!root.join(".socket").exists()); + } + + /// A tampered lock record naming a path outside the root is refused. + #[tokio::test] + async fn revert_refuses_an_unsafe_lock_path() { + let dir = tempfile::tempdir().unwrap(); + let entry = entry_with_wiring( + UUID, + vec![WiringRecord { + file: "../outside/packages.lock.json".to_string(), + kind: LOCK_WIRING_KIND.to_string(), + action: WiringAction::Rewritten, + key: Some("Newtonsoft.Json".to_string()), + original: Some(Value::String("A==".to_string())), + new: Some(Value::String("B==".to_string())), + }], + ); + let outcome = revert_nuget(&entry, dir.path(), false).await; + assert!(!outcome.success); + assert!( + outcome + .error + .as_deref() + .is_some_and(|e| e.contains("unsafe wiring file path")), + "{:?}", + outcome.error + ); + } + /// #623: dotnet restores a BOM'd lock, so vendor pins it (the BOM kept) /// and revert restores it byte-identically. #[tokio::test] diff --git a/crates/socket-patch-core/src/vex/discover/nuget.rs b/crates/socket-patch-core/src/vex/discover/nuget.rs index 6a01a1fed..584b727bb 100644 --- a/crates/socket-patch-core/src/vex/discover/nuget.rs +++ b/crates/socket-patch-core/src/vex/discover/nuget.rs @@ -57,12 +57,16 @@ //! refuses is [`DIAG_LOCKFILE_UNPARSEABLE`]); a source listed in //! `` with `value="true"` wires nothing (diagnosed). //! +//! The lock is every lock the root config governs: the root +//! `packages.lock.json` plus, on disk, each lock a project under the root +//! restores into (member projects, `packages..lock.json`, a literal +//! `NuGetLockFilePath`; [`crate::formats::nuget::lock::governed_locks`], the +//! discovery both writers pin through). +//! //! Non-goals (documented, not guessed): parent-directory / user-level -//! configs and per-project locks below the root (the redirect rewriter only -//! edits the root pair too); `` inheritance semantics (neither -//! writer emits one); a non-Socket source that ALSO maps the exact id (the -//! lock's `contentHash` is what makes such a restore fail); custom -//! `NuGetLockFilePath` names. +//! configs; `` inheritance semantics (neither writer emits one); a +//! non-Socket source that ALSO maps the exact id (the lock's `contentHash` +//! is what makes such a restore fail). use std::collections::{BTreeMap, BTreeSet}; @@ -70,8 +74,8 @@ use serde_json::Value; use super::{ parse_json, simple_purl, socket_patch_name_uuid, vendor_ref, vendor_uuid_dir, DiscoverCtx, - Discovery, PatchedRef, UnlockedPin, WiringMode, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, - DIAG_REF_UNATTRIBUTABLE, + Discovery, PatchedRef, UnlockedPin, WiringMode, DIAG_LOCKFILE_UNPARSEABLE, + DIAG_LOCKFILE_UNREADABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; use crate::formats::nuget::lock::nuget_lock_entries; use crate::formats::nuget::{parse_config, NugetConfig}; @@ -281,42 +285,89 @@ enum Lock { Parsed(BTreeMap>), } +/// Every lock the root config governs: the root `packages.lock.json`, and on +/// disk each lock a project under the root restores into (#353, #514; the +/// writers pin all of them). Their entries are merged: one version's +/// `contentHash` must agree across them, as within one lock. async fn load_lock(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> Lock { - if !ctx.exists(PACKAGES_LOCK).await { - return Lock::Absent; + let mut rels = vec![PACKAGES_LOCK.to_string()]; + // On disk only (the in-memory engine refuses NuGet). Walked through the + // view, never its raw root: a re-scan's read cache keeps recording. + if !matches!( + ctx.view, + crate::vendor::lock_inventory::ProjectView::Memory(_) + ) { + // The writers refuse a tree whose locks they cannot all find; a + // reader that fell back to the root lock alone would take a pinned + // member lock for no lock at all, so it is unusable here too. + match crate::vendor::nuget_config::governed_locks_in(&ctx.view).await { + Ok(governed) => { + if let Some((project, detail)) = governed.unresolved.first() { + out.diag( + DIAG_LOCKFILE_UNREADABLE, + project, + format!("{project}: {detail}; its NuGet lock cannot be located"), + ); + return Lock::Unusable; + } + for rel in governed.locks { + if !rels.contains(&rel) { + rels.push(rel); + } + } + } + Err(why) => { + out.diag( + DIAG_LOCKFILE_UNREADABLE, + PACKAGES_LOCK, + format!("cannot list the project's NuGet locks: {why}"), + ); + return Lock::Unusable; + } + } } - let Some(bytes) = ctx.read_bytes(PACKAGES_LOCK, out).await else { - return Lock::Unusable; - }; - let doc: Value = match parse_json(PACKAGES_LOCK, &bytes) { - Ok(Value::Object(doc)) => Value::Object(doc), - Ok(_) => { - out.diag( - DIAG_LOCKFILE_UNPARSEABLE, - PACKAGES_LOCK, - format!("{PACKAGES_LOCK} is not a JSON object"), - ); - return Lock::Unusable; + let mut index: BTreeMap> = BTreeMap::new(); + let mut any = false; + for rel in &rels { + if !ctx.exists(rel).await { + continue; } - Err(detail) => { - out.diag(DIAG_LOCKFILE_UNPARSEABLE, PACKAGES_LOCK, detail); + any = true; + let Some(bytes) = ctx.read_bytes(rel, out).await else { return Lock::Unusable; - } - }; - let mut index: BTreeMap> = BTreeMap::new(); - for entry in nuget_lock_entries(&doc) { - let pins = index - .entry(entry.id.to_ascii_lowercase()) - .or_default() - .entry(entry.resolved.trim().to_string()) - .or_default(); - match entry.content_hash { - Some(hash) if !hash.trim().is_empty() => { - pins.hashes.insert(hash.trim().to_string()); + }; + let doc: Value = match parse_json(rel, &bytes) { + Ok(Value::Object(doc)) => Value::Object(doc), + Ok(_) => { + out.diag( + DIAG_LOCKFILE_UNPARSEABLE, + rel, + format!("{rel} is not a JSON object"), + ); + return Lock::Unusable; + } + Err(detail) => { + out.diag(DIAG_LOCKFILE_UNPARSEABLE, rel, detail); + return Lock::Unusable; + } + }; + for entry in nuget_lock_entries(&doc) { + let pins = index + .entry(entry.id.to_ascii_lowercase()) + .or_default() + .entry(entry.resolved.trim().to_string()) + .or_default(); + match entry.content_hash { + Some(hash) if !hash.trim().is_empty() => { + pins.hashes.insert(hash.trim().to_string()); + } + _ => pins.unpinned = true, } - _ => pins.unpinned = true, } } + if !any { + return Lock::Absent; + } Lock::Parsed(index) } @@ -1384,6 +1435,57 @@ mod tests { assert_eq!(diag_codes(&out), vec![DIAG_REF_INVALID]); } + /// #353 / #514: with no root lock, the version and pin come from the + /// member-project (or named) lock the root config governs. + #[tokio::test] + async fn hosted_version_and_pin_come_from_a_member_lock() { + let cfg = config( + &[(&socket_key(UUID_A), &index_url(UUID_A))], + &[(&socket_key(UUID_A), "Newtonsoft.Json")], + ); + for (project, lock_rel) in [ + ("src/App/App.csproj", "src/App/packages.lock.json"), + ("app.csproj", "packages.app.lock.json"), + ] { + let p = Project::new(); + p.write("nuget.config", &cfg); + p.write(project, ""); + p.write( + lock_rel, + lock(&[("net8.0", "Newtonsoft.Json", "13.0.1", Some(HASH))]), + ); + let out = run(&p).await; + assert_refs( + &out, + &[( + "pkg:nuget/newtonsoft.json@13.0.1", + UUID_A, + WiringMode::Hosted, + )], + ); + assert!(out.refs[0].lockfile_basis_ok(), "{lock_rel}"); + } + // A project whose lock cannot be located: no ref is read off a + // partial lock set (the writers refuse such a tree too). + let p = Project::new(); + p.write("nuget.config", &cfg); + p.write( + "app.csproj", + "$(X).json", + ); + p.write( + "packages.lock.json", + lock(&[("net8.0", "Newtonsoft.Json", "13.0.1", Some(HASH))]), + ); + let out = run(&p).await; + assert_refs(&out, &[]); + assert!( + diag_codes(&out).contains(&DIAG_LOCKFILE_UNREADABLE), + "{:?}", + diag_codes(&out) + ); + } + /// Two Socket sources mapping the same id are both emitted — precedence /// is the CLI's `wiring_conflict` gate, not the extractor's. #[tokio::test]