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/cfg-if@1.0.0 "$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.


Backlog review — 2026-10-08

Priority: P2 → P1. Rollback deletes restore blobs while the shared Cargo cache remains patched. This is a loss-of-recovery-data issue, not ordinary compatibility cleanup.

Activity

  1. mikolalysenko commented on Sep 30, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triaged: priority:p2 (Cargo). Not a duplicate, and no existing fix PR. The cause is that rollback.rs partitions vendor-owned purls out of the in-place restore, and then its manifest cleanup and GC treat the purl as fully reverted, even though the agent→vendored takeover in vendor never undid the earlier in-place edit.


    Generated by Claude Code

  2. mikolalysenko commented on Oct 1, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Re-triage on main 2463257 (v5 consolidation #277, Linux, cargo 1.97.0): still reproduces, twice.

    v5 vendor needs the patch service for artifacts, so I ran it against the repo's prebuilt_common fixture server (SOCKET_VENDOR_URL) and kept the same hand-staged agent patch for cfg-if@1.0.0:

    apply --offline            -> success; registry/src/.../cfg-if-1.0.0/src/lib.rs patched (grep -c socket_patched = 1)
    vendor                     -> success: [applied, skipped vendor_prebuilt_downloaded]; cache still patched (1)
    rollback --offline         -> exit 0, status "success", rolledBack 0, vendoredReverted ["pkg:cargo/cfg-if@1.0.0"],
                                  manifest.removedEntries ["pkg:cargo/cfg-if@1.0.0"], gc.removedBlobs 2
                                  warnings: [reinstall_required: "unwired packages keep their patched bytes in installed trees until the next package-manager install"]
    cache after rollback       -> still patched (1); .socket/manifest.json = {"patches": {}}
    other project, same CARGO_HOME: cargo run --offline -> patched=1
    second rollback            -> success, rolledBack 0 (nothing left to restore)
    

    What's new on v5 is the reinstall_required warning. For Cargo it doesn't help: no later cargo build/cargo fetch re-extracts a crate that's already in registry/src, so "the next package-manager install" never restores the bytes. The revert blobs are still garbage-collected while the patched copy remains on disk.


    Generated by Claude Code

  3. mikolalysenko commented on Oct 4, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Same root cause on npm / yarn classic (from the Yarn classic bug-hunt, ledger #304). The closing note above says "other ecosystems' installers usually overwrite the installed copy on the next install". That's not true for yarn classic: its next install doesn't restore the bytes either.

    Main 045d7ec, Linux, Node 22, local mock patch API (agent blobs + service tarball), left-pad@1.3.0, marker /* SOCKET-PATCHED */ prepended to index.js:

    yarn install                                  # pristine lock + node_modules
    socket-patch scan --mode agent --yes          # node_modules/left-pad/index.js patched
    socket-patch scan --mode vendored --yes       # lock -> file:./.socket/vendor/npm/<uuid>/left-pad-1.3.0.tgz; node_modules untouched (still agent-patched)
    socket-patch rollback --json                  # status success, rolledBack 0, vendoredReverted [left-pad], warnings [reinstall_required]
                                                  # yarn.lock byte-identical to the pre-agent lock; .socket/ = only manifest.json {"patches": {}} (blobs gone)
    yarn install --frozen-lockfile                # "Already up-to-date": .yarn-integrity matches the restored lock
    head -c 20 node_modules/left-pad/index.js     # /* SOCKET-PATCHED */  <- still patched
    yarn check --integrity                        # passes
    yarn install --check-files                    # the only thing that restores the upstream bytes
    
    yarn rollback lock next yarn install --frozen-lockfile node_modules after
    1.7.0 success, exit 0 byte-exact Already up-to-date patched
    1.10.1 success, exit 0 byte-exact Already up-to-date patched
    1.22.22 (2 runs) success, exit 0 byte-exact Already up-to-date patched

    remove pkg:npm/left-pad@1.3.0 and vendor --revert after the takeover end the same way: the manifest entry is dropped and node_modules stays patched.

    On yarn classic, the reinstall_required remedy ("…until the next package-manager install") is wrong for this path. The rollback puts the lock back byte-for-byte to the state yarn last installed from, so yarn's integrity check short-circuits and never re-extracts the package. The leftover is project-local here, not machine-wide as with Cargo. It still means a "successful" rollback leaves patched code installed with no socket-patch record of it.

    For comparison, agent→hosted then rollback restores both yarn.lock and node_modules, because hosted purls aren't partitioned out of the in-place leg.


    Generated by Claude Code

  4. mikolalysenko commented on Oct 5, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] From the Yarn classic bug-hunt (ledger #304): the yarn classic leg above also reproduces on macOS and Windows, so it doesn't depend on the OS.

    Main 045d7ec, probe run 37248870997 (macos-latest and windows-latest × yarn 1.7.0 / 1.10.1 / 1.22.22, Node 22, local mock patch API). The flow is the same as in my comment above: scan --mode agent --apply, then scan --mode vendored, then rollback, then yarn install --frozen-lockfile.

    OS yarn 1.7.0 yarn 1.10.1 yarn 1.22.22
    macOS fail fail fail
    Windows fail fail fail
    Linux (sandbox, re-run today) — — fail

    In all 7 cells, rollback exits 0 and yarn.lock is restored byte-exact (CRLF on Windows). yarn then prints success Already up-to-date., and node_modules/left-pad/index.js keeps the patched marker. The other agent cells in the same probe pass on both OSes: agent apply, vex, plain agent rollback and an agent re-run.


    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