Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1148,9 +1148,9 @@ worse, lets a warm cache silently serve unpatched bytes):

A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatched and cleans up the local state, in phases under one `apply.lock` acquisition:

1. **State discovery.** A missing manifest is no longer fatal when the vendor ledger or the lockfiles' hosted pins hold work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` and v5 `scan --mode hosted` project is manifest-less). The **truly-empty** project — no manifest, no vendor ledger, no hosted pin — keeps the "Manifest not found" exit 1 (JSON: the error envelope with `error.code: "manifest_not_found"` plus a top-level `path`), with one v5.0 exception: when a pre-v5 `.socket/vendor/redirect-state.json` is the only thing left, nothing pins it any more, so a wet run deletes it and exits 0 (human `Removed the pre-v5 hosted ledger .socket/vendor/redirect-state.json: no lockfile pins a hosted patch.`, `Would remove …` on `--dry-run`, which deletes nothing; JSON: a `success` envelope with no events plus `legacyRedirectLedgerRemoved` (bool); a failed delete is the `legacy_redirect_ledger_kept` warning, still exit 0). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors asking for `.socket/vendor/state.json` to be restored from version control first (v5.0: `repair` no longer reconstructs the ledger). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent and hosted legs still run; it drives `partialFailure` exit 1, and an emergency restore is never blocked by it. When the ONLY state on disk is an unreadable vendor ledger, the run fails closed naming the store. A pre-v5 redirect ledger is never read by rollback (v4's `redirect_state_unreadable` is no longer emitted). Under `--global`/`--global-prefix` the project's hosted pins and vendor ledger are not discovered at all, so the vendored and hosted legs below do not run (see "Global scope never touches the project's state").
1. **State discovery.** A missing manifest is no longer fatal when the vendor ledger or the lockfiles' hosted pins hold work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` and v5 `scan --mode hosted` project is manifest-less). The **truly-empty** project — no manifest, no vendor ledger, no hosted pin — keeps the "Manifest not found" exit 1 (JSON: the error envelope with `error.code: "manifest_not_found"` plus a top-level `path`), with one v5.0 exception: when a pre-v5 `.socket/vendor/redirect-state.json` is the only thing left, nothing pins it any more, so a wet run deletes it and exits 0 (human `Removed the pre-v5 hosted ledger .socket/vendor/redirect-state.json: no lockfile pins a hosted patch.`, `Would remove …` on `--dry-run`, which deletes nothing; JSON: a `success` envelope with no events plus `legacyRedirectLedgerRemoved` (bool); a failed delete is the `legacy_redirect_ledger_kept` warning, still exit 0). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors asking for `.socket/vendor/state.json` to be restored from version control first (v5.0: `repair` no longer reconstructs the ledger). An unscoped run whose ledger is present but does not own every `.socket/vendor/<eco>/<uuid>/` dir a lockfile references (in the `--ecosystems` scope) refuses the same way, before any write and on `--dry-run` and `--preserve-state` too: the usual cause is a git merge that kept one side of `state.json`, and reverting the rest would exit 0, leave that package wired and delete the ledger (#991). The `vendor_ledger_missing` error names each unowned dir; recovery is to restore its entry (after such a merge, merge both sides' `entries`) or return the lockfiles to their pre-vendor versions. The `vendor_orphan_still_wired` advisory names only the ledger restore: it follows a revert that already rewrote the lockfile. A scoped run does not own that wiring and proceeds. **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent and hosted legs still run; it drives `partialFailure` exit 1, and an emergency restore is never blocked by it. When the ONLY state on disk is an unreadable vendor ledger, the run fails closed naming the store. A pre-v5 redirect ledger is never read by rollback (v4's `redirect_state_unreadable` is no longer emitted). Under `--global`/`--global-prefix` the project's hosted pins and vendor ledger are not discovered at all, so the vendored and hosted legs below do not run (see "Global scope never touches the project's state").
2. **Agent leg** — the existing in-place restore machinery, unchanged (v5.0 presentation: the human `No patches found in manifest` line prints only for an unscoped run with no work in ANY leg — a run whose work is all vendored/hosted stays quiet about the manifest): multi-copy restore, release-variant narrowing, the before-blob gate (+ on-demand download; a gate abort still exits 1 with per-package `missing_blob` failure results **and** skips manifest cleanup + GC entirely — nothing was restored, and the retry's revert data must survive), local-go redirect drop, and the `not_installed` exit-0 asymmetry verbatim. Vendor-owned purls are still excluded here (see the vendored-mode section) — they are handled by the next leg instead of being punted to other commands — with one exception: a vendored Cargo crate whose copy in the shared `$CARGO_HOME/registry/src` cache (or a `cargo vendor` dir) still carries an earlier agent-mode patch is restored here too (vendoring never touches that copy, and the manifest record holds the only blobs that can restore it); its record leaves the manifest only when both this leg and the vendored leg succeeded. `remove` restores it the same way.
3. **Vendored leg** — each in-scope ledger entry (embedded-record entries included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted (and its emptied `.socket/vendor/<eco>/` husk pruned, v5.0), ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (a `failed` `vendor_revert_kept` event, exit 1 — the system is still patched); a failure is recorded and other entries proceed. Then, on a run with work in any leg, uuid dirs under `.socket/vendor/<eco>/` that no ledger entry owns (the losing side of a git merge of two vendor runs, an interrupted run) are swept as `vendor --revert` sweeps them: every one in the `--ecosystems` scope on an unscoped run, only those whose leaf names a targeted package (or whose uuid is a targeted uuid) on a scoped one; path-glob targets sweep none. The confirmation prompt counts them (`delete N orphaned vendored dirs with no ledger record`). A dir a lockfile still points into is kept with a `vendor_orphan_still_wired` warning, and one that cannot be deleted is a `cleanup_failed` warning. Skipped under `--preserve-state`, global scope, an unreadable ledger and a before-blob gate abort. `remove <target>` sweeps the orphans its target names the same way, once its vendored revert and manifest write have succeeded; they are `removed` `vendor_orphan_removed` events that `summary.removed` does not count, and the kept or undeletable ones `skipped` advisories (#1610).
3. **Vendored leg** — each in-scope ledger entry (embedded-record entries included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted (and its emptied `.socket/vendor/<eco>/` husk pruned, v5.0), ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (a `failed` `vendor_revert_kept` event, exit 1 — the system is still patched); a failure is recorded and other entries proceed. Then, on a run with work in any leg, uuid dirs under `.socket/vendor/<eco>/` that no ledger entry owns (the losing side of a git merge of two vendor runs, an interrupted run) are swept as `vendor --revert` sweeps them: every one in the `--ecosystems` scope on an unscoped run, only those whose leaf names a targeted package (or whose uuid is a targeted uuid) on a scoped one; path-glob targets sweep none. The confirmation prompt counts them (`delete N orphaned vendored dirs with no ledger record`). A dir a lockfile still points into is kept with a `vendor_orphan_still_wired` warning (only a scoped run can reach one: an unscoped run refuses such wiring up front, see State discovery), and one that cannot be deleted is a `cleanup_failed` warning. Skipped under `--preserve-state`, global scope, an unreadable ledger and a before-blob gate abort. `remove <target>` sweeps the orphans its target names the same way, once its vendored revert and manifest write have succeeded; they are `removed` `vendor_orphan_removed` events that `summary.removed` does not count, and the kept or undeletable ones `skipped` advisories (#1610).
4. **Hosted leg** — each in-scope hosted pin is restored to its default upstream registry entry; see "Hosted unwind coverage" below. After a hosted leg with no failure, a wet run deletes a pre-v5 `redirect-state.json` once no lockfile pins a hosted patch any more (a failed delete is the `legacy_redirect_ledger_kept` warning).
5. **Manifest cleanup** — entries are removed ONLY for in-scope purls whose legs fully succeeded, were not-installed, or were release-variant siblings narrowed away by an attempted variant that succeeded (half a variant group never lingers — `remove` parity); drift-kept and failed purls keep their records, and a failed variant holds its whole group. A manifest record that a live hosted pin has superseded (the lockfile wires the same package release to a different patch uuid, e.g. an agent → hosted migration after the patch was replaced) and whose installed copy holds neither side of the record is not restored in place and does not fail the run: the hosted leg's lock restore and the next install unwind it, and the record leaves the manifest with the `rollback_record_superseded` warning (`remove` does the same instead of aborting before its hosted leg). A copy still holding the record's patched bytes is restored as usual. No-op removals never rewrite the file. A failed write surfaces as `manifest_write_failed` (warning + `partialFailure` exit 1; GC still runs against the unchanged manifest).
6. **GC** — blob, diff and legacy package-archive sweeps against the post-removal manifest, using the same artifact-reference policy as `remove`, retaining beforeHash blobs for (a) removed-but-not-installed entries (a crawler miss must not destroy the only local revert data — `remove` parity) and (b) EVERY entry remaining in the post-removal manifest — still-active patches (failed, drift-kept, eco-/path-excluded) keep their revert data, so a scoped or failed run never destroys the blobs a later rollback needs; only blobs referenced solely by genuinely-removed entries are swept. GC errors warn (`cleanup_failed`) and continue — they never affect the exit (repair's posture).
Expand Down Expand Up @@ -1732,7 +1732,7 @@ outcome for a selected record, rather than inferring a write from exit 0.
| `socket_yml_invalid` / `socket_yml_ambiguous` | scan | The socket.yml patch policy cannot be honored (see socket.yml patch policy). |
| `patch_not_found` | rollback | No manifest, ledger or hosted patch matches the identifier. |
| `path_glob_no_match` | rollback, scan | rollback: a path target matched no patched package (exit 1). scan: a PATH glob matched no directory (usage error, exit 2). |
| `vendor_ledger_missing` | rollback | Lockfiles reference `.socket/vendor/` artifacts but the vendor ledger is missing. |
| `vendor_ledger_missing` | rollback | Lockfiles reference `.socket/vendor/` artifacts but the vendor ledger is missing, or (unscoped run) has no entry for a referenced uuid dir (#991). |
| `rollback_failed` | rollback | The rollback pipeline failed before any per-package result, or (#1066) something failed and nothing was rolled back, previewed, already original or not installed (the `failed` events stay in `events`). |
| `lock_held` / `lock_io` | every lock-taking command | Another live run holds `apply.lock`, or the lock file could not be opened. |
| `invalid_args` | scan, get, remove, repair | Usage error (exit 2): flags that cannot be combined. |
Expand Down
56 changes: 56 additions & 0 deletions crates/socket-patch-cli/src/commands/rollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,40 @@ fn error_envelope(dry_run: bool, err: EnvelopeError) -> Envelope {
env
}

/// The `vendor_ledger_missing` refusal for lockfile references into
/// `.socket/vendor/<eco>/<uuid>/` (in the `--ecosystems` scope) that no
/// entry of `state` owns, or `None` when every reference is ledgered.
async fn unledgered_vendor_wiring(
common: &crate::args::GlobalArgs,
ledger_root: &Path,
state: &VendorState,
) -> Option<String> {
let mut dirs: Vec<String> =
crate::commands::vendored_backend::repair::unledgered_vendor_references(ledger_root, state)
.await
.into_iter()
// An entry that carries the uuid under another ecosystem (an
// unknown backend, a hand edit) still owns it: its own revert
// reports what is wrong with it.
.filter(|(eco, uuid, _)| {
crate::commands::vendor::ecosystem_in_scope(common, eco)
&& !state.entries.values().any(|e| e.uuid == *uuid)
})
.map(|(eco, uuid, _)| format!(".socket/vendor/{eco}/{uuid}/"))
.collect();
if dirs.is_empty() {
return None;
}
dirs.sort();
Some(format!(
"lockfiles reference {}, which the vendor ledger (.socket/vendor/state.json) has no \
entry for, so rollback cannot unwind them; {} and roll back, or return the \
lockfiles to their pre-vendor versions",
dirs.join(", "),
crate::commands::vendor_orphans::UNLEDGERED_WIRING_REMEDY,
))
}

/// Report a top-level rollback error: the error envelope on `--json`
/// (message verbatim), an `Error:` stderr line otherwise. Errors print even
/// under --silent ("errors only", never "nothing").
Expand Down Expand Up @@ -1589,6 +1623,28 @@ pub async fn run(args: RollbackArgs) -> i32 {

let scoped = !identifiers.is_empty() || !path_scope.is_empty();

// A lockfile still wired into a `.socket/vendor/` dir that the present
// ledger does not own (a merge kept one side of state.json, #991) is a
// patch this run cannot unwind: the pre-vendor originals lived in the
// lost entry. An unscoped run promises to unwind everything, so it
// refuses before writing, like the ledger-missing case above, instead
// of reverting the rest and deleting the ledger around the wiring.
if !scoped && project_state {
if let Ok(state) = &vendor_state_result {
if let Some(detail) = unledgered_vendor_wiring(&args.common, &ledger_root, state).await
{
track_patch_rollback_failed(&detail, &telemetry).await;
emit_rollback_error(
args.common.json,
args.common.dry_run,
"vendor_ledger_missing",
&detail,
);
return 1;
}
}
}

// Identifier matching runs across ALL THREE stores; an identifier
// matching nothing anywhere is the familiar exit-1 error.
let mut manifest_scope: HashSet<String> = HashSet::new();
Expand Down
9 changes: 3 additions & 6 deletions crates/socket-patch-cli/src/commands/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1330,13 +1330,10 @@ async fn run_check(args: &VendorArgs) -> i32 {
// know (the ledger was ignored or dropped from the commit along with the
// manifest) leaves every fresh install failing; the manifest keys above
// cannot see it, so the references are read from the wiring itself.
let references = crate::commands::vendored_backend::repair::scan_vendor_references(root).await;
let references =
crate::commands::vendored_backend::repair::unledgered_vendor_references(root, &state).await;
for (eco, uuid, rel) in references {
let ledgered = state
.entries
.values()
.any(|entry| entry.uuid == uuid && entry.ecosystem == eco);
if ledgered || unledgered_uuids.contains(uuid.as_str()) {
if unledgered_uuids.contains(uuid.as_str()) {
continue;
}
// No ledger entry means no purl to name: like repair, the event
Expand Down
41 changes: 39 additions & 2 deletions crates/socket-patch-cli/src/commands/vendor_orphans.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,26 @@ pub(crate) fn orphan_label(unit: &vendor::path::SweptVendorDir) -> String {
.unwrap_or_else(|| format!("{}/{}", unit.eco, unit.uuid))
}

/// How to recover a lockfile reference into `.socket/vendor/` that the
/// ledger lost. `repair` does not rebuild ledger entries from lockfiles
/// (v5.0), so the entry must come back from version control (after a git
/// merge kept one side of state.json, both sides' `entries` merged). It
/// names no lockfile restore: the advisory below follows a revert that
/// already rewrote the lockfile, and checking it out again would re-wire
/// what that revert removed.
pub(crate) const UNLEDGERED_WIRING_REMEDY: &str =
"restore the missing entries in .socket/vendor/state.json from version control (if a \
git merge kept one side of state.json, merge both sides' `entries`)";

/// The advisory for an orphan uuid dir the sweep kept because a project
/// lockfile still points into it.
pub(crate) fn orphan_still_wired(unit: &vendor::path::SweptVendorDir) -> VendorWarning {
VendorWarning::new(
"vendor_orphan_still_wired",
format!(
"a project lockfile still points at .socket/vendor/{}/{}, which no ledger \
entry owns; the artifacts were kept (run `socket-patch repair` to re-adopt \
them into the ledger, then revert again)",
entry owns; the artifacts were kept ({UNLEDGERED_WIRING_REMEDY}, then revert \
again)",
Comment thread
mikolalysenko marked this conversation as resolved.
Resolved
unit.eco, unit.uuid
),
)
Expand Down Expand Up @@ -202,4 +213,30 @@ mod tests {
assert!(orphan_in_target(&Target::parse(uuid), &unparsed));
assert!(!orphan_in_target(&Target::parse("left-pad"), &unparsed));
}

/// A kept, still-wired orphan points the user at a remedy that works
/// in v5: `repair` no longer rebuilds ledger entries from lockfiles,
/// so the advisory names the ledger restore (and the merge case,
/// #991) or the lockfile restore instead.
#[test]
fn orphan_still_wired_names_a_working_remedy() {
let unit = vendor::path::SweptVendorDir {
eco: "npm".into(),
uuid: "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa".into(),
dir: PathBuf::from("/x"),
purls: vec![],
};
let warning = orphan_still_wired(&unit);
assert_eq!(warning.code, "vendor_orphan_still_wired");
let detail = &warning.detail;
assert!(
detail.contains(".socket/vendor/npm/aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa"),
"{detail}"
);
assert!(!detail.contains("socket-patch repair"), "{detail}");
assert!(detail.contains("entries"), "{detail}");
// The advisory follows a revert that rewrote the lockfile:
// checking the lockfile out again would re-wire what it removed.
assert!(!detail.contains("git checkout"), "{detail}");
}
}
Loading
Loading