diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 1bab28023..dd67ce274 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -220,7 +220,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 as a `skipped` event with `errorCode` `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` (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`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`; the dep is skipped when a profile holds its only declaration), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; 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; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **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`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`; the dep is skipped when a profile holds its only declaration), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; 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`). @@ -833,7 +833,7 @@ to **six flavors**. | npm / bun (`bun.lock`, lockfileVersion 0, 1 or 2 — `vendor_lockfile_version_unsupported` otherwise) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`; the entry's `{deps}` meta, the lock's version line and its line endings are preserved. A lock holding `workspace:` packages is refused `vendor_bun_workspace_unsupported` unless lockfileVersion is 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the MEMBER (ENOENT on our root-relative path), 1.4 relative to the lockfile, and a committed version-2 lock is the only proof every consumer runs Bun ≥ 1.4 (a deliberate over-approximation: a package declared only by the workspace root would install on version 1 too). The gate fires only on a run that would WRITE a new local tuple, so in-sync re-runs, `already_vendored` skips and `repair` redownloads on such a lock pass. The detail names the version and the remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing lockfileVersion), or `--mode hosted`. Native binary support is described in the next row. `scan`/`get --mode vendored` apply all four refusals BEFORE downloading (see the `get --mode vendored` bullet). Bun 1.1.39–1.3.9 re-save the local tuple WITHOUT its `sha512` on any later lock re-save (`bun add`, `bun install` after a manifest change); the digest-less 2-tuple is recognised as the same wiring — an in-sync re-run stays `already_vendored` and re-pins the digest on disk (no new wiring record) when the committed artifact still holds the bytes the lock was written from — otherwise, as for any stale tuple of ours, the line is re-pinned and the fresh entry carries the new fingerprint — `repair` redownloads through it, and `vendor --revert` / `rollback` restore the registry line over it (a 2-tuple at ANOTHER uuid is still `vendor_lock_entry_drifted`) | `bun install --frozen-lockfile`, cold cache (the local tarball's sha512 is enforced by Bun ≥ 1.3.10; 1.1.39–1.3.9 install it unverified — the committed artifact is the protection there) | | npm / bun binary (`bun.lockb`, native binary format 1, 2 or 3) | (same tarball) | Rewrite matching binary package resolutions and integrity in place; preserve topology and unrelated metadata, update binary offsets and the package metadata hash. A second record of the same `name@version` (Bun writes one for a dependent added after vendoring) is folded into the tarball record — its dependents re-pointed, the record and its own dependency edges dropped and the hoisting trees re-derived with Bun's hoister — so no two records share a tarball resolution, which Bun's isolated linker fails to install (`EEXIST`); where that is not exact (the records' dependencies resolve to different packages, or the lock's hoisting is not one the codec reproduces) both are rewired with `vendor_bun_lockb_duplicate_records`. Text `bun.lock` takes precedence. `bun_lockb_package` wiring snapshots recover pristine registry metadata for repair and support per-package revert and hosted ↔ vendored migration. Binary discovery and rewrites require no installed Bun runtime. Malformed or unsupported content refuses `vendor_bun_lockb_invalid` before download or takeover. | Frozen installs with the original compatible Bun reader; see `docs/testing/bun-compatibility.md` for the release matrix and historical runtime integrity limits. | | npm / vlt (`vlt-lock.json`, lockfileVersion 0 or 1 — A0 locks without a version and every other version refuse `vendor_lockfile_version_unsupported`; flavor `vlt`) | patched package **directory** `.socket/vendor/npm//[@scope/]-/node_modules//` (the extra `node_modules/` level lets a package `require()` its own name), its `package.json` without `devDependencies`, plus `/.gitignore` (re-includes the payload against the project's ignores, ignores vlt's links inside it) and `/.gitattributes` (`-text`) | direct dependencies of the root or a workspace member only: the lock node becomes a `file` node for the directory, its importer edges and outgoing edges are re-keyed, and each importer's `package.json` spec becomes `file:`; every moved entry lands where vlt's serializer puts it. A node whose only extra is one peer context (`ṗ:N`, `peer.N`, `peer.<16 hex>`: from vlt 1.0.8 a root dependency with resolved peers, from rc.15 a workspace member's) becomes a `file` node without the extra, as vlt writes `file:` dependencies, keeping its peer edges; revert restores the extra-bearing DepID. Refused before any write: transitive targets (`vendor_vlt_transitive_unsupported`), two or more instances of one `name@version` or a modifier extra, importer `peer` edges, foreign registries, a git, remote-tarball or local-directory node of the same package name (vlt records no version for it), a package `vlt build` would build in place (`vendor_vlt_build_scripts_unsupported`), a name declared in several dependency fields (`vendor_lock_entry_unsupported`), a spec that disagrees with the lock (`vendor_vlt_lock_out_of_sync`), a payload git would ignore (`vendor_artifact_gitignored`), a purl vendored under another flavor (`vendor_flavor_changed`); era-A locks warn `vendor_vlt_legacy_lockfile`; an optional dependency (or any dependency node_modules still links to its installed upstream copy) gets `vendor_vlt_reinstall_required` | fresh checkout, `vlt ci` with cold caches: the patched bytes load and `vlt-lock.json` stays byte-identical, also through a warm and a cold `vlt install --frozen-lockfile` (checked on 1.2.0, 1.0.10, 1.0.4, 1.0.0-rc.32 and 1.0.0-rc.14, and on every release by `docs/testing/vlt-compatibility.md`); no-op installs, `vlt install `, `uninstall` and `vlt update` keep the direct dependency vendored. `vendor --revert` restores the registry node, edges and specs, keeping what vlt re-laid since, and refuses on drift | -| cargo | crate dir `-/` (no `.cargo-checksum.json`) | (v5.0) `[patch.crates-io]` path entry in the **workspace-root `Cargo.toml`** (the manifest beside the `Cargo.lock` it detaches — never `.cargo/config*`) **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed and its `version` set to the copy's TAGGED version `+socket.` — `+.socket.` when the version already has build metadata — with every lock reference that spells the old version rewritten, formats v1–v4; the copy's own `Cargo.toml` version carries the same tag, so the patched crate sees it in `CARGO_PKG_VERSION`; revert restores the lock byte for byte). Key: always the Socket-owned `-socket-` with `package = ""` (the full uuid hex when that key is taken), never the bare crate name — cargo lets a config-file `[patch]` item (project, ancestor directory or `$CARGO_HOME`) replace the manifest item with the same key whatever its version, so keys any of those configs use are avoided and a re-run moves an entry off a now-shadowed key; two versions of one crate are wired side by side. Pre-v5 wiring in `.cargo/config.toml` / `.cargo/config` is moved into `Cargo.toml` by a re-run (`vendor`, `scan`/`get --mode vendored`) or `repair` (`cargo_wiring_migrated` note; the ledger's `cargo_patch_entry` record then names `Cargo.toml`); a detached lock entry left unwired by the pre-v5 multi-version overwrite is re-wired the same way (`cargo_wiring_restored`); every revert removes both spellings | `cargo build --locked --offline` on a fresh checkout — single-version manifest `[patch]` also builds with no network on cargo older than 1.56 (the old config-file wiring's floor); two vendored versions of ONE crate need cargo 1.45 or newer (`--offline` from an empty CARGO_HOME is enough there); older cargo fails closed whatever the index state, and a project that does not pin cargo ≥ 1.45 (`rust-version` or toolchain file) gets the `cargo_multi_version_old_cargo` warning. Note: path deps build **without** `--cap-lints allow` | +| cargo | crate dir `-/` (no `.cargo-checksum.json`) | (v5.0) `[patch.crates-io]` path entry in the **workspace-root `Cargo.toml`** (the manifest beside the `Cargo.lock` it detaches — never `.cargo/config*`) **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed and its `version` set to the copy's TAGGED version `+socket.` — `+.socket.` when the version already has build metadata — with every lock reference that spells the old version rewritten, formats v1–v4; the copy's own `Cargo.toml` version carries the same tag, so the patched crate sees it in `CARGO_PKG_VERSION`; revert restores the lock byte for byte). Key: always the Socket-owned `-socket-` with `package = ""` (the full uuid hex when that key is taken), never the bare crate name — cargo lets a config-file `[patch]` item (project, ancestor directory or `$CARGO_HOME`) replace the manifest item with the same key whatever its version, so keys any of those configs use are avoided and a re-run moves an entry off a now-shadowed key; two versions of one crate are wired side by side. Pre-v5 wiring in `.cargo/config.toml` / `.cargo/config` is moved into `Cargo.toml` by a re-run (`vendor`, `scan`/`get --mode vendored`) or `repair` (`cargo_wiring_migrated` note; the ledger's `cargo_patch_entry` record then names `Cargo.toml`); a detached lock entry left unwired by the pre-v5 multi-version overwrite is re-wired the same way (`cargo_wiring_restored`); every revert removes both spellings | `cargo build --locked --offline` on a fresh checkout — single-version manifest `[patch]` also builds with no network on cargo older than 1.56 (the old config-file wiring's floor); two vendored versions of ONE crate need cargo 1.45 or newer (`--offline` from an empty CARGO_HOME is enough there); older cargo fails closed whatever the index state, and a project that does not pin cargo ≥ 1.45 (`rust-version`, a `rust-version.workspace = true` inheritor's `[workspace.package]` value, or toolchain file) gets the `cargo_multi_version_old_cargo` warning. Note: path deps build **without** `--cap-lints allow` | | golang | module dir `@/` | `go.mod` `replace => ./.socket/vendor/golang//@` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) | | composer | package dir `/@/`; the copy's `.gitignore` / `.hgignore` are emptied and its `.gitattributes` `export-ignore` rules dropped, because Composer's path mirror skips the files they match (`vendor_composer_mirror_filters_neutralized`; a patch that rewrites one of them is refused `vendor_composer_mirror_filter_conflict`). Re-runs heal copies vendored before this | `composer.lock` only: entry's `dist` → `{type: "path", url, reference: ""}`, `source` removed, `transport-options: {symlink: false}` added. `content-hash` unaffected; `composer.json` untouched | `composer install` (from the lock alone, real copy not symlink, works under `--network none`). Composer 1 does not reinstall an already-installed package whose dist changed: remove `//` first (the hint names the project's resolved vendor dir: `COMPOSER_VENDOR_DIR`, else `config.vendor-dir`, else `vendor`). `composer update ` reverts it. See `docs/testing/composer-compatibility.md` | | gem | gem dir `-/` + gemspec materialized from `specifications/` | **Gemfile + Gemfile.lock pair**: the `gem` line gains `path:` (or a managed block for transitive deps); the lock's spec block moves GEM→PATH and the DEPENDENCIES entry becomes ` (= )!`, in bundler's exact canonical form | `bundle install` (normal **and** `BUNDLE_FROZEN=true`), byte-stable lock. Lock-only edits are a silent unpatch — hence the mandatory pair | diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 069dc5ceb..a8dc24609 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -4223,6 +4223,8 @@ mod tests { "Cargo.lock", ".cargo/config.toml", ".cargo/config", + "rust-toolchain.toml", + "rust-toolchain", "composer.lock", "nuget.config", "NuGet.config", diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index 2733dd040..2c0c21bfd 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -1294,6 +1294,38 @@ async fn cargo_hosted_refuses_a_lockless_project_with_other_dependencies() { let _ = run_shape(shape).await; } +/// #653: the hosted redirect is a `sparse+` registry, which cargo loads +/// only from 1.68. A project whose `rust-version` declares cargo 1.67 is +/// refused, with nothing written, instead of getting pins every build on +/// its declared cargo then fails on (`usage of sparse registries requires +/// -Z sparse-registry`). (A toolchain file would make rustup fetch that +/// toolchain for the `cargo fetch` below; the planner unit tests cover it.) +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_refuses_a_project_declaring_cargo_below_1_68() { + let shape = Shape { + tag: "declared-cargo-1-67", + files: vec![ + ( + "Cargo.toml", + consumer_manifest("cfg-if = \"1.0.4\"\n").replace( + "edition = \"2018\"\n", + "edition = \"2018\"\nrust-version = \"1.67\"\n", + ), + ), + ("src/main.rs", "fn main() {}\n".to_string()), + ], + patches: vec![CFG_IF_1], + oracle: Vec::new(), + crlf: false, + lockless: false, + refused: Some("redirect_cargo_old_cargo"), + prefetch: "", + contest: None, + restored: vec![], + }; + let _ = run_shape(shape).await; +} + fn local_crate(name: &str, version: &str) -> String { format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2018\"\n") } diff --git a/crates/socket-patch-core/src/formats/cargo/floor.rs b/crates/socket-patch-core/src/formats/cargo/floor.rs new file mode 100644 index 000000000..7e0a4090e --- /dev/null +++ b/crates/socket-patch-core/src/formats/cargo/floor.rs @@ -0,0 +1,290 @@ +//! The lowest cargo a project TELLS us it builds on: the ONE reader of the +//! project's declared floor, shared by the vendored multi-version warning +//! and the hosted planner's sparse-registry gate. +//! +//! Read offline from the project itself, in this order: the workspace +//! root's `rust-version` (`[package]`, else `[workspace.package]`, and a +//! `rust-version.workspace = true` inheritor reads `[workspace.package]`), +//! else the toolchain rustup picks from the root: the legacy +//! `rust-toolchain` file when it exists (rustup prefers it over +//! `rust-toolchain.toml`, with a warning), else `rust-toolchain.toml`'s +//! `[toolchain] channel`. +//! +//! Everything here is pure; the callers own the reads. + +use toml_edit::{Document, Item, Table}; + +use super::manifest::package_table; + +/// The legacy extensionless toolchain file (a bare channel name, or the +/// TOML form). rustup reads it in preference to [`TOOLCHAIN_TOML`]. +pub(crate) const TOOLCHAIN_LEGACY: &str = "rust-toolchain"; +/// The TOML toolchain file. +pub(crate) const TOOLCHAIN_TOML: &str = "rust-toolchain.toml"; + +/// Days from 1970-01-01 to a proleptic Gregorian date. +fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { + let y = if month <= 2 { year - 1 } else { year }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/// The minor of a dated `nightly-YYYY-MM-DD` / `beta-YYYY-MM-DD` channel. +/// Rust ships a stable release every six weeks (1.68 on 2023-03-09, 1.45 +/// on 2020-07-16 — the train has not slipped since 1.0), and on that day +/// beta becomes the next minor and nightly the one after. +fn dated_channel_minor(spec: &str) -> Option { + let (ahead, date) = if let Some(date) = spec.strip_prefix("nightly-") { + (2, date) + } else if let Some(date) = spec.strip_prefix("beta-") { + (1, date) + } else { + return None; + }; + let mut parts = date.get(..10)?.split('-').map(str::parse::); + let (year, month, day) = ( + parts.next()?.ok()?, + parts.next()?.ok()?, + parts.next()?.ok()?, + ); + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + let since = days_from_civil(year, month, day) - days_from_civil(2023, 3, 9); + let stable = 68 + since.div_euclid(42); + u32::try_from(stable + ahead).ok() +} + +/// The `1.` of a `rust-version` or toolchain channel (`1.67`, +/// `1.67.1`, `1.67-x86_64-unknown-linux-gnu`, or a dated +/// `nightly-2022-06-01`); `None` for a rolling channel (`stable`, +/// `nightly`, …) or anything else. +fn minor_of(spec: &str) -> Option { + let spec = spec.trim().trim_matches('"'); + let Some(rest) = spec.strip_prefix("1.") else { + return dated_channel_minor(spec); + }; + rest.split(['.', '-', '+']).next()?.parse().ok() +} + +/// `[workspace.package].rust-version` of a parsed root manifest. +fn workspace_rust_version(root: &Table) -> Option<&str> { + root.get("workspace") + .and_then(|w| w.get("package")) + .and_then(|p| p.get("rust-version")) + .and_then(Item::as_str) +} + +/// The package's own `rust-version`: `Ok(Some)` for a literal, `Err(())` +/// for an inheritor (`rust-version.workspace = true`, or any non-string +/// value), `Ok(None)` when it has none. +fn own_rust_version(root: &Table) -> Result, ()> { + match package_table(root).and_then(|p| p.get("rust-version")) { + None => Ok(None), + Some(item) => item.as_str().map(Some).ok_or(()), + } +} + +/// The `rust-version` the root `Cargo.toml` declares. `[package]`'s own +/// string wins; an inheritor, a package without one, or a virtual +/// workspace reads `[workspace.package]`. +pub(crate) fn root_manifest_minor(text: &str) -> Option { + let doc = Document::parse(text).ok()?; + let root = doc.as_table(); + match own_rust_version(root) { + Ok(Some(own)) => Some(own), + _ => workspace_rust_version(root), + } + .and_then(minor_of) +} + +/// The `rust-version` a workspace member declares: its own literal, or the +/// root's `[workspace.package]` value when it inherits it. A member with +/// none declares nothing (cargo does not inherit implicitly). +pub(crate) fn member_manifest_minor(member: &str, root: Option<&str>) -> Option { + let doc = Document::parse(member).ok()?; + match own_rust_version(doc.as_table()) { + Ok(Some(own)) => minor_of(own), + Ok(None) => None, + Err(()) => { + let root = Document::parse(root?).ok()?; + workspace_rust_version(root.as_table()).and_then(minor_of) + } + } +} + +/// The `[toolchain] channel` of a TOML toolchain file. +fn toolchain_channel(text: &str) -> Option { + let doc = Document::parse(text).ok()?; + doc.get("toolchain") + .and_then(|t| t.get("channel")) + .and_then(Item::as_str) + .map(str::to_string) +} + +/// The minor of the toolchain rustup picks from the root's toolchain files +/// (`None` for a file that is absent or unreadable): the legacy file when +/// it exists, else the TOML one. A blank legacy text names no toolchain +/// (the hosted engine records an unreadable in-memory entry as `""`), so +/// it does not hide the TOML file's channel. +pub(crate) fn toolchain_minor( + toolchain_toml: Option<&str>, + toolchain_legacy: Option<&str>, +) -> Option { + if let Some(legacy) = toolchain_legacy.filter(|t| !t.trim().is_empty()) { + return match toolchain_channel(legacy) { + Some(channel) => minor_of(&channel), + None => minor_of(legacy), + }; + } + minor_of(&toolchain_channel(toolchain_toml?)?) +} + +/// The lowest cargo minor the project declares, from the root `Cargo.toml` +/// ([`root_manifest_minor`]), else its toolchain files +/// ([`toolchain_minor`]). +/// +/// `None` when the project declares nothing, or pins a rolling channel — +/// socket-patch never runs `cargo`, so there is no other signal, and "no +/// declaration" is NOT evidence of a modern cargo. +pub(crate) fn declared_cargo_minor( + manifest: Option<&str>, + toolchain_toml: Option<&str>, + toolchain_legacy: Option<&str>, +) -> Option { + manifest + .and_then(root_manifest_minor) + .or_else(|| toolchain_minor(toolchain_toml, toolchain_legacy)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn from_manifest(text: &str) -> Option { + declared_cargo_minor(Some(text), None, None) + } + + #[test] + fn reads_package_and_workspace_rust_version() { + assert_eq!( + from_manifest("[package]\nname = \"a\"\nrust-version = \"1.70\"\n"), + Some(70) + ); + assert_eq!( + from_manifest("\u{feff}[package]\nname = \"a\"\nrust-version = \"1.67.1\"\n"), + Some(67) + ); + assert_eq!( + from_manifest("[workspace]\n\n[workspace.package]\nrust-version = \"1.60\"\n"), + Some(60) + ); + // The package's own value wins over the workspace default. + assert_eq!( + from_manifest( + "[workspace.package]\nrust-version = \"1.60\"\n\n\ + [package]\nname = \"a\"\nrust-version = \"1.75\"\n" + ), + Some(75) + ); + assert_eq!(from_manifest("[package]\nname = \"a\"\n"), None); + assert_eq!(from_manifest("not toml ["), None); + } + + /// #651: `rust-version.workspace = true` inherits `[workspace.package]` + /// — in the dotted spelling and the inline-table one. + #[test] + fn workspace_inheritor_reads_workspace_package() { + for spelling in [ + "rust-version.workspace = true", + "rust-version = { workspace = true }", + ] { + let text = format!( + "[workspace]\n\n[workspace.package]\nrust-version = \"1.70\"\n\n\ + [package]\nname = \"consumer\"\nversion = \"0.1.0\"\n{spelling}\n" + ); + assert_eq!(from_manifest(&text), Some(70), "{spelling}"); + } + } + + #[test] + fn falls_back_to_toolchain_files() { + let toml = "[toolchain]\nchannel = \"1.67.1\"\n"; + assert_eq!(declared_cargo_minor(None, Some(toml), None), Some(67)); + assert_eq!(declared_cargo_minor(None, None, Some("1.56.0\n")), Some(56)); + assert_eq!(declared_cargo_minor(None, None, Some(toml)), Some(67)); + assert_eq!(declared_cargo_minor(None, None, Some("stable\n")), None); + // rustup prefers the legacy file when both exist. + let stable = "[toolchain]\nchannel = \"stable\"\n"; + assert_eq!( + declared_cargo_minor(None, Some(stable), Some("1.50\n")), + Some(50) + ); + assert_eq!( + declared_cargo_minor(None, Some(toml), Some("stable\n")), + None + ); + // A blank legacy placeholder (an unreadable entry) is no toolchain. + for blank in ["", " \n"] { + assert_eq!( + declared_cargo_minor(None, Some(toml), Some(blank)), + Some(67) + ); + } + // `rust-version` wins over the toolchain files. + assert_eq!( + declared_cargo_minor( + Some("[package]\nname = \"a\"\nrust-version = \"1.70\"\n"), + Some(toml), + None + ), + Some(70) + ); + } + + /// A dated nightly or beta pins the cargo of that day's release train. + #[test] + fn dated_channels_map_to_their_release_train() { + let cases = [ + ("nightly-2022-06-01", Some(63)), + ("beta-2022-06-01", Some(62)), + // The day 1.68 shipped: nightly is 1.70, beta 1.69. + ("nightly-2023-03-09", Some(70)), + ("beta-2023-03-08", Some(68)), + ("nightly-2020-07-16", Some(47)), + ("nightly-2024-13-01", None), + ("nightly", None), + ("stable", None), + ]; + for (channel, want) in cases { + let toml = format!("[toolchain]\nchannel = \"{channel}\"\n"); + assert_eq!( + declared_cargo_minor(None, Some(&toml), None), + want, + "{channel}" + ); + } + } + + #[test] + fn a_member_declares_its_own_or_its_inherited_rust_version() { + let root = "[workspace]\n\n[workspace.package]\nrust-version = \"1.60\"\n"; + let member = |rv: &str| format!("[package]\nname = \"m\"\n{rv}\n"); + assert_eq!( + member_manifest_minor(&member("rust-version = \"1.64\""), Some(root)), + Some(64) + ); + assert_eq!( + member_manifest_minor(&member("rust-version.workspace = true"), Some(root)), + Some(60) + ); + assert_eq!(member_manifest_minor(&member(""), Some(root)), None); + assert_eq!( + member_manifest_minor(&member("rust-version.workspace = true"), None), + None + ); + } +} diff --git a/crates/socket-patch-core/src/formats/cargo/mod.rs b/crates/socket-patch-core/src/formats/cargo/mod.rs index cc922f8a3..4671b49d7 100644 --- a/crates/socket-patch-core/src/formats/cargo/mod.rs +++ b/crates/socket-patch-core/src/formats/cargo/mod.rs @@ -21,10 +21,13 @@ //! * the vendored planner (`vendor::cargo_lock`) edits the same document //! with `toml_edit`; //! -//! [`manifest`] is the one reader of a `Cargo.toml`'s `[package]` identity. +//! [`manifest`] is the one reader of a `Cargo.toml`'s `[package]` identity, +//! and [`floor`] the one reader of the cargo the project declares it builds +//! on (`rust-version`, `rust-toolchain[.toml]`). //! //! Everything here is pure; the callers own the reads. +pub(crate) mod floor; pub(crate) mod hosted; pub mod manifest; pub mod source_replacement; diff --git a/crates/socket-patch-core/src/formats/registry.rs b/crates/socket-patch-core/src/formats/registry.rs index ff65ee51e..4db99f267 100644 --- a/crates/socket-patch-core/src/formats/registry.rs +++ b/crates/socket-patch-core/src/formats/registry.rs @@ -122,6 +122,11 @@ const REGISTRY: &[FormatFile] = &[ // must see it (it wires the managed registry into whichever one is // present); vendored wiring before v5 lived there too. row(".cargo/config", "cargo", HOSTED | VENDORED | PROBE), + // Never edited: the toolchain the project pins is its declared cargo + // floor when `Cargo.toml` has no `rust-version`, which the hosted + // planner checks against the sparse-registry floor (cargo 1.68). + row("rust-toolchain.toml", "cargo", HOSTED | PRESENCE_ONLY), + row("rust-toolchain", "cargo", HOSTED | PRESENCE_ONLY), // ── composer ── row("composer.json", "composer", VENDORED), row( diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 0e18639eb..3b63c1cf7 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -3356,6 +3356,33 @@ mod tests { (vec![(purl.to_string(), uuid.to_string())], refs) } + /// #653: the toolchain files are the cargo planner's declared-floor + /// input when `Cargo.toml` has no `rust-version`, so they are read + /// (never edited, and naming no ecosystem to refuse). + #[tokio::test] + async fn the_cargo_toolchain_files_are_read_as_advisory_input() { + let (selected, refs) = cargo_reference("u-1"); + let mut p = MemoryProject::new(); + p.insert_text("Cargo.toml", "[dependencies]\nserde = \"1\"\n"); + p.insert_text("rust-toolchain.toml", "[toolchain]\nchannel = \"1.67.1\"\n"); + p.insert_text("rust-toolchain", "1.56.0\n"); + let mut skipped = Vec::new(); + let candidates = build_candidates(&selected, &refs, &mut skipped); + let view = ProjectView::Memory(&p); + let read = read_candidate_files(&view, &BTreeSet::new(), &candidates).await; + assert_eq!( + read.files.get("rust-toolchain.toml").map(String::as_str), + Some("[toolchain]\nchannel = \"1.67.1\"\n") + ); + assert_eq!( + read.files.get("rust-toolchain").map(String::as_str), + Some("1.56.0\n") + ); + for rel in ["rust-toolchain.toml", "rust-toolchain"] { + assert_eq!(crate::formats::registry::hosted_file_ecosystem(rel), None); + } + } + #[tokio::test] async fn an_unreadable_candidate_file_refuses_its_ecosystem() { let (selected, refs) = cargo_reference("u-1"); diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 5d126f487..14183398c 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -1651,6 +1651,10 @@ fn rewrite_npm_entry( }) } +/// The first stable cargo that loads a `sparse+` registry — the hosted +/// redirect's source kind. +const CARGO_SPARSE_MINOR: u32 = 68; + // ── cargo (Cargo.toml + .cargo/config.toml + Cargo.lock) ───────────────────── // // TRANSACTIONAL per dependency: a dep is redirected ONLY if its Cargo.toml pin @@ -1802,8 +1806,62 @@ fn rewrite_cargo_with( // The manifests as they arrived: a generation they pin that the final // manifests no longer do was superseded by this run (see below). let manifests_before: Vec = manifests.iter().map(|(_, t)| t.clone()).collect(); + // The redirect is a `sparse+` registry, which cargo loads only from + // 1.68: older cargo refuses the source ("usage of sparse registries + // requires `-Z sparse-registry`"), so a project that declares an older + // cargo would fail every build on the pins this run writes. Whichever + // package is built, cargo loads every source the lock names, so the + // lowest floor among the root and the members read here governs. An + // undeclared floor is no evidence either way and still redirects. + let declared_floor = { + use crate::formats::cargo::floor; + let root = manifests + .iter() + .find(|(k, _)| k == "Cargo.toml") + .map(|(_, t)| t.as_str()); + let root_floor = floor::declared_cargo_minor( + root, + files.get(floor::TOOLCHAIN_TOML).map(String::as_str), + files.get(floor::TOOLCHAIN_LEGACY).map(String::as_str), + ); + manifests + .iter() + .filter(|(k, _)| k != "Cargo.toml") + .filter_map(|(_, member)| floor::member_manifest_minor(member, root)) + .chain(root_floor) + .min() + .filter(|minor| *minor < CARGO_SPARSE_MINOR) + }; for dep in &cargo { + if let Some(minor) = declared_floor { + // An earlier run's pins stay as they are (this refusal writes + // nothing), and they fail on that cargo too: say how to drop them. + let pinned = manifests + .iter() + .any(|(_, t)| CargoRegistryPins::of(t).pin_for(&dep.name).is_some()); + let earlier = if pinned { + format!( + " The pins an earlier run wrote for {} are still in place and fail on \ + that cargo too: `socket-patch rollback pkg:cargo/{}@{}` removes them.", + dep.name, dep.name, dep.version + ) + } else { + String::new() + }; + result.warnings.push(RewriteWarning { + code: "redirect_cargo_old_cargo".into(), + detail: format!( + "{} not redirected: this project declares cargo 1.{minor} (`rust-version` \ + or `rust-toolchain`), but the hosted patch is served from a sparse \ + registry, which needs cargo 1.{CARGO_SPARSE_MINOR} or newer; nothing \ + rewritten. Use `--mode vendored` (builds on cargo 1.41+), or raise the \ + declared cargo to 1.{CARGO_SPARSE_MINOR}+.{earlier}", + dep.name + ), + }); + continue; + } let Some(ov) = registry_override_of_kind(dep, "cargo-sparse") else { result.warnings.push(RewriteWarning { code: "redirect_cargo_missing_override".into(), @@ -13950,6 +14008,133 @@ mod tests { assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); } + /// #653: the hosted redirect is a `sparse+` registry, which cargo loads + /// only from 1.68. A project that declares an older cargo (its + /// `rust-version`, inherited or not, or a toolchain file) is refused + /// with nothing written, instead of a redirect every build then fails + /// on. A newer or undeclared cargo still redirects. + #[test] + fn cargo_declared_floor_below_sparse_support_is_refused() { + let app = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n"; + let deps = "\n[dependencies]\nserde = \"1.0.190\"\n"; + /// `(case, Cargo.toml, a toolchain file to add)`. + type Case = (&'static str, String, Option<(&'static str, &'static str)>); + let old: [Case; 5] = [ + ( + "rust-version", + format!("{app}rust-version = \"1.67\"\n{deps}"), + None, + ), + ( + "inherited rust-version", + format!( + "[workspace]\n\n[workspace.package]\nrust-version = \"1.60\"\n\n\ + {app}rust-version.workspace = true\n{deps}" + ), + None, + ), + ( + "rust-toolchain.toml", + format!("{app}{deps}"), + Some(("rust-toolchain.toml", "[toolchain]\nchannel = \"1.67.1\"\n")), + ), + ( + "rust-toolchain", + format!("{app}{deps}"), + Some(("rust-toolchain", "1.56.0\n")), + ), + ( + "CRLF rust-version", + format!("{app}rust-version = \"1.67\"\n{deps}").replace('\n', "\r\n"), + None, + ), + ]; + for (case, toml, toolchain) in old { + let mut files = cargo_files(&toml); + if let Some((path, text)) = toolchain { + files.insert(path.to_string(), text.to_string()); + } + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert_eq!( + r.warnings + .iter() + .map(|w| w.code.as_str()) + .collect::>(), + vec!["redirect_cargo_old_cargo"], + "{case}: {:?}", + r.warnings + ); + let detail = &r.warnings[0].detail; + assert!( + detail.contains("1.68") && detail.contains("--mode vendored"), + "{case}: {detail}" + ); + assert!(r.files.is_empty(), "{case}: nothing written: {:?}", r.files); + assert!(r.edits.is_empty(), "{case}: {:?}", r.edits); + assert!(r.confirmed_cargo_uuids.is_empty(), "{case}"); + } + + // A workspace member pinned by this run declares the floor too. + let mut files = cargo_files(&format!("[workspace]\nmembers = [\"b\"]\n\n{app}{deps}")); + files.insert( + "b/Cargo.toml".to_string(), + format!( + "[package]\nname = \"b\"\nversion = \"0.1.0\"\nrust-version = \"1.64\"\n{deps}" + ), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert_eq!( + r.warnings + .iter() + .map(|w| w.code.as_str()) + .collect::>(), + vec!["redirect_cargo_old_cargo"], + "member floor: {:?}", + r.warnings + ); + assert!( + r.warnings[0].detail.contains("cargo 1.64"), + "{:?}", + r.warnings + ); + assert!(r.files.is_empty(), "{:?}", r.files); + + // Pins an earlier run wrote stay put, and the refusal says how to + // drop them. + let redirected = rewrite_registry_redirect( + &cargo_files(&format!("{app}{deps}")), + &[cargo_sparse_override()], + ); + let mut files = cargo_files(&format!("{app}{deps}")); + files.extend(redirected.files); + let pinned = files["Cargo.toml"].replacen( + "version = \"0.1.0\"\n", + "version = \"0.1.0\"\nrust-version = \"1.67\"\n", + 1, + ); + files.insert("Cargo.toml".to_string(), pinned); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings); + assert!( + r.warnings[0] + .detail + .contains("socket-patch rollback pkg:cargo/serde@1.0.190"), + "{:?}", + r.warnings + ); + assert!(r.files.is_empty(), "{:?}", r.files); + assert!(r.confirmed_cargo_uuids.is_empty()); + + for (case, toml) in [ + ("1.68", format!("{app}rust-version = \"1.68\"\n{deps}")), + ("undeclared", format!("{app}{deps}")), + ] { + let r = rewrite_registry_redirect(&cargo_files(&toml), &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "{case}: {:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID), "{case}"); + } + } + /// The residue probe (`vendor`'s fail-closed guard against wiring /// `[patch.crates-io]` on top of a live hosted redirect) reads back /// EVERY declaration shape this rewriter pins — driven through the diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index d12b010ce..48ed75522 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -26,6 +26,7 @@ use std::path::{Path, PathBuf}; +use crate::formats::cargo::floor; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; use crate::patch::copy_tree::remove_tree; @@ -70,62 +71,25 @@ use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, Vendo const MULTI_VERSION_CARGO_MINOR: u32 = 45; /// The lowest cargo minor this project TELLS us it must build on, read -/// offline from the project itself: the workspace root's `rust-version` -/// (`[package]`, else `[workspace.package]`), else `rust-toolchain.toml`'s -/// `[toolchain] channel`, else the legacy plain-text `rust-toolchain` file. -/// -/// `None` when the project declares nothing, or pins a rolling channel -/// (`stable`, `nightly`, …) — socket-patch never runs `cargo`, so there is -/// no other signal, and "no declaration" is NOT evidence of a modern cargo. +/// offline from the project root ([`floor::declared_cargo_minor`]); the +/// toolchain files are read only when `Cargo.toml` declares nothing. async fn declared_cargo_minor(project_root: &Path) -> Option { - fn minor_of(spec: &str) -> Option { - let spec = spec.trim().trim_matches('"'); - let rest = spec.strip_prefix("1.")?; - rest.split(['.', '-', '+']).next()?.parse().ok() - } - if let Ok(text) = read_regular_to_string(&project_root.join(cargo_manifest::CARGO_TOML)).await { - if let Ok(doc) = text.parse::() { - let rust_version = doc - .get("package") - .and_then(|p| p.get("rust-version")) - .or_else(|| { - doc.get("workspace") - .and_then(|w| w.get("package")) - .and_then(|p| p.get("rust-version")) - }) - .and_then(|v| v.as_str()) - .and_then(minor_of); - if rust_version.is_some() { - return rust_version; - } - } - } - if let Ok(text) = read_regular_to_string(&project_root.join("rust-toolchain.toml")).await { - if let Ok(doc) = text.parse::() { - if let Some(channel) = doc - .get("toolchain") - .and_then(|t| t.get("channel")) - .and_then(|v| v.as_str()) - { - return minor_of(channel); - } - } - } - // The legacy file is a bare channel name, but rustup also accepts the - // TOML form under this name. - if let Ok(text) = read_regular_to_string(&project_root.join("rust-toolchain")).await { - if let Ok(doc) = text.parse::() { - if let Some(channel) = doc - .get("toolchain") - .and_then(|t| t.get("channel")) - .and_then(|v| v.as_str()) - { - return minor_of(channel); - } - } - return minor_of(&text); + let read = |name: &str| project_root.join(name); + let manifest = read_regular_to_string(&read(cargo_manifest::CARGO_TOML)).await; + if let Some(minor) = manifest + .ok() + .as_deref() + .and_then(floor::root_manifest_minor) + { + return Some(minor); } - None + let toolchain_toml = read_regular_to_string(&read(floor::TOOLCHAIN_TOML)) + .await + .ok(); + let toolchain_legacy = read_regular_to_string(&read(floor::TOOLCHAIN_LEGACY)) + .await + .ok(); + floor::toolchain_minor(toolchain_toml.as_deref(), toolchain_legacy.as_deref()) } /// A warning when THIS vendor puts a SECOND version of `name` behind @@ -2149,6 +2113,25 @@ mod tests { assert!(warning.detail.contains("pins cargo 1.41"), "{warning:?}"); } + /// #651: a root package that inherits its floor with + /// `rust-version.workspace = true` declares `[workspace.package]`'s + /// `rust-version`, not nothing — no multi-version warning for 1.70. + #[tokio::test] + async fn a_workspace_inherited_rust_version_is_the_declared_floor() { + let two = patch_table(&[("1.0.4", UUID), ("0.1.10", UUID2)]); + let inherited = two.replacen( + "[package]\n", + "[workspace]\n\n[workspace.package]\nrust-version = \"1.70\"\n\n\ + [package]\nrust-version.workspace = true\n", + 1, + ); + let dir = toolchain_fixture(&[("Cargo.toml", &inherited)]).await; + assert_eq!(declared_cargo_minor(dir.path()).await, Some(70)); + assert!(multi_version_warning(dir.path(), "cfg-if", "1.0.4") + .await + .is_none()); + } + /// The path of the root manifest's Socket-owned `[patch.crates-io]` /// entry for cfg-if (any key), if any. async fn manifest_path(root: &Path) -> Option { diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 7d23a3b40..4539b58f3 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -16,7 +16,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. |-----------|------------------------|------------------------------|--------------------------| | npm (`npm`) — pnpm / yarn / berry / bun / vlt | ✅ any install layout, vlt's `node_modules/.vlt` store included (every store copy, copy-on-write) | ✅ seven lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun text `bun.lock` lockfileVersion 0/1/2 and native binary `bun.lockb` revisions 1/2/3 (binary locks stay binary; text workspace vendoring requires lockfileVersion 2 — see [Bun compatibility](testing/bun-compatibility.md)), vlt `vlt-lock.json` lockfileVersion 0/1 (patched package directories for direct dependencies of the root or a workspace member; transitive targets refused — see [vlt notes](#npm-vlt-notes)). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml and legacy shrinkwrap.yaml (pnpm majors 1–12; block and flow resolutions), yarn classic, yarn berry, bun, vlt (`vlt-lock.json` without `lockfileVersion`, 0 or 1) — pnpm, berry, bun and vlt carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) and [vlt notes](#npm-vlt-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ in place | ✅ uv project/script locks, PEP 751 `pylock.toml` / `pylock..toml`, poetry, pdm, pipenv (Pipenv 2018 or later — every `Pipfile.lock` category is rewired, lock-only checkouts included; Pipenv 2023+ does not hash-check local wheels — `vendor_integrity_unverified`; a venv still holding the upstream release is reported as `pypi_pipenv_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)), and requirements.txt. Native uv vendoring requires uv ≥ 0.2.35 (the `[[package]]` lock grammar); hosted mode covers native `uv.lock` from uv 0.1.45 (the first release whose `uv lock` writes one) and requirements from uv 0.0.5; see [uv compatibility](testing/uv-compatibility.md). | ✅ requirements.txt including hash continuations, uv project/script locks, and PEP 751 locks. Version/source ambiguity is refused; see [uv compatibility](testing/uv-compatibility.md). Poetry 1.x and 2.x locks are supported; Poetry 0.x ignores URL sources and is refused. See [Poetry compatibility](testing/poetry-compatibility.md). Pipenv `Pipfile.lock` (pipfile-spec 6 — Pipenv 7 and later; `path` references for 7–11, `file` from 2018; lock-only checkouts and Pipenv's out-of-tree venv are discovered; a warm venv that Pipenv will not reinstall over warns `redirect_pypi_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)). `pdm.lock` is supported for the lock formats PDM 0.12–1.4 and 2.8.1+ write (`lock_version` 2 / 4.3–4.5.1); the identity-losing 3.1 / 4.0–4.2 formats (PDM 1.8–2.7) are refused. PDM 2.8.0 writes an indistinguishable `4.3` lock but shares that identity-loss bug, so a rewritten 2.8.0 lock crashes `pdm sync` — upgrade to ≥ 2.8.1. See [PDM compatibility](testing/pdm-compatibility.md). | -| Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry in the root `Cargo.toml` (v5; per-version Socket keys; pre-v5 `.cargo/config*` wiring migrates on re-run) | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum); direct dependencies only — a crate another dependency also pulls in is refused, use `--mode vendored`; a crate the user overrides with `[patch]` (path, git or another registry) is refused too, the override is left alone; a `cargo vendor` or `cargo local-registry` project (crates.io replaced by an on-disk source in the project's, a parent directory's or `$CARGO_HOME`'s `.cargo/config*`) is refused, use agent mode; with no `Cargo.lock` the graph is unknown, so only a project whose sole dependency is the patched crate is redirected | +| Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry in the root `Cargo.toml` (v5; per-version Socket keys; pre-v5 `.cargo/config*` wiring migrates on re-run) | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum); needs cargo 1.68+ (sparse registries) — a project whose `rust-version` or `rust-toolchain[.toml]` declares an older cargo is refused (`redirect_cargo_old_cargo`), use `--mode vendored`; direct dependencies only — a crate another dependency also pulls in is refused, use `--mode vendored`; a crate the user overrides with `[patch]` (path, git or another registry) is refused too, the override is left alone; a `cargo vendor` or `cargo local-registry` project (crates.io replaced by an on-disk source in the project's, a parent directory's or `$CARGO_HOME`'s `.cargo/config*`) is refused, use agent mode; with no `Cargo.lock` the graph is unknown, so only a project whose sole dependency is the patched crate is redirected | | RubyGems (`gem`) | ✅ in place | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` twin, which bundler ≥ 2 loads instead, or a `BUNDLE_GEMFILE`-configured manifest makes vendoring refuse with `gemfile_not_loaded` before any write) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` or `Gemfile` + `Gemfile.lock`, whichever the project holds (a `Gemfile` + `gems.rb` twin is refused with `redirect_gem_twin_manifest_ambiguous`: bundler 1.x loads the `Gemfile`, bundler ≥ 2 loads `gems.rb`, and nothing in the project says which bundler installs it; bundler 4's `BUNDLE_LOCKFILE` (environment, app config or global config) naming any other lock is refused with `redirect_gem_bundle_lockfile_unsupported`, as is vendoring with `gemfile_not_loaded`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`; `BUNDLE_GEMFILE` from `.bundle/config` (which outranks the environment, as in bundler), the environment, or the global `~/.bundle/config` / `$BUNDLE_USER_CONFIG` (lowest, as in bundler) is followed when it names the project's `Gemfile` / `gems.rb`, and any other configured manifest is refused with `redirect_gem_bundle_gemfile_unsupported`); a Bundler all-source, exact-source or hostname mirror in app config or the scan environment can capture the patch registry, so the redirect is refused with `redirect_gem_mirror_overrides_source` without printing mirror URLs (scope mirrors to `mirror.https://rubygems.org`; user-global config and mirrors set only in a later install environment are not inspected); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | | Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ✅ (free tier) fork-style `replace` → `patch.socket.dev/gopatch/` + committed `go.sum` pin; see [Go notes](#go-directory-replaces-and-gosum). Paid hosted patches are unsupported; `redirect_golang_unsupported` names the vendored remedy | | Maven (`maven`) — Maven and Gradle | ✅ in place in every copy the build consumes: each `~/.m2` copy it reads and each Gradle `files-2.1` copy; `~/.m2` `.sha1`/`.md5` sidecars are rewritten, Gradle copies get advisories; jar-member records swap in the patch service's whole jar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) and [Gradle](#gradle) | ✅ suffixed Maven repository (`-socket.` pin + `.mvn/maven.config` + fallback file repository) for every pom root, single-module or reactor, or Gradle 6.8+ same-GAV repository with settings wiring and SHA-256 checks (a root with both `pom.xml` and a Gradle build wires both); see [JVM vendoring](design/maven-vendoring.md) and [Gradle](#gradle) | ✅ fail-closed by a Socket-only `-socket.` suffix: pom projects get a pinned `` (`${property}` versions are refused); Gradle 6.8+ builds get an owned settings script, lock-entry rewrites and a resolution tripwire — see [Maven & NuGet caveats](#maven--nuget-caveats) and [Gradle](#gradle) | @@ -1215,8 +1215,9 @@ version = "1.0.4+socket." 1.42, 1.43 and 1.44 refuse the adversarial order; 1.45, 1.49, 1.53, 1.56 and current stable resolve either order, each lock entry to its own copy. Vendoring a second version of a crate therefore warns - (`cargo_multi_version_old_cargo`) unless the project's `rust-version` or - `rust-toolchain[.toml]` promises cargo 1.45+ — socket-patch never runs + (`cargo_multi_version_old_cargo`) unless the project's `rust-version` + (its own, or `[workspace.package]`'s through `rust-version.workspace = + true`) or `rust-toolchain[.toml]` promises cargo 1.45+ — socket-patch never runs `cargo`, so those files are the only signal it has. On an old cargo, `cargo build --offline` from an empty `$CARGO_HOME` is enough; without `--offline` it loads the crates.io index first and fails when that is