Skip to content

Cargo vendored → hosted takeover un-vendors a crate before hosted mode refuses its Cargo.toml spelling (dotted keys, single quotes, registry = "crates-io"), leaving it unpatched, while --dry-run previews redirected: 1 #1020

Description

[agent] Found by the scheduled Cargo bug-hunt routine (ledger #315).

Summary

scan --mode hosted over a vendored cargo crate reverts the vendored wiring (the [patch.crates-io] entry, the detached lock entry, the ledger entry and the committed copy) and only then runs the hosted Cargo.toml rewriter. If the rewriter can't pin the dependency, the crate ends up in neither mode. That happens for any spelling the cargo rewriter refuses with redirect_cargo_toml_dep_unrewritable:

Vendored mode accepts all three spellings, because it doesn't edit the dependency line. The refusal can be predicted from Cargo.toml alone, before anything is reverted. A plain hosted --dry-run on a never-vendored project with the same spelling already reports it. But the cargo branch of the takeover pre-check returns None, so the revert always runs first.

--dry-run doesn't model this either. It previews redirect_would_revert_vendored, redirected: 1 and exit 0, and the wet run then exits 1.

This is the cargo counterpart of #945 (Poetry), #853 (pnpm) and #944 (uv). #963 fixed the opposite direction (hosted → vendored) for every ecosystem, and I confirmed that fix holds for cargo (see below).

Impact

A project that was vendored and patched, and that builds the patched crate under --locked --offline, goes back to building the unpatched crates.io release after one scan --mode hosted. The run is loud (exit 1, partial_failure, redirect_takeover_unpatched), but by then the vendored copy, ledger entry and wiring are deleted. The user has to restore them from git or re-run scan --mode vendored. A CI job that previews with --dry-run sees a clean takeover.

Repro

This used a scratch copy of crates/socket-patch-cli/tests/mode_migration_cargo.rs (the same fixture, wiremock hosted registry and crates-index mocks). The only change was the root Cargo.toml dependency line, plus a main.rs that calls the patch's marker cfg_if::socket_patched() as a compile oracle.

# 1. vendored (offline), with Cargo.toml:
[dependencies]
cfg-if.version = "1.0"          # or: cfg-if = '1.0'  /  cfg-if = { version = "1.0", registry = "crates-io" }

socket-patch vendor --json --offline           # exit 0
cargo build --locked --offline                 # ok: links the patched copy (marker resolves)

# 2. preview the hosted takeover
socket-patch scan --mode hosted --dry-run --json --yes --api-url <mock> --org test-org --api-token fake
#   exit 0, status "success", redirect.redirected = 1,
#   warnings: [redirect_would_revert_vendored]   <- no hint of the refusal

# 3. wet run
socket-patch scan --mode hosted --json --yes --api-url <mock> --org test-org --api-token fake
#   exit 1, status "partial_failure", redirect.redirected = 0, warnings:
#     redirect_cargo_toml_dep_unrewritable  "cfg-if in Cargo.toml cannot be pinned (declared with dotted keys this rewriter does not edit); dependency skipped (nothing rewritten)"
#     redirect_takeover_reverted_vendored
#     redirect_takeover_unpatched           "... the project now installs the UNPATCHED registry release ..."
#   Cargo.toml: [patch.crates-io] gone; Cargo.lock: entry back on crates.io; vendor ledger: no entry

# 4. fresh checkout, empty CARGO_HOME
cargo build --locked
#   error[E0425]: cannot find function `socket_patched` in crate `cfg_if`   <- unpatched crates.io copy

Control on the same project with cfg-if = "1.0": the dry run and the wet run both report redirected: 1 / exit 0, and the fresh --locked build links the hosted patch.

Control on a never-vendored project with cfg-if.version = "1.0": the dry run and the wet run both report redirected: 0 with redirect_cargo_toml_dep_unrewritable. So the refusal is predictable without reverting anything.

Expected vs actual

  • Expected. The takeover should leave a package it can't pin still vendored. CLI_CONTRACT.md already does this for PyPI: a vendored requirements.txt package outside the hosted rewriter's reach "is refused BEFORE its revert, wet and --dry-run alike … Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0)". The same contract says --dry-run "predicts the same refusal from the same signals instead of previewing redirect_would_revert_vendored". Keep the hosted pin when a vendored takeover is refused (#853, #944) #963 established the same rule for the opposite direction: a takeover the target mode doesn't carry through keeps the old mode's wiring.
  • Actual. For cargo, the dependency spelling is never checked before the revert. The crate is un-vendored, then skipped by the rewriter, and left unpatched. The dry run previews a clean takeover.

OS × version

OS cargo lock dotted keys single-quoted registry = "crates-io" plain "1.0" (control)
Linux 1.93.1 v4 (cargo default) fail (×2) fail fail pass
Linux 1.97.0 (stable) v3 fail fail fail pass
Linux 1.97.0 (stable) v4 fail fail fail pass
macOS / Windows — — untested; the decision is pure socket-patch logic over Cargo.toml text, so no OS dependence is expected

Tested on main db83f01 (CLI 4.0.0, latest release v4.0.0). I didn't bisect this: the takeover-first ordering is how v5 hosted mode works.

Suspect code

  • crates/socket-patch-cli/src/commands/scan/hosted.rs:1887-1903 (takeover_refusal): Maven, PyPI and npm-family purls get a pre-revert refusal check, but every other purl, pkg:cargo/ included, returns None. A cargo arm could run the Cargo.toml rewriter's own dependency-spelling checks (the logic behind redirect_cargo_toml_dep_unrewritable) against the root manifest before dispatch_revert_one.
  • crates/socket-patch-cli/src/commands/scan/hosted.rs:1960-2005: the dry-run branch emits redirect_would_revert_vendored and pushes the purl into dry_run / migrated without consulting the rewriter. A pre-revert refusal check would fix the preview too.

Related

No probe runs: this is Linux-only evidence.

Activity

  1. added a commit that references this issue on Oct 7, 2026
  2. mikolalysenko commented on Oct 7, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triage: priority:p2 (Cargo). Not a duplicate: it is the cargo counterpart of #945 (PyPI, PR #946), #853 and #944, but the fix lives in the cargo branch of the takeover pre-check, which currently returns None. Eligible for a fix.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions