diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26fa1954..fea73c77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1455,7 +1455,7 @@ jobs: SOCKET_PATCH_CARGO_E2E_LOCK_VERSION: ${{ matrix.lock }} run: | set -euo pipefail - cargo test -p socket-patch-cli --test e2e_redirect_cargo_build --test e2e_vendor_cargo_build --test mode_migration_cargo + cargo test -p socket-patch-cli --test e2e_redirect_cargo_build --test e2e_redirect_cargo_shapes --test e2e_vendor_cargo_build --test mode_migration_cargo cargo test -p socket-patch-cli --test e2e_safety_cargo_build -- --ignored # Manifest `[patch]` + the tagged detached lock (the v5 vendored cargo diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c1f2d5a..21874c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -967,6 +967,41 @@ into the new version's section — see docs/releasing.md. vendored rewrites now follow the `[metadata]` checksum table and rewrite dependents' full-id references, so `cargo --locked` accepts the lock (and the revert stays byte-identical). +- **Hosted cargo pins every declaration of the patched version.** Each + version of a multi-version crate is pinned only in the declarations whose + requirement selects it (a requirement matching several locked versions is + refused `redirect_cargo_toml_dep_unrewritable`), and workspace-member and + in-root path-dependency manifests are pinned beside the root, so + `cargo --locked` accepts the redirected lock. Member discovery never + follows a symbolic link, so nothing outside the project is rewritten. +- **Hosted cargo refuses crates a pin cannot reach.** A crate another + `Cargo.lock` package also depends on (a crates.io or git crate, or a path + package outside the project) now warns + `redirect_cargo_transitive_dependents` and is skipped instead of being + reported redirected while that package compiled the unpatched copy; a + transitive-only crate's `redirect_cargo_toml_dep_not_found` detail now + says so and points to `--mode vendored`. A crate declared only with + requirements the patched version does not satisfy (cargo resolves those + declarations to another version) is refused + `redirect_cargo_toml_dep_unrewritable`, the Socket backend's code for the + same shape, instead of `redirect_cargo_toml_dep_not_found`. A project with + NO `Cargo.lock` has no resolved graph to ask, so a crate declared beside + any other dependency — anything but a path dependency on a manifest the + same run pins — or beside a workspace member this run did not read (a + glob, a member outside the project or behind a symbolic link) is refused + `redirect_cargo_lockless_dependents` (commit a lockfile, or use `--mode + vendored`); a project whose only dependency is the patched crate has + nothing that could pull it in and still redirects. +- **CRLF cargo projects redirect in hosted mode.** All-CRLF `Cargo.toml`, + `Cargo.lock` and cargo configs are rewritten with their endings kept + (they were refused), and `remove` / rollback still find the recorded + edits after a checkout converts the line endings. +- **Hosted cargo `remove` restores every byte, in any order.** An appended + registry block leaves the user's config exactly as it was (trailing blank + lines or a missing final newline included) and a created config is + deleted with the last block; v1-lock and multi-version patches, ledgers + written by older CLIs included, can be removed in any order; and a crate + declared with the same line in two sections gets both pins reverted. - **yarn 4.0.x checksums keep the lock's own spelling.** Vendored and hosted berry rewrites write bare-hex `cacheKey: 10c0` checksums when the lock does, so `yarn install --immutable` no longer fails with YN0028. diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 4c4595fc..f6991b70 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -125,7 +125,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output record zero new edits. **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/` (and never quarantine: a `--dry-run` or a zero-grant wet run that finds a malformed `redirect-state.json` reports it as the hard error it is — exit 1, the repair-or-move-aside remedy — but moves nothing; only a run holding the lock moves it aside to `redirect-state.json.corrupt`); contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE the redirect ledger is read or written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: `scan --mode hosted` prints the results table and update detection like the other modes and confirms once — `Redirect N packages to the hosted patch server?` (singular for one), default yes, skipped by `--yes`/`--json`, on `--dry-run` (the engine honors the preview itself; nothing mutates), and when the detail fetch leaves nothing to redirect (that run enters the engine as a no-op — `Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/` — without prompting); without `--yes` on a non-TTY stdin the shared prompt prints `Non-interactive mode detected, proceeding automatically.` to stderr (unless `--silent`) and proceeds — before rewriting anything (parity with the agent/vendored arms and with `get --mode hosted`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine (previously `Redirected 0 packages; rewrote 0 files.`); a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). A malformed redirect ledger on a human hosted run that returns before the engine (empty discovery, nothing downloadable, a detail-fetch failure, a declined confirm) is surfaced there as the read-only `Warning: the redirect ledger … is malformed …` advisory (muted by `--silent`), never moved; the `--json` arm always enters the engine and hard-errors instead. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang where it used to skip silently, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips, and the `redirect_yarn_berry_entry` ledger edits record the lock's ON-DISK (CRLF) fragments, which the reverts match byte-exactly. A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock` / `bun.lockb`), `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), `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 the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). +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 and `bun.lock` / `bun.lockb`), `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 the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** — this run's fetched records first, then the redirect ledger's persisted ones, so a transiently failed `/patches/view` fetch cannot retire the warning (it re-fires on every re-scan until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten but the ledger fallback could otherwise judge an already-redirected project. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index d0e3a1df..2c218c53 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1709,6 +1709,19 @@ pub(crate) async fn run_redirect_selected( } } + // Cargo workspace members (and in-root path dependencies) declare + // dependencies of their own: a member's direct `cfg-if = "1"` must + // be pinned alongside the root's, or the redirected lock entry is + // unsatisfiable. Keyed `/Cargo.toml` for the cargo rewriter. + if files.contains_key("Cargo.toml") && candidates.iter().any(|c| c.dep.ecosystem == "cargo") + { + for rel in socket_patch_core::utils::cargo_workspace::member_manifests(&common.cwd) { + if let Ok(content) = read_regular_to_string(&common.cwd.join(&rel)).await { + files.insert(rel, content); + } + } + } + if let Ok(paths) = socket_patch_core::utils::python_lock::python_lock_paths(&common.cwd) { for path in paths { if let Some(script_path) = @@ -2579,6 +2592,13 @@ pub(crate) async fn run_redirect_selected( } } } + // Dedup against the ledger as this run found it, never within + // this run: one run legitimately records identical edits (a + // Cargo.toml declaring the crate with the same line in two + // sections), and each one reverts one occurrence — collapsing + // them made `remove` leave the second pin (and its registry + // block) in place while reporting success. + let recorded = ledger.edits.len(); for edit in &rewrite.edits { let is_rebased = REBASE_KINDS.contains(&edit.kind.as_str()) && rebased.iter().any(|&t| { @@ -2588,7 +2608,7 @@ pub(crate) async fn run_redirect_selected( && old.key == edit.key && old.new == edit.new }); - if !is_rebased && !ledger.edits.contains(edit) { + if !is_rebased && !ledger.edits[..recorded].contains(edit) { ledger.edits.push(edit.clone()); } } diff --git a/crates/socket-patch-cli/tests/cargo_e2e_matrix/mod.rs b/crates/socket-patch-cli/tests/cargo_e2e_matrix/mod.rs index 863f33ef..f58f8fcf 100644 --- a/crates/socket-patch-cli/tests/cargo_e2e_matrix/mod.rs +++ b/crates/socket-patch-cli/tests/cargo_e2e_matrix/mod.rs @@ -1,6 +1,7 @@ //! Toolchain / Cargo.lock-format knobs shared by the real-cargo e2e suites -//! (`e2e_redirect_cargo_build`, `e2e_vendor_cargo_build`, -//! `mode_migration_cargo`, `e2e_safety_cargo_build`), so one local loop (or +//! (`e2e_redirect_cargo_build`, `e2e_redirect_cargo_shapes`, +//! `e2e_vendor_cargo_build`, `mode_migration_cargo`, +//! `e2e_safety_cargo_build`), so one local loop (or //! one CI matrix leg per cell) drives every hosted + vendored flow through a //! given cargo release and lock format: //! @@ -28,6 +29,7 @@ //! SOCKET_PATCH_CARGO_E2E_REQUIRED=1 SOCKET_PATCH_CARGO_E2E_TOOLCHAIN=$tc \ //! SOCKET_PATCH_CARGO_E2E_LOCK_VERSION=$lv \ //! cargo test -p socket-patch-cli --test e2e_redirect_cargo_build \ +//! --test e2e_redirect_cargo_shapes \ //! --test e2e_vendor_cargo_build --test mode_migration_cargo //! done; done //! ``` @@ -145,10 +147,21 @@ pub struct LockPackage { pub version: String, pub source: Option, pub checksum: Option, - /// Dependency NAMES (the fixtures never lock two versions of a crate). + /// Dependency package ids (`name version`, plus ` (source)` for a + /// sourced package), resolved from whichever short form the lock used — + /// so two locked versions of one crate stay distinct across formats. pub dependencies: Vec, } +impl LockPackage { + fn id(&self) -> String { + match &self.source { + Some(src) => format!("{} {} ({src})", self.name, self.version), + None => format!("{} {}", self.name, self.version), + } + } +} + /// The lock format of `text`: `version = N` (3 / 4), else v1 when it has /// a `[metadata]` table or `"name version (source)"` references, else v2. pub fn lock_format(text: &str) -> u8 { @@ -208,8 +221,7 @@ pub fn parse_lock(text: &str) -> Vec { if t == "]" { in_deps = false; } else if let Some(dep) = quoted(t.trim_end_matches(',')) { - let name = dep.split(' ').next().unwrap_or_default().to_string(); - pkg.dependencies.push(name); + pkg.dependencies.push(dep); } continue; } @@ -235,6 +247,38 @@ pub fn parse_lock(text: &str) -> Vec { _ => {} } } + // A dependency is written as `name`, `name version` or the full + // `name version (source)`, whichever is unambiguous in its lock. + let ids: Vec<(String, String, Option, String)> = pkgs + .iter() + .map(|p| (p.name.clone(), p.version.clone(), p.source.clone(), p.id())) + .collect(); + for pkg in &mut pkgs { + for dep in &mut pkg.dependencies { + let (head, source) = match dep.split_once(" (") { + Some((head, rest)) => (head, rest.strip_suffix(')').map(str::to_string)), + None => (dep.as_str(), None), + }; + let mut parts = head.split(' '); + let name = parts.next().unwrap_or_default(); + let version = parts.next(); + let matches: Vec<&String> = ids + .iter() + .filter(|(n, v, s, _)| { + n == name + && version.is_none_or(|want| want == v) + && (source.is_none() || *s == source) + }) + .map(|(_, _, _, id)| id) + .collect(); + assert_eq!( + matches.len(), + 1, + "lock dependency {dep:?} must name exactly one locked package" + ); + *dep = matches[0].clone(); + } + } for pkg in &mut pkgs { if pkg.checksum.is_none() { if let Some(src) = &pkg.source { @@ -265,17 +309,26 @@ pub fn write_lock(pkgs: &[LockPackage], version: u8) -> String { if version >= 3 { out.push_str(&format!("version = {version}\n\n")); } - let dep_ref = |name: &str| -> String { - if version >= 2 { - return name.to_string(); - } + // v1 always writes the full id; v2+ the shortest unambiguous form. + let dep_ref = |id: &str| -> String { let dep = sorted .iter() - .find(|p| p.name == name) - .unwrap_or_else(|| panic!("lock dependency {name} is not a locked package")); - match &dep.source { - Some(src) => format!("{} {} ({src})", dep.name, dep.version), - None => format!("{} {}", dep.name, dep.version), + .find(|p| p.id() == id) + .unwrap_or_else(|| panic!("lock dependency {id} is not a locked package")); + if version == 1 { + return dep.id(); + } + let same_name = sorted.iter().filter(|p| p.name == dep.name).count(); + let same_version = sorted + .iter() + .filter(|p| p.name == dep.name && p.version == dep.version) + .count(); + if same_name == 1 { + dep.name.clone() + } else if same_version == 1 { + format!("{} {}", dep.name, dep.version) + } else { + dep.id() } }; let blocks: Vec = sorted diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs new file mode 100644 index 00000000..b5abf52e --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -0,0 +1,935 @@ +//! Real-cargo hosted-mode regressions for project SHAPES beyond the single +//! root dependency `e2e_redirect_cargo_build` covers — each one a defect a +//! real-cargo capture matrix found in the hosted rewriter / revert: +//! +//! * `multi_version` — two patched versions of one crate (`cfg-if` 1.0.4 and +//! a renamed `cfg-if-legacy` at 0.1.10): each declaration is pinned to its +//! own version's registry, and removing both purls restores every byte. +//! * `legacy_config` — an existing legacy `.cargo/config`: the registry +//! block lands there, and `remove` restores the file byte-for-byte — +//! also when it lacks a final newline or ends in a blank line. +//! * `crlf` — CRLF `Cargo.toml` + `Cargo.lock`: rewritten with CRLF kept, +//! and restored byte-for-byte. +//! * `workspace_direct_member` — a virtual workspace whose root pins +//! `[workspace.dependencies]`, one member inheriting and one declaring the +//! crate itself: both members build against the patched copy. +//! * `two_sections` — the same declaration line in `[dependencies]` and +//! `[dev-dependencies]`: both pins revert on `remove`. +//! * `direct_and_transitive` — the crate is also a dependency of another +//! crates.io crate: hosted mode refuses it loudly and rewrites nothing. +//! * `lockless_other_dependencies` — the same project with no committed +//! `Cargo.lock`: with no resolved graph to read, a crate declared beside +//! any other dependency is refused just as loudly. +//! +//! Every shape runs the same chain against the real cargo: a baseline build +//! with a private CARGO_HOME (network to crates.io for fixture setup only), +//! patched `.crate`s rebuilt from the ACTUAL crates.io bytes and served by a +//! wiremock sparse registry per patch, `scan --mode hosted`, then a FRESH +//! checkout (only the committed files travel) where `cargo fetch --locked` +//! and an offline `cargo build --locked` must link each patched-only symbol +//! and a post-install `vex` must attest exactly the patches, and finally +//! `remove ` for every patch — in apply order and, from the same +//! post-scan state, in reverse — which must leave the project +//! byte-identical to its pre-scan state. +//! +//! `SOCKET_PATCH_CARGO_E2E_LOCK_VERSION` / `_TOOLCHAIN` (see +//! `cargo_e2e_matrix`) re-encode the baseline lock, so a v1 lock's full-id +//! dependency edges and `[metadata]` checksums meet every shape too. +//! +//! Skips (with a println) when `cargo` is missing or crates.io is +//! unreachable (a failure instead under `SOCKET_PATCH_CARGO_E2E_REQUIRED=1`). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use sha2::{Digest, Sha256}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + +#[path = "cargo_e2e_matrix/mod.rs"] +mod cargo_e2e_matrix; + +const ORG: &str = "test-org"; +/// Appended to each patched crate's `src/lib.rs`: the oracle links it. +const PATCH_SUFFIX: &str = + "\n/// Socket-patch shape marker (added by the hosted patch).\npub fn socket_patched() -> u32 { 1 }\n"; + +/// One patched crate version the fixture serves. +#[derive(Clone, Copy)] +struct Patch { + name: &'static str, + version: &'static str, + uuid: &'static str, + token: &'static str, +} + +impl Patch { + fn purl(&self) -> String { + format!("pkg:cargo/{}@{}", self.name, self.version) + } +} + +const CFG_IF_1: Patch = Patch { + name: "cfg-if", + version: "1.0.4", + uuid: "c1f90104-5a0c-4e7a-9c0d-1a2b3c4d5e01", + token: "70ce0104-1111-4111-8111-111111111101", +}; +const CFG_IF_0: Patch = Patch { + name: "cfg-if", + version: "0.1.10", + uuid: "c1f90010-5a0c-4e7a-9c0d-1a2b3c4d5e02", + token: "70ce0010-1111-4111-8111-111111111102", +}; + +/// A project shape: its files before the lock exists, the patches, and the +/// oracle sources that reference each patched crate's marker. +struct Shape { + tag: &'static str, + files: Vec<(&'static str, String)>, + patches: Vec, + /// Written into the fresh checkout before the offline build. + oracle: Vec<(&'static str, String)>, + /// Re-encode every `Cargo.toml` and the generated `Cargo.lock` with CRLF + /// line endings (a Windows checkout) before the scan. + crlf: bool, + /// Delete `Cargo.lock` after the baseline build, before the scan: the + /// shape a Rust library that gitignores its lock presents. + lockless: bool, + /// A shape hosted mode must REFUSE: the rewriter warning code every + /// patch is skipped with. The scan must leave every file untouched. + refused: Option<&'static str>, +} + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +fn run_socket(cwd: &Path, args: &[&str], cargo_home: &Path) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + cmd.env("SOCKET_NO_CONFIG", "1"); + cmd.env("CARGO_HOME", cargo_home); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn cargo(cwd: &Path, args: &[&str], cargo_home: &Path) -> Output { + cargo_e2e_matrix::cargo_command(cwd, cargo_home) + .args(args) + .output() + .expect("failed to run cargo") +} + +fn stderr(out: &Output) -> String { + String::from_utf8_lossy(&out.stderr).into_owned() +} + +fn find_registry_crate(cargo_home: &Path, leaf: &str) -> Option { + let src = cargo_home.join("registry").join("src"); + std::fs::read_dir(src) + .ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path().join(leaf)) + .find(|p| p.is_dir()) +} + +fn sparse_index_rel(name: &str) -> String { + match name.len() { + 1 => format!("1/{name}"), + 2 => format!("2/{name}"), + 3 => format!("3/{}/{name}", &name[..1]), + _ => format!("{}/{}/{name}", &name[..2], &name[2..4]), + } +} + +fn build_crate(stage: &Path, crate_dir: &Path, leaf: &str, patched: &[u8]) -> Vec { + let pkg = stage.join(leaf); + copy_tree(crate_dir, &pkg); + let _ = std::fs::remove_file(pkg.join(".cargo-checksum.json")); + std::fs::write(pkg.join("src/lib.rs"), patched).unwrap(); + let mut bytes = Vec::new(); + { + let enc = flate2::write::GzEncoder::new(&mut bytes, flate2::Compression::new(6)); + let mut builder = tar::Builder::new(enc); + builder.append_dir_all(leaf, &pkg).unwrap(); + builder.into_inner().unwrap().finish().unwrap(); + } + bytes +} + +fn copy_tree(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_tree(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +/// Every project file except build output and the `.socket/` ledger dir. +fn snapshot(root: &Path) -> BTreeMap> { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap>) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if rel == "target" || rel == ".socket" || rel.ends_with("/target") { + continue; + } + if path.is_dir() { + walk(root, &path, out); + } else { + out.insert(rel, std::fs::read(&path).unwrap()); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +struct Served { + patch: Patch, + orig: Vec, + patched: Vec, + crate_bytes: Vec, +} + +/// Route every patch-API, sparse-index and download request the CLI and +/// cargo make, for any number of patches. +fn router(origin: String, served: Vec) -> impl Fn(&Request) -> ResponseTemplate { + move |req: &Request| { + let path = req.url.path().to_string(); + let find = |uuid: &str| served.iter().find(|s| s.patch.uuid == uuid); + let index_url = |p: &Patch| { + format!( + "sparse+{origin}/patch-registry/cargo/{}/{}/index/", + p.token, p.uuid + ) + }; + let hosted_url = |p: &Patch| { + format!( + "{origin}/patch/cargo/{0}/{1}/{2}/{3}/{0}-{1}.crate", + p.name, p.version, p.token, p.uuid + ) + }; + let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let api = format!("/v0/orgs/{ORG}/patches/"); + if path == format!("{api}batch") { + let packages: Vec = served + .iter() + .map(|s| { + serde_json::json!({ + "purl": s.patch.purl(), + "patches": [{ + "uuid": s.patch.uuid, "purl": s.patch.purl(), "tier": "free", + "cveIds": [], "ghsaIds": [format!("GHSA-shape-{}", &s.patch.uuid[..8])], + "severity": "high", "title": "cargo shape fixture" + }] + }) + }) + .collect(); + return ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "packages": packages, "canAccessPaidPatches": false }), + ); + } + if let Some(rest) = path.strip_prefix(&format!("{api}by-package/")) { + let purl = urlencoding_decode(rest); + let patches: Vec = served + .iter() + .filter(|s| s.patch.purl() == purl) + .map(|s| { + serde_json::json!({ + "uuid": s.patch.uuid, "purl": s.patch.purl(), + "publishedAt": "2026-01-01T00:00:00Z", "description": "x", + "license": "MIT", "tier": "free", "vulnerabilities": {} + }) + }) + .collect(); + return ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "patches": patches, "canAccessPaidPatches": false }), + ); + } + if path == format!("{api}package") { + let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap_or_default(); + let mut results = serde_json::Map::new(); + for uuid in body["uuids"].as_array().into_iter().flatten() { + let uuid = uuid.as_str().unwrap_or_default(); + let value = match find(uuid) { + Some(s) => { + let cksum = hex::encode(Sha256::digest(&s.crate_bytes)); + serde_json::json!({ + "status": "granted", + "url": hosted_url(&s.patch), + "purl": s.patch.purl(), + "artifacts": [{ + "kind": "tarball", "url": hosted_url(&s.patch), + "integrity": { "sha256": cksum } + }], + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": index_url(&s.patch), + "identifiers": { + "name": s.patch.name, "version": s.patch.version, + "cargoCksumSha256": cksum, + } + } + }) + } + None => serde_json::json!({ "status": "not_found" }), + }; + results.insert(uuid.to_string(), value); + } + return ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "results": results })); + } + if let Some(uuid) = path.strip_prefix(&format!("{api}view/")) { + if let Some(s) = find(uuid) { + return ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": uuid, "purl": s.patch.purl(), + "publishedAt": "2026-01-01T00:00:00Z", + "files": { "src/lib.rs": { + "beforeHash": compute_git_sha256_from_bytes(&s.orig), + "afterHash": compute_git_sha256_from_bytes(&s.patched), + }}, + "vulnerabilities": { format!("GHSA-shape-{}", &uuid[..8]): { + "cves": [], "summary": "s", "severity": "high", "description": "d" + }}, + "description": "x", "license": "MIT", "tier": "free" + })); + } + } + // /patch-registry/cargo///index/... + if segs.len() >= 6 && segs[..2] == ["patch-registry", "cargo"] && segs[4] == "index" { + if let Some(s) = find(segs[3]) { + let rest = segs[5..].join("/"); + if rest == "config.json" { + return ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "dl": format!("{origin}/dl/{}", s.patch.uuid), + })); + } + if rest == sparse_index_rel(s.patch.name) { + let line = serde_json::json!({ + "name": s.patch.name, "vers": s.patch.version, "deps": [], + "cksum": hex::encode(Sha256::digest(&s.crate_bytes)), + "features": {}, "yanked": false, + }); + return ResponseTemplate::new(200).set_body_string(line.to_string()); + } + } + } + // /dl////download + if segs.len() == 5 && segs[0] == "dl" && segs[4] == "download" { + if let Some(s) = find(segs[1]) { + return ResponseTemplate::new(200).set_body_bytes(s.crate_bytes.clone()); + } + } + ResponseTemplate::new(404) + } +} + +fn urlencoding_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) { + out.push(b); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Run one shape through scan → fresh-checkout fetch + offline build → +/// remove. Returns `None` when the fixture could not be built (skipped). +async fn run_shape(shape: Shape) -> Option<()> { + let suite = format!("e2e_redirect_cargo_shapes ({})", shape.tag); + if !cargo_e2e_matrix::cargo_available(&suite) { + return None; + } + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + let home = tmp.path().join("cargo-home"); + std::fs::create_dir_all(&home).unwrap(); + for (rel, content) in &shape.files { + let path = proj.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + } + let generate = cargo(&proj, &["generate-lockfile"], &home); + if !generate.status.success() { + let _ = cargo_e2e_matrix::skip( + &suite, + &format!( + "`cargo generate-lockfile` failed (crates.io unreachable?):\n{}", + stderr(&generate) + ), + ); + return None; + } + pin_patched_versions(&proj, &home, &shape.patches); + cargo_e2e_matrix::apply_lock_version(&proj); + let build = cargo(&proj, &["build", "-q", "--locked"], &home); + if !build.status.success() { + let _ = cargo_e2e_matrix::skip( + &suite, + &format!( + "baseline `cargo build` failed (crates.io unreachable?):\n{}", + stderr(&build) + ), + ); + return None; + } + let _ = std::fs::remove_dir_all(proj.join("target")); + if shape.crlf { + for rel in snapshot(&proj).keys() { + if rel.ends_with("Cargo.toml") || rel == "Cargo.lock" { + let path = proj.join(rel); + let text = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, text.replace('\n', "\r\n")).unwrap(); + } + } + let rebuilt = cargo(&proj, &["build", "-q", "--locked"], &home); + assert!( + rebuilt.status.success(), + "{}: the CRLF baseline must build:\n{}", + shape.tag, + stderr(&rebuilt) + ); + let _ = std::fs::remove_dir_all(proj.join("target")); + } + if shape.lockless { + std::fs::remove_file(proj.join("Cargo.lock")).unwrap(); + } + let before = snapshot(&proj); + + let mut served = Vec::new(); + for patch in &shape.patches { + let leaf = format!("{}-{}", patch.name, patch.version); + let dir = find_registry_crate(&home, &leaf) + .unwrap_or_else(|| panic!("{leaf} must be extracted by the baseline build")); + let orig = std::fs::read(dir.join("src/lib.rs")).unwrap(); + let patched = [orig.as_slice(), PATCH_SUFFIX.as_bytes()].concat(); + let crate_bytes = build_crate(&tmp.path().join("stage"), &dir, &leaf, &patched); + served.push(Served { + patch: *patch, + orig, + patched, + crate_bytes, + }); + } + let server = MockServer::start().await; + Mock::given(wiremock::matchers::any()) + .respond_with(router(server.uri(), served)) + .mount(&server) + .await; + + let uri = server.uri(); + let proj_s = proj.to_str().unwrap().to_string(); + let (code, stdout, err) = run_socket( + &proj, + &[ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--no-telemetry", + "--cwd", + &proj_s, + "--api-url", + &uri, + "--org", + ORG, + "--api-token", + "fake", + ], + &home, + ); + assert_eq!( + code, 0, + "{}: scan failed\nstdout:\n{stdout}\nstderr:\n{err}", + shape.tag + ); + let env: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + if let Some(code) = shape.refused { + assert_eq!(env["redirect"]["redirected"], 0, "{}: {env}", shape.tag); + let codes: Vec<&str> = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .filter_map(|w| w["code"].as_str()) + .collect(); + assert_eq!( + codes, + vec![code; shape.patches.len()], + "{}: every patch refused loudly: {env}", + shape.tag + ); + assert_eq!( + snapshot(&proj), + before, + "{}: a refused redirect rewrites nothing", + shape.tag + ); + assert!(!proj.join(".socket").exists(), "{}", shape.tag); + if !shape.lockless { + let fetch = cargo(&proj, &["fetch", "--locked"], &home); + assert!( + fetch.status.success(), + "{}: the untouched project still fetches --locked:\n{}", + shape.tag, + stderr(&fetch) + ); + } + return Some(()); + } + assert_eq!( + env["redirect"]["redirected"], + shape.patches.len(), + "{}: every patch redirected: {env}", + shape.tag + ); + assert_eq!( + env["redirect"]["warnings"], + serde_json::json!([]), + "{}: {env}", + shape.tag + ); + + if shape.crlf { + for (rel, bytes) in snapshot(&proj) { + if rel.ends_with("Cargo.toml") || rel == "Cargo.lock" { + let text = String::from_utf8(bytes).unwrap(); + assert_eq!( + text.matches("\r\n").count(), + text.matches('\n').count(), + "{}: {rel} must keep CRLF endings:\n{text}", + shape.tag + ); + } + } + } + + // Fresh checkout: only committed files travel; an EMPTY CARGO_HOME. + let fresh = tmp.path().join("fresh"); + for (rel, _) in snapshot(&proj) { + let to = fresh.join(&rel); + std::fs::create_dir_all(to.parent().unwrap()).unwrap(); + std::fs::copy(proj.join(&rel), to).unwrap(); + } + if proj.join(".socket").is_dir() { + copy_tree(&proj.join(".socket"), &fresh.join(".socket")); + } + let fresh_home = tmp.path().join("fresh-home"); + std::fs::create_dir_all(&fresh_home).unwrap(); + let fetch = cargo(&fresh, &["fetch", "--locked"], &fresh_home); + assert!( + fetch.status.success(), + "{}: fresh `cargo fetch --locked` failed:\n{}", + shape.tag, + stderr(&fetch) + ); + for (rel, content) in &shape.oracle { + std::fs::write(fresh.join(rel), content).unwrap(); + } + let build = cargo(&fresh, &["build", "--locked", "--offline"], &fresh_home); + assert!( + build.status.success(), + "{}: offline `cargo build --locked` must link every patched marker:\n{}", + shape.tag, + stderr(&build) + ); + + // Post-install VEX over the fresh checkout: every patch is attested, + // hash-verified against the extracted (patched) registry sources. + let doc_path = fresh.join("doc.vex.json"); + let fresh_s = fresh.to_str().unwrap().to_string(); + let (code, stdout, err) = run_socket( + &fresh, + &[ + "vex", + "--output", + doc_path.to_str().unwrap(), + "--product", + "pkg:cargo/consumer@0.1.0", + "--patch-server-url", + &uri, + "--cwd", + &fresh_s, + ], + &fresh_home, + ); + assert_eq!( + code, 0, + "{}: vex\nstdout:\n{stdout}\nstderr:\n{err}", + shape.tag + ); + let doc: serde_json::Value = + serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap(); + let mut attested: Vec = doc["statements"] + .as_array() + .unwrap() + .iter() + .flat_map(|st| st["products"].as_array().unwrap().clone()) + .flat_map(|p| p["subcomponents"].as_array().unwrap().clone()) + .map(|c| c["@id"].as_str().unwrap().to_string()) + .collect(); + attested.sort(); + attested.dedup(); + let mut expected: Vec = shape.patches.iter().map(Patch::purl).collect(); + expected.sort(); + assert_eq!(attested, expected, "{}: attested purls: {doc}", shape.tag); + + // Rollback: removing every purl restores the pre-scan project exactly — + // in apply order AND in reverse (a v1 lock's shared dependent blocks + // once made the first-applied purl unremovable before its sibling). + let post_scan = tmp.path().join("post-scan"); + copy_tree(&proj, &post_scan); + let mut orders = vec![shape.patches.clone()]; + if shape.patches.len() > 1 { + orders.push(shape.patches.iter().rev().copied().collect()); + } + for (n, order) in orders.iter().enumerate() { + if n > 0 { + std::fs::remove_dir_all(&proj).unwrap(); + copy_tree(&post_scan, &proj); + } + for patch in order { + let purl = patch.purl(); + let (code, stdout, err) = run_socket( + &proj, + &[ + "remove", + &purl, + "--cwd", + &proj_s, + "--json", + "--yes", + "--no-telemetry", + ], + &home, + ); + assert_eq!( + code, 0, + "{} (removal order {n}): remove {purl}\nstdout:\n{stdout}\nstderr:\n{err}", + shape.tag + ); + } + let after = snapshot(&proj); + for (rel, bytes) in &before { + assert_eq!( + after + .get(rel) + .map(|b| String::from_utf8_lossy(b).into_owned()), + Some(String::from_utf8_lossy(bytes).into_owned()), + "{} (removal order {n}): {rel} not restored byte-for-byte by remove", + shape.tag + ); + } + let extra: Vec<&String> = after.keys().filter(|k| !before.contains_key(*k)).collect(); + assert!( + extra.is_empty(), + "{} (removal order {n}): remove left files behind: {extra:?}", + shape.tag + ); + } + Some(()) +} + +/// Pin each patched version: a caret requirement locks the newest +/// compatible release, which moves as crates.io publishes. +fn pin_patched_versions(proj: &Path, home: &Path, patches: &[Patch]) { + for patch in patches { + let lock = std::fs::read_to_string(proj.join("Cargo.lock")).unwrap(); + let locked = cargo_e2e_matrix::parse_lock(&lock); + if locked + .iter() + .any(|p| p.name == patch.name && p.version == patch.version) + { + continue; + } + let pinned = locked + .iter() + .filter(|p| p.name == patch.name) + .filter(|p| { + !patches + .iter() + .any(|q| q.name == p.name && q.version == p.version) + }) + .any(|p| { + let spec = format!("{}@{}", p.name, p.version); + cargo( + proj, + &["update", "-p", &spec, "--precise", patch.version], + home, + ) + .status + .success() + }); + assert!( + pinned, + "cannot lock {}@{}:\n{lock}", + patch.name, patch.version + ); + } +} + +fn consumer_manifest(deps: &str) -> String { + format!("[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2018\"\n\n[dependencies]\n{deps}") +} + +/// Bugs B + I: two patched versions of one crate. +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_multi_version_pins_each_declaration_and_removes_cleanly() { + let shape = Shape { + tag: "multi-version", + files: vec![ + ( + "Cargo.toml", + consumer_manifest( + "cfg-if = \"1.0.4\"\ncfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n", + ), + ), + ("src/main.rs", "fn main() {}\n".to_string()), + ], + patches: vec![CFG_IF_1, CFG_IF_0], + oracle: vec![( + "src/main.rs", + "fn main() { println!(\"{}\", cfg_if::socket_patched() + cfg_if_legacy::socket_patched()); }\n" + .to_string(), + )], + crlf: false, + lockless: false, + refused: None, + }; + let _ = run_shape(shape).await; +} + +/// Bug H: an existing legacy `.cargo/config` gets the registry block +/// appended; removing the purl must restore its exact bytes. +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_legacy_config_is_restored_byte_for_byte() { + let shape = Shape { + tag: "legacy-config", + files: vec![ + ("Cargo.toml", consumer_manifest("cfg-if = \"1.0.4\"\n")), + ("src/main.rs", "fn main() {}\n".to_string()), + (".cargo/config", "[net]\nretry = 2\n".to_string()), + ], + patches: vec![CFG_IF_1], + oracle: vec![( + "src/main.rs", + "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), + )], + crlf: false, + lockless: false, + refused: None, + }; + let _ = run_shape(shape).await; +} + +/// Bug H, exactly: a config without a final newline, and one ending in a +/// blank line, both come back byte-for-byte (the appended block's removal +/// once normalized the trailing newline run). +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_config_trailing_bytes_are_restored() { + for (tag, rel, config) in [ + ( + "config-unterminated", + ".cargo/config.toml", + "[net]\nretry = 2", + ), + ( + "config-trailing-blank", + ".cargo/config", + "[net]\nretry = 2\n\n", + ), + ] { + let shape = Shape { + tag, + files: vec![ + ("Cargo.toml", consumer_manifest("cfg-if = \"1.0.4\"\n")), + ("src/main.rs", "fn main() {}\n".to_string()), + (rel, config.to_string()), + ], + patches: vec![CFG_IF_1], + oracle: vec![( + "src/main.rs", + "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), + )], + crlf: false, + lockless: false, + refused: None, + }; + if run_shape(shape).await.is_none() { + return; + } + } +} + +/// The crate declared with the SAME line in two dependency sections: the +/// rewrite records two identical manifest edits, and the ledger must keep +/// both — it collapsed them, so `remove` reverted one pin, kept the other +/// (and the registry block it references) and still reported success. +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_same_line_in_two_sections_removes_cleanly() { + let shape = Shape { + tag: "two-sections", + files: vec![ + ( + "Cargo.toml", + format!( + "{}\n[dev-dependencies]\ncfg-if = \"1.0.4\"\n", + consumer_manifest("cfg-if = \"1.0.4\"\n") + ), + ), + ("src/main.rs", "fn main() {}\n".to_string()), + ], + patches: vec![CFG_IF_1], + oracle: vec![( + "src/main.rs", + "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), + )], + crlf: false, + lockless: false, + refused: None, + }; + let _ = run_shape(shape).await; +} + +/// Bug F: a workspace member's own declaration must be pinned too. +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_workspace_member_declaration_is_pinned() { + let member = |name: &str, dep: &str| { + format!( + "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2018\"\n\n\ + [dependencies]\n{dep}\n" + ) + }; + let oracle = "pub fn marker() -> u32 { cfg_if::socket_patched() }\n".to_string(); + let shape = Shape { + tag: "workspace-direct-member", + files: vec![ + ( + "Cargo.toml", + "[workspace]\nmembers = [\"inherits\", \"direct\"]\n\n\ + [workspace.dependencies]\ncfg-if = \"1.0.4\"\n" + .to_string(), + ), + ( + "inherits/Cargo.toml", + member("inherits", "cfg-if = { workspace = true }"), + ), + ("inherits/src/lib.rs", String::new()), + ("direct/Cargo.toml", member("direct", "cfg-if = \"1.0.4\"")), + ("direct/src/lib.rs", String::new()), + ], + patches: vec![CFG_IF_1], + oracle: vec![ + ("inherits/src/lib.rs", oracle.clone()), + ("direct/src/lib.rs", oracle), + ], + crlf: false, + lockless: false, + refused: None, + }; + let _ = run_shape(shape).await; +} + +/// Bug K: a CRLF checkout is redirected (it was refused) with its line +/// endings kept, and removed byte-for-byte. +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_crlf_project_keeps_its_line_endings() { + let shape = Shape { + tag: "crlf", + files: vec![ + ("Cargo.toml", consumer_manifest("cfg-if = \"1.0.4\"\n")), + ("src/main.rs", "fn main() {}\n".to_string()), + ], + patches: vec![CFG_IF_1], + oracle: vec![( + "src/main.rs", + "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), + )], + crlf: true, + lockless: false, + refused: None, + }; + let _ = run_shape(shape).await; +} + +/// A crate that is both a direct dependency and a dependency of another +/// crates.io crate (`crc32fast` depends on `cfg-if ^1`): a hosted pin cannot +/// reach crc32fast's edge, so the redirect is refused loudly instead of +/// repointing the lock (`--locked` then fails, and crc32fast compiled the +/// unpatched crates.io copy while scan reported the crate redirected). +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_refuses_a_crate_another_crate_depends_on() { + let shape = Shape { + tag: "direct-and-transitive", + files: vec![ + ( + "Cargo.toml", + consumer_manifest("cfg-if = \"1.0.4\"\ncrc32fast = \"=1.5.0\"\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_transitive_dependents"), + }; + let _ = run_shape(shape).await; +} + +/// The SAME project with its `Cargo.lock` gitignored (the common shape for +/// a Rust library): with no resolved graph the dependents check above has +/// nothing to read, so the manifests answer instead — cfg-if is declared +/// beside crc32fast, which may well pull it in, and the redirect is refused +/// rather than reported as one while the build links an unpatched copy +/// through crc32fast. +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_refuses_a_lockless_project_with_other_dependencies() { + let shape = Shape { + tag: "lockless-other-dependencies", + files: vec![ + ( + "Cargo.toml", + consumer_manifest("cfg-if = \"1.0.4\"\ncrc32fast = \"=1.5.0\"\n"), + ), + ("src/main.rs", "fn main() {}\n".to_string()), + ], + patches: vec![CFG_IF_1], + oracle: Vec::new(), + crlf: false, + lockless: true, + refused: Some("redirect_cargo_lockless_dependents"), + }; + let _ = run_shape(shape).await; +} diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 7338c7d4..abdb014e 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -3964,6 +3964,193 @@ async fn cargo_granted_but_nothing_pinned_is_not_confirmed_or_attested() { ); } +/// A granted cargo patch for a crate the project reaches only +/// TRANSITIVELY (in Cargo.lock, declared by no manifest) stays a refusal — +/// a manifest `registry` pin cannot reach it — but a loud one: nothing is +/// written, recorded or attested, the requested VEX fails the run, and the +/// warning names the crate transitive-only and points at vendored mode. +#[tokio::test] +#[serial] +async fn cargo_transitive_only_crate_is_refused_loudly_and_not_attested() { + const CARGO_PURL: &str = "pkg:cargo/cfg-if@1.0.0"; + const CARGO_UUID: &str = "22222222-2222-4222-8222-222222222222"; + let cksum = "cd".repeat(32); + let index_url = format!("sparse+http://patch.test/registry/cargo/{CARGO_UUID}/index/"); + let server = MockServer::start().await; + mock_cargo_patch( + &server, + CARGO_PURL, + CARGO_UUID, + "cfg-if", + "1.0.0", + &index_url, + &cksum, + "GHSA-carg-tttt-tttt", + ) + .await; + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("Cargo.toml"), + "[package]\nname = \"consumer\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\nlog = \"0.4\"\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("Cargo.lock"), + "version = 3\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"ee\"\n\n[[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"ff\"\ndependencies = [\n \"cfg-if\",\n]\n", + ) + .unwrap(); + write_vendored_crate(tmp.path(), "cfg-if", "1.0.0"); + let toml_before = std::fs::read(tmp.path().join("Cargo.toml")).unwrap(); + let lock_before = std::fs::read(tmp.path().join("Cargo.lock")).unwrap(); + + let vex_path = tmp.path().join("out.vex.json"); + let mut args = redirect_args(tmp.path(), server.uri()); + args.vex = socket_patch_cli::commands::vex::VexEmbedArgs { + vex: Some(vex_path.clone()), + vex_product: Some("pkg:cargo/consumer@0.0.0".to_string()), + ..Default::default() + }; + let code = run(args).await; + + assert_eq!( + std::fs::read(tmp.path().join("Cargo.toml")).unwrap(), + toml_before + ); + assert_eq!( + std::fs::read(tmp.path().join("Cargo.lock")).unwrap(), + lock_before + ); + assert!( + !tmp.path().join(".cargo").exists(), + "no registry block may be written for an unpinnable crate" + ); + assert!( + !tmp.path() + .join(".socket/vendor/redirect-state.json") + .exists(), + "nothing redirected, nothing recorded" + ); + assert!( + !vex_path.exists(), + "a transitive-only crate is never attested" + ); + assert_eq!(code, 1, "the requested attestation must fail the run"); + + // Without --vex the run succeeds, reporting nothing redirected and the + // transitive-only warning in the envelope. + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["redirect"]["redirected"], 0, "{env}"); + let warning = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "redirect_cargo_toml_dep_not_found") + .unwrap_or_else(|| panic!("{env}")); + let detail = warning["detail"].as_str().unwrap(); + assert!( + detail.contains("cfg-if@1.0.0 is a transitive-only dependency") + && detail.contains("--mode vendored"), + "{detail}" + ); + assert_eq!( + std::fs::read(tmp.path().join("Cargo.lock")).unwrap(), + lock_before + ); +} + +/// A workspace member or path dependency the rewriter must not write — +/// outside the project root, or reached through a symbolic link — still +/// declares the patched crate to cargo. Pinning only the root would leave +/// that package resolving crates.io (`--locked` fails, the unpatched copy is +/// compiled) while the crate is reported redirected. REGRESSION: the root +/// was pinned and the dep confirmed; a literal symlinked member was even +/// written through the link, outside the project. Now the Cargo.lock +/// dependents check refuses the crate and nothing is written anywhere. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn cargo_member_outside_the_project_or_behind_a_symlink_refuses_the_crate() { + const CARGO_PURL: &str = "pkg:cargo/cfg-if@1.0.0"; + const CARGO_UUID: &str = "33333333-3333-4333-8333-333333333333"; + let cksum = "cd".repeat(32); + let index_url = format!("sparse+http://patch.test/registry/cargo/{CARGO_UUID}/index/"); + let server = MockServer::start().await; + mock_cargo_patch( + &server, + CARGO_PURL, + CARGO_UUID, + "cfg-if", + "1.0.0", + &index_url, + &cksum, + "GHSA-carg-ssss-ssss", + ) + .await; + + let lib_manifest = + "[package]\nname = \"lib\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\ncfg-if = \"1\"\n"; + let lock = "version = 3\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"ee\"\n\n[[package]]\nname = \"consumer\"\nversion = \"0.0.0\"\ndependencies = [\n \"cfg-if\",\n \"lib\",\n]\n\n[[package]]\nname = \"lib\"\nversion = \"0.1.0\"\ndependencies = [\n \"cfg-if\",\n]\n"; + for (shape, root_manifest, link) in [ + ( + "out-of-root path dependency", + "[package]\nname = \"consumer\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\ncfg-if = \"1.0.0\"\nlib = { path = \"../lib\" }\n", + None, + ), + ( + "glob-matched symlinked member", + "[workspace]\nmembers = [\"crates/*\"]\n\n[package]\nname = \"consumer\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\ncfg-if = \"1.0.0\"\n", + Some("crates/lib"), + ), + ( + "literal symlinked member", + "[workspace]\nmembers = [\"lib\"]\n\n[package]\nname = \"consumer\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\ncfg-if = \"1.0.0\"\n", + Some("lib"), + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + let outside = tmp.path().join("lib"); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("Cargo.toml"), lib_manifest).unwrap(); + let app = tmp.path().join("app"); + std::fs::create_dir_all(&app).unwrap(); + std::fs::write(app.join("Cargo.toml"), root_manifest).unwrap(); + std::fs::write(app.join("Cargo.lock"), lock).unwrap(); + if let Some(link) = link { + let at = app.join(link); + std::fs::create_dir_all(at.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(&outside, at).unwrap(); + } + write_vendored_crate(&app, "cfg-if", "1.0.0"); + + let env = run_redirect_subprocess(&app, &server.uri()); + assert_eq!(env["redirect"]["redirected"], 0, "{shape}: {env}"); + let warning = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "redirect_cargo_transitive_dependents") + .unwrap_or_else(|| panic!("{shape}: {env}")); + let detail = warning["detail"].as_str().unwrap(); + assert!( + detail.contains("lib 0.1.0 (a path package") && detail.contains("--mode vendored"), + "{shape}: {detail}" + ); + assert_eq!( + std::fs::read_to_string(outside.join("Cargo.toml")).unwrap(), + lib_manifest, + "{shape}: a manifest outside the project is never written" + ); + assert_eq!( + std::fs::read_to_string(app.join("Cargo.toml")).unwrap(), + root_manifest, + "{shape}" + ); + assert_eq!(std::fs::read_to_string(app.join("Cargo.lock")).unwrap(), lock, "{shape}"); + assert!(!app.join(".cargo").exists(), "{shape}"); + } +} + /// AUDIT A2 (green side): the multi-line `[dependencies.]` table form — /// with NO Cargo.lock — is fully pinned: the manifest entry gains a registry /// line, the managed registry block is wired in, and the patch is recorded + diff --git a/crates/socket-patch-cli/tests/mode_migration_cargo.rs b/crates/socket-patch-cli/tests/mode_migration_cargo.rs index d8cd2fb6..de3d8197 100644 --- a/crates/socket-patch-cli/tests/mode_migration_cargo.rs +++ b/crates/socket-patch-cli/tests/mode_migration_cargo.rs @@ -1087,6 +1087,9 @@ async fn vendor_over_hosted_without_ledger_is_refused() { let purl = format!("pkg:cargo/{DEP}@{version}"); let orig = std::fs::read(crate_dir.join("src/lib.rs")).unwrap(); let patched: Vec = [orig.as_slice(), PATCH_SUFFIX.as_bytes()].concat(); + // Kept for the second arm: the pristine crates.io lock a checkout + // restores over the redirected one. + let pristine_lock = std::fs::read_to_string(proj.join("Cargo.lock")).unwrap(); let server = MockServer::start().await; let crate_bytes = @@ -1142,4 +1145,49 @@ async fn vendor_over_hosted_without_ledger_is_refused() { assert!(!read(&proj, ".cargo/config.toml").contains("[patch.crates-io]")); assert!(!read(&proj, "Cargo.toml").contains("[patch.crates-io]")); assert!(!vendor_ledger_claims(&proj, &purl)); + + // The half-reverted state this guard really exists for: the lock is + // back on crates.io (restored from version control, or re-resolved) + // while the manifest pin survives — here in the TABLE form the hosted + // rewriter writes for a `[dependencies.]` declaration, which a + // ` = { … }` probe reads as "not redirected". + let registry = toml_before + .split("registry = \"") + .nth(1) + .and_then(|rest| rest.split('"').next()) + .expect("the hosted scan pinned a socket-patch registry") + .to_string(); + assert!(registry.starts_with("socket-patch-"), "{registry}"); + std::fs::write(proj.join("Cargo.lock"), &pristine_lock).unwrap(); + std::fs::write( + proj.join("Cargo.toml"), + format!( + "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\ + [dependencies.{DEP}]\nversion = \"1.0\"\nregistry = \"{registry}\"\n" + ), + ) + .unwrap(); + let table_toml = read(&proj, "Cargo.toml"); + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + &cargo_home, + ); + assert_eq!( + code, 1, + "the table-form pin must fail closed too: {stdout}\n{stderr}" + ); + assert!( + stdout.contains("hosted_redirect_live"), + "actionable refusal code missing: {stdout}" + ); + assert_eq!(read(&proj, "Cargo.toml"), table_toml); + assert_eq!(read(&proj, "Cargo.lock"), pristine_lock); + assert!(!vendor_ledger_claims(&proj, &purl)); } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 8c885688..ebcd2fab 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -733,8 +733,51 @@ fn rewrite_cargo( if cargo.is_empty() { return; } - let mut cargo_toml = files.get("Cargo.toml").cloned(); - let mut cargo_lock = files.get("Cargo.lock").cloned(); + // The root manifest first, then every workspace-member manifest the + // caller supplied (`/Cargo.toml`): a member's own declaration of the + // crate resolves exactly like the root's, so it must be pinned too, or + // the lock's repointed entry is unsatisfiable (`--locked` fails) while + // the dep is reported redirected. + let mut manifests: Vec<(String, String)> = files + .iter() + .filter(|(k, _)| k.as_str() == "Cargo.toml") + .chain( + files + .iter() + .filter(|(k, _)| is_cargo_member_manifest_key(k)), + ) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + // Every planner below matches LF text. A file whose every line ends in + // CRLF (a Windows checkout) is planned as LF and written back — edit + // fragments included, so `remove` finds them — as CRLF. Mixed endings + // stay as they are (and refuse where the LF grammar does not match). + let mut crlf_paths: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut to_lf = |path: &str, text: String| -> String { + match crlf_to_lf(&text) { + Some(lf) => { + crlf_paths.insert(path.to_string()); + lf + } + None => text, + } + }; + for (path, text) in manifests.iter_mut() { + *text = to_lf(path, std::mem::take(text)); + } + // Each manifest's own `[package] name` — how Cargo.lock names the + // source-less (workspace / path) package it declares. + let manifest_packages: Vec> = manifests + .iter() + .map(|(_, text)| cargo_manifest_package_name(text)) + .collect(); + let edits_before = result.edits.len(); + let mut changed_manifests: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + let mut cargo_lock = files + .get("Cargo.lock") + .cloned() + .map(|t| to_lf("Cargo.lock", t)); // Cargo reads the LEGACY extensionless `.cargo/config` in preference to // `config.toml` when both exist (it warns about the duplicate), so a // managed `[registries.…]` block written to `config.toml` there is @@ -746,8 +789,12 @@ fn rewrite_cargo( } else { ".cargo/config.toml" }; - let mut cargo_config = files.get(cargo_config_key).cloned().unwrap_or_default(); - let (mut toml_changed, mut lock_changed, mut config_changed) = (false, false, false); + let mut cargo_config = files + .get(cargo_config_key) + .cloned() + .map(|t| to_lf(cargo_config_key, t)) + .unwrap_or_default(); + let (mut lock_changed, mut config_changed) = (false, false); for dep in &cargo { let Some(ov) = registry_override_of_kind(dep, "cargo-sparse") else { @@ -816,7 +863,7 @@ fn rewrite_cargo( // 1. Plan the Cargo.toml pin FIRST — it is the gate for everything // else. Without a manifest pin nothing forces resolution through the // managed registry, so no other file may be touched for this dep. - let Some(toml_text) = cargo_toml.as_ref() else { + if !manifests.iter().any(|(k, _)| k == "Cargo.toml") { result.warnings.push(RewriteWarning { code: "redirect_cargo_toml_dep_not_found".into(), detail: format!( @@ -825,40 +872,126 @@ fn rewrite_cargo( ), }); continue; - }; - let toml_plan = match plan_cargo_toml(toml_text, &dep.name, ®) { - Ok(plan) => plan, - Err(CargoTomlPlanError::NotFound) => { + } + let other_versions = + cargo_lock_other_versions(cargo_lock.as_deref(), &dep.name, &dep.version); + // The root manifest's `[workspace.dependencies]` verdicts feed its + // members' `workspace = true` inheritors. + let mut root_workspace: BTreeMap = BTreeMap::new(); + let mut toml_plans: Vec<(usize, CargoTomlPlan)> = Vec::new(); + let mut excluded: Vec<(String, String)> = Vec::new(); + let mut refused: Option<(String, String)> = None; + for (i, (path, text)) in manifests.iter().enumerate() { + match plan_cargo_toml( + text, + path, + &dep.name, + &dep.version, + &other_versions, + ®, + &root_workspace, + ) { + Ok(plan) => { + if path == "Cargo.toml" { + root_workspace = plan.workspace.clone(); + } + excluded.extend(plan.excluded.iter().map(|req| (path.clone(), req.clone()))); + if plan.found { + toml_plans.push((i, plan)); + } + } + Err(reason) => { + refused = Some((path.clone(), reason)); + break; + } + } + } + if let Some((path, reason)) = refused { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_toml_dep_unrewritable".into(), + detail: format!( + "{} in {path} cannot be pinned ({reason}); dependency skipped \ + (nothing rewritten)", + dep.name + ), + }); + continue; + } + // Declared, but no declaration's requirement accepts the patched + // version: cargo resolves each to another version, so a pin cannot + // reach the locked one (the TS twin's requirement-coverage refusal). + if toml_plans.is_empty() && !excluded.is_empty() { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_toml_dep_unrewritable".into(), + detail: cargo_requirement_excludes_detail( + &dep.name, + &dep.version, + &excluded, + cargo_lock.as_deref(), + ), + }); + continue; + } + if toml_plans.is_empty() { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_toml_dep_not_found".into(), + detail: cargo_not_declared_detail( + &dep.name, + &dep.version, + manifests.len(), + cargo_lock.as_deref(), + ), + }); + continue; + } + // A pin reaches only the declarations it sits on: every OTHER lock + // package depending on the crate — a registry/git crate, or a path + // package whose manifest was not planned (outside the project, behind + // a symlink) — keeps resolving it from crates.io, so the repointed + // lock is unsatisfiable and that consumer compiles the unpatched copy. + if let Some(lock_text) = cargo_lock.as_deref() { + let pinned_packages: std::collections::BTreeSet<&str> = toml_plans + .iter() + .filter_map(|(i, _)| manifest_packages[*i].as_deref()) + .collect(); + let blocking = + cargo_unpinnable_dependents(lock_text, &dep.name, &dep.version, &pinned_packages); + if !blocking.is_empty() { result.warnings.push(RewriteWarning { - code: "redirect_cargo_toml_dep_not_found".into(), - detail: format!( - "no [dependencies] entry for {} in Cargo.toml; dependency skipped \ - (nothing rewritten)", - dep.name - ), + code: "redirect_cargo_transitive_dependents".into(), + detail: cargo_transitive_dependents_detail(&dep.name, &dep.version, &blocking), }); continue; } - Err(CargoTomlPlanError::Refused(reason)) => { + } else { + // NO Cargo.lock: the resolved graph the check above reads does + // not exist, so nothing here can say whether some other + // dependency's own graph also pulls in the crate. Any other + // declared dependency might, and the pin reaches only the + // declarations it sits on — that consumer would compile the + // unpatched crates.io copy while the scan reports the crate + // redirected and VEX attests it. Fail closed, exactly as the + // locked path does for a dependent it CAN see; a project whose + // only dependency is the patched crate has nothing that could + // pull it in, and still redirects. + let others = cargo_lockless_other_dependencies(&manifests, &dep.name); + if !others.is_empty() { result.warnings.push(RewriteWarning { - code: "redirect_cargo_toml_dep_unrewritable".into(), - detail: format!( - "{} in Cargo.toml cannot be pinned ({reason}); dependency skipped \ - (nothing rewritten)", - dep.name - ), + code: "redirect_cargo_lockless_dependents".into(), + detail: cargo_lockless_dependents_detail(&dep.name, &dep.version, &others), }); continue; } - }; + } // 2. Plan the Cargo.lock repoint. A lock that exists but has no // [[package]] for the dep means the project does not actually resolve // it — rewriting the manifest anyway would desync manifest and lock. // Skip the dep entirely (discarding the manifest plan). A project - // with NO lockfile is fine: the manifest pin alone forces the next - // resolution through the managed registry, which serves the patched - // checksum. + // with NO lockfile reaches here only when the patched crate is its + // one declared dependency (the dependents gate above): the manifest + // pin alone then forces the next resolution through the managed + // registry, which serves the patched checksum. enum LockCommit { Write(String, Vec), InPlace, @@ -908,10 +1041,12 @@ fn rewrite_cargo( result.edits.push(plan.edit); config_changed = true; } - if toml_plan.changed { - cargo_toml = Some(toml_plan.content); - result.edits.extend(toml_plan.edits); - toml_changed = true; + for (i, plan) in toml_plans { + if plan.changed { + changed_manifests.insert(manifests[i].0.clone()); + manifests[i].1 = plan.content; + result.edits.extend(plan.edits); + } } match lock_commit { LockCommit::Write(content, edits) => { @@ -924,19 +1059,367 @@ fn rewrite_cargo( result.confirmed_cargo_uuids.insert(dep.patch_uuid.clone()); } - if toml_changed { - if let Some(t) = cargo_toml { - result.files.insert("Cargo.toml".into(), t); + let restore = |path: &str, text: String| -> String { + if crlf_paths.contains(path) { + text.replace('\n', "\r\n") + } else { + text + } + }; + for edit in &mut result.edits[edits_before..] { + if crlf_paths.contains(&edit.path) { + for fragment in [&mut edit.original, &mut edit.new] { + if let Some(Value::String(text)) = fragment { + *text = text.replace('\n', "\r\n"); + } + } + } + } + for (path, text) in manifests { + if changed_manifests.contains(&path) { + let text = restore(&path, text); + result.files.insert(path, text); } } if lock_changed { if let Some(l) = cargo_lock { - result.files.insert("Cargo.lock".into(), l); + result + .files + .insert("Cargo.lock".into(), restore("Cargo.lock", l)); } } if config_changed { - result.files.insert(cargo_config_key.into(), cargo_config); + result.files.insert( + cargo_config_key.into(), + restore(cargo_config_key, cargo_config), + ); + } +} + +/// `text` with every CRLF turned into LF, when every line break in it is a +/// CRLF (and there is at least one); `None` for LF-only or mixed text. +fn crlf_to_lf(text: &str) -> Option { + let crlf = text.matches("\r\n").count(); + (crlf > 0 && crlf == text.matches('\n').count()).then(|| text.replace("\r\n", "\n")) +} + +/// A workspace-member manifest key the caller supplied: `/Cargo.toml`, +/// a plain repo-relative path (never absolute, never `..`, never under the +/// ledger's `.socket/` or a build `target/`). +fn is_cargo_member_manifest_key(key: &str) -> bool { + let Some(dir) = key.strip_suffix("/Cargo.toml") else { + return false; + }; + !dir.is_empty() + && !key.starts_with('/') + && !key.contains('\\') + && !key.contains(':') + && dir.split('/').all(|seg| { + !seg.is_empty() && seg != "." && seg != ".." && seg != ".socket" && seg != "target" + }) +} + +/// The not-declared warning for a crate no manifest names at the patched +/// version. A crate Cargo.lock nonetheless resolves is a TRANSITIVE-only +/// dependency: a `registry = "…"` pin reaches only the declaration it sits +/// on, so hosted mode cannot redirect it at all — say so, and name the mode +/// that can (vendored `[patch.crates-io]` applies to the whole graph). +fn cargo_not_declared_detail( + crate_name: &str, + version: &str, + manifests: usize, + lock: Option<&str>, +) -> String { + let scope = if manifests > 1 { + format!("any of the {manifests} workspace manifests") + } else { + "Cargo.toml".to_string() + }; + let head = format!("[[package]]\nname = \"{crate_name}\"\nversion = \"{version}\"\n"); + let transitive = lock.is_some_and(|lock| { + lock.match_indices(head.as_str()) + .any(|(at, _)| at == 0 || lock.as_bytes()[at - 1] == b'\n') + }); + if transitive { + format!( + "{crate_name}@{version} is a transitive-only dependency (Cargo.lock resolves it, \ + but no [dependencies] entry in {scope} declares it); hosted mode can pin only \ + direct dependencies, so it was NOT redirected and stays unpatched — patch it with \ + `socket-patch scan --mode vendored`, or declare it directly and re-run \ + (nothing rewritten)" + ) + } else { + format!( + "no [dependencies] entry for {crate_name} in {scope}; dependency skipped \ + (nothing rewritten)" + ) + } +} + +/// The refusal for a crate every declaration of which requires another +/// version (`excluded`: each declaring manifest and its requirement). +fn cargo_requirement_excludes_detail( + crate_name: &str, + version: &str, + excluded: &[(String, String)], + lock: Option<&str>, +) -> String { + let declared = excluded + .iter() + .map(|(path, req)| format!("\"{req}\" in {path}")) + .collect::>() + .join(", "); + let head = format!("[[package]]\nname = \"{crate_name}\"\nversion = \"{version}\"\n"); + let locked = lock.is_some_and(|lock| { + lock.match_indices(head.as_str()) + .any(|(at, _)| at == 0 || lock.as_bytes()[at - 1] == b'\n') + }); + let remedy = if locked { + "; Cargo.lock resolves it for another package, which a pin cannot reach — patch it \ + with `socket-patch scan --mode vendored`" + } else { + "" + }; + format!( + "{crate_name} is declared as {declared}, which {version} does not satisfy (cargo \ + resolves that declaration to another version){remedy}; dependency skipped (nothing \ + rewritten)" + ) +} + +/// The `[package] name` a manifest declares (`None` for a virtual workspace +/// root or an unparseable file). +fn cargo_manifest_package_name(text: &str) -> Option { + let doc = text.parse::().ok()?; + doc.get("package")? + .get("name")? + .as_str() + .map(str::to_string) +} + +/// The Cargo.lock packages that depend on `crate_name@version` and that a +/// manifest pin cannot reach: any package with a `source` (a registry or +/// git crate), and any source-less (workspace / path) package whose +/// manifest is not among `pinned_packages`. Dependency edges are matched +/// in every spelling — `"name"`, `"name version"` and the full +/// `"name version (source)"` id — so a v1 lock and a twin's full id are +/// covered alike. A lock that does not parse yields one entry saying so. +fn cargo_unpinnable_dependents( + lock: &str, + crate_name: &str, + version: &str, + pinned_packages: &std::collections::BTreeSet<&str>, +) -> Vec { + let Ok(doc) = lock.parse::() else { + return vec!["Cargo.lock (it does not parse as TOML)".to_string()]; + }; + let Some(packages) = doc + .get("package") + .and_then(toml_edit::Item::as_array_of_tables) + else { + return Vec::new(); + }; + let mut out = Vec::new(); + for package in packages.iter() { + let field = |key: &str| package.get(key).and_then(toml_edit::Item::as_str); + let (Some(name), Some(pkg_version)) = (field("name"), field("version")) else { + continue; + }; + let depends = package + .get("dependencies") + .and_then(toml_edit::Item::as_array) + .is_some_and(|deps| { + deps.iter().filter_map(|d| d.as_str()).any(|d| { + let mut parts = d.splitn(3, ' '); + parts.next() == Some(crate_name) && parts.next().is_none_or(|v| v == version) + }) + }); + if !depends { + continue; + } + match field("source") { + Some(source) => { + let kind = if source.starts_with("git+") { + "git" + } else { + "registry" + }; + out.push(format!("{name} {pkg_version} ({kind})")); + } + None if !pinned_packages.contains(name) => { + out.push(format!( + "{name} {pkg_version} (a path package whose Cargo.toml is outside the \ + project or not rewritable)" + )); + } + None => {} + } + } + out +} + +/// Dependencies OTHER than `crate_name` declared across the manifests this +/// rewriter can pin, as ` (in )`. This is the question a +/// Cargo.lock answers outright; without one, every such dependency is a +/// possible second consumer of the patched crate. A path dependency on a +/// manifest in this same list is NOT one of them — that package's own +/// declarations are listed here too — and a `workspace = true` inheritor +/// resolves to the root's `[workspace.dependencies]` entry, which is. +/// A manifest that does not parse is itself blocking (fail closed). +fn cargo_lockless_other_dependencies( + manifests: &[(String, String)], + crate_name: &str, +) -> Vec { + fn field<'a>(entry: &'a toml_edit::Item, key: &str) -> Option<&'a str> { + match entry { + toml_edit::Item::Table(t) => t.get(key).and_then(toml_edit::Item::as_str), + toml_edit::Item::Value(v) => v + .as_inline_table() + .and_then(|t| t.get(key)) + .and_then(toml_edit::Value::as_str), + _ => None, + } + } + fn flag(entry: &toml_edit::Item, key: &str) -> bool { + match entry { + toml_edit::Item::Table(t) => t.get(key).and_then(toml_edit::Item::as_bool), + toml_edit::Item::Value(v) => v + .as_inline_table() + .and_then(|t| t.get(key)) + .and_then(toml_edit::Value::as_bool), + _ => None, + } + .unwrap_or(false) + } + const KINDS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"]; + let known: std::collections::BTreeSet<&str> = + manifests.iter().map(|(k, _)| k.as_str()).collect(); + let mut out: Vec = Vec::new(); + for (path, text) in manifests { + let dir = path.strip_suffix("/Cargo.toml").unwrap_or(""); + let Ok(doc) = text.parse::() else { + out.push(format!("{path} (it does not parse as TOML)")); + continue; + }; + let scan = |item: Option<&toml_edit::Item>, out: &mut Vec| { + let Some(table) = item.and_then(toml_edit::Item::as_table_like) else { + return; + }; + for (key, entry) in table.iter() { + let name = field(entry, "package").unwrap_or(key); + if name == crate_name || flag(entry, "workspace") { + continue; + } + if let Some(rel) = field(entry, "path") { + let inside = crate::utils::cargo_workspace::normalize_rel(dir, rel) + .is_some_and(|d| { + d.is_empty() || known.contains(format!("{d}/Cargo.toml").as_str()) + }); + if inside { + continue; + } + } + let named = if path == "Cargo.toml" { + name.to_string() + } else { + format!("{name} (in {path})") + }; + if !out.contains(&named) { + out.push(named); + } + } + }; + for kind in KINDS { + scan(doc.get(kind), &mut out); + } + if let Some(targets) = doc.get("target").and_then(toml_edit::Item::as_table) { + for (_, target) in targets.iter() { + let Some(target) = target.as_table() else { + continue; + }; + for kind in KINDS { + scan(target.get(kind), &mut out); + } + } + } + if let Some(ws) = doc.get("workspace").and_then(toml_edit::Item::as_table) { + scan(ws.get("dependencies"), &mut out); + // A member manifest this run did not read is a second consumer + // nothing can rule out: it may declare the crate itself (a pin + // never reaches it) or a dependency that pulls it in. Member + // discovery drops what it must not follow — a symbolic link, a + // path outside the project — and a glob's expansion is not + // visible here at all, so only a literal member whose manifest + // IS in this run's set is accounted for. + let members = ws + .get("members") + .and_then(toml_edit::Item::as_array) + .into_iter() + .flat_map(|a| a.iter().filter_map(toml_edit::Value::as_str)); + for member in members { + let named = if member.contains(['*', '?']) { + format!("the workspace members pattern `{member}`") + } else if crate::utils::cargo_workspace::normalize_rel(dir, member) + .is_some_and(|d| known.contains(format!("{d}/Cargo.toml").as_str())) + { + continue; + } else { + format!("the workspace member `{member}`") + }; + if !out.contains(&named) { + out.push(named); + } + } + } + } + out +} + +/// The refusal for a lockless project that declares other dependencies. +fn cargo_lockless_dependents_detail(crate_name: &str, version: &str, others: &[String]) -> String { + const SHOWN: usize = 5; + let mut names = others + .iter() + .take(SHOWN) + .cloned() + .collect::>() + .join(", "); + if others.len() > SHOWN { + names.push_str(&format!(" and {} more", others.len() - SHOWN)); + } + format!( + "this project has no Cargo.lock, so nothing says whether {names} also pull in \ + {crate_name}@{version}; a `registry = …` pin reaches only the declarations it sits \ + on, so such a consumer would compile the unpatched crates.io copy while \ + {crate_name} is reported redirected — commit a lockfile (`cargo generate-lockfile`) \ + and re-run, or patch it with `socket-patch scan --mode vendored`, whose \ + `[patch.crates-io]` covers the whole graph (nothing rewritten)" + ) +} + +/// The refusal for a crate other lock packages also depend on. +fn cargo_transitive_dependents_detail( + crate_name: &str, + version: &str, + blocking: &[String], +) -> String { + const SHOWN: usize = 5; + let mut names = blocking + .iter() + .take(SHOWN) + .cloned() + .collect::>() + .join(", "); + if blocking.len() > SHOWN { + names.push_str(&format!(" and {} more", blocking.len() - SHOWN)); } + format!( + "{crate_name}@{version} is also a dependency of {names} in Cargo.lock; a `registry = …` \ + pin reaches only the declarations it sits on, so those would keep resolving \ + {crate_name} from crates.io (a `--locked` build fails, and the unpatched copy is \ + compiled) — it was NOT redirected and stays unpatched; patch it with \ + `socket-patch scan --mode vendored` (nothing rewritten)" + ) } /// Sparse index URLs land verbatim inside quoted TOML strings in both @@ -1218,15 +1701,17 @@ struct CargoTomlPlan { /// `false` when every occurrence already carried our registry (idempotent /// re-run) — the pin is in place, nothing to write. changed: bool, -} - -enum CargoTomlPlanError { - /// The crate is not declared anywhere in this manifest (rename-aware: - /// a key that matches but has `package = ""` is NOT the crate). - NotFound, - /// At least one occurrence exists that cannot be pinned to the managed - /// registry — the whole dep must be skipped. - Refused(String), + /// Whether this manifest declares the crate at the patched version at + /// all (rename-aware: a key that matches but has `package = ""` + /// is NOT the crate). `false` plans nothing. + found: bool, + /// This manifest's `[workspace.dependencies]` verdicts, per key — what + /// its members' `workspace = true` inheritors resolve against. + workspace: BTreeMap, + /// The requirements of this manifest's declarations of the crate that + /// do NOT accept the patched version (cargo resolves each to another + /// version). + excluded: Vec, } /// How one occurrence of the dep will be handled. @@ -1274,11 +1759,98 @@ static CARGO_TOML_PATH_GIT_RE: LazyLock = LazyLock::new(|| { Regex::new(r"\b(?:path|git)\s*=").expect("static path/git probe regex is valid") }); +static CARGO_TOML_VERSION_VAL_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"\bversion\s*=\s*"([^"]*)""#).expect("static version-value regex is valid") +}); + +/// Whether one declaration's version requirement selects the patched +/// version. Cargo resolves a declaration to ONE version, so a project that +/// locks several versions of a crate (`cfg-if = "1"` beside a renamed +/// `cfg-if-legacy = { package = "cfg-if", version = "0.1" }`) must pin +/// only the declaration whose requirement matches the patched version — +/// pinning every same-named declaration to one registry leaves the other +/// requirement unsatisfiable there. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum CargoReqMatch { + Ours, + NotOurs, + /// The requirement also matches another locked version (or cannot be + /// read while another version is locked): which one cargo picked for + /// this declaration cannot be told from the manifest. + Ambiguous, +} + +pub(crate) fn cargo_req_selects( + req: Option<&str>, + version: &str, + other_versions: &[String], +) -> CargoReqMatch { + let unknown = if other_versions.is_empty() { + CargoReqMatch::Ours + } else { + CargoReqMatch::Ambiguous + }; + let (Some(req), Ok(patched)) = (req, semver::Version::parse(version)) else { + return unknown; + }; + let Ok(req) = semver::VersionReq::parse(req.trim()) else { + return unknown; + }; + if !req.matches(&patched) { + return CargoReqMatch::NotOurs; + } + let also_other = other_versions + .iter() + .any(|v| semver::Version::parse(v).is_ok_and(|v| req.matches(&v))); + if also_other { + CargoReqMatch::Ambiguous + } else { + CargoReqMatch::Ours + } +} + +/// What a `[workspace.dependencies]` entry means for the patched version — +/// resolved per entry KEY, since `workspace = true` inherits by key. +#[derive(Debug, Clone, Copy, PartialEq)] +enum CargoWorkspaceEntry { + /// The entry lands (or already carries) the pin. + Pinned, + /// The entry names the crate at another version. + OtherVersion, +} + +/// Every version of `crate_name` a Cargo.lock holds other than `version`. +fn cargo_lock_other_versions(lock: Option<&str>, crate_name: &str, version: &str) -> Vec { + let Some(lock) = lock else { + return Vec::new(); + }; + let head = format!("[[package]]\nname = \"{crate_name}\"\nversion = \""); + let mut versions: Vec = lock + .match_indices(head.as_str()) + .filter(|&(at, _)| at == 0 || lock.as_bytes()[at - 1] == b'\n') + .filter_map(|(at, _)| { + let rest = &lock[at + head.len()..]; + rest.split_once('"').map(|(v, _)| v.to_string()) + }) + .filter(|v| v != version) + .collect(); + versions.sort(); + versions.dedup(); + versions +} + +/// `Err` carries the refusal reason: an occurrence exists that cannot be +/// pinned to the managed registry, so the whole dep must be skipped. +/// `inherited` is the workspace root's verdicts when planning a member. fn plan_cargo_toml( content: &str, + path: &str, crate_name: &str, + version: &str, + other_versions: &[String], reg: &str, -) -> Result { + inherited: &BTreeMap, +) -> Result { let lines: Vec<&str> = content.split('\n').collect(); let header_re: &Regex = &CARGO_TOML_HEADER_RE; let package_re: &Regex = &CARGO_TOML_PACKAGE_RE; @@ -1287,18 +1859,24 @@ fn plan_cargo_toml( let registry_index_re: &Regex = &CARGO_TOML_REGISTRY_INDEX_RE; let workspace_key_re: &Regex = &CARGO_TOML_WORKSPACE_KEY_RE; let path_git_re: &Regex = &CARGO_TOML_PATH_GIT_RE; + let version_val_re: &Regex = &CARGO_TOML_VERSION_VAL_RE; + let ambiguous = + || format!("its version requirement also matches another locked version of {crate_name}"); // A pending occurrence: what was found, resolved to an action in pass 2 // (workspace-inheriting entries need the whole file scanned first). enum Pending { Action(CargoTomlAction), - NeedsWorkspacePin, + /// `workspace = true` under this key. + NeedsWorkspacePin(String), Refuse(String), } let mut pending: Vec = Vec::new(); - // Whether the `[workspace.dependencies]` entry for the crate lands (or - // already carries) the pin — satisfies `workspace = true` inheritors. - let mut workspace_pinned = false; + // Per `[workspace.dependencies]` key naming the crate: whether that + // entry lands (or already carries) the pin — satisfies `workspace = + // true` inheritors of the same key — or names another version. + let mut ws_entries: BTreeMap = BTreeMap::new(); + let mut excluded: Vec = Vec::new(); let mut section = CargoTomlSection::Other; for (idx, raw) in lines.iter().enumerate() { @@ -1362,8 +1940,23 @@ fn plan_cargo_toml( }) }) }; + let req = find_value("version").map(|(_, v)| v); + let selects = if has("workspace") { + CargoReqMatch::Ours + } else { + cargo_req_selects(req.as_deref(), version, other_versions) + }; + if selects == CargoReqMatch::NotOurs { + excluded.extend(req); + if ws { + ws_entries.insert(key.clone(), CargoWorkspaceEntry::OtherVersion); + } + continue; + } if has("workspace") { - pending.push(Pending::NeedsWorkspacePin); + pending.push(Pending::NeedsWorkspacePin(key.clone())); + } else if selects == CargoReqMatch::Ambiguous { + pending.push(Pending::Refuse(ambiguous())); } else if has("path") || has("git") { pending.push(Pending::Refuse( "declared as a path/git dependency".to_string(), @@ -1377,7 +1970,7 @@ fn plan_cargo_toml( if value == reg { pending.push(Pending::Action(CargoTomlAction::Already)); if ws { - workspace_pinned = true; + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } } else if is_socket_patch_registry_name(&value) { let old_line = lines[line_idx]; @@ -1389,7 +1982,7 @@ fn plan_cargo_toml( new_text, })); if ws { - workspace_pinned = true; + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } } else { pending.push(Pending::Refuse(format!( @@ -1403,7 +1996,7 @@ fn plan_cargo_toml( inserted: format!("{indent}registry = \"{reg}\""), })); if ws { - workspace_pinned = true; + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } } } @@ -1422,7 +2015,7 @@ fn plan_cargo_toml( let sub = parse_cargo_entry_key(dotted).map(|(k, _)| k); if key == crate_name { if sub.as_deref() == Some("workspace") { - pending.push(Pending::NeedsWorkspacePin); + pending.push(Pending::NeedsWorkspacePin(key.clone())); } else { pending.push(Pending::Refuse( "declared with dotted keys this rewriter does not edit".to_string(), @@ -1465,8 +2058,25 @@ fn plan_cargo_toml( continue; } if workspace_key_re.is_match(inner) { - pending.push(Pending::NeedsWorkspacePin); - } else if path_git_re.is_match(inner) { + pending.push(Pending::NeedsWorkspacePin(key.clone())); + continue; + } + let req = version_val_re.captures(inner).map(|c| c[1].to_string()); + match cargo_req_selects(req.as_deref(), version, other_versions) { + CargoReqMatch::NotOurs => { + excluded.extend(req); + if workspace { + ws_entries.insert(key.clone(), CargoWorkspaceEntry::OtherVersion); + } + continue; + } + CargoReqMatch::Ambiguous => { + pending.push(Pending::Refuse(ambiguous())); + continue; + } + CargoReqMatch::Ours => {} + } + if path_git_re.is_match(inner) { pending.push(Pending::Refuse( "declared as a path/git dependency".to_string(), )); @@ -1475,7 +2085,7 @@ fn plan_cargo_toml( if value == reg { pending.push(Pending::Action(CargoTomlAction::Already)); if workspace { - workspace_pinned = true; + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } } else if is_socket_patch_registry_name(&value) { let new_text = registry_val_re @@ -1486,7 +2096,7 @@ fn plan_cargo_toml( new_text, })); if workspace { - workspace_pinned = true; + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } } else { pending.push(Pending::Refuse(format!( @@ -1518,7 +2128,7 @@ fn plan_cargo_toml( new_text, })); if workspace { - workspace_pinned = true; + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } } } else if value.starts_with('"') { @@ -1540,6 +2150,24 @@ fn plan_cargo_toml( )); continue; }; + let req = m + .get(2) + .expect("line_re always captures group 2 (version)") + .as_str(); + match cargo_req_selects(Some(req), version, other_versions) { + CargoReqMatch::NotOurs => { + excluded.push(req.to_string()); + if workspace { + ws_entries.insert(key.clone(), CargoWorkspaceEntry::OtherVersion); + } + continue; + } + CargoReqMatch::Ambiguous => { + pending.push(Pending::Refuse(ambiguous())); + continue; + } + CargoReqMatch::Ours => {} + } let new_text = format!( "{}{{ version = \"{}\", registry = \"{reg}\" }}{}", m.get(1) @@ -1557,7 +2185,7 @@ fn plan_cargo_toml( new_text, })); if workspace { - workspace_pinned = true; + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } } else if key == crate_name { pending.push(Pending::Refuse( @@ -1566,8 +2194,16 @@ fn plan_cargo_toml( } } + let not_found = |ws_entries: BTreeMap| CargoTomlPlan { + content: content.to_string(), + edits: Vec::new(), + changed: false, + found: false, + workspace: ws_entries, + excluded: excluded.clone(), + }; if pending.is_empty() { - return Err(CargoTomlPlanError::NotFound); + return Ok(not_found(ws_entries)); } // Resolve: any refusal (including an unsatisfiable `workspace = true` // inheritor) refuses the WHOLE dep — no partial pin is ever applied. @@ -1575,20 +2211,25 @@ fn plan_cargo_toml( for p in pending { match p { Pending::Action(a) => actions.push(a), - Pending::NeedsWorkspacePin => { - if workspace_pinned { + Pending::NeedsWorkspacePin(key) => match ws_entries.get(&key).or(inherited.get(&key)) { + Some(CargoWorkspaceEntry::Pinned) => { actions.push(CargoTomlAction::InheritsWorkspace); - } else { - return Err(CargoTomlPlanError::Refused( - "inherits from [workspace.dependencies] with no rewritable entry \ - in this manifest" - .to_string(), - )); } - } - Pending::Refuse(reason) => return Err(CargoTomlPlanError::Refused(reason)), + // Inherits another version of the crate: not this dep. + Some(CargoWorkspaceEntry::OtherVersion) => {} + None => { + return Err("inherits from [workspace.dependencies] with no rewritable \ + entry for it" + .to_string()); + } + }, + Pending::Refuse(reason) => return Err(reason), } } + // Every occurrence named another version (inheritors included). + if actions.is_empty() { + return Ok(not_found(ws_entries)); + } // Apply bottom-up so line indices stay valid; record edits top-down. let mut new_lines: Vec = lines.iter().map(|s| s.to_string()).collect(); @@ -1606,7 +2247,7 @@ fn plan_cargo_toml( match action { CargoTomlAction::ReplaceLine { new_text, .. } => { edits.push(FileEdit { - path: "Cargo.toml".into(), + path: path.into(), kind: "redirect_cargo_toml_dep".into(), action: "rewritten".into(), key: Some(crate_name.into()), @@ -1616,7 +2257,7 @@ fn plan_cargo_toml( } CargoTomlAction::InsertAfterHeader { inserted, .. } => { edits.push(FileEdit { - path: "Cargo.toml".into(), + path: path.into(), kind: "redirect_cargo_toml_dep".into(), action: "rewritten".into(), key: Some(crate_name.into()), @@ -1643,9 +2284,17 @@ fn plan_cargo_toml( content: new_lines.join("\n"), edits, changed, + found: true, + workspace: ws_entries, + excluded, }) } +/// The Cargo.lock edit kind for dependents' full-id references: `original` +/// / `new` are the quoted `" ()"` ids, keyed +/// `@`, and the inverse replaces EVERY occurrence of `new`. +pub(crate) const CARGO_LOCK_REFERENCE_KIND: &str = "redirect_cargo_lock_reference"; + static CARGO_LOCK_SOURCE_LINE_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"(?m)^source = "([^"]*)"$"#).expect("static lock source-line regex is valid") }); @@ -1673,8 +2322,9 @@ static CARGO_LOCK_AFTER_SOURCE_RE: LazyLock = LazyLock::new(|| { /// Full-id references are rewritten in any format (v2+ spells them that way /// when a name + version is ambiguous). Each changed fragment is its own /// `redirect_cargo_lock_entry` edit (unique text, so the fragment revert is -/// unambiguous): the entry, the `[metadata]` line, and each dependent's -/// whole `[[package]]` block. +/// unambiguous) — the entry and the `[metadata]` line — and the dependents' +/// references are one `redirect_cargo_lock_reference` edit holding the +/// quoted full id, reverted at every occurrence. fn plan_cargo_lock( content: &str, crate_name: &str, @@ -1778,22 +2428,48 @@ fn plan_cargo_lock( edits.push(edit(&line, &pinned)); } } - // Dependents' full-id references to the OLD source. + // Dependents' full-id references to the OLD source, recorded as ONE + // `redirect_cargo_lock_reference` edit holding just the quoted id — + // never a dependent's whole block: a block referencing two patched + // packages (the root of a v1 lock) would hold two overlapping block + // edits, and reverting the first-applied one alone found neither of its + // fragments. The id names this name + version + source exactly, so its + // inverse puts back EVERY occurrence, independently of any other + // package's edits and in any removal order. if let Some(old) = old_source.filter(|old| old != index_url) { let from = format!("\"{crate_name} {version} ({old})\""); let to = format!("\"{crate_name} {version} ({index_url})\""); + let mut repointed_any = false; + // The oldest v1 locks keep the ROOT package in a standalone `[root]` + // table instead of the `[[package]]` array, with its own full-id + // `dependencies`. It precedes the array, so the block walk below + // never reaches it and the lock would keep naming a package it no + // longer contains (`--locked` fails; an unlocked build silently + // re-resolves). + if let Some((start, end)) = lock_root_table(&new_content) { + if new_content[start..end].contains(&from) { + let repointed = new_content[start..end].replace(&from, &to); + new_content.replace_range(start..end, &repointed); + repointed_any = true; + } + } let mut cursor = 0; while let Some((start, end)) = next_lock_block(&new_content, cursor) { - let block = new_content[start..end].to_string(); - if block.contains(&from) { - let repointed = block.replace(&from, &to); + if new_content[start..end].contains(&from) { + let repointed = new_content[start..end].replace(&from, &to); new_content.replace_range(start..end, &repointed); - edits.push(edit(&block, &repointed)); + repointed_any = true; cursor = start + repointed.len(); } else { cursor = end; } } + if repointed_any { + edits.push(FileEdit { + kind: CARGO_LOCK_REFERENCE_KIND.into(), + ..edit(&from, &to) + }); + } } // Already redirected (re-run): every fragment is at the target values; a // recorded edit would have original == new and grow the ledger forever. @@ -1806,6 +2482,19 @@ fn plan_cargo_lock( } } +/// The v1 `[root]` table's span, when the lock has one: cargo before the +/// `[root]` removal recorded the root package there rather than in the +/// `[[package]]` array, and its `dependencies` spell full package ids the +/// same way. Bounded by [`lock_block_end`], like a package block. +fn lock_root_table(content: &str) -> Option<(usize, usize)> { + const HEADER: &str = "[root]\n"; + let at = content + .match_indices(HEADER) + .map(|(at, _)| at) + .find(|&at| at == 0 || content.as_bytes()[at - 1] == b'\n')?; + Some((at, lock_block_end(content, at + HEADER.len()))) +} + /// The next `[[package]]` block starting at or after `from`, as /// [`lock_block_end`] bounds it. fn next_lock_block(content: &str, from: usize) -> Option<(usize, usize)> { @@ -1922,6 +2611,10 @@ fn plan_cargo_config( "" }; let prefix = if config.is_empty() { "" } else { "\n" }; + // The newline a config without a final one needed rides in the recorded + // fragment, so the revert (which also drops the one blank separator + // before the fragment) restores the config's exact bytes. + let recorded = format!("{sep}{block}"); Some(CargoConfigPlan { content: format!("{config}{sep}{prefix}{block}"), edit: FileEdit { @@ -1930,7 +2623,7 @@ fn plan_cargo_config( action: "added".into(), key: Some(reg.to_string()), original: None, - new: Some(Value::String(block)), + new: Some(Value::String(recorded)), }, }) } @@ -8072,15 +8765,129 @@ mod tests { assert_eq!(cargo_socket_registry_pin(&renamed_other, "serde"), None); } - fn cargo_lock_with(name: &str, version: &str) -> String { - format!( - "# This file is automatically @generated by Cargo.\n\ - version = 3\n\ - \n\ - [[package]]\n\ - name = \"{name}\"\n\ - version = \"{version}\"\n\ - source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\ + /// NO Cargo.lock: the transitive-dependents refusal reads the resolved + /// graph, and without one nothing says whether another dependency also + /// pulls in the patched crate — a pin reaches only the declarations it + /// sits on, so that consumer would compile the unpatched crates.io copy + /// while the scan reported the crate redirected and VEX attested it. + /// Every OTHER declared dependency is therefore blocking; a path + /// dependency on a manifest this run pins is not (its own declarations + /// are pinned too), and neither is a `workspace = true` inheritor of the + /// root table this run scans. + #[test] + fn cargo_lockless_other_dependencies_are_refused() { + let head = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n"; + let refused = |files: BTreeMap| { + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty(), "nothing rewritten: {:?}", r.files); + assert!(r.edits.is_empty(), "{:?}", r.edits); + assert!(r.confirmed_cargo_uuids.is_empty(), "never confirmed"); + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_lockless_dependents"] + ); + r.warnings[0].detail.clone() + }; + let one = |toml: &str| { + let mut files = BTreeMap::new(); + files.insert("Cargo.toml".to_string(), toml.to_string()); + files + }; + + // A registry dependency beside the patched crate. + let detail = refused(one(&format!( + "{head}[dependencies]\nserde = \"1.0.190\"\ntokio = \"1\"\n" + ))); + assert!(detail.contains("tokio"), "{detail}"); + assert!(detail.contains("cargo generate-lockfile"), "{detail}"); + assert!(detail.contains("--mode vendored"), "{detail}"); + // A dev-dependency counts (it is linked into the test build too). + refused(one(&format!( + "{head}[dependencies]\nserde = \"1.0.190\"\n\n\ + [dev-dependencies]\ntokio = \"1\"\n" + ))); + // A path dependency this run cannot pin (outside the project). + let detail = refused(one(&format!( + "{head}[dependencies]\nserde = \"1.0.190\"\n\ + shared = {{ path = \"../shared\" }}\n" + ))); + assert!(detail.contains("shared"), "{detail}"); + // A member's own other dependency blocks as well. + let mut files = one(&format!( + "[workspace]\nmembers = [\"b\"]\n\n{head}\ + [dependencies]\nserde = \"1.0.190\"\n" + )); + files.insert( + "b/Cargo.toml".to_string(), + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nrand = \"0.8\"\n" + .to_string(), + ); + let detail = refused(files); + assert!(detail.contains("rand (in b/Cargo.toml)"), "{detail}"); + + // A member manifest this run did NOT read — dropped by member + // discovery (a symbolic link, a path outside the project) or hidden + // behind a glob it cannot expand — may declare the crate itself or + // pull it in, and nothing here can tell. + let detail = refused(one( + "[workspace]\nmembers = [\"b\"]\n\n[package]\nname = \"app\"\n\ + version = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + )); + assert!(detail.contains("the workspace member `b`"), "{detail}"); + let detail = refused(one( + "[workspace]\nmembers = [\"crates/*\"]\n\n[package]\nname = \"app\"\n\ + version = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + )); + assert!( + detail.contains("the workspace members pattern `crates/*`"), + "{detail}" + ); + } + + /// The lockless shapes that stay redirectable: the patched crate alone, + /// the same crate declared again by a member this run pins, and a path + /// dependency on that member (whose own declarations are pinned too). + #[test] + fn cargo_lockless_self_contained_workspace_still_redirects() { + let mut files = BTreeMap::new(); + files.insert( + "Cargo.toml".to_string(), + "[workspace]\nmembers = [\"b\"]\n\n\ + [package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\nb = { path = \"b\" }\n" + .to_string(), + ); + files.insert( + "b/Cargo.toml".to_string(), + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\n" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + for key in ["Cargo.toml", "b/Cargo.toml"] { + assert!( + r.files + .get(key) + .is_some_and(|t| t.contains(&format!("registry = \"{}\"", cargo_reg()))), + "{key} must be pinned: {:?}", + r.files.get(key) + ); + } + assert!(!r.files.contains_key("Cargo.lock")); + } + + fn cargo_lock_with(name: &str, version: &str) -> String { + format!( + "# This file is automatically @generated by Cargo.\n\ + version = 3\n\ + \n\ + [[package]]\n\ + name = \"{name}\"\n\ + version = \"{version}\"\n\ + source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\ checksum = \"91f70896d6720bc714a4a57d22fc91f1db634680e65c8efe13323f1fa38d53f5\"\n" ) } @@ -8664,6 +9471,422 @@ mod tests { assert!(r.confirmed_cargo_uuids.is_empty()); } + /// A second patched version of the crate, uuid distinct from + /// [`CARGO_UUID`]. + const CARGO_UUID_2: &str = "3c5d7e9f-2a4b-4c6d-8e0f-1a3b5c7d9e1f"; + + fn cfg_if_override(version: &str, uuid: &str) -> DepOverride { + let mut dep = cargo_sparse_override(); + dep.name = "cfg-if".into(); + dep.version = version.into(); + dep.patch_uuid = uuid.into(); + let ov = dep.registry_override.as_mut().expect("fixture override"); + ov.index_url = format!("sparse+https://patch.test/cargo/{uuid}/index/"); + ov.identifiers.name = "cfg-if".into(); + ov.identifiers.version = version.into(); + dep + } + + /// Both cfg-if versions locked from crates.io (the `multi-version` shape). + fn cfg_if_multi_files(manifest_deps: &str) -> BTreeMap { + let block = |v: &str| { + format!( + "[[package]]\nname = \"cfg-if\"\nversion = \"{v}\"\n\ + source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\ + checksum = \"{}\"\n", + "1".repeat(64) + ) + }; + let mut files = BTreeMap::new(); + files.insert( + "Cargo.toml".to_string(), + format!("[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n{manifest_deps}"), + ); + files.insert( + "Cargo.lock".to_string(), + format!("version = 3\n\n{}\n{}", block("0.1.10"), block("1.0.4")), + ); + files + } + + const CFG_IF_MULTI_DEPS: &str = "[dependencies]\ncfg-if = \"1.0.4\"\n\ + cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n"; + + /// Bug B: the manifest pin matched the crate NAME only, so every + /// same-named declaration — `cfg-if-legacy = { package = "cfg-if", + /// version = "0.1.10" }` too — was pinned to the one patched version's + /// registry, where `^0.1.10` cannot resolve. Each declaration is pinned + /// only by the patch its version requirement selects. + #[test] + fn cargo_multi_version_pins_only_the_declaration_the_version_selects() { + let files = cfg_if_multi_files(CFG_IF_MULTI_DEPS); + let reg1 = format!("socket-patch-{CARGO_UUID}"); + let reg2 = format!("socket-patch-{CARGO_UUID_2}"); + + let r = rewrite_registry_redirect(&files, &[cfg_if_override("1.0.4", CARGO_UUID)]); + let toml = r.files.get("Cargo.toml").expect("manifest pinned"); + assert!( + toml.contains(&format!( + "cfg-if = {{ version = \"1.0.4\", registry = \"{reg1}\" }}\n" + )), + "{toml}" + ); + assert!( + toml.contains("cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n"), + "the 0.1.10 declaration is not the patched version's: {toml}" + ); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + + let r = rewrite_registry_redirect(&files, &[cfg_if_override("0.1.10", CARGO_UUID_2)]); + let toml = r.files.get("Cargo.toml").expect("manifest pinned"); + assert!(toml.contains("cfg-if = \"1.0.4\"\n"), "{toml}"); + assert!( + toml.contains(&format!( + "cfg-if-legacy = {{ package = \"cfg-if\", version = \"0.1.10\", registry = \"{reg2}\" }}" + )), + "{toml}" + ); + let lock = r.files.get("Cargo.lock").expect("lock repointed"); + assert!( + lock.contains(&format!( + "version = \"0.1.10\"\nsource = \"sparse+https://patch.test/cargo/{CARGO_UUID_2}/index/\"" + )) && lock.contains( + "version = \"1.0.4\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"" + ), + "only the 0.1.10 entry moves: {lock}" + ); + + let r = rewrite_registry_redirect( + &files, + &[ + cfg_if_override("1.0.4", CARGO_UUID), + cfg_if_override("0.1.10", CARGO_UUID_2), + ], + ); + let toml = r.files.get("Cargo.toml").expect("manifest pinned"); + assert!( + toml.contains(&format!("version = \"1.0.4\", registry = \"{reg1}\"")) + && toml.contains(&format!("version = \"0.1.10\", registry = \"{reg2}\"")), + "{toml}" + ); + assert_eq!(r.confirmed_cargo_uuids.len(), 2); + } + + /// A requirement that also matches another locked version cannot be + /// attributed to the patched one — refuse the dep, write nothing. + #[test] + fn cargo_requirement_matching_several_locked_versions_refuses() { + let files = cfg_if_multi_files( + "[dependencies]\ncfg-if = \">=0.1\"\n\ + cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n", + ); + let r = rewrite_registry_redirect(&files, &[cfg_if_override("1.0.4", CARGO_UUID)]); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_toml_dep_unrewritable"] + ); + assert!(r.confirmed_cargo_uuids.is_empty()); + } + + /// A declaration whose requirement excludes the patched version is not + /// the patched crate, and a pin there cannot reach the locked one: the + /// requirement-coverage refusal (the TS twin's code), nothing written. + #[test] + fn cargo_requirement_excluding_the_patched_version_is_unrewritable() { + let mut files = BTreeMap::new(); + files.insert( + "Cargo.toml".to_string(), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"2\"\n" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty(), "{:?}", r.files); + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_toml_dep_unrewritable"] + ); + assert!( + r.warnings[0].detail.contains("\"2\" in Cargo.toml"), + "{:?}", + r.warnings + ); + assert!(r.confirmed_cargo_uuids.is_empty()); + } + + /// A `workspace = true` inheritor of the entry that names ANOTHER + /// version is not this dep (and does not refuse it). + #[test] + fn cargo_workspace_inheritor_of_another_version_is_skipped() { + let files = cfg_if_multi_files( + "[workspace.dependencies]\ncfg-if = \"1.0.4\"\n\ + cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n\n\ + [dependencies]\ncfg-if = { workspace = true }\n\ + cfg-if-legacy = { workspace = true }\n", + ); + let r = rewrite_registry_redirect(&files, &[cfg_if_override("0.1.10", CARGO_UUID_2)]); + let toml = r.files.get("Cargo.toml").expect("workspace entry pinned"); + assert!( + toml.contains(&format!( + "cfg-if-legacy = {{ package = \"cfg-if\", version = \"0.1.10\", registry = \"socket-patch-{CARGO_UUID_2}\" }}" + )) && toml.contains("[workspace.dependencies]\ncfg-if = \"1.0.4\"\n"), + "{toml}" + ); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID_2)); + } + + /// A project the name-only matcher already damaged (the 0.1.10 + /// declaration pinned to the 1.0.4 patch's registry) is repaired: the + /// 0.1.10 patch supersedes its own declaration's socket pin, and the + /// 1.0.4 patch leaves it alone. + #[test] + fn cargo_mispinned_other_version_declaration_is_repaired() { + let reg1 = format!("socket-patch-{CARGO_UUID}"); + let reg2 = format!("socket-patch-{CARGO_UUID_2}"); + let files = cfg_if_multi_files(&format!( + "[dependencies]\ncfg-if = {{ version = \"1.0.4\", registry = \"{reg1}\" }}\n\ + cfg-if-legacy = {{ package = \"cfg-if\", version = \"0.1.10\", registry = \"{reg1}\" }}\n" + )); + let r = rewrite_registry_redirect( + &files, + &[ + cfg_if_override("1.0.4", CARGO_UUID), + cfg_if_override("0.1.10", CARGO_UUID_2), + ], + ); + let toml = r.files.get("Cargo.toml").expect("legacy pin superseded"); + assert!( + toml.contains(&format!("version = \"1.0.4\", registry = \"{reg1}\"")) + && toml.contains(&format!("version = \"0.1.10\", registry = \"{reg2}\"")), + "{toml}" + ); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + } + + /// A virtual workspace: the root pins `[workspace.dependencies]`, member + /// `a` inherits, member `b` declares serde itself. + fn cargo_workspace_files(b_manifest: &str) -> BTreeMap { + let mut files = cargo_files( + "[workspace]\nmembers = [\"a\", \"b\"]\n\n\ + [workspace.dependencies]\nserde = \"1.0.190\"\n", + ); + files.insert( + "a/Cargo.toml".to_string(), + "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + serde.workspace = true\n" + .to_string(), + ); + files.insert("b/Cargo.toml".to_string(), b_manifest.to_string()); + files + } + + /// Bug F: only the root's `[workspace.dependencies]` was pinned; member + /// `b`'s own `serde = "1.0.190"` stayed on crates.io, so `--locked` + /// failed against the repointed lock while the dep was reported + /// redirected. Every member manifest the caller supplies is planned in + /// the same transaction; inheritors are satisfied by the root's pin. + #[test] + fn cargo_workspace_member_direct_declaration_is_pinned() { + let files = cargo_workspace_files( + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let pin = format!( + "serde = {{ version = \"1.0.190\", registry = \"{}\" }}", + cargo_reg() + ); + assert!(r.files["Cargo.toml"].contains(&pin), "{:?}", r.files); + assert!(r.files["b/Cargo.toml"].contains(&pin), "{:?}", r.files); + assert!( + !r.files.contains_key("a/Cargo.toml"), + "the inheriting member needs no edit" + ); + assert!(r + .edits + .iter() + .any(|e| e.path == "b/Cargo.toml" && e.kind == "redirect_cargo_toml_dep")); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + + /// A member that cannot be pinned (a path dependency here) refuses the + /// WHOLE dep: the root stays untouched too. + #[test] + fn cargo_workspace_member_refusal_refuses_every_manifest() { + let files = cargo_workspace_files( + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + serde = { path = \"../serde\" }\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_toml_dep_unrewritable"] + ); + assert!( + r.warnings[0].detail.contains("b/Cargo.toml"), + "{:?}", + r.warnings + ); + } + + /// Only a member declares the crate (the root has no workspace entry): + /// that member is pinned, and a member inheriting an entry the root + /// does not have refuses. + #[test] + fn cargo_member_only_declaration_and_unsatisfied_inheritor() { + let mut files = cargo_files("[workspace]\nmembers = [\"b\"]\n"); + files.insert( + "b/Cargo.toml".to_string(), + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1\"\n" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert_eq!( + r.files.keys().map(String::as_str).collect::>(), + vec![".cargo/config.toml", "Cargo.lock", "b/Cargo.toml"] + ); + files.insert( + "b/Cargo.toml".to_string(), + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + serde = { workspace = true }\n" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty(), "{:?}", r.files); + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_toml_dep_unrewritable"] + ); + } + + /// Manifest keys outside a plain repo-relative `/Cargo.toml` are + /// never treated as members. + #[test] + fn cargo_member_manifest_keys() { + for ok in ["a/Cargo.toml", "crates/x-y/Cargo.toml"] { + assert!(is_cargo_member_manifest_key(ok), "{ok}"); + } + for bad in [ + "Cargo.toml", + "/abs/Cargo.toml", + "../up/Cargo.toml", + "a/../b/Cargo.toml", + "./a/Cargo.toml", + ".socket/vendor/cargo/x/Cargo.toml", + "target/generated/Cargo.toml", + "crates/a/target/gen/Cargo.toml", + "a//Cargo.toml", + "a/Cargo.toml.orig", + ] { + assert!(!is_cargo_member_manifest_key(bad), "{bad}"); + } + } + + /// Bug K: CRLF manifests and locks (Windows checkouts) were refused — + /// every planner matched LF text only. A CRLF-only file is now planned + /// as LF and written back CRLF, recorded fragments included, and a + /// re-run over the output is a silent no-op. + #[test] + fn cargo_crlf_files_are_rewritten_with_crlf_kept() { + let lf = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + ); + let crlf: BTreeMap = lf + .iter() + .map(|(k, v)| (k.clone(), v.replace('\n', "\r\n"))) + .collect(); + let want = rewrite_registry_redirect(&lf, &[cargo_sparse_override()]); + let got = rewrite_registry_redirect(&crlf, &[cargo_sparse_override()]); + assert!(got.warnings.is_empty(), "{:?}", got.warnings); + assert!(got.confirmed_cargo_uuids.contains(CARGO_UUID)); + for key in ["Cargo.toml", "Cargo.lock"] { + assert_eq!( + got.files[key], + want.files[key].replace('\n', "\r\n"), + "{key}" + ); + } + assert_eq!( + got.files[".cargo/config.toml"], want.files[".cargo/config.toml"], + "a created config stays LF" + ); + let lock_edit = got + .edits + .iter() + .find(|e| e.kind == "redirect_cargo_lock_entry") + .unwrap(); + let (Some(Value::String(orig)), Some(Value::String(new))) = + (&lock_edit.original, &lock_edit.new) + else { + panic!("lock edit fragments"); + }; + assert!(crlf["Cargo.lock"].contains(orig.as_str())); + assert!(got.files["Cargo.lock"].contains(new.as_str())); + + let mut again = crlf.clone(); + again.extend(got.files.clone()); + let rerun = rewrite_registry_redirect(&again, &[cargo_sparse_override()]); + assert!( + rerun.files.is_empty() && rerun.edits.is_empty() && rerun.warnings.is_empty(), + "{:?} {:?}", + rerun.files.keys(), + rerun.warnings + ); + } + + /// Mixed line endings are not normalized: the LF grammar still refuses + /// what it cannot match, writing nothing. + #[test] + fn cargo_mixed_line_endings_still_refuse() { + let mut files = cargo_files( + "[package]\r\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\r\nserde = \"1.0.190\"\r\n", + ); + files.insert( + "Cargo.lock".into(), + files["Cargo.lock"].replace('\n', "\r\n"), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_toml_dep_unrewritable"] + ); + } + + /// Bug J (kept a refusal): a crate only reached transitively cannot be + /// pinned by a manifest `registry` key. Nothing is written or + /// confirmed, and the warning says it is transitive-only, unpatched, and + /// which mode can patch it. + #[test] + fn cargo_transitive_only_crate_is_refused_loudly() { + let mut files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nother = \"1\"\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); + assert!(r.confirmed_cargo_uuids.is_empty()); + assert_eq!(warning_codes(&r), vec!["redirect_cargo_toml_dep_not_found"]); + let detail = &r.warnings[0].detail; + assert!( + detail.contains("transitive-only") + && detail.contains("NOT redirected") + && detail.contains("--mode vendored"), + "{detail}" + ); + // Not in the lock either: the plain not-declared wording. + files.remove("Cargo.lock"); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!( + r.warnings[0] + .detail + .starts_with("no [dependencies] entry for serde"), + "{:?}", + r.warnings + ); + } + /// A cargo dep whose override kind is not `cargo-sparse` warns (the TS /// twin's behavior) instead of vanishing silently. #[test] @@ -13584,8 +14807,7 @@ packages: let lock = format!( "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ \"log 0.4.20 ({CRATES_IO})\",\n \"serde 1.0.190 ({CRATES_IO})\",\n]\n\n\ - [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{CRATES_IO}\"\n\ - dependencies = [\n \"serde 1.0.190 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{CRATES_IO}\"\n\n\ [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"{CRATES_IO}\"\n\n\ [metadata]\n\"checksum log 0.4.20 ({CRATES_IO})\" = \"{a}\"\n\ \"checksum serde 1.0.190 ({CRATES_IO})\" = \"{b}\"\n", @@ -13601,8 +14823,7 @@ packages: let want = format!( "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ \"log 0.4.20 ({CRATES_IO})\",\n \"serde 1.0.190 ({idx})\",\n]\n\n\ - [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{CRATES_IO}\"\n\ - dependencies = [\n \"serde 1.0.190 ({idx})\",\n]\n\n\ + [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{CRATES_IO}\"\n\n\ [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"{idx}\"\n\n\ [metadata]\n\"checksum log 0.4.20 ({CRATES_IO})\" = \"{a}\"\n\ \"checksum serde 1.0.190 ({idx})\" = \"{cksum}\"\n", @@ -13611,15 +14832,19 @@ packages: assert_eq!(out, &want, "v1 lock stays v1, fully repointed"); assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); - // Four fragment edits (entry, metadata line, two dependents), each - // unique in the rewritten file, and reverting them newest-first (the - // replay order) restores the original byte-for-byte. + // Three fragment edits (entry, metadata line, the dependent's + // reference), each unique in the rewritten file, and reverting them + // newest-first (the replay order) restores the original + // byte-for-byte. let edits: Vec<&FileEdit> = r .edits .iter() - .filter(|e| e.kind == "redirect_cargo_lock_entry") + .filter(|e| { + e.kind == "redirect_cargo_lock_entry" || e.kind == CARGO_LOCK_REFERENCE_KIND + }) .collect(); - assert_eq!(edits.len(), 4, "{edits:#?}"); + assert_eq!(edits.len(), 3, "{edits:#?}"); + assert_eq!(edits[2].kind, CARGO_LOCK_REFERENCE_KIND, "{edits:#?}"); let mut reverted = out.clone(); for e in edits.iter().rev() { assert_eq!(e.key.as_deref(), Some("serde@1.0.190")); @@ -13642,15 +14867,211 @@ packages: ); let again = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); assert!( - !again - .edits - .iter() - .any(|e| e.kind == "redirect_cargo_lock_entry"), + !again.edits.iter().any(|e| e.path == "Cargo.lock"), "{:?}", again.edits ); } + /// The OLDEST v1 locks (cargo before the `[root]` removal) record the + /// root package in a standalone `[root]` table — not in the + /// `[[package]]` array — and its `dependencies` spell full package ids + /// the same way. REGRESSION: the reference walk searched `[[package]]` + /// blocks only, so `[root]` kept naming the crates.io id of a package + /// the repointed lock no longer contained: `cargo build --locked` fails + /// and an unlocked build silently discards the lock, while the scan + /// reports the crate redirected. The vendored twin has handled this + /// table since `dependency_tables_mut`. + #[test] + fn cargo_lock_v1_root_table_references_are_repointed() { + const CRATES_IO: &str = "registry+https://github.com/rust-lang/crates.io-index"; + let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\nlog = \"0.4\"\n"; + let cksum = "e".repeat(64); + let lock = format!( + "[root]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"log 0.4.20 ({CRATES_IO})\",\n \"serde 1.0.190 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{CRATES_IO}\"\n\n\ + [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"{CRATES_IO}\"\n\n\ + [metadata]\n\"checksum log 0.4.20 ({CRATES_IO})\" = \"{a}\"\n\ + \"checksum serde 1.0.190 ({CRATES_IO})\" = \"{b}\"\n", + a = "a".repeat(64), + b = "b".repeat(64), + ); + let mut files = BTreeMap::new(); + files.insert("Cargo.toml".to_string(), manifest.to_string()); + files.insert("Cargo.lock".to_string(), lock.clone()); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let out = r.files.get("Cargo.lock").expect("lock rewritten"); + let idx = cargo_index_url(); + let want = format!( + "[root]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"log 0.4.20 ({CRATES_IO})\",\n \"serde 1.0.190 ({idx})\",\n]\n\n\ + [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{CRATES_IO}\"\n\n\ + [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"{idx}\"\n\n\ + [metadata]\n\"checksum log 0.4.20 ({CRATES_IO})\" = \"{a}\"\n\ + \"checksum serde 1.0.190 ({idx})\" = \"{cksum}\"\n", + a = "a".repeat(64), + ); + assert_eq!(out, &want, "the [root] table is repointed with the rest"); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + + // The reference edit's inverse puts back EVERY occurrence, so the + // recorded fragments restore the lock byte for byte whether the id + // sat in `[root]`, in a package block, or in both. + let mut reverted = out.clone(); + for e in r + .edits + .iter() + .filter(|e| { + e.kind == "redirect_cargo_lock_entry" || e.kind == CARGO_LOCK_REFERENCE_KIND + }) + .rev() + { + let new = e.new.as_ref().and_then(Value::as_str).unwrap(); + let orig = e.original.as_ref().and_then(Value::as_str).unwrap(); + reverted = if e.kind == CARGO_LOCK_REFERENCE_KIND { + reverted.replace(new, orig) + } else { + reverted.replacen(new, orig, 1) + }; + } + assert_eq!(reverted, lock); + } + + /// Both places at once: a `[root]` table AND a package block reference + /// the patched crate by full id, and one reference edit repoints both. + #[test] + fn cargo_lock_v1_root_and_package_references_share_one_edit() { + const CRATES_IO: &str = "registry+https://github.com/rust-lang/crates.io-index"; + let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\n"; + let lock = format!( + "[root]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"serde 1.0.190 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"helper\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"serde 1.0.190 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"{CRATES_IO}\"\n\n\ + [metadata]\n\"checksum serde 1.0.190 ({CRATES_IO})\" = \"{b}\"\n", + b = "b".repeat(64), + ); + let mut files = BTreeMap::new(); + files.insert("Cargo.toml".to_string(), manifest.to_string()); + files.insert("Cargo.lock".to_string(), lock.clone()); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + // `helper` is a source-less path package that no manifest pins, so + // the dependents refusal owns this shape: nothing is rewritten. + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_transitive_dependents"] + ); + assert!(r.files.is_empty(), "{:?}", r.files); + } + + /// A lock of `app` (source-less, declares serde + `extra`) where `extra` + /// resolves from `extra_source` and depends on serde via `edge`. + fn cargo_shared_dependency_files( + extra: &str, + extra_source: Option<&str>, + edge: &str, + ) -> BTreeMap { + const CRATES_IO: &str = "registry+https://github.com/rust-lang/crates.io-index"; + let source = extra_source.map_or(String::new(), |s| format!("source = \"{s}\"\n")); + let mut files = BTreeMap::new(); + files.insert( + "Cargo.toml".to_string(), + format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + serde = \"1.0.190\"\n{extra} = {{ path = \"../{extra}\" }}\n" + ), + ); + files.insert( + "Cargo.lock".to_string(), + format!( + "version = 3\n\n[[package]]\nname = \"app\"\nversion = \"0.1.0\"\n\ + dependencies = [\n \"{extra}\",\n \"serde\",\n]\n\n\ + [[package]]\nname = \"{extra}\"\nversion = \"0.2.0\"\n{source}\ + dependencies = [\n \"{edge}\",\n]\n\n\ + [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"{CRATES_IO}\"\n\ + checksum = \"{}\"\n", + "1".repeat(64) + ), + ); + files + } + + /// A crate that is BOTH a direct dependency and a dependency of another + /// crate (cfg-if, libc, serde…) cannot be hosted-redirected: the pin + /// reaches only the root's declaration, the other crate keeps resolving + /// it from crates.io, so the repointed lock fails `--locked` and the + /// unpatched copy is compiled. REGRESSION: it was pinned, repointed and + /// confirmed (reported redirected, attested by VEX). + #[test] + fn cargo_crate_another_lock_package_depends_on_is_refused() { + const CRATES_IO: &str = "registry+https://github.com/rust-lang/crates.io-index"; + let git = "git+https://example.test/extra#0123456789abcdef"; + for (source, edge, kind) in [ + (Some(CRATES_IO), "serde".to_string(), "registry"), + (Some(CRATES_IO), "serde 1.0.190".to_string(), "registry"), + ( + Some(CRATES_IO), + format!("serde 1.0.190 ({CRATES_IO})"), + "registry", + ), + (Some(git), "serde".to_string(), "git"), + // A source-less path package whose manifest was never supplied + // (outside the project root, or behind a symlink). + (None, "serde".to_string(), "a path package"), + ] { + let files = cargo_shared_dependency_files("extra", source, &edge); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty(), "{edge}: {:?}", r.files.keys()); + assert!(r.edits.is_empty(), "{edge}: {:?}", r.edits); + assert!(r.confirmed_cargo_uuids.is_empty(), "{edge}"); + let [w] = r.warnings.as_slice() else { + panic!("{edge}: one warning: {:?}", r.warnings); + }; + assert_eq!(w.code, "redirect_cargo_transitive_dependents", "{edge}"); + assert!( + w.detail.contains(&format!("extra 0.2.0 ({kind}")), + "{edge}: {}", + w.detail + ); + assert!(w.detail.contains("--mode vendored"), "{}", w.detail); + } + } + + /// The dependent check is edge-exact: another VERSION of the crate is + /// not ours, and a source-less dependent whose manifest is planned (and + /// pinned) resolves through the pin. + #[test] + fn cargo_dependents_that_the_pin_reaches_or_another_version_do_not_refuse() { + let files = cargo_shared_dependency_files( + "extra", + Some("registry+https://github.com/rust-lang/crates.io-index"), + "serde 1.0.100", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + + let mut files = cargo_shared_dependency_files("extra", None, "serde"); + files.insert( + "extra/Cargo.toml".to_string(), + "[package]\nname = \"extra\"\nversion = \"0.2.0\"\n\n[dependencies]\nserde = \"1\"\n" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + assert!( + r.files["extra/Cargo.toml"].contains(&format!("registry = \"{}\"", cargo_reg())), + "{:?}", + r.files + ); + } + /// A checksum-less entry whose `source` line ends the block (the /// trailing newline sits outside the block region) still gets its pin. #[test] diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index 9c603b16..324aa583 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -54,6 +54,10 @@ enum Inverse { /// writers record an `original` that is a substring of `new` (the /// Cargo.toml insert variant, the maven version suffix). ReplaceFragment, + /// Like [`Inverse::ReplaceFragment`], but `new` legitimately occurs + /// several times and stands for every occurrence (a cargo v1 lock's + /// full-id dependency reference, named by several dependents). + ReplaceEveryFragment, PipenvEntry, HatchDocument, /// action `added` with only `new` recorded: the redirect inserted the @@ -68,6 +72,11 @@ enum Inverse { /// go.sum lines the redirect added (`new`, `\n`-joined): each is removed /// as a whole line, whatever the file's line endings. RemoveAddedLines, + /// The appended cargo `[registries.…]` block (`redirect_cargo_registry`, + /// action `added`): removed together with exactly the one blank + /// separator the rewriter put before it — see + /// [`remove_appended_cargo_block`]. + RemoveAppendedCargoBlock, /// Cleanup of PRIOR socket wiring performed during a redirect refresh /// (`redirect_golang_stale_*`). The removal already moved the file /// toward pristine; restoring it would re-create socket wiring, so @@ -106,10 +115,11 @@ fn classify(kind: &str, action: &str) -> (&'static str, Inverse) { "redirect_cargo_toml_dep" | "redirect_cargo_lock_entry" => { ("cargo", Inverse::ReplaceFragment) } + super::CARGO_LOCK_REFERENCE_KIND => ("cargo", Inverse::ReplaceEveryFragment), "redirect_cargo_registry" => ( "cargo", if action == "added" { - Inverse::RemoveAddedFragment + Inverse::RemoveAppendedCargoBlock } else { Inverse::ReplaceFragment }, @@ -250,7 +260,18 @@ fn safe_rel_path(path: &str) -> bool { /// fragment + newline) is byte-AMBIGUOUS to invert — `"m\n\n" + "F\n"` /// and `"m\n" + "\nF\n"` produce identical files — so the tidy form (the /// one `go mod tidy` itself emits) is chosen. -fn remove_fragment_once(content: &str, fragment: &str) -> String { +pub(super) fn remove_fragment_once(content: &str, fragment: &str) -> String { + // A CRLF file (its fragments recorded CRLF too) is inverted as LF and + // written back CRLF, so the separator bookkeeping below sees real line + // breaks instead of stranding a `\r` line. + let crlf = content.matches("\r\n").count(); + if crlf > 0 && crlf == content.matches('\n').count() && content.contains(fragment) { + return remove_fragment_once( + &content.replace("\r\n", "\n"), + &fragment.replace("\r\n", "\n"), + ) + .replace('\n', "\r\n"); + } let Some(pos) = content.find(fragment) else { return content.to_string(); }; @@ -289,6 +310,104 @@ fn remove_fragment_once(content: &str, fragment: &str) -> String { format!("{}{}", &content[..start], &content[end..]) } +/// Every line break in `text` is a CRLF (and there is at least one). +fn is_all_crlf(text: &str) -> bool { + let crlf = text.matches("\r\n").count(); + crlf > 0 && crlf == text.matches('\n').count() +} + +/// One fragment-edit inverse, tolerant of a line-ending conversion between +/// the scan and the revert (git `core.autocrlf` rewrites the committed +/// files but never the JSON-escaped fragments in the ledger). +#[derive(Debug, PartialEq)] +pub(super) enum FragmentRevert { + /// `new` was found and put back to `original` (the file's own line + /// endings kept). + Reverted(String), + /// `new` is gone but `original` is present: already unwound. + AlreadyOriginal, + /// Neither fragment is present. + Drifted, +} + +/// Replace `new` with `original` in `content` — once, or at `every` +/// occurrence — matching regardless of CRLF/LF: an all-CRLF file is +/// matched as LF and written back CRLF; any other file is matched with the +/// recorded fragments, then with their LF forms. `new` is looked for +/// before `original` (an `original` may be a substring of `new`). +pub(super) fn revert_fragment_eol( + content: &str, + new: &str, + original: &str, + every: bool, +) -> FragmentRevert { + if is_all_crlf(content) { + return match revert_fragment_eol( + &content.replace("\r\n", "\n"), + &new.replace("\r\n", "\n"), + &original.replace("\r\n", "\n"), + every, + ) { + FragmentRevert::Reverted(lf) => FragmentRevert::Reverted(lf.replace('\n', "\r\n")), + other => other, + }; + } + let (lf_new, lf_original) = (new.replace("\r\n", "\n"), original.replace("\r\n", "\n")); + for (n, o) in [(new, original), (lf_new.as_str(), lf_original.as_str())] { + if content.contains(n) { + return FragmentRevert::Reverted(if every { + content.replace(n, o) + } else { + content.replacen(n, o, 1) + }); + } + } + if content.contains(original) || content.contains(&lf_original) { + FragmentRevert::AlreadyOriginal + } else { + FragmentRevert::Drifted + } +} + +/// Whether `content` holds `fragment`, ignoring CRLF/LF differences. +pub(super) fn contains_eol(content: &str, fragment: &str) -> bool { + content.contains(fragment) + || content + .replace("\r\n", "\n") + .contains(&fragment.replace("\r\n", "\n")) +} + +/// Invert the cargo rewriter's append of a `[registries.…]` block: it wrote +/// `config + "\n" + block` (just `block` into an empty config) and records +/// `block` — or `"\n" + block` when the config lacked a final newline (the +/// extra newline it had to add first). Removing the recorded fragment plus +/// the one newline before it therefore restores the config's exact bytes: +/// a missing final newline or trailing blank lines included, and anything +/// the user appended after the block kept. An all-CRLF file is inverted as +/// LF and written back CRLF; a fragment recorded with the other line +/// endings (a checkout converted them) still matches. `None` when the +/// fragment is not in the file. +pub(super) fn remove_appended_cargo_block(content: &str, fragment: &str) -> Option { + if is_all_crlf(content) { + return remove_appended_cargo_block( + &content.replace("\r\n", "\n"), + &fragment.replace("\r\n", "\n"), + ) + .map(|lf| lf.replace('\n', "\r\n")); + } + let lf_fragment = fragment.replace("\r\n", "\n"); + let (pos, len) = match content.find(fragment) { + Some(pos) => (pos, fragment.len()), + None => (content.find(&lf_fragment)?, lf_fragment.len()), + }; + let before = &content[..pos]; + let before = before + .strip_suffix("\r\n") + .or_else(|| before.strip_suffix('\n')) + .unwrap_or(before); + Some(format!("{before}{}", &content[pos + len..])) +} + /// The string payloads of an edit, or `None` when a payload is missing or /// not a string (a shape the inverse table said must be there). fn str_payload(v: &Option) -> Option<&str> { @@ -316,6 +435,19 @@ pub async fn revert_remaining_redirect_edits( groups.entry(group).or_default().push(idx); } + // Where a cargo `[registries.…]` block can still be referenced from: + // the root manifest and lock, plus every manifest the ledger pinned. + let mut cargo_probes: Vec = vec!["Cargo.toml".to_string(), "Cargo.lock".to_string()]; + for edit in state + .edits + .iter() + .filter(|e| e.kind == "redirect_cargo_toml_dep") + { + if !cargo_probes.contains(&edit.path) { + cargo_probes.push(edit.path.clone()); + } + } + let mut drop_indices: BTreeSet = BTreeSet::new(); let mut refused_groups: BTreeSet<&'static str> = BTreeSet::new(); let mut pending_warnings: Vec<(String, String)> = Vec::new(); @@ -439,7 +571,9 @@ pub async fn revert_remaining_redirect_edits( } } } - Inverse::ReplaceFragment | Inverse::HatchDocument => { + Inverse::ReplaceFragment + | Inverse::ReplaceEveryFragment + | Inverse::HatchDocument => { let (Some(original), Some(new)) = (str_payload(&edit.original), str_payload(&edit.new)) else { @@ -505,6 +639,71 @@ pub async fn revert_remaining_redirect_edits( }; // `new` before `original`: original may be a substring // of new (Cargo.toml insert, maven version suffix). + // cargo: matched regardless of a CRLF/LF conversion since + // the scan (a checkout's `core.autocrlf` rewrites the + // files, never the ledger's escaped fragments). + if *group == "cargo" { + let every = inverse == Inverse::ReplaceEveryFragment; + let lf = |t: &str| t.replace("\r\n", "\n"); + // ONE scan legitimately records identical cargo + // edits (a manifest declaring the crate with the + // same line in two sections), and each one unwinds + // one occurrence — what `revert_cargo_redirect_purl` + // does over this same ledger. So the file may hold + // as many occurrences as there are identical edits + // left to spend on them; only a surplus is + // ambiguous. + let twins = indices + .iter() + .filter(|&&i| { + let other = &state.edits[i]; + !group_drops.contains(&i) + && other.path == edit.path + && other.kind == edit.kind + && other.action == edit.action + && other.key == edit.key + && other.original == edit.original + && other.new == edit.new + }) + .count() + .max(1); + if !every && lf(&content).matches(&lf(new)).count() > twins { + refuse( + format!( + "{}: the redirected fragment appears more than once — \ + ambiguous, refusing to guess", + edit.path + ), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } + match revert_fragment_eol(&content, new, original, every) { + FragmentRevert::Reverted(restored) => { + staged.insert(edit.path.clone(), Some(restored)); + group_drops.insert(idx); + } + // Same substring guard as below. + FragmentRevert::AlreadyOriginal if !lf(new).contains(&lf(original)) => { + group_drops.insert(idx); + } + _ => { + refuse( + format!( + "{}: content matches neither the redirected nor the \ + original fragment for {} — the file drifted; re-run \ + `scan --mode hosted` to normalize", + edit.path, edit.kind + ), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } + } + continue; + } if content.contains(new) { if content.matches(new).count() > 1 { refuse( @@ -571,6 +770,58 @@ pub async fn revert_remaining_redirect_edits( } } } + Inverse::RemoveAppendedCargoBlock => { + let Some(new) = str_payload(&edit.new) else { + refuse( + format!("{} edit is missing its recorded fragment", edit.kind), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + }; + // A block something still references (a hand-pinned dep) + // stays: removing it would leave that pin naming an + // undefined registry. The reverse walk has already + // unwound this ledger's own references. + let reg = edit.key.as_deref().unwrap_or_default(); + let index = new.split('"').nth(1).unwrap_or_default(); + let mut referenced = false; + for probe in &cargo_probes { + if let Ok(Some(text)) = staged_read(&staged, project_root, probe).await { + if (!reg.is_empty() && text.contains(reg)) + || (!index.is_empty() && text.contains(index)) + { + referenced = true; + break; + } + } + } + if referenced { + group_drops.insert(idx); + continue; + } + match staged_read(&staged, project_root, &edit.path).await { + Ok(Some(content)) => { + // Absent fragment == already clean. A config the + // rewrite created ends empty and goes with it. + if let Some(restored) = remove_appended_cargo_block(&content, new) { + staged.insert( + edit.path.clone(), + (!restored.is_empty()).then_some(restored), + ); + } + group_drops.insert(idx); + } + Ok(None) => { + group_drops.insert(idx); + } + Err(e) => { + refuse(e, &mut outcome); + refused_groups.insert(group); + continue 'group; + } + } + } Inverse::RemoveAddedFragment => { let Some(new) = str_payload(&edit.new) else { refuse( @@ -1618,6 +1869,52 @@ mod tests { assert_eq!(state.edits.len(), 1, "the edit must survive for a retry"); } + /// ONE scan records one cargo edit per OCCURRENCE, so a manifest that + /// declares the crate with the same line in two sections leaves two + /// IDENTICAL edits in the ledger. The whole-ledger replay — the + /// records-empty path a degraded (record-fetch-failed) run leaves + /// behind — must spend one edit per occurrence, exactly as the per-purl + /// `revert_cargo_redirect_purl` does over the same ledger. An + /// occurrence no edit accounts for is still ambiguous and refuses. + #[tokio::test] + async fn identical_cargo_edits_each_unwind_one_occurrence() { + let plain = "cfg-if = \"1\""; + let pinned = "cfg-if = { version = \"1\", registry = \"socket-patch-u\" }"; + let two = |line: &str| format!("[dependencies]\n{line}\n\n[dev-dependencies]\n{line}\n"); + let dir = TempDir::new().unwrap(); + write(dir.path(), "Cargo.toml", &two(pinned)).await; + let cargo_edit = || { + edit( + "Cargo.toml", + "redirect_cargo_toml_dep", + "rewritten", + Some(plain), + Some(pinned), + ) + }; + let mut state = state_with(vec![cargo_edit(), cargo_edit()], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!(read(dir.path(), "Cargo.toml").await, two(plain)); + assert!(state.edits.is_empty(), "{:?}", state.edits); + + // A THIRD occurrence with only two edits to spend: nothing says + // which one the ledger owns, so the group refuses byte-untouched. + let dir = TempDir::new().unwrap(); + let surplus = format!("{}\n[build-dependencies]\n{pinned}\n", two(pinned)); + write(dir.path(), "Cargo.toml", &surplus).await; + let mut state = state_with(vec![cargo_edit(), cargo_edit()], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!(out.refusals.len(), 1, "{out:?}"); + assert!( + out.refusals[0].reason.contains("more than once"), + "{:?}", + out.refusals[0] + ); + assert_eq!(read(dir.path(), "Cargo.toml").await, surplus); + assert_eq!(state.edits.len(), 2); + } + #[tokio::test] async fn gem_section_move_record_fails_closed() { // redirect_gemfile_lock_gem_source records only the bare URLs of a @@ -2838,6 +3135,57 @@ mod tests { // ---------- remove_fragment_once unit pins ---------- + #[test] + fn remove_fragment_once_keeps_crlf_separators_straight() { + assert_eq!( + remove_fragment_once("[net]\r\nretry = 2\r\n\r\nF\r\nG\r\n", "F\r\nG\r\n"), + "[net]\r\nretry = 2\r\n" + ); + assert_eq!( + remove_fragment_once("a\r\n\r\nF\r\n\r\nb\r\n", "F\r\n"), + "a\r\n\r\nb\r\n" + ); + } + + #[test] + fn remove_appended_cargo_block_inverts_exactly_what_was_appended() { + let block = "[registries.r]\nindex = \"i\"\n"; + for (written, fragment, want) in [ + // Empty config: the block alone (a created file ends empty). + (block.to_string(), block.to_string(), ""), + // One separator after a config ending in newline(s). + (format!("a\n\n{block}"), block.to_string(), "a\n"), + (format!("a\n\n\n{block}"), block.to_string(), "a\n\n"), + // No final newline: the added newline rides in the fragment. + (format!("a\n\n{block}"), format!("\n{block}"), "a"), + // The user appended after the block: kept. + ( + format!("a\n\n{block}b = 1\n"), + block.to_string(), + "a\nb = 1\n", + ), + // CRLF file, and a CRLF-recorded fragment against an LF file. + ( + format!("a\r\n\r\n{}", block.replace('\n', "\r\n")), + block.replace('\n', "\r\n"), + "a\r\n", + ), + (format!("a\n\n{block}"), block.replace('\n', "\r\n"), "a\n"), + ( + format!("a\r\n\r\n{}", block.replace('\n', "\r\n")), + block.to_string(), + "a\r\n", + ), + ] { + assert_eq!( + remove_appended_cargo_block(&written, &fragment).as_deref(), + Some(want), + "{written:?}" + ); + } + assert_eq!(remove_appended_cargo_block("a\n", block), None); + } + #[test] fn remove_fragment_once_absent_fragment_is_identity() { // Defensive edge: callers check contains() first, so the not-found diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index 6c7d6dd4..5280cf91 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -58,6 +58,7 @@ use crate::vendor::go_mod_edit::{ is_hosted_module_path, parse_replace_entries, HOSTED_GO_MODULE_PREFIX, }; +use super::replay::FragmentRevert; use super::staged::{flush_staged, read_rel, staged_read, Staged, StagedBytes}; use super::state::RedirectState; use super::FileEdit; @@ -148,6 +149,59 @@ static SOCKET_REGISTRY_UUID: LazyLock = LazyLock::new(|| { .expect("static registry-uuid regex is valid") }); +/// The `socket-patch-` registry uuids a recorded fragment names. +fn registry_uuids(fragment: Option<&Value>) -> impl Iterator + '_ { + fragment + .and_then(Value::as_str) + .into_iter() + .flat_map(|s| SOCKET_REGISTRY_UUID.captures_iter(s)) + .map(|c| c[1].to_string()) +} + +/// A Cargo.lock edit (keyed `@`): the entry / `[metadata]` +/// fragments, or the dependents' full-id reference. +fn is_cargo_lock_edit(e: &FileEdit) -> bool { + e.kind == "redirect_cargo_lock_entry" || e.kind == super::CARGO_LOCK_REFERENCE_KIND +} + +/// Every patch uuid `name@version` was redirected at: its record's, plus +/// each managed registry whose index URL one of its (version-keyed) +/// Cargo.lock edits names — the older links of a re-redirect chain. +fn cargo_lineage(state: &RedirectState, name: &str, version: &str, uuid: &str) -> HashSet { + let lock_key = format!("{name}@{version}"); + let lock_fragments: Vec<&str> = state + .edits + .iter() + .filter(|e| is_cargo_lock_edit(e) && e.key.as_deref() == Some(lock_key.as_str())) + .flat_map(|e| [e.original.as_ref(), e.new.as_ref()]) + .flatten() + .filter_map(Value::as_str) + .collect(); + let mut lineage: HashSet = HashSet::from([uuid.to_string()]); + for e in state + .edits + .iter() + .filter(|e| e.kind == "redirect_cargo_registry") + { + let Some(u) = e + .key + .as_deref() + .and_then(|k| k.strip_prefix("socket-patch-")) + else { + continue; + }; + let index = e + .new + .as_ref() + .and_then(Value::as_str) + .and_then(|block| block.split('"').nth(1)); + if index.is_some_and(|index| lock_fragments.iter().any(|f| f.contains(index))) { + lineage.insert(u.to_string()); + } + } + lineage +} + /// Revert every hosted-redirect edit the ledger records for `purl` (a cargo /// package), then drop that purl's record and edits from `state`. The caller /// persists the mutated ledger (see `persist_redirect_state`). @@ -172,21 +226,58 @@ pub async fn revert_cargo_redirect_purl( let (name, version) = (name.into_owned(), version.into_owned()); let lock_key = format!("{name}@{version}"); + // Manifest edits are keyed by crate NAME (the shared golden ledger + // shape), so when another version of the crate is redirected too, its + // pins carry the same key. Attribute each such edit to the version its + // declaration's requirement selects — the planner's own rule, and the + // only one that also holds for ledgers an older, name-only CLI wrote + // (a declaration of one version pinned to, then re-pinned from, the + // other version's registry). An edit whose line carries no readable + // requirement (a table-form header / registry line) falls back to the + // registry it pins: skipped when that is a sibling lineage's. Without a + // sibling the claim stays name-wide, as before. + let siblings: Vec<(String, HashSet)> = state + .records + .iter() + .filter(|(key, _)| **key != record_key) + .filter_map(|(key, rec)| { + let (n, v) = parse_cargo_purl(strip_purl_qualifiers(key))?; + (n == name && v != version) + .then(|| (v.to_string(), cargo_lineage(state, &n, &v, &rec.uuid))) + }) + .collect(); + let sibling_versions: Vec = siblings.iter().map(|(v, _)| v.clone()).collect(); + let sibling_uuids: HashSet = siblings.into_iter().flat_map(|(_, u)| u).collect(); + let is_my_manifest_edit = |e: &FileEdit| { + if sibling_versions.is_empty() { + return true; + } + let req = cargo_declared_req(e.original.as_ref()); + match super::cargo_req_selects(req.as_deref(), &version, &sibling_versions) { + super::CargoReqMatch::Ours if req.is_some() => true, + super::CargoReqMatch::NotOurs => false, + _ => !registry_uuids(e.new.as_ref()).any(|u| sibling_uuids.contains(&u)), + } + }; let is_wiring_edit = |e: &FileEdit| { - (e.kind == "redirect_cargo_toml_dep" && e.key.as_deref() == Some(name.as_str())) - || (e.kind == "redirect_cargo_lock_entry" - && e.key.as_deref() == Some(lock_key.as_str())) + (e.kind == "redirect_cargo_toml_dep" + && e.key.as_deref() == Some(name.as_str()) + && is_my_manifest_edit(e)) + || (is_cargo_lock_edit(e) && e.key.as_deref() == Some(lock_key.as_str())) }; // Registry blocks tie to this purl via the `socket-patch-` names in // its record + wiring edits (a patch uuid is per purl, so this cannot - // claim another package's block). + // claim another package's block) — never a sibling version's, which a + // re-pinned legacy declaration also names. let mut uuids: HashSet = HashSet::new(); uuids.insert(state.records[&record_key].uuid.clone()); for e in state.edits.iter().filter(|e| is_wiring_edit(e)) { for v in [&e.original, &e.new] { if let Some(s) = v.as_ref().and_then(Value::as_str) { for c in SOCKET_REGISTRY_UUID.captures_iter(s) { - uuids.insert(c[1].to_string()); + if !sibling_uuids.contains(&c[1]) { + uuids.insert(c[1].to_string()); + } } } } @@ -207,8 +298,34 @@ pub async fn revert_cargo_redirect_purl( .map(|(i, _)| i) .collect(); + // Every manifest the ledger ever pinned (workspace members included), + // plus the lock: where a registry block can still be referenced from. + let mut probes: Vec = vec!["Cargo.toml".to_string(), "Cargo.lock".to_string()]; + for e in state + .edits + .iter() + .filter(|e| e.kind == "redirect_cargo_toml_dep") + { + if !probes.contains(&e.path) { + probes.push(e.path.clone()); + } + } + let mut out = RedirectRevert::default(); let mut staged: Staged = Staged::new(); + // A block kept because it is still referenced normally leaves the + // ledger (it now belongs to whatever hand pin references it). The + // exception is a block another, still-recorded wiring edit pins to (an + // older CLI's cross-version pin): that edit stays in the ledger so the + // removal that retires the last such pin also removes the block. + let still_pinned: HashSet = state + .edits + .iter() + .enumerate() + .filter(|(i, e)| e.kind == "redirect_cargo_toml_dep" && !mine.contains(i)) + .flat_map(|(_, e)| registry_uuids(e.new.as_ref()).collect::>()) + .collect(); + let mut kept: HashSet = HashSet::new(); // Newest-first: the hosted flow appends edits, so reverse index order // unwinds re-redirect chains correctly (each step's `original` is the // previous step's `new`), and the registry-block removals — recorded @@ -216,7 +333,9 @@ pub async fn revert_cargo_redirect_purl( for &i in mine.iter().rev() { let edit = &state.edits[i]; match edit.kind.as_str() { - "redirect_cargo_toml_dep" | "redirect_cargo_lock_entry" => { + "redirect_cargo_toml_dep" + | "redirect_cargo_lock_entry" + | super::CARGO_LOCK_REFERENCE_KIND => { let (Some(new), Some(orig)) = ( edit.new.as_ref().and_then(Value::as_str), edit.original.as_ref().and_then(Value::as_str), @@ -234,21 +353,28 @@ pub async fn revert_cargo_redirect_purl( edit.path )); }; - if content.contains(new) { - let reverted = content.replacen(new, orig, 1); - staged.insert(edit.path.clone(), Some(reverted)); - out.reverted_files.push(edit.path.clone()); - } else if content.contains(orig) { + // A full-id reference edit stands for every dependent's + // occurrence of that exact id. Matching ignores a CRLF/LF + // conversion since the scan (a Windows checkout's CRLF + // fragments against an LF checkout, and vice versa). + let every = edit.kind == super::CARGO_LOCK_REFERENCE_KIND; + match super::replay::revert_fragment_eol(&content, new, orig, every) { + FragmentRevert::Reverted(reverted) => { + staged.insert(edit.path.clone(), Some(reverted)); + out.reverted_files.push(edit.path.clone()); + } // Already at (or unwound to) the pre-redirect fragment. - } else { - return Err(format!( - "the {} entry for {name}@{version} has drifted from the \ - recorded hosted redirect (neither the redirected nor the \ - original fragment is present); refusing to touch it — \ - re-run `scan --mode hosted` to normalize the redirect, \ - or restore the crates.io wiring manually, then re-run", - edit.path - )); + FragmentRevert::AlreadyOriginal => {} + FragmentRevert::Drifted => { + return Err(format!( + "the {} entry for {name}@{version} has drifted from the \ + recorded hosted redirect (neither the redirected nor the \ + original fragment is present); refusing to touch it — \ + re-run `scan --mode hosted` to normalize the redirect, \ + or restore the crates.io wiring manually, then re-run", + edit.path + )); + } } } "redirect_cargo_registry" => { @@ -258,7 +384,7 @@ pub async fn revert_cargo_redirect_purl( let Some(content) = staged_read(&staged, project_root, &edit.path).await? else { continue; // config already gone }; - if !content.contains(block) { + if !super::replay::contains_eol(&content, block) { continue; // block already removed } // Keep the block while anything still references its registry @@ -271,7 +397,7 @@ pub async fn revert_cargo_redirect_purl( .map(str::to_string) .unwrap_or_default(); let mut referenced = false; - for probe in ["Cargo.toml", "Cargo.lock"] { + for probe in &probes { if let Some(text) = staged_read(&staged, project_root, probe).await? { if (!reg.is_empty() && text.contains(reg)) || (!index.is_empty() && text.contains(&index)) @@ -282,6 +408,12 @@ pub async fn revert_cargo_redirect_purl( } } if referenced { + if reg + .strip_prefix("socket-patch-") + .is_some_and(|u| still_pinned.contains(u)) + { + kept.insert(i); + } continue; } // A REGENERATED block (`action: "rewritten"` — the rewriter @@ -289,22 +421,27 @@ pub async fn revert_cargo_redirect_purl( // it as `original`) restores that pre-existing region instead // of deleting it: the original bytes are the user's. if let Some(orig) = edit.original.as_ref().and_then(Value::as_str) { - let reverted = content.replacen(block, orig, 1); - staged.insert(edit.path.clone(), Some(reverted)); - out.reverted_files.push(edit.path.clone()); + if let FragmentRevert::Reverted(reverted) = + super::replay::revert_fragment_eol(&content, block, orig, false) + { + staged.insert(edit.path.clone(), Some(reverted)); + out.reverted_files.push(edit.path.clone()); + } continue; } - let mut trimmed = content.replacen(block, "", 1); - // Collapse the blank separator the rewrite inserted. - while trimmed.contains("\n\n\n") { - trimmed = trimmed.replace("\n\n\n", "\n\n"); - } - let trimmed = trimmed.trim_start_matches('\n').to_string(); - if trimmed.trim().is_empty() { - staged.insert(edit.path.clone(), None); - } else { - staged.insert(edit.path.clone(), Some(trimmed)); - } + // The block leaves with exactly the blank separator the + // rewrite put before it, so the user's config comes back + // byte-for-byte — its trailing newlines (or missing final + // newline) included. A config the rewrite created ends + // empty and is deleted. + let Some(restored) = super::replay::remove_appended_cargo_block(&content, block) + else { + continue; + }; + staged.insert( + edit.path.clone(), + (!restored.is_empty()).then_some(restored), + ); out.reverted_files.push(edit.path.clone()); } _ => {} @@ -322,6 +459,7 @@ pub async fn revert_cargo_redirect_purl( flush_staged(project_root, &staged, &StagedBytes::new()).await?; } + let mine: Vec = mine.into_iter().filter(|i| !kept.contains(i)).collect(); drop_claimed(state, mine, &record_key); Ok(out) } @@ -437,6 +575,29 @@ pub async fn revert_golang_redirect_purl( }) } +/// The version requirement a recorded Cargo.toml declaration line carries: +/// the `version = "…"` of an inline table, or the value of a plain +/// `name = "…"` entry. `None` for a table-form header or `registry` line. +fn cargo_declared_req(fragment: Option<&Value>) -> Option { + static PLAIN_ENTRY: LazyLock = LazyLock::new(|| { + Regex::new(r#"^\s*(?:"[^"]+"|'[^']+'|[A-Za-z0-9_-]+)\s*=\s*"([^"]+)""#) + .expect("static plain-entry regex is valid") + }); + static INLINE_VERSION: LazyLock = LazyLock::new(|| { + Regex::new(r#"\bversion\s*=\s*"([^"]*)""#).expect("static inline-version regex is valid") + }); + let line = fragment.and_then(Value::as_str)?; + if line.contains('\n') || line.trim_start().starts_with('[') { + return None; + } + let req = if line.contains('{') { + INLINE_VERSION.captures(line)?[1].to_string() + } else { + PLAIN_ENTRY.captures(line)?[1].to_string() + }; + semver::VersionReq::parse(req.trim()).is_ok().then_some(req) +} + /// The npm-family text-fragment edit kinds CLAIMED BY KEY: `original`/`new` /// hold the whole lock fragment as a string, the edit's `key` embeds /// `@`, and the revert is a `replacen(new, original)`. @@ -1229,6 +1390,605 @@ mod tests { (tmp, state) } + /// Bug I: two redirected versions of one crate share the manifest edit + /// key (the crate name). Removing one version must revert ONLY its own + /// declaration + lock entry + registry block — claiming the sibling's + /// manifest edit reverted the other pin while its lock entry stayed + /// hosted (a broken build), and dropped the sibling's registry edit, so + /// the second removal left the created `.cargo/config.toml` behind. + #[tokio::test] + async fn multi_version_removes_each_version_independently() { + const UUID_OLD: &str = "3c5d7e9f-2a4b-4c6d-8e0f-1a3b5c7d9e1f"; + const PURL_OLD: &str = "pkg:cargo/cfg-if@0.1.10"; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let toml = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + cfg-if = \"1.0\"\ncfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n"; + let lock = format!( + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\n\ + source = \"{CRATES_IO}\"\nchecksum = \"{}\"\n\n{}\n", + "8".repeat(64), + pristine_lock_block() + ); + let dep = |version: &str, uuid: &str| -> crate::patch::redirect::DepOverride { + serde_json::from_value(serde_json::json!({ + "ecosystem": "cargo", "name": "cfg-if", "version": version, "token": "tok", + "patchUuid": uuid, + "artifactUrl": format!("http://127.0.0.1:5555/cfg-if-{version}.crate"), + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": format!("sparse+http://127.0.0.1:5555/{uuid}/index/"), + "identifiers": { + "name": "cfg-if", "version": version, + "cargoCksumSha256": "a".repeat(64), + }, + }, + "integrity": { "sha256": "a".repeat(64) }, + })) + .unwrap() + }; + let mut files: BTreeMap = BTreeMap::new(); + files.insert("Cargo.toml".into(), toml.to_string()); + files.insert("Cargo.lock".into(), lock.clone()); + let rewrite = crate::patch::redirect::rewrite_registry_redirect( + &files, + &[dep("1.0.4", UUID), dep("0.1.10", UUID_OLD)], + ); + assert_eq!( + rewrite.confirmed_cargo_uuids.len(), + 2, + "{:?}", + rewrite.warnings + ); + tokio::fs::write(root.join("Cargo.toml"), toml) + .await + .unwrap(); + tokio::fs::write(root.join("Cargo.lock"), &lock) + .await + .unwrap(); + for (rel, content) in &rewrite.files { + let path = root.join(rel); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&path, content).await.unwrap(); + } + let mut state = RedirectState::new(); + state.edits = rewrite.edits; + state.records.insert(PURL.to_string(), record()); + let mut old = record(); + old.uuid = UUID_OLD.to_string(); + state.records.insert(PURL_OLD.to_string(), old); + + revert_cargo_redirect_purl(root, &mut state, PURL, false) + .await + .expect("1.0.4 reverts"); + let t = tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(); + assert!( + t.contains("cfg-if = \"1.0\"\n") + && t.contains(&format!("registry = \"socket-patch-{UUID_OLD}\"")), + "only the 1.0.4 pin is reverted: {t}" + ); + let l = tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(); + assert!( + l.contains(&format!("sparse+http://127.0.0.1:5555/{UUID_OLD}/index/")), + "{l}" + ); + let cfg = tokio::fs::read_to_string(root.join(".cargo/config.toml")) + .await + .unwrap(); + assert!( + !cfg.contains(&format!("socket-patch-{UUID}]")) && cfg.contains(UUID_OLD), + "{cfg}" + ); + + revert_cargo_redirect_purl(root, &mut state, PURL_OLD, false) + .await + .expect("0.1.10 reverts"); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(), + toml + ); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), + lock + ); + assert!( + !root.join(".cargo/config.toml").exists(), + "the created config goes with the last block" + ); + assert!( + state.edits.is_empty() && state.records.is_empty(), + "{:?}", + state.edits + ); + } + + /// A hosted override for `name@version` at patch `uuid`. + fn cargo_dep(name: &str, version: &str, uuid: &str) -> crate::patch::redirect::DepOverride { + serde_json::from_value(serde_json::json!({ + "ecosystem": "cargo", "name": name, "version": version, "token": "tok", + "patchUuid": uuid, + "artifactUrl": format!("http://127.0.0.1:5555/{name}-{version}.crate"), + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": format!("sparse+http://127.0.0.1:5555/{uuid}/index/"), + "identifiers": { + "name": name, "version": version, + "cargoCksumSha256": "a".repeat(64), + }, + }, + "integrity": { "sha256": "a".repeat(64) }, + })) + .unwrap() + } + + /// Redirect `deps` (applied in order) over a pristine project with the + /// real rewriter, write the output to a tempdir, and return the ledger + /// with one record per purl. + async fn redirect_on_disk( + toml: &str, + lock: &str, + deps: &[(&str, &str, &str)], + ) -> (tempfile::TempDir, RedirectState) { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut files: BTreeMap = BTreeMap::new(); + files.insert("Cargo.toml".into(), toml.to_string()); + files.insert("Cargo.lock".into(), lock.to_string()); + let overrides: Vec<_> = deps + .iter() + .map(|(name, version, uuid)| cargo_dep(name, version, uuid)) + .collect(); + let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &overrides); + assert_eq!( + rewrite.confirmed_cargo_uuids.len(), + deps.len(), + "{:?}", + rewrite.warnings + ); + for (rel, content) in files.iter().chain(rewrite.files.iter()) { + let path = root.join(rel); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&path, content).await.unwrap(); + } + let mut state = RedirectState::new(); + state.edits = rewrite.edits; + for (name, version, uuid) in deps { + let mut rec = record(); + rec.uuid = uuid.to_string(); + state + .records + .insert(format!("pkg:cargo/{name}@{version}"), rec); + } + (tmp, state) + } + + /// Redirect `deps`, then remove the purls in EVERY order: each order + /// must succeed and restore the manifest and lock byte-for-byte, with no + /// config and no ledger left. + async fn assert_removes_in_every_order(toml: &str, lock: &str, deps: &[(&str, &str, &str)]) { + let purls: Vec = deps + .iter() + .map(|(name, version, _)| format!("pkg:cargo/{name}@{version}")) + .collect(); + for order in [purls.clone(), purls.iter().rev().cloned().collect()] { + let (tmp, mut state) = redirect_on_disk(toml, lock, deps).await; + let root = tmp.path(); + for purl in &order { + revert_cargo_redirect_purl(root, &mut state, purl, false) + .await + .unwrap_or_else(|e| panic!("remove {purl} (order {order:?}): {e}")); + } + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(), + toml, + "{order:?}" + ); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), + lock, + "{order:?}" + ); + assert!(!root.join(".cargo/config.toml").exists(), "{order:?}"); + assert!( + state.edits.is_empty() && state.records.is_empty(), + "{order:?}: {:?}", + state.edits + ); + } + } + + /// A v1 lock names every dependency by its full id, so the root block + /// references BOTH patched cfg-if versions. REGRESSION: each dependent + /// block was one whole-block edit, the second version's edit recorded + /// the first one's output as its `original`, and removing the + /// first-applied version alone found neither fragment — `remove` + /// refused as drifted unless purls went in exact reverse apply order. + #[tokio::test] + async fn v1_lock_multi_version_removes_in_any_order() { + const UUID_OLD: &str = "3c5d7e9f-2a4b-4c6d-8e0f-1a3b5c7d9e1f"; + let toml = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + cfg-if = \"1.0\"\ncfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n"; + let lock = format!( + "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"cfg-if 0.1.10 ({CRATES_IO})\",\n \"cfg-if 1.0.4 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\nsource = \"{CRATES_IO}\"\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{CRATES_IO}\"\n\n\ + [metadata]\n\"checksum cfg-if 0.1.10 ({CRATES_IO})\" = \"{}\"\n\ + \"checksum cfg-if 1.0.4 ({CRATES_IO})\" = \"{}\"\n", + "8".repeat(64), + "9".repeat(64) + ); + assert_removes_in_every_order( + toml, + &lock, + &[("cfg-if", "1.0.4", UUID), ("cfg-if", "0.1.10", UUID_OLD)], + ) + .await; + } + + const UUID_LEGACY: &str = "3c5d7e9f-2a4b-4c6d-8e0f-1a3b5c7d9e1f"; + + /// The two-declaration cfg-if project (1.0.4 as `cfg-if`, 0.1.10 as a + /// renamed `cfg-if-legacy`) and its crates.io lock. + fn multi_version_project() -> (String, String) { + let toml = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + cfg-if = \"1.0\"\ncfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n"; + let lock = format!( + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\n\ + source = \"{CRATES_IO}\"\nchecksum = \"{}\"\n\n{}\n", + "8".repeat(64), + pristine_lock_block() + ); + (toml.to_string(), lock) + } + + /// Remove `order` from `state` over `root`, then require the pristine + /// project back with no config and an empty ledger. + async fn remove_all_and_expect_pristine( + root: &Path, + mut state: RedirectState, + order: &[&str], + toml: &str, + lock: &str, + ) { + for purl in order { + revert_cargo_redirect_purl(root, &mut state, purl, false) + .await + .unwrap_or_else(|e| panic!("remove {purl} (order {order:?}): {e}")); + } + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(), + toml, + "{order:?}" + ); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), + lock, + "{order:?}" + ); + assert!(!root.join(".cargo/config.toml").exists(), "{order:?}"); + assert!( + state.edits.is_empty() && state.records.is_empty(), + "{order:?}: {:?}", + state.edits + ); + } + + /// A ledger an older CLI wrote with its name-only manifest matcher for + /// both cfg-if versions: each run pinned BOTH declarations to its own + /// registry, so the file ends with both on the 0.1.10 registry and the + /// ledger holds plain→1.0.4 then 1.0.4→0.1.10 edits for both lines. + /// REGRESSION: removing 1.0.4 first claimed the plain→1.0.4 edit of the + /// 0.1.10 declaration but not its 1.0.4→0.1.10 successor, found neither + /// fragment and refused as drifted; removing 0.1.10 first dropped the + /// still-referenced 0.1.10 registry edit and left the config behind. + #[tokio::test] + async fn legacy_name_only_multi_version_ledger_removes_in_any_order() { + let (toml, lock) = multi_version_project(); + let reg = |uuid: &str| format!("socket-patch-{uuid}"); + let index = |uuid: &str| format!("sparse+http://127.0.0.1:5555/{uuid}/index/"); + let line1 = |r: Option<&str>| match r { + None => "cfg-if = \"1.0\"".to_string(), + Some(r) => format!("cfg-if = {{ version = \"1.0\", registry = \"{r}\" }}"), + }; + let line2 = |r: Option<&str>| { + match r { + None => "cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }".to_string(), + Some(r) => format!( + "cfg-if-legacy = {{ package = \"cfg-if\", version = \"0.1.10\", registry = \"{r}\" }}" + ), + } + }; + let block = |version: &str, source: &str, cksum: &str| { + format!( + "[[package]]\nname = \"cfg-if\"\nversion = \"{version}\"\nsource = \"{source}\"\n\ + checksum = \"{cksum}\"" + ) + }; + let (a, b) = (reg(UUID), reg(UUID_LEGACY)); + let edit = + |path: &str, kind: &str, key: &str, orig: Option, new: String| FileEdit { + path: path.to_string(), + kind: kind.to_string(), + action: if orig.is_some() { "rewritten" } else { "added" }.to_string(), + key: Some(key.to_string()), + original: orig.map(Value::String), + new: Some(Value::String(new)), + }; + let registry = |uuid: &str| { + edit( + ".cargo/config.toml", + "redirect_cargo_registry", + ®(uuid), + None, + format!("[registries.{}]\nindex = \"{}\"\n", reg(uuid), index(uuid)), + ) + }; + let lock_edit = |version: &str, uuid: &str, crates_cksum: &str| { + edit( + "Cargo.lock", + "redirect_cargo_lock_entry", + &format!("cfg-if@{version}"), + Some(block(version, CRATES_IO, crates_cksum)), + block(version, &index(uuid), &"a".repeat(64)), + ) + }; + let toml_edit = |orig: String, new: String| { + edit( + "Cargo.toml", + "redirect_cargo_toml_dep", + "cfg-if", + Some(orig), + new, + ) + }; + let edits = vec![ + registry(UUID), + toml_edit(line1(None), line1(Some(&a))), + toml_edit(line2(None), line2(Some(&a))), + lock_edit("1.0.4", UUID, &"9".repeat(64)), + registry(UUID_LEGACY), + toml_edit(line1(Some(&a)), line1(Some(&b))), + toml_edit(line2(Some(&a)), line2(Some(&b))), + lock_edit("0.1.10", UUID_LEGACY, &"8".repeat(64)), + ]; + let live_toml = toml + .replace(&line1(None), &line1(Some(&b))) + .replace(&line2(None), &line2(Some(&b))); + let live_lock = lock + .replace( + &block("1.0.4", CRATES_IO, &"9".repeat(64)), + &block("1.0.4", &index(UUID), &"a".repeat(64)), + ) + .replace( + &block("0.1.10", CRATES_IO, &"8".repeat(64)), + &block("0.1.10", &index(UUID_LEGACY), &"a".repeat(64)), + ); + let live_config = format!( + "{}\n{}", + edits[0].new.as_ref().and_then(Value::as_str).unwrap(), + edits[4].new.as_ref().and_then(Value::as_str).unwrap() + ); + for order in [ + ["pkg:cargo/cfg-if@1.0.4", "pkg:cargo/cfg-if@0.1.10"], + ["pkg:cargo/cfg-if@0.1.10", "pkg:cargo/cfg-if@1.0.4"], + ] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); + tokio::fs::write(root.join("Cargo.toml"), &live_toml) + .await + .unwrap(); + tokio::fs::write(root.join("Cargo.lock"), &live_lock) + .await + .unwrap(); + tokio::fs::write(root.join(".cargo/config.toml"), &live_config) + .await + .unwrap(); + let mut state = RedirectState::new(); + state.edits = edits.clone(); + state.records.insert(PURL.to_string(), record()); + let mut old = record(); + old.uuid = UUID_LEGACY.to_string(); + state + .records + .insert("pkg:cargo/cfg-if@0.1.10".to_string(), old); + remove_all_and_expect_pristine(root, state, &order, &toml, &lock).await; + } + } + + /// An older CLI redirected only 1.0.4 and its name-only matcher also + /// pinned the 0.1.10 declaration to 1.0.4's registry; the current + /// planner then repaired it (superseding that pin with 0.1.10's own + /// registry) while redirecting 0.1.10. REGRESSION: removing 1.0.4 + /// claimed the old mis-pin edit, whose fragments were both gone, and + /// refused as drifted. + #[tokio::test] + async fn repaired_legacy_mispin_removes_in_any_order() { + let (toml, lock) = multi_version_project(); + let legacy = "cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }"; + let mispinned = format!( + "cfg-if-legacy = {{ package = \"cfg-if\", version = \"0.1.10\", registry = \"socket-patch-{UUID}\" }}" + ); + // The old CLI's run: 1.0.4 only, plus its mis-pin of the legacy line. + let mut files: BTreeMap = BTreeMap::new(); + files.insert("Cargo.toml".into(), toml.clone()); + files.insert("Cargo.lock".into(), lock.clone()); + let old_run = crate::patch::redirect::rewrite_registry_redirect( + &files, + &[cargo_dep("cfg-if", "1.0.4", UUID)], + ); + let mut edits = old_run.edits.clone(); + let at = edits + .iter() + .position(|e| e.kind == "redirect_cargo_toml_dep") + .unwrap(); + let mut mispin = edits[at].clone(); + mispin.original = Some(Value::String(legacy.to_string())); + mispin.new = Some(Value::String(mispinned.clone())); + edits.insert(at + 1, mispin); + let mut live = files.clone(); + live.extend(old_run.files.clone()); + let t = live["Cargo.toml"].replace(legacy, &mispinned); + live.insert("Cargo.toml".into(), t); + // The current CLI's run with both patches repairs the mis-pin. + let new_run = crate::patch::redirect::rewrite_registry_redirect( + &live, + &[ + cargo_dep("cfg-if", "1.0.4", UUID), + cargo_dep("cfg-if", "0.1.10", UUID_LEGACY), + ], + ); + assert!(new_run.warnings.is_empty(), "{:?}", new_run.warnings); + edits.extend(new_run.edits.clone()); + live.extend(new_run.files.clone()); + for order in [ + ["pkg:cargo/cfg-if@1.0.4", "pkg:cargo/cfg-if@0.1.10"], + ["pkg:cargo/cfg-if@0.1.10", "pkg:cargo/cfg-if@1.0.4"], + ] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for (rel, content) in &live { + let path = root.join(rel); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&path, content).await.unwrap(); + } + let mut state = RedirectState::new(); + state.edits = edits.clone(); + state.records.insert(PURL.to_string(), record()); + let mut old = record(); + old.uuid = UUID_LEGACY.to_string(); + state + .records + .insert("pkg:cargo/cfg-if@0.1.10".to_string(), old); + remove_all_and_expect_pristine(root, state, &order, &toml, &lock).await; + } + } + + /// The whole-ledger replay (rollback) inverts a v1 full-id reference + /// named by several dependents at every occurrence. + #[tokio::test] + async fn v1_lock_reference_named_by_several_dependents_replays_clean() { + let toml = "[workspace]\nmembers = [\"a\", \"b\"]\n\n\ + [workspace.dependencies]\ncfg-if = \"1.0\"\n"; + let member = |name: &str| { + format!( + "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\ncfg-if = {{ workspace = true }}\n" + ) + }; + let lock = format!( + "[[package]]\nname = \"a\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"cfg-if 1.0.4 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"b\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"cfg-if 1.0.4 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{CRATES_IO}\"\n\n\ + [metadata]\n\"checksum cfg-if 1.0.4 ({CRATES_IO})\" = \"{}\"\n", + "9".repeat(64) + ); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut files: BTreeMap = BTreeMap::new(); + files.insert("Cargo.toml".into(), toml.to_string()); + files.insert("Cargo.lock".into(), lock.clone()); + files.insert("a/Cargo.toml".into(), member("a")); + files.insert("b/Cargo.toml".into(), member("b")); + let rewrite = crate::patch::redirect::rewrite_registry_redirect( + &files, + &[cargo_dep("cfg-if", "1.0.4", UUID)], + ); + assert_eq!( + rewrite.confirmed_cargo_uuids.len(), + 1, + "{:?}", + rewrite.warnings + ); + let references: Vec<&FileEdit> = rewrite + .edits + .iter() + .filter(|e| e.kind == super::super::CARGO_LOCK_REFERENCE_KIND) + .collect(); + assert_eq!( + references.len(), + 1, + "one edit per full id: {:?}", + rewrite.edits + ); + for (rel, content) in files.iter().chain(rewrite.files.iter()) { + let path = root.join(rel); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&path, content).await.unwrap(); + } + let mut state = RedirectState::new(); + state.edits = rewrite.edits; + state.records.insert(PURL.to_string(), record()); + let outcome = + crate::patch::redirect::revert_remaining_redirect_edits(root, &mut state, false).await; + assert!(outcome.fully_reverted(), "{:?}", outcome.refusals); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), + lock + ); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(), + toml + ); + } + + /// Two different patched crates with one shared v1 dependent block. + #[tokio::test] + async fn v1_lock_two_crates_sharing_a_dependent_remove_in_any_order() { + const UUID_ITOA: &str = "4d6e8f0a-3b5c-4d7e-9f1a-2b4c6d8e0f2a"; + let toml = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + cfg-if = \"1.0\"\nitoa = \"1.0.11\"\n"; + let lock = format!( + "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"cfg-if 1.0.4 ({CRATES_IO})\",\n \"itoa 1.0.11 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{CRATES_IO}\"\n\n\ + [[package]]\nname = \"itoa\"\nversion = \"1.0.11\"\nsource = \"{CRATES_IO}\"\n\n\ + [metadata]\n\"checksum cfg-if 1.0.4 ({CRATES_IO})\" = \"{}\"\n\ + \"checksum itoa 1.0.11 ({CRATES_IO})\" = \"{}\"\n", + "8".repeat(64), + "9".repeat(64) + ); + assert_removes_in_every_order( + toml, + &lock, + &[("cfg-if", "1.0.4", UUID), ("itoa", "1.0.11", UUID_ITOA)], + ) + .await; + } + #[tokio::test] async fn reverts_toml_lock_and_registry_block_and_drops_ledger_entries() { let (tmp, mut state) = redirected_fixture().await; @@ -3802,6 +4562,219 @@ mod tests { assert!(state.edits.is_empty(), "edits dropped"); } + /// Bug H: removing the block the redirect APPENDED to an existing + /// config (the legacy `.cargo/config` here) restores the user's bytes — + /// it left a trailing blank line (`[net]\nretry = 2\n\n`) — and never + /// touches blank runs of the user's own elsewhere in the file. + #[tokio::test] + async fn appended_registry_block_revert_restores_the_config_bytes() { + let cases = [ + "[net]\nretry = 2\n", + "[net]\n\n\n\nretry = 2\n", + "# a comment\n\n[http]\ntimeout = 5\n", + "[net]\r\nretry = 2\r\n", + // No final newline, trailing blank lines, whitespace only. + "[net]\nretry = 2", + "[net]\r\nretry = 2", + "[net]\nretry = 2\n\n", + "[net]\r\nretry = 2\r\n\r\n", + "\n", + ]; + // Both unwind paths: `remove ` and the whole-ledger replay. + for (user_cfg, replay) in cases.iter().flat_map(|cfg| [(*cfg, false), (*cfg, true)]) { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let lock = format!("version = 4\n\n{}\n", pristine_lock_block()); + let mut files: BTreeMap = BTreeMap::new(); + files.insert("Cargo.toml".into(), pristine_toml()); + files.insert("Cargo.lock".into(), lock.clone()); + files.insert(".cargo/config".into(), user_cfg.to_string()); + let dep: crate::patch::redirect::DepOverride = + serde_json::from_value(serde_json::json!({ + "ecosystem": "cargo", "name": "cfg-if", "version": "1.0.4", + "token": "tok", "patchUuid": UUID, + "artifactUrl": "http://127.0.0.1:5555/cfg-if-1.0.4.crate", + "registryOverride": { + "kind": "cargo-sparse", "indexUrl": INDEX, + "identifiers": { + "name": "cfg-if", "version": "1.0.4", + "cargoCksumSha256": "a".repeat(64), + }, + }, + "integrity": { "sha256": "a".repeat(64) }, + })) + .unwrap(); + let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &[dep]); + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); + for (rel, content) in files.iter().chain(rewrite.files.iter()) { + tokio::fs::write(root.join(rel), content).await.unwrap(); + } + let mut state = RedirectState::new(); + state.edits = rewrite.edits; + state.records.insert(PURL.to_string(), record()); + + if replay { + let outcome = crate::patch::redirect::revert_remaining_redirect_edits( + root, &mut state, false, + ) + .await; + assert!(outcome.fully_reverted(), "{:?}", outcome.refusals); + } else { + revert_cargo_redirect_purl(root, &mut state, PURL, false) + .await + .expect("revert succeeds"); + } + assert_eq!( + tokio::fs::read_to_string(root.join(".cargo/config")) + .await + .unwrap(), + user_cfg, + "replay: {replay}" + ); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(), + pristine_toml() + ); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), + lock + ); + } + } + + /// Bug K: a CRLF project (manifest, lock and legacy config) is + /// redirected with CRLF kept and `remove` restores every byte. + #[tokio::test] + async fn crlf_project_reverts_byte_for_byte() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let crlf = |s: &str| s.replace('\n', "\r\n"); + let mut files: BTreeMap = BTreeMap::new(); + files.insert("Cargo.toml".into(), crlf(&pristine_toml())); + files.insert( + "Cargo.lock".into(), + crlf(&format!("version = 4\n\n{}\n", pristine_lock_block())), + ); + files.insert(".cargo/config".into(), crlf("[net]\nretry = 2\n")); + let dep: crate::patch::redirect::DepOverride = serde_json::from_value(serde_json::json!({ + "ecosystem": "cargo", "name": "cfg-if", "version": "1.0.4", + "token": "tok", "patchUuid": UUID, + "artifactUrl": "http://127.0.0.1:5555/cfg-if-1.0.4.crate", + "registryOverride": { + "kind": "cargo-sparse", "indexUrl": INDEX, + "identifiers": { + "name": "cfg-if", "version": "1.0.4", + "cargoCksumSha256": "a".repeat(64), + }, + }, + "integrity": { "sha256": "a".repeat(64) }, + })) + .unwrap(); + let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &[dep]); + assert_eq!(rewrite.files.len(), 3, "{:?}", rewrite.warnings); + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); + for (rel, content) in files.iter().chain(rewrite.files.iter()) { + tokio::fs::write(root.join(rel), content).await.unwrap(); + } + let mut state = RedirectState::new(); + state.edits = rewrite.edits; + state.records.insert(PURL.to_string(), record()); + revert_cargo_redirect_purl(root, &mut state, PURL, false) + .await + .expect("revert succeeds"); + for (rel, content) in &files { + assert_eq!( + &tokio::fs::read_to_string(root.join(rel)).await.unwrap(), + content, + "{rel}" + ); + } + } + + /// A ledger recorded on one side of a line-ending conversion reverts + /// files checked out on the other: a Windows scan (CRLF fragments, + /// JSON-escaped, so git never converts them) removed on an LF checkout, + /// and an LF scan removed on a CRLF (`core.autocrlf`) checkout — through + /// `remove` and through the whole-ledger replay. REGRESSION: both + /// refused as "drifted" (neither fragment found byte-for-byte). + #[tokio::test] + async fn revert_survives_a_checkout_line_ending_conversion() { + let crlf = |s: &str| s.replace('\n', "\r\n"); + let lf = |s: &str| s.replace("\r\n", "\n"); + let pristine: Vec<(&str, String)> = vec![ + ("Cargo.toml", pristine_toml()), + ( + "Cargo.lock", + format!("version = 4\n\n{}\n", pristine_lock_block()), + ), + (".cargo/config", "[net]\nretry = 2\n".to_string()), + ]; + for (scan_crlf, replay) in [(true, false), (false, false), (true, true), (false, true)] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let scanned = |s: &str| if scan_crlf { crlf(s) } else { s.to_string() }; + let checked_out = |s: &str| if scan_crlf { lf(s) } else { crlf(s) }; + let files: BTreeMap = pristine + .iter() + .map(|(rel, text)| (rel.to_string(), scanned(text))) + .collect(); + let rewrite = crate::patch::redirect::rewrite_registry_redirect( + &files, + &[cargo_dep("cfg-if", "1.0.4", UUID)], + ); + assert_eq!(rewrite.files.len(), 3, "{:?}", rewrite.warnings); + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); + for (rel, content) in files.iter().chain(rewrite.files.iter()) { + tokio::fs::write(root.join(rel), checked_out(content)) + .await + .unwrap(); + } + // The ledger round-trips through its JSON file unchanged. + let mut state: RedirectState = serde_json::from_str( + &serde_json::to_string(&{ + let mut state = RedirectState::new(); + state.edits = rewrite.edits.clone(); + state.records.insert(PURL.to_string(), record()); + state + }) + .unwrap(), + ) + .unwrap(); + if replay { + let outcome = crate::patch::redirect::revert_remaining_redirect_edits( + root, &mut state, false, + ) + .await; + assert!( + outcome.fully_reverted(), + "scan crlf {scan_crlf}: {:?}", + outcome.refusals + ); + } else { + revert_cargo_redirect_purl(root, &mut state, PURL, false) + .await + .unwrap_or_else(|e| panic!("scan crlf {scan_crlf}: {e}")); + } + for (rel, text) in &pristine { + assert_eq!( + tokio::fs::read_to_string(root.join(rel)).await.unwrap(), + checked_out(text), + "{rel} (scan crlf {scan_crlf}, replay {replay})" + ); + } + } + } + /// The socket block was already hand-removed (the config now holds only /// user content): skip it, byte-untouched, and still succeed. #[tokio::test] @@ -3838,6 +4811,40 @@ mod tests { assert!(state.edits.is_empty(), "edits dropped"); } + /// The whole-ledger replay keeps a hand-pinned block too (it removed + /// it unconditionally, leaving the hand pin naming an undefined + /// registry), while still unwinding the wiring it owns. + #[tokio::test] + async fn replay_keeps_a_registry_block_still_referenced() { + let (tmp, mut state) = redirected_fixture().await; + let root = tmp.path(); + let reg = format!("socket-patch-{UUID}"); + let pinned_line = format!("other = {{ version = \"1.0\", registry = \"{reg}\" }}\n"); + let wired_toml = tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(); + tokio::fs::write( + root.join("Cargo.toml"), + format!("{wired_toml}{pinned_line}"), + ) + .await + .unwrap(); + let outcome = + crate::patch::redirect::revert_remaining_redirect_edits(root, &mut state, false).await; + assert!(outcome.fully_reverted(), "{:?}", outcome.refusals); + let cfg = tokio::fs::read_to_string(root.join(".cargo/config.toml")) + .await + .unwrap(); + assert!(cfg.contains(®), "block kept while referenced: {cfg}"); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(), + format!("{}{pinned_line}", pristine_toml()) + ); + assert!(state.edits.is_empty(), "{:?}", state.edits); + } + /// A user hand-pinned a SECOND dep to the socket registry: the block is /// kept while anything still references it (the documented defensive /// keep), and the takeover still reverts the wiring it owns. diff --git a/crates/socket-patch-core/src/utils/cargo_workspace.rs b/crates/socket-patch-core/src/utils/cargo_workspace.rs new file mode 100644 index 00000000..5f30771a --- /dev/null +++ b/crates/socket-patch-core/src/utils/cargo_workspace.rs @@ -0,0 +1,432 @@ +//! The manifests of a cargo workspace beside its root `Cargo.toml`: every +//! `[workspace] members` glob (minus `exclude`) plus every path dependency +//! reachable from them, as repo-relative `/Cargo.toml` keys. +//! +//! The hosted cargo rewriter pins a patched crate in EVERY manifest that +//! declares it — a member's own `cfg-if = "1"` resolves exactly like the +//! root's, so leaving it unpinned makes the repointed Cargo.lock entry +//! unsatisfiable. Only manifests inside the project root, reached without +//! crossing a symbolic link, are returned: a file outside the root is not +//! the project's to rewrite, and a link may lead outside it (the writer +//! would follow it). Cargo still reads those manifests; the rewriter's +//! Cargo.lock dependents check refuses a crate one of them depends on. + +use std::collections::BTreeSet; +use std::path::{Component, Path, PathBuf}; + +use toml_edit::{DocumentMut, Item, Table}; + +/// Upper bound on discovered manifests — a runaway glob (or a hostile tree) +/// must not turn one scan into an unbounded walk. +const MAX_MANIFESTS: usize = 4096; + +/// Repo-relative `/Cargo.toml` keys of the workspace members and +/// in-root path dependencies of the project at `root`, sorted. Empty when +/// `root/Cargo.toml` is absent or unparseable. +pub fn member_manifests(root: &Path) -> Vec { + let Some(doc) = read_manifest(&root.join("Cargo.toml")) else { + return Vec::new(); + }; + let mut dirs: BTreeSet = BTreeSet::new(); + let mut queue: Vec<(String, DocumentMut)> = Vec::new(); + + if let Some(ws) = doc.get("workspace").and_then(Item::as_table_like) { + let patterns = |key: &str| -> Vec { + ws.get(key) + .and_then(Item::as_array) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + }; + let excluded: BTreeSet = patterns("exclude") + .iter() + .flat_map(|p| expand_glob(root, p)) + .collect(); + for pattern in patterns("members") { + for dir in expand_glob(root, &pattern) { + if !excluded.contains(&dir) { + enqueue(root, dir, &mut dirs, &mut queue); + } + } + } + } + for dep_dir in path_dependencies(&doc) { + if let Some(dir) = normalize_rel("", &dep_dir) { + enqueue(root, dir, &mut dirs, &mut queue); + } + } + while let Some((dir, doc)) = queue.pop() { + for dep_dir in path_dependencies(&doc) { + if let Some(dep) = normalize_rel(&dir, &dep_dir) { + enqueue(root, dep, &mut dirs, &mut queue); + } + } + } + dirs.into_iter() + .map(|dir| format!("{dir}/Cargo.toml")) + .collect() +} + +fn read_manifest(path: &Path) -> Option { + if !std::fs::symlink_metadata(path).is_ok_and(|m| m.is_file()) { + return None; + } + std::fs::read_to_string(path).ok()?.parse().ok() +} + +/// A directory that is not itself a symbolic link. +fn is_real_dir(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir()) +} + +/// Every component of repo-relative `dir` is a real directory under +/// `root` — none is a symbolic link (which may lead outside the root). +fn is_real_dir_path(root: &Path, dir: &str) -> bool { + let mut at = root.to_path_buf(); + dir.split('/').all(|seg| { + at.push(seg); + is_real_dir(&at) + }) +} + +fn enqueue( + root: &Path, + dir: String, + dirs: &mut BTreeSet, + queue: &mut Vec<(String, DocumentMut)>, +) { + // `target` is cargo's build directory at every level: the glob walk + // already skips it, and a literal `members = ["target/gen"]` or a path + // dependency into it names a manifest the next `cargo clean` deletes — + // pinning it would record a hosted edit whose file can vanish, blocking + // the rollback of every other cargo edit. + if dir.is_empty() + || dirs.len() >= MAX_MANIFESTS + || dirs.contains(&dir) + || dir.split('/').any(|seg| seg == "target") + || !is_real_dir_path(root, &dir) + { + return; + } + let Some(doc) = read_manifest(&root.join(&dir).join("Cargo.toml")) else { + return; + }; + dirs.insert(dir.clone()); + queue.push((dir, doc)); +} + +/// Every `path = "…"` of a dependency declaration: the dependency tables +/// (plain and per-target), `[workspace.dependencies]` and `[patch.*]`. +fn path_dependencies(doc: &DocumentMut) -> Vec { + fn dep_paths(table: Option<&Item>, out: &mut Vec) { + let Some(table) = table.and_then(Item::as_table_like) else { + return; + }; + for (_, entry) in table.iter() { + let path = match entry { + Item::Table(t) => t.get("path").and_then(Item::as_str), + Item::Value(v) => v + .as_inline_table() + .and_then(|t| t.get("path")) + .and_then(|p| p.as_str()), + _ => None, + }; + if let Some(path) = path { + out.push(path.to_string()); + } + } + } + fn dep_tables(table: &Table, out: &mut Vec) { + for kind in ["dependencies", "dev-dependencies", "build-dependencies"] { + dep_paths(table.get(kind), out); + } + } + let mut out = Vec::new(); + dep_tables(doc.as_table(), &mut out); + if let Some(targets) = doc.get("target").and_then(Item::as_table) { + for (_, target) in targets.iter() { + if let Some(target) = target.as_table() { + dep_tables(target, &mut out); + } + } + } + if let Some(ws) = doc.get("workspace").and_then(Item::as_table) { + dep_paths(ws.get("dependencies"), &mut out); + } + if let Some(patch) = doc.get("patch").and_then(Item::as_table) { + for (_, source) in patch.iter() { + dep_paths(Some(source), &mut out); + } + } + out +} + +/// `base/rel` lexically normalized to a repo-relative slash path; `None` +/// when it is absolute or climbs out of the root. +pub(crate) fn normalize_rel(base: &str, rel: &str) -> Option { + let rel = rel.replace('\\', "/"); + if rel.starts_with('/') || Path::new(&rel).is_absolute() { + return None; + } + let mut parts: Vec = base + .split('/') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + for component in Path::new(&rel).components() { + match component { + Component::Normal(seg) => parts.push(seg.to_str()?.to_string()), + Component::CurDir => {} + Component::ParentDir => { + parts.pop()?; + } + Component::RootDir | Component::Prefix(_) => return None, + } + } + Some(parts.join("/")) +} + +/// Expand a cargo `members` / `exclude` glob (`*`, `?`, `**`) to the +/// repo-relative directories it names. +fn expand_glob(root: &Path, pattern: &str) -> Vec { + let Some(normalized) = normalize_rel("", pattern.trim_end_matches('/')) else { + return Vec::new(); + }; + let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect(); + let mut out = Vec::new(); + expand_from(root, PathBuf::new(), &segments, &mut out); + out.sort(); + out.dedup(); + out +} + +fn expand_from(root: &Path, at: PathBuf, rest: &[&str], out: &mut Vec) { + if out.len() >= MAX_MANIFESTS { + return; + } + let Some((seg, tail)) = rest.split_first() else { + out.push(at.to_string_lossy().replace('\\', "/")); + return; + }; + if !seg.contains(['*', '?']) { + let next = at.join(seg); + if is_real_dir(&root.join(&next)) { + expand_from(root, next, tail, out); + } + return; + } + let Ok(entries) = std::fs::read_dir(root.join(&at)) else { + return; + }; + let mut children: Vec = entries + .filter_map(Result::ok) + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter_map(|e| e.file_name().to_str().map(str::to_string)) + .filter(|name| !name.starts_with('.') && name != "target") + .collect(); + children.sort(); + if *seg == "**" { + expand_from(root, at.clone(), tail, out); + for child in children { + expand_from(root, at.join(child), rest, out); + } + return; + } + for child in children { + if wildcard_match(seg.as_bytes(), child.as_bytes()) { + expand_from(root, at.join(child), tail, out); + } + } +} + +fn wildcard_match(pattern: &[u8], name: &[u8]) -> bool { + match (pattern.first(), name.first()) { + (None, None) => true, + (Some(b'*'), _) => { + wildcard_match(&pattern[1..], name) + || (!name.is_empty() && wildcard_match(pattern, &name[1..])) + } + (Some(b'?'), Some(_)) => wildcard_match(&pattern[1..], &name[1..]), + (Some(p), Some(n)) if p == n => wildcard_match(&pattern[1..], &name[1..]), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(root: &Path, rel: &str, content: &str) { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + } + + fn pkg(name: &str) -> String { + format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n") + } + + #[test] + fn members_globs_excludes_and_path_dependencies() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "Cargo.toml", + "[workspace]\nmembers = [\"app\", \"crates/*\"]\nexclude = [\"crates/skip\"]\n", + ); + write( + root, + "app/Cargo.toml", + &format!( + "{}[dependencies]\nhelper = {{ path = \"../libs/helper\" }}\n", + pkg("app") + ), + ); + write(root, "crates/a/Cargo.toml", &pkg("a")); + write(root, "crates/skip/Cargo.toml", &pkg("skip")); + write(root, "crates/no-manifest/README", ""); + write( + root, + "libs/helper/Cargo.toml", + &format!( + "{}[target.'cfg(unix)'.dependencies]\nleaf = {{ path = \"../leaf\" }}\n\ + outside = {{ path = \"../../../elsewhere\" }}\n", + pkg("helper") + ), + ); + write(root, "libs/leaf/Cargo.toml", &pkg("leaf")); + assert_eq!( + member_manifests(root), + vec![ + "app/Cargo.toml", + "crates/a/Cargo.toml", + "libs/helper/Cargo.toml", + "libs/leaf/Cargo.toml", + ] + ); + } + + #[test] + fn a_package_without_workspace_still_reaches_its_path_dependencies() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "Cargo.toml", + &format!("{}[dependencies.inner]\npath = \"inner\"\n", pkg("root")), + ); + write(root, "inner/Cargo.toml", &pkg("inner")); + assert_eq!(member_manifests(root), vec!["inner/Cargo.toml"]); + } + + #[test] + fn recursive_globs_and_missing_root() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + assert!(member_manifests(root).is_empty()); + write(root, "Cargo.toml", "[workspace]\nmembers = [\"**/m?\"]\n"); + write(root, "x/y/m1/Cargo.toml", &pkg("m1")); + write(root, "m2/Cargo.toml", &pkg("m2")); + write(root, "target/m3/Cargo.toml", &pkg("m3")); + assert_eq!( + member_manifests(root), + vec!["m2/Cargo.toml", "x/y/m1/Cargo.toml"] + ); + } + + /// Members and path dependencies behind a symbolic link — whether the + /// link is a literal member, matched by a glob, a path dependency, an + /// intermediate directory or the manifest itself — are never returned: + /// the writer would follow the link, possibly out of the project. + #[cfg(unix)] + #[test] + fn symlinked_members_and_path_dependencies_are_not_returned() { + use std::os::unix::fs::symlink; + let tmp = tempfile::tempdir().unwrap(); + let outside = tmp.path().join("outside"); + write(&outside, "shared/Cargo.toml", &pkg("shared")); + write(&outside, "lone/Cargo.toml", &pkg("lone")); + let root = tmp.path().join("proj"); + write( + &root, + "Cargo.toml", + &format!( + "[workspace]\nmembers = [\"crates/*\", \"linked\", \"nested/inner\"]\n\n\ + {}[dependencies]\nvia = {{ path = \"via\" }}\nreal = {{ path = \"real\" }}\n", + pkg("root") + ), + ); + std::fs::create_dir_all(root.join("crates")).unwrap(); + symlink(outside.join("shared"), root.join("crates/one")).unwrap(); + write(&root, "crates/two/Cargo.toml", &pkg("two")); + symlink(outside.join("shared"), root.join("linked")).unwrap(); + symlink(&outside, root.join("nested")).unwrap(); + std::fs::create_dir_all(outside.join("inner")).unwrap(); + std::fs::write(outside.join("inner/Cargo.toml"), pkg("inner")).unwrap(); + symlink(outside.join("lone"), root.join("via")).unwrap(); + std::fs::create_dir_all(root.join("real")).unwrap(); + symlink( + outside.join("lone/Cargo.toml"), + root.join("real/Cargo.toml"), + ) + .unwrap(); + assert_eq!(member_manifests(&root), vec!["crates/two/Cargo.toml"]); + } + + #[test] + fn members_and_path_dependencies_outside_the_root_are_not_returned() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "lib/Cargo.toml", &pkg("lib")); + write(tmp.path(), "shared/Cargo.toml", &pkg("shared")); + let root = tmp.path().join("app"); + write( + &root, + "Cargo.toml", + &format!( + "[workspace]\nmembers = [\"../shared\"]\n\n{}[dependencies]\n\ + lib = {{ path = \"../lib\" }}\n", + pkg("app") + ), + ); + assert!(member_manifests(&root).is_empty()); + } + + /// Cargo's build directory is never a member, whichever way it is + /// named: the glob walk skips it, and so do a literal `members` entry + /// and a path dependency pointing into it. `cargo clean` deletes those + /// manifests, and a recorded hosted edit for a file that no longer + /// exists blocks the rollback of every other cargo edit. + #[test] + fn build_directory_manifests_are_never_members() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "Cargo.toml", + &format!( + "[workspace]\nmembers = [\"crates/*\", \"target/generated\"]\n\n\ + {}[dependencies]\ngen2 = {{ path = \"target/gen2\" }}\n\ + nested = {{ path = \"crates/a/target/gen3\" }}\n", + pkg("root") + ), + ); + write(root, "crates/a/Cargo.toml", &pkg("a")); + write(root, "crates/a/target/gen3/Cargo.toml", &pkg("gen3")); + write(root, "target/generated/Cargo.toml", &pkg("generated")); + write(root, "target/gen2/Cargo.toml", &pkg("gen2")); + assert_eq!(member_manifests(root), vec!["crates/a/Cargo.toml"]); + } + + #[test] + fn wildcard_matching() { + assert!(wildcard_match(b"a*c", b"abbc")); + assert!(wildcard_match(b"*", b"")); + assert!(wildcard_match(b"a?c", b"abc")); + assert!(!wildcard_match(b"a?c", b"ac")); + assert!(!wildcard_match(b"a*d", b"abc")); + } +} diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index 840cdbf2..9c57a536 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -1,3 +1,4 @@ +pub mod cargo_workspace; pub(crate) mod digest; pub mod env_compat; pub mod fs; diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index bb783c5b..3a8143dd 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -232,8 +232,9 @@ pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> } /// A LIVE hosted-redirect wiring for `name`+`version`: the lock resolves it -/// from a Socket hosted patch registry, or Cargo.toml pins it to a -/// `socket-patch-` registry (the shapes `scan --mode hosted` writes). +/// from a Socket hosted patch registry, or a manifest — the root or any +/// workspace member — pins it to a `socket-patch-` registry in any +/// declaration shape (the shapes `scan --mode hosted` writes). /// Registry indexes are matched against the config-declared /// `[registries.socket-patch-*]` URLs, not a hardcoded host, so test /// registries are recognised too. `Some(description)` when residue is found. @@ -250,11 +251,23 @@ async fn hosted_redirect_residue(project_root: &Path, name: &str, version: &str) )); } } - // Guarded read (`open_regular_file`: O_NONBLOCK + regular-file check) — - // a FIFO planted as `Cargo.toml` would otherwise wedge every wet vendor - // run in an open(2) that waits for a writer; an unreadable manifest has - // no readable residue, matching the read_to_string Err arm this guards. - if let Ok(toml) = read_regular_to_string(&project_root.join("Cargo.toml")).await { + // The root manifest AND every workspace-member manifest: the hosted + // rewriter pins the crate in each one that declares it, so a member's + // surviving pin is residue just as much as the root's. + let root = project_root.to_path_buf(); + let members = + tokio::task::spawn_blocking(move || crate::utils::cargo_workspace::member_manifests(&root)) + .await + .unwrap_or_default(); + for rel in std::iter::once("Cargo.toml".to_string()).chain(members) { + // Guarded read (`open_regular_file`: O_NONBLOCK + regular-file + // check) — a FIFO planted as `Cargo.toml` would otherwise wedge + // every wet vendor run in an open(2) that waits for a writer; an + // unreadable manifest has no readable residue, matching the + // read_to_string Err arm this guards. + let Ok(toml) = read_regular_to_string(&project_root.join(&rel)).await else { + continue; + }; // The rewriter's own reader, not a single-line regex: the hosted pin // is just as often a standalone `registry = …` line under a // `[dependencies.]` header, or sits under a renamed key @@ -263,7 +276,7 @@ async fn hosted_redirect_residue(project_root: &Path, name: &str, version: &str) // the half-reverted state this guard exists for. if let Some(reg) = crate::patch::redirect::cargo_socket_registry_pin(&toml, name) { return Some(format!( - "Cargo.toml pins `{name}` to the socket-patch hosted registry `{reg}`" + "{rel} pins `{name}` to the socket-patch hosted registry `{reg}`" )); } } @@ -3971,6 +3984,51 @@ mod tests { } } + /// A pin that survives only in a WORKSPACE MEMBER's manifest is residue + /// too: the hosted rewriter pins every member that declares the crate, + /// so a root-only probe misses exactly the projects it was extended to + /// cover. + #[tokio::test] + async fn test_refuses_live_hosted_redirect_pinned_in_a_member() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + tokio::fs::write( + root.join("Cargo.toml"), + "[workspace]\nmembers = [\"b\"]\n\n\ + [package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .await + .unwrap(); + tokio::fs::create_dir_all(root.join("b")).await.unwrap(); + tokio::fs::write( + root.join("b/Cargo.toml"), + format!( + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n\ + [dependencies.cfg-if]\nversion = \"1\"\n\ + registry = \"socket-patch-{UUID}\"\n" + ), + ) + .await + .unwrap(); + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); + tokio::fs::write( + root.join(".cargo/config.toml"), + format!( + "[registries.socket-patch-{UUID}]\nindex = \"sparse+http://127.0.0.1:5555/index/\"\n" + ), + ) + .await + .unwrap(); + let detail = expect_refused( + run_vendor(PURL, root, &blobs, &pristine, &record, false).await, + "hosted_redirect_live", + ); + assert!(detail.contains("b/Cargo.toml"), "{detail}"); + assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); + } + /// A dependency pinned to a registry that is NOT ours, and an unpinned /// one, are not hosted residue: vendoring proceeds. #[tokio::test] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected-edits.json new file mode 100644 index 00000000..7679392d --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected-edits.json @@ -0,0 +1,25 @@ +[ + { + "path": ".cargo/config", + "kind": "redirect_cargo_registry", + "action": "added", + "key": "socket-patch-55555555-5555-5555-5555-555555555555", + "new": "[registries.socket-patch-55555555-5555-5555-5555-555555555555]\r\nindex = \"sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/\"\r\n" + }, + { + "path": "Cargo.toml", + "kind": "redirect_cargo_toml_dep", + "action": "rewritten", + "key": "serde", + "original": "serde = \"1.0.190\"", + "new": "serde = { version = \"1.0.190\", registry = \"socket-patch-55555555-5555-5555-5555-555555555555\" }" + }, + { + "path": "Cargo.lock", + "kind": "redirect_cargo_lock_entry", + "action": "rewritten", + "key": "serde@1.0.190", + "original": "[[package]]\r\nname = \"serde\"\r\nversion = \"1.0.190\"\r\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\r\nchecksum = \"91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000\"", + "new": "[[package]]\r\nname = \"serde\"\r\nversion = \"1.0.190\"\r\nsource = \"sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/\"\r\nchecksum = \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\"" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/.cargo/config b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/.cargo/config new file mode 100644 index 00000000..51cf6dc4 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/.cargo/config @@ -0,0 +1,5 @@ +[net] +retry = 2 + +[registries.socket-patch-55555555-5555-5555-5555-555555555555] +index = "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/Cargo.lock new file mode 100644 index 00000000..e77698b0 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/" +checksum = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/Cargo.toml new file mode 100644 index 00000000..bcb9a444 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0.190", registry = "socket-patch-55555555-5555-5555-5555-555555555555" } +anyhow = { version = "1.0", features = ["backtrace"] } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/.cargo/config b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/.cargo/config new file mode 100644 index 00000000..206b4886 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/.cargo/config @@ -0,0 +1,2 @@ +[net] +retry = 2 diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/Cargo.lock new file mode 100644 index 00000000..7b725829 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/Cargo.toml new file mode 100644 index 00000000..af0b8b13 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = "1.0.190" +anyhow = { version = "1.0", features = ["backtrace"] } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/expected-warnings.json new file mode 100644 index 00000000..5e643ea8 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_cargo_lockless_dependents" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/input/Cargo.toml new file mode 100644 index 00000000..adaf565e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/input/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = "1.0.190" +serde_json = "1.0.108" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected-edits.json new file mode 100644 index 00000000..4abcfd73 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected-edits.json @@ -0,0 +1,48 @@ +[ + { + "path": ".cargo/config.toml", + "kind": "redirect_cargo_registry", + "action": "added", + "key": "socket-patch-55555555-5555-5555-5555-555555555555", + "new": "[registries.socket-patch-55555555-5555-5555-5555-555555555555]\nindex = \"sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/\"\n" + }, + { + "path": "Cargo.toml", + "kind": "redirect_cargo_toml_dep", + "action": "rewritten", + "key": "cfg-if", + "original": "cfg-if = \"1.0.4\"", + "new": "cfg-if = { version = \"1.0.4\", registry = \"socket-patch-55555555-5555-5555-5555-555555555555\" }" + }, + { + "path": "Cargo.lock", + "kind": "redirect_cargo_lock_entry", + "action": "rewritten", + "key": "cfg-if@1.0.4", + "original": "[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801\"", + "new": "[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/\"\nchecksum = \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"" + }, + { + "path": ".cargo/config.toml", + "kind": "redirect_cargo_registry", + "action": "added", + "key": "socket-patch-66666666-6666-6666-6666-666666666666", + "new": "[registries.socket-patch-66666666-6666-6666-6666-666666666666]\nindex = \"sparse+https://patch.socket.dev/patch-registry/cargo/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/index/\"\n" + }, + { + "path": "Cargo.toml", + "kind": "redirect_cargo_toml_dep", + "action": "rewritten", + "key": "cfg-if", + "original": "cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }", + "new": "cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\", registry = \"socket-patch-66666666-6666-6666-6666-666666666666\" }" + }, + { + "path": "Cargo.lock", + "kind": "redirect_cargo_lock_entry", + "action": "rewritten", + "key": "cfg-if@0.1.10", + "original": "[[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822\"", + "new": "[[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\nsource = \"sparse+https://patch.socket.dev/patch-registry/cargo/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/index/\"\nchecksum = \"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/.cargo/config.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/.cargo/config.toml new file mode 100644 index 00000000..1ef2fc0c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/.cargo/config.toml @@ -0,0 +1,5 @@ +[registries.socket-patch-55555555-5555-5555-5555-555555555555] +index = "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/" + +[registries.socket-patch-66666666-6666-6666-6666-666666666666] +index = "sparse+https://patch.socket.dev/patch-registry/cargo/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/index/" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/Cargo.lock new file mode 100644 index 00000000..306a322d --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/Cargo.lock @@ -0,0 +1,23 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "sparse+https://patch.socket.dev/patch-registry/cargo/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/index/" +checksum = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/" +checksum = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "cfg-if 0.1.10", + "cfg-if 1.0.4", +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/Cargo.toml new file mode 100644 index 00000000..cd0456b4 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2018" + +[dependencies] +cfg-if = { version = "1.0.4", registry = "socket-patch-55555555-5555-5555-5555-555555555555" } +cfg-if-legacy = { package = "cfg-if", version = "0.1.10", registry = "socket-patch-66666666-6666-6666-6666-666666666666" } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/input/Cargo.lock new file mode 100644 index 00000000..cfcf0991 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/input/Cargo.lock @@ -0,0 +1,23 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "cfg-if 0.1.10", + "cfg-if 1.0.4", +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/input/Cargo.toml new file mode 100644 index 00000000..a2f02505 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/input/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2018" + +[dependencies] +cfg-if = "1.0.4" +cfg-if-legacy = { package = "cfg-if", version = "0.1.10" } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/overrides.json new file mode 100644 index 00000000..e24a3142 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/overrides.json @@ -0,0 +1,42 @@ +[ + { + "ecosystem": "cargo", + "name": "cfg-if", + "version": "1.0.4", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/cfg-if/1.0.4/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/cfg-if-1.0.4.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "cfg-if", + "version": "1.0.4", + "cargoCksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + "integrity": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "ecosystem": "cargo", + "name": "cfg-if", + "version": "0.1.10", + "token": "22222222-2222-2222-2222-222222222222", + "patchUuid": "66666666-6666-6666-6666-666666666666", + "artifactUrl": "https://patch.socket.dev/patch/cargo/cfg-if/0.1.10/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/cfg-if-0.1.10.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/index/", + "identifiers": { + "name": "cfg-if", + "version": "0.1.10", + "cargoCksumSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + }, + "integrity": { + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/expected-warnings.json new file mode 100644 index 00000000..5259ffdc --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_cargo_toml_dep_unrewritable" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/input/Cargo.lock new file mode 100644 index 00000000..2c0622f2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/input/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.190" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/input/Cargo.toml new file mode 100644 index 00000000..cf1d2a6b --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/input/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { path = "third_party/serde", version = "1.0.190" } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/expected-warnings.json new file mode 100644 index 00000000..5259ffdc --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_cargo_toml_dep_unrewritable" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/input/Cargo.lock new file mode 100644 index 00000000..bf6fa585 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/input/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/input/Cargo.toml new file mode 100644 index 00000000..88553e52 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/input/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = "=1.0.180" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/expected-warnings.json new file mode 100644 index 00000000..97c5d3b0 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_cargo_transitive_dependents" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/input/Cargo.lock new file mode 100644 index 00000000..182db3ae --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/input/Cargo.lock @@ -0,0 +1,26 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000" + +[[package]] +name = "serde_json" +version = "1.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +dependencies = [ + "serde", +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/input/Cargo.toml new file mode 100644 index 00000000..adaf565e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/input/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = "1.0.190" +serde_json = "1.0.108" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/expected-warnings.json new file mode 100644 index 00000000..97c5d3b0 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_cargo_transitive_dependents" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/input/Cargo.lock new file mode 100644 index 00000000..621622b6 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/input/Cargo.lock @@ -0,0 +1,25 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "serde", + "serde-helper", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000" + +[[package]] +name = "serde-helper" +version = "0.3.1" +source = "git+https://github.com/example/serde-helper#0123456789abcdef0123456789abcdef01234567" +dependencies = [ + "serde", +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/input/Cargo.toml new file mode 100644 index 00000000..b5bc2c4b --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/input/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = "1.0.190" +serde-helper = { git = "https://github.com/example/serde-helper" } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/expected-warnings.json new file mode 100644 index 00000000..acfb99e8 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_cargo_toml_dep_not_found" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/input/Cargo.lock new file mode 100644 index 00000000..157d89ad --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/input/Cargo.lock @@ -0,0 +1,25 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000" + +[[package]] +name = "serde_json" +version = "1.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +dependencies = [ + "serde", +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/input/Cargo.toml new file mode 100644 index 00000000..df66812e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/input/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde_json = "1.0.108" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected-edits.json new file mode 100644 index 00000000..b6f367d7 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected-edits.json @@ -0,0 +1,33 @@ +[ + { + "path": ".cargo/config.toml", + "kind": "redirect_cargo_registry", + "action": "added", + "key": "socket-patch-55555555-5555-5555-5555-555555555555", + "new": "[registries.socket-patch-55555555-5555-5555-5555-555555555555]\nindex = \"sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/\"\n" + }, + { + "path": "Cargo.toml", + "kind": "redirect_cargo_toml_dep", + "action": "rewritten", + "key": "serde", + "original": "serde = \"1.0.190\"", + "new": "serde = { version = \"1.0.190\", registry = \"socket-patch-55555555-5555-5555-5555-555555555555\" }" + }, + { + "path": "b/Cargo.toml", + "kind": "redirect_cargo_toml_dep", + "action": "rewritten", + "key": "serde", + "original": "serde = \"1.0.190\"", + "new": "serde = { version = \"1.0.190\", registry = \"socket-patch-55555555-5555-5555-5555-555555555555\" }" + }, + { + "path": "Cargo.lock", + "kind": "redirect_cargo_lock_entry", + "action": "rewritten", + "key": "serde@1.0.190", + "original": "[[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000\"", + "new": "[[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/\"\nchecksum = \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\"" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/.cargo/config.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/.cargo/config.toml new file mode 100644 index 00000000..743fa5dc --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/.cargo/config.toml @@ -0,0 +1,2 @@ +[registries.socket-patch-55555555-5555-5555-5555-555555555555] +index = "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/Cargo.lock new file mode 100644 index 00000000..1532947c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/Cargo.lock @@ -0,0 +1,23 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "a" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "b" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/" +checksum = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/Cargo.toml new file mode 100644 index 00000000..a4faf226 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +members = ["a", "b"] + +[workspace.dependencies] +serde = { version = "1.0.190", registry = "socket-patch-55555555-5555-5555-5555-555555555555" } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/b/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/b/Cargo.toml new file mode 100644 index 00000000..c90450fa --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/b/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "b" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0.190", registry = "socket-patch-55555555-5555-5555-5555-555555555555" } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/Cargo.lock new file mode 100644 index 00000000..fa5ee8fd --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/Cargo.lock @@ -0,0 +1,23 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "a" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "b" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/Cargo.toml new file mode 100644 index 00000000..2cfb1173 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +members = ["a", "b"] + +[workspace.dependencies] +serde = "1.0.190" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/a/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/a/Cargo.toml new file mode 100644 index 00000000..0dee9c7e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/a/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "a" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde.workspace = true diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/b/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/b/Cargo.toml new file mode 100644 index 00000000..9aedd9ed --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/b/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "b" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = "1.0.190" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json index 9f82f122..3acf08ca 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json @@ -120,6 +120,168 @@ } ] }, + "redirect/cargo/cargo/crlf/expected": { + "refs": [ + { + "purl": "pkg:cargo/serde@1.0.190", + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "source_file": "Cargo.lock", + "artifact_rel": null, + "locked_integrity": "Sha256Hex(\"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\")", + "integrity_required": true, + "url": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": ".cargo/config" + }, + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "Cargo.lock" + }, + { + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "file": ".cargo/config" + }, + { + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "file": "Cargo.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "55555555-5555-5555-5555-555555555555", + "purl": "pkg:cargo/serde@1.0.190" + } + ] + }, + "redirect/cargo/cargo/crlf/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, + "redirect/cargo/cargo/lockless-dependents/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, + "redirect/cargo/cargo/multi-version/expected": { + "refs": [ + { + "purl": "pkg:cargo/cfg-if@0.1.10", + "uuid": "66666666-6666-6666-6666-666666666666", + "mode": "hosted", + "source_file": "Cargo.lock", + "artifact_rel": null, + "locked_integrity": "Sha256Hex(\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\")", + "integrity_required": true, + "url": "sparse+https://patch.socket.dev/patch-registry/cargo/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/index/", + "lockfile_basis_ok": true + }, + { + "purl": "pkg:cargo/cfg-if@1.0.4", + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "source_file": "Cargo.lock", + "artifact_rel": null, + "locked_integrity": "Sha256Hex(\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\")", + "integrity_required": true, + "url": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": ".cargo/config.toml" + }, + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "Cargo.lock" + }, + { + "uuid": "22222222-2222-2222-2222-222222222222", + "mode": "hosted", + "file": ".cargo/config.toml" + }, + { + "uuid": "22222222-2222-2222-2222-222222222222", + "mode": "hosted", + "file": "Cargo.lock" + }, + { + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "file": ".cargo/config.toml" + }, + { + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "file": "Cargo.lock" + }, + { + "uuid": "66666666-6666-6666-6666-666666666666", + "mode": "hosted", + "file": ".cargo/config.toml" + }, + { + "uuid": "66666666-6666-6666-6666-666666666666", + "mode": "hosted", + "file": "Cargo.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "55555555-5555-5555-5555-555555555555", + "purl": "pkg:cargo/cfg-if@1.0.4" + }, + { + "mode": "hosted", + "uuid": "66666666-6666-6666-6666-666666666666", + "purl": "pkg:cargo/cfg-if@0.1.10" + } + ] + }, + "redirect/cargo/cargo/multi-version/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, + "redirect/cargo/cargo/path-dependency/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/cargo/cargo/renamed/expected": { "refs": [ { @@ -175,6 +337,14 @@ "elsewhere": [], "live_claims": [] }, + "redirect/cargo/cargo/requirement-excludes-patched-version/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/cargo/cargo/rerun/input": { "refs": [ { @@ -222,6 +392,14 @@ } ] }, + "redirect/cargo/cargo/shared-dependency/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/cargo/cargo/supersede/expected": { "refs": [ { @@ -376,6 +554,22 @@ "elsewhere": [], "live_claims": [] }, + "redirect/cargo/cargo/transitive-dependents-git/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, + "redirect/cargo/cargo/transitive-refusal/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/cargo/cargo/two-sections/expected": { "refs": [ { @@ -430,5 +624,60 @@ "unlocked_pins": [], "elsewhere": [], "live_claims": [] + }, + "redirect/cargo/cargo/workspace-member/expected": { + "refs": [ + { + "purl": "pkg:cargo/serde@1.0.190", + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "source_file": "Cargo.lock", + "artifact_rel": null, + "locked_integrity": "Sha256Hex(\"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\")", + "integrity_required": true, + "url": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": ".cargo/config.toml" + }, + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "Cargo.lock" + }, + { + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "file": ".cargo/config.toml" + }, + { + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "file": "Cargo.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "55555555-5555-5555-5555-555555555555", + "purl": "pkg:cargo/serde@1.0.190" + } + ] + }, + "redirect/cargo/cargo/workspace-member/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] } } diff --git a/docs/ecosystems.md b/docs/ecosystems.md index d070106f..4038cf42 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 | ✅ any install layout; `setup` postinstall hook | ✅ six 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)). 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 — pnpm, berry, and bun carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ `.pth` startup hook via `setup` | ✅ 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) | +| 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`; with no `Cargo.lock` the graph is unknown, so only a project whose sole dependency is the patched crate is redirected | | RubyGems (`gem`) | ✅ Bundler plugin via `setup` — needs bundler ≥ 2.2 (1.x cannot load `plugin ... path:` directives; `setup` refuses below the floor and `setup --check` red-flags a wired 1.x project) | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`); 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 [golang-hosted.md](design/golang-hosted.md). Paid tier stays ❌ ([golang-hosted-no-go.md](design/golang-hosted-no-go.md)); `redirect_golang_unsupported` names the vendored remedy | | Maven (`maven`) | ✅ apply-only (no `setup` hook — reports `no_files`); in-place jar patching leaves the `~/.m2` checksum sidecars stale — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed maven2 `file://` repository. A root pom declaring `` (multi-module aggregator) is refused (`vendor_maven_multimodule_unsupported`), and a gradle-only project is refused (`vendor_gradle_unsupported`) | ✅ **pom projects only, fail-closed** — the patched jar is pinned at a Socket-only `-socket.` suffix; `${property}` versions are refused; Gradle gets a manual `exclusiveContent` snippet — see [Maven & NuGet caveats](#maven--nuget-caveats) |