Skip to content

Cargo rollback after an agent→vendored takeover leaves the shared registry cache patched, reports success, and deletes the revert blobs #336

Description

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

Summary

Take a crate that was first patched in agent mode, which writes into the shared $CARGO_HOME/registry/src cache, and later moved to vendored mode with socket-patch vendor. A bare rollback on that crate then does three things:

  1. It reverts the vendored copy.
  2. It skips the in-place restore for the purl, because vendor-owned purls are excluded from the agent leg.
  3. It drops the manifest entry and garbage-collects the before and after blobs.

The shared registry copy stays patched, and rollback exits 0 with success. Worse, the revert data is gone: a second rollback has nothing to do, and every other project on the machine that uses cfg-if 1.0.0 keeps building the patched bytes, with no socket-patch record left anywhere.

vendor itself also leaves the in-place patch behind without saying anything: no warning and no event. So an agent→vendored takeover never cleans up the cache edit.

Impact

  • rollback is documented as moving the system "toward fully unpatched". It also promises that "everything that leaves the system still patched DOES flip it to partial_failure exit 1". Here it reports success while a patched shared cache remains, and it deletes the only blobs that could restore it.
  • The leftover edit silently changes the source of an unrelated project on the same CARGO_HOME, which the second repro below shows. The only recovery is cargo clean/cache prune or deleting registry/src by hand.

Repro

The patch is a hand-staged .socket/manifest.json plus both blobs (stage.py is below). It appends pub fn socket_patched() to cfg-if 1.0.0.

W=$(mktemp -d); cd "$W"; export CARGO_HOME=$W/cargo-home
cargo init -q --name app --vcs none && cargo add -q cfg-if@=1.0.0 && cargo fetch -q
C=$(ls -d cargo-home/registry/src/*/cfg-if-1.0.0)
python3 stage.py . pkg:cargo/[email protected] "$C" src/lib.rs
socket-patch apply --json --offline      # success; $C/src/lib.rs patched (grep -c socket_patched → 1)
socket-patch vendor --json --offline     # success; no warning about the in-place edit; $C still patched
socket-patch rollback --json --offline   # "status": "success", "rolledBack": 0, vendoredReverted: 1
grep -c socket_patched $C/src/lib.rs     # 1  ← shared cache still patched
find .socket -type f                     # only manifest.json, with "patches": {} (blobs GC'd)
# an unrelated project on the same CARGO_HOME still compiles the patched crate:
mkdir other && cd other && cargo init -q --name other --vcs none && cargo add -q --offline cfg-if@=1.0.0
echo 'fn main(){ println!("patched={}", cfg_if::socket_patched()); }' > src/main.rs
cargo run -q --offline                   # patched=1
cd .. && socket-patch rollback --json --offline   # "status": "success", nothing to restore

stage.py:

import sys, json, hashlib, os
proj, purl, src, rel = sys.argv[1:5]
def g(b): return hashlib.sha256(b"blob %d\0" % len(b) + b).hexdigest()
before = open(os.path.join(src, rel), "rb").read()
after = before + b"\n/// socket marker\npub fn socket_patched() -> u32 { 1 }\n"
s = os.path.join(proj, ".socket"); os.makedirs(os.path.join(s, "blobs"), exist_ok=True)
m = {"patches": {purl: {"uuid": "11111111-2222-4333-8444-555555555555",
     "exportedAt": "2026-01-01T00:00:00Z", "files": {rel: {"beforeHash": g(before), "afterHash": g(after)}},
     "vulnerabilities": {"GHSA-xxxx-xxxx-xxxx": {"cves": ["CVE-2024-1"], "summary": "s", "severity": "high", "description": "d"}},
     "description": "m", "license": "MIT", "tier": "free"}}}
json.dump(m, open(os.path.join(s, "manifest.json"), "w"), indent=2)
for b in (before, after): open(os.path.join(s, "blobs", g(b)), "wb").write(b)

This was reproduced twice on the current main, with the same output both times.

Expected vs actual

  • Expected (CLI_CONTRACT.md, "Default behavior: full-state rollback"): a bare rollback "restores the SYSTEM to unpatched". GC keeps before-blobs for any entry whose revert is still needed, and anything left patched means partial_failure/exit 1. So either:

    • vendor reverts the in-place edit when it takes over an agent-applied purl (it has the before-blob), or
    • rollback's agent leg still restores installed copies whose on-disk hash equals the recorded afterHash, even when the purl is vendor-owned.

    At a minimum, it shouldn't drop the manifest entry and GC the blobs while a patched copy is still on disk.

  • Actual: exit 0 success, the shared cache stays patched, and the revert data is deleted.

Matrix

OS cargo main f6b7fb9 release 4.0.0
Linux (sandbox) 1.93.1 fail: cache left patched, manifest entry and blobs deleted, exit 0. Reproduced 2× fail, less severe: rollback reverts nothing (no vendored leg in 4.0.0) and the cache stays patched, but the manifest entry and blobs are kept, so the revert data survives

The logic isn't OS-specific (it's the rollback partition and GC). macOS and Windows weren't probed.

First bad

The data loss (manifest entry and blobs removed while the cache is still patched) is new since 4.0.0. It comes from the v5 full-state rollback (vendored leg + manifest cleanup + GC, #231). The leftover in-place edit after vendor is present in 4.0.0 too.

Suspect code

  • crates/socket-patch-cli/src/commands/rollback.rs:2283-2292: vendor-owned purls are partitioned out of the in-place restore. The comment's premise ("their patch lives in the committed .socket/vendor/ artifact … not in the installed tree") doesn't hold after an agent→vendored takeover.
  • The same file's manifest-cleanup and GC phases then treat the purl as fully rolled back.
  • crates/socket-patch-core/src/vendor/cargo.rs (vendor takeover) doesn't revert, or warn about, an existing in-place patch of the same purl.

This isn't strictly Cargo-only, since the rollback partition is ecosystem-agnostic. But Cargo is where the leftover is persistent and shared across projects: other ecosystems' installers usually overwrite the installed copy on the next install.

Activity

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