From 46bbc428496cc3f98231c833139eed65b89ae6cd Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 10 Nov 2026 11:33:55 -0500 Subject: [PATCH 001/111] Refuse a Cargo.lock crate name crates.io cannot hold instead of panicking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rollback` and `remove` restore a hosted cargo pin by looking its crate up in the crates.io sparse index. The index path was cut from the lock's `name` at byte offsets 1, 2 and 4, so a `[[package]]` on a hosted source named `""` or `"éa"` panicked (exit 101, "byte index 1 is not a char boundary") and no restore could run on that checkout. crates_index_path now returns None unless the name is non-empty ASCII letters, digits, `-` and `_` (crates.io's alphabet), and cargo_cksum refuses the pin with `"éa" is not a crates.io crate name` before any request, offline or not. The slices are then only taken from ASCII. Checking the rest of the cargo restore for the same input class found a second panic: a v1 lock that holds the same hosted block twice beside a crates.io twin (cargo refuses such a lock, TOML parses it) queued the one `[metadata]` checksum line for removal twice, and the second `replace_range` ran past the end of the text. Equal splice ranges are now applied once. Tests (each fails on the old code with the panic): cargo_cksum_refuses_a_name_crates_io_cannot_hold, restore_refuses_a_hosted_block_named_like_no_crate, restore_merge_drops_a_shared_metadata_line_once. Co-Authored-By: Claude Fable 5.2 --- .../src/patch/redirect/upstream/cargo.rs | 76 +++++++++++++++++++ .../src/patch/redirect/upstream/client.rs | 55 +++++++++++--- 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs index fe2b39946..93b2fdfcd 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs @@ -178,6 +178,10 @@ pub(crate) async fn restore( } } splices.sort_by_key(|(r, _)| std::cmp::Reverse(r.start)); + // Two blocks of one name, version and source (a hand-merged lock) + // name the same `[metadata]` line. Every other range is its own + // block or value, so equal ranges are the only ones that meet. + splices.dedup_by(|a, b| a.0 == b.0); for (range, with) in splices { lock.replace_range(range, &with); } @@ -850,6 +854,78 @@ mod tests { (outcome, after) } + /// A hosted-source block whose `name` crates.io cannot hold (empty, or + /// not ASCII) refuses its pin with the name, online too, and leaves the + /// lock as it was: the index path is never built from it. + #[tokio::test] + async fn restore_refuses_a_hosted_block_named_like_no_crate() { + for name in ["éa", ""] { + let lock = format!( + "version = 4\n\n[[package]]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [[package]]\nname = \"{name}\"\nversion = \"1.0.4\"\n\ + source = \"{}\"\nchecksum = \"{SOCKET_CKSUM}\"\n", + socket_source(B) + ); + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("Cargo.toml"), manifest("")).unwrap(); + std::fs::write(tmp.path().join("Cargo.lock"), &lock).unwrap(); + let pins = [super::super::HostedPin { + purl: "pkg:cargo/cfg-if@1.0.4".into(), + uuid: B.into(), + files: vec!["Cargo.lock".into(), "Cargo.toml".into()], + }]; + // Online: the name is refused before any registry request. + let opts = super::super::RestoreOptions::default(); + let outcome = super::super::restore_upstream(tmp.path(), &pins, &opts).await; + let refusals: Vec<&str> = outcome.refused().map(|(_, why)| why).collect(); + assert_eq!(refusals.len(), 1, "{:?}", outcome.pins); + assert!( + refusals[0].contains(&format!("{name:?} is not a crates.io crate name")), + "{refusals:?}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("Cargo.lock")).unwrap(), + lock + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(), + manifest("") + ); + } + } + + /// A hand-merged v1 lock holding the Socket block twice (cargo refuses + /// it, TOML does not) names one `[metadata]` line from both blocks: the + /// line is removed once, not spliced out of the text a second time. + #[tokio::test] + async fn restore_merge_drops_a_shared_metadata_line_once() { + let socket = socket_source(B); + let socket_block = format!( + "[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{socket}\"\n\n" + ); + let lock = format!( + "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"cfg-if 1.0.4 ({socket})\",\n]\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n\ + source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\n\ + {socket_block}{socket_block}\ + [metadata]\n\ + \"checksum cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)\" = \"{CRATES_IO_CKSUM}\"\n\ + \"checksum cfg-if 1.0.4 ({socket})\" = \"{SOCKET_CKSUM}\"\n" + ); + let (outcome, after) = restore_lock(&lock).await; + assert_eq!(outcome.restored().count(), 1, "{:?}", outcome.pins); + let want = format!( + "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)\",\n]\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n\ + source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\n\ + [metadata]\n\ + \"checksum cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)\" = \"{CRATES_IO_CKSUM}\"\n" + ); + assert_eq!(after, want); + } + /// The lock cargo writes once a later dependency (crc32fast) locks a /// crates.io copy of the hosted-pinned cfg-if 1.0.4 (#679): two blocks /// for one name@version, so every reference is a full package id. diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/client.rs b/crates/socket-patch-core/src/patch/redirect/upstream/client.rs index 8627e35fa..af5b7dedd 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/client.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/client.rs @@ -70,15 +70,26 @@ fn crates_index_base() -> String { .unwrap_or_else(|| DEFAULT_CRATES_INDEX.to_string()) } -/// The sparse-index path of a crate (`cargo`'s `index_path` layout). -fn crates_index_path(name: &str) -> String { +/// The sparse-index path of a crate (`cargo`'s `index_path` layout), or +/// `None` for a name crates.io cannot hold. Its names are non-empty ASCII +/// letters, digits, `-` and `_`; the name here comes from a `Cargo.lock`, +/// so anything else is refused rather than cut at byte offsets (which is +/// only sound for ASCII) or placed in a URL path. +fn crates_index_path(name: &str) -> Option { + let crates_io_name = !name.is_empty() + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); + if !crates_io_name { + return None; + } let lower = name.to_ascii_lowercase(); - match lower.len() { + Some(match lower.len() { 1 => format!("1/{lower}"), 2 => format!("2/{lower}"), 3 => format!("3/{}/{lower}", &lower[..1]), _ => format!("{}/{}/{lower}", &lower[..2], &lower[2..4]), - } + }) } /// The RubyGems compact index root; override with `SOCKET_RUBYGEMS_URL`. @@ -412,10 +423,12 @@ impl UpstreamClient { return hit.clone(); } let result = async { + let path = crates_index_path(name) + .ok_or_else(|| format!("{name:?} is not a crates.io crate name"))?; if self.offline { return Err(OFFLINE.to_string()); } - let url = format!("{}/{}", crates_index_base(), crates_index_path(name)); + let url = format!("{}/{path}", crates_index_base()); let text = self.get_text(&url).await?; for line in text.lines().filter(|l| !l.trim().is_empty()) { let Ok(row) = serde_json::from_str::(line) else { @@ -840,10 +853,34 @@ mod tests { #[test] fn crates_index_paths_follow_cargo_layout() { - assert_eq!(crates_index_path("a"), "1/a"); - assert_eq!(crates_index_path("ab"), "2/ab"); - assert_eq!(crates_index_path("abc"), "3/a/abc"); - assert_eq!(crates_index_path("Serde"), "se/rd/serde"); + let path = |name: &str| crates_index_path(name).unwrap(); + assert_eq!(path("a"), "1/a"); + assert_eq!(path("ab"), "2/ab"); + assert_eq!(path("abc"), "3/a/abc"); + assert_eq!(path("Serde"), "se/rd/serde"); + assert_eq!(path("serde_json"), "se/rd/serde_json"); + assert_eq!(path("cfg-if"), "cf/g-/cfg-if"); + } + + /// A `Cargo.lock` is a project file: a `name` crates.io cannot hold + /// (empty, non-ASCII, or holding a path or URL character) is refused by + /// name, never sliced into an index path or sent in a URL. + #[tokio::test] + async fn cargo_cksum_refuses_a_name_crates_io_cannot_hold() { + for name in [ + "", "éa", "aé", "aéb", "日本", "a/b", "..", "a b", "a?b", "a.b", + ] { + assert_eq!(crates_index_path(name), None, "{name:?}"); + // Online and offline alike: the name is refused before any + // request, so this never reaches the network. + for offline in [false, true] { + let why = UpstreamClient::new(offline) + .cargo_cksum(name, "1.0.0") + .await + .expect_err(name); + assert_eq!(why, format!("{name:?} is not a crates.io crate name")); + } + } } #[test] From 3d0fda8ae476064ff3f45422df0b11cf2a9ca0b6 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 10 Nov 2026 11:33:55 -0500 Subject: [PATCH 002/111] Score a NuGet prefix pattern without slicing the package id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NuGet restore decides which source a restored id resolves from by scoring each `` of the config. pattern_score cut the id at the prefix's byte length, guarded only by the id being at least that long. NuGet ids admit Unicode letters, so an id such as `Ünicode.Pkg` beside a mapping `A*` panicked `rollback` ("byte index 1 is not a char boundary"). The prefix is now taken with `str::get`, so a length that falls inside a character is a pattern that does not match. A prefix cannot equal the id's bytes up to the middle of a character, so no match is lost. Test: pattern_scores_never_split_a_character_of_the_id (panics on the old code). Co-Authored-By: Claude Fable 5.2 --- .../src/patch/redirect/upstream/nuget.rs | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs index 387dc006d..15a512949 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs @@ -151,9 +151,12 @@ fn drop_fanout_mapping(config: &str) -> String { /// beats the longest `prefix*`, which beats `*`)? fn pattern_score(pattern: &str, id: &str) -> Option { match pattern.strip_suffix('*') { - Some(prefix) => (id.len() >= prefix.len() - && id[..prefix.len()].eq_ignore_ascii_case(prefix)) - .then_some(prefix.len()), + // `get`: an id shorter than the prefix, or one with a multi-byte + // character across the prefix's length, does not start with it. + Some(prefix) => id + .get(..prefix.len()) + .is_some_and(|head| head.eq_ignore_ascii_case(prefix)) + .then_some(prefix.len()), None => pattern.eq_ignore_ascii_case(id).then_some(usize::MAX), } } @@ -399,6 +402,26 @@ mod tests { ); } + /// NuGet ids admit Unicode letters and a `nuget.config` is a project + /// file: a prefix pattern whose byte length falls inside a character of + /// the id is a pattern that does not match, not a slice to take. + #[test] + fn pattern_scores_never_split_a_character_of_the_id() { + assert_eq!(pattern_score("A*", "Ünicode.Pkg"), None); + assert_eq!(pattern_score("Ün*", "Ünicode.Pkg"), Some(3)); + assert_eq!(pattern_score("Ü*", "Ünicode.Pkg"), Some(2)); + assert_eq!(pattern_score("Ünicode.Pkg.Extra*", "Ünicode.Pkg"), None); + assert_eq!(pattern_score("abc*", "日本"), None); + assert_eq!(pattern_score("*", "日本"), Some(0)); + // The whole restore check, on the config that reaches it. + let mapped = cfg( + r#""#, + ); + assert!(check_upstream_feed(&mapped, "Ünicode.Pkg", "nuget.config").is_ok()); + let why = check_upstream_feed(&mapped, "Abc", "nuget.config").unwrap_err(); + assert!(why.contains("corp"), "{why}"); + } + fn cfg(text: &str) -> NugetConfig { parse_config(text).unwrap() } From 9faf64fc9903bb6a32b273171284cdd1ae312791 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 10 Nov 2026 11:36:22 -0500 Subject: [PATCH 003/111] Keep the project's manifest entries and blobs through a global prune `scan --prune`/`--sync` under `--global` or `--global-prefix` judged the manifest against the global crawl alone. The manifest is still `/.socket/manifest.json`, so every record of the project read as uninstalled: the run emptied the manifest and deleted `.socket/blobs/`, exit 0. The project's `apply` then had nothing to re-apply and `rollback` nothing to restore from. A global prune now also keeps every entry the `--cwd` project accounts for, its installed packages and the ones only its lockfiles list, which is what the project's own prune keeps. It removes only entries neither scope has. Two more ways the same GC destroyed data it should have kept: * When the pruned manifest could not be written, the sweep still ran against the pruned in-memory copy and deleted the blobs the manifest on disk still names. A failed write now prunes and sweeps nothing, and `manifest_write_failed` says so. * The blob, diff and package sweeps followed a symlinked `.socket/` level. A checkout can commit `.socket/packages -> ../../elsewhere`, and `repair`, `remove`, `rollback` and `scan --prune` then deleted every regular file there as an orphan; the dry run listed them too. The sweep refuses a linked level with the containment refusal every other writer and deleter of `.socket/` uses, reported as `cleanup_failed`. Found by the 2026-11-10 full review. The first was reproduced with the 5.1.0 binary: a project with one installed, patched package, scanned with `--global-prefix --sync`, was left with `{"patches": {}}` and no blobs. Each case has a test that fails without the change: scan_global_prune_keeps_the_projects_entries, scan_prune_keeps_blobs_when_the_manifest_cannot_be_rewritten and sweep_refuses_a_linked_artifact_dir_and_spares_its_target. Co-Authored-By: Claude Fable 5.2 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../socket-patch-cli/src/commands/scan/gc.rs | 56 +++++-- .../socket-patch-cli/tests/in_process_scan.rs | 141 ++++++++++++++++++ .../src/manifest/cleanup_blobs.rs | 84 ++++++++++- 4 files changed, 265 insertions(+), 18 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 259b5dbfd..cf125aca3 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -332,7 +332,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (each is also a `skipped` / `vendored` event); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the lockfiles still pin scanned package(s) to a hosted patch (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, which restores the upstream registry entries, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, migrate via `scan --mode vendored`, or `socket-patch rollback`). The warning keys on the hosted pins lockfile discovery finds at scan time, so a flow that restored the upstream entries retires it. The human path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning: ` (stderr, muted by `--silent`); never a status or exit change. `go_installed_tool_stale` (v5.0; `apply`, dry runs included) — a `--global` Go apply patched a module cache copy that a tool installed with `go install` was built from before the patch; the detail names the binary and the `go install @` remedy (or, when they can't be inspected, what could not be checked); never a status or exit change. `stale_bytecode_not_removed` (`apply` and `rollback` `warnings[]` alike) — a `.py` file WAS patched (or restored) but CPython bytecode compiled from its previous bytes (`__pycache__/.[.opt-N].pyc`) could not be deleted, so Python may keep running the old code until it is removed; the detail is `: could not remove stale Python bytecode (: , …); …` and the human line `Warning: `; never a status or exit change. `cargo_build_cache_stale` (v5.0; `apply` and `rollback` `warnings[]` alike) — a cargo crate's bytes changed but its compiled copy in a project build directory could not be invalidated (a fingerprint directory that could not be removed, or a `build.build-dir` with a `{workspace-path-hash}` template); the detail names the crates to `cargo clean -p` before the next build, which may otherwise link the stale code; never a status or exit change. -`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob files, and every obsolete diff and package archive, from `.socket/`. A blob is an orphan when no patch left in the manifest references it: the afterHash and beforeHash blobs of every remaining patch are kept, the same retention policy `repair` uses (the beforeHash blobs are an offline rollback's only restore data, and `repair` downloads afterHash blobs only). Diff and legacy package archives are never kept, even for a remaining patch: nothing reads them any more. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). A Cargo entry is also exempt while its agent-mode copy is still patched: the project crawl looks up only the crates `Cargo.lock` resolves, but a crate the lock bumped or dropped keeps its patched copy in the machine-wide `$CARGO_HOME/registry/src` cache (nothing deletes it), and the entry holds the only blobs that can restore that copy. The wet pass keeps it with a `cargo_cache_patch_kept` warning naming the `socket-patch rollback ` that restores the copy and drops the entry; the `--dry-run` preview reports no event (and no warning) for it. The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). Under `--json` (v5.0, MAJOR) the pass is recorded into the scan envelope: each pruned manifest entry is a `removed` event with `details.manifest: true`, each reverted vendored entry a `removed` event (`errorCode: "vendor_reverted"`, `details.mode: "vendored"`), each drift-kept entry a `skipped` / `vendor_revert_kept` event and each entry whose revert failed a `skipped` / `vendor_revert_failed` event (a GC revert failure never fails the run); a `--dry-run` preview reports the removals as `verified`. The artifact sweep is the envelope's `gc` (`summary.bytesFreed` mirrors it) and the orphan vendor dirs swept are the top-level `removedVendorOrphanDirs` count. The pass's warnings join the top-level `warnings[]`: `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed), `cleanup_failed` (an orphan sweep failed mid-way), `cargo_cache_patch_kept` (see above), and the reinstall advisories of the vendored reverts (`vendor_bun_reinstall_required`, `vendor_vlt_reinstall_required`, `vendor_pypi_reinstall_required`; a revert's other warnings, such as `vendor_lock_entry_removed` or a drift keep's, are not repeated). A pass skipped at the lock (`lock_held` | `lock_io`) has no `gc` and no events, only a `gc_skipped` warning whose detail ends with the code. Human mode prints `GC: skipped: .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one). A drift keep is an entry the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so its `verified` reverts may over-promise what a wet run will actually reclaim. +`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob files, and every obsolete diff and package archive, from `.socket/`. A blob is an orphan when no patch left in the manifest references it: the afterHash and beforeHash blobs of every remaining patch are kept, the same retention policy `repair` uses (the beforeHash blobs are an offline rollback's only restore data, and `repair` downloads afterHash blobs only). Diff and legacy package archives are never kept, even for a remaining patch: nothing reads them any more. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Under `--global`/`--global-prefix` the crawl covers only the global installs while the manifest is still `--cwd`'s, so the prune also keeps every entry the `--cwd` project accounts for (its installed packages and its lockfile-listed ones, exactly what the project's own `--prune` keeps): a global prune removes only entries neither scope has. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). A Cargo entry is also exempt while its agent-mode copy is still patched: the project crawl looks up only the crates `Cargo.lock` resolves, but a crate the lock bumped or dropped keeps its patched copy in the machine-wide `$CARGO_HOME/registry/src` cache (nothing deletes it), and the entry holds the only blobs that can restore that copy. The wet pass keeps it with a `cargo_cache_patch_kept` warning naming the `socket-patch rollback ` that restores the copy and drops the entry; the `--dry-run` preview reports no event (and no warning) for it. The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). Under `--json` (v5.0, MAJOR) the pass is recorded into the scan envelope: each pruned manifest entry is a `removed` event with `details.manifest: true`, each reverted vendored entry a `removed` event (`errorCode: "vendor_reverted"`, `details.mode: "vendored"`), each drift-kept entry a `skipped` / `vendor_revert_kept` event and each entry whose revert failed a `skipped` / `vendor_revert_failed` event (a GC revert failure never fails the run); a `--dry-run` preview reports the removals as `verified`. The artifact sweep is the envelope's `gc` (`summary.bytesFreed` mirrors it) and the orphan vendor dirs swept are the top-level `removedVendorOrphanDirs` count. The pass's warnings join the top-level `warnings[]`: `vendor_state_write_failed` (entries were reverted but the ledger rewrite failed), `manifest_write_failed` (the pruned manifest could not be written: no entry was pruned and no blob, diff or package archive was swept, since the manifest on disk still references them), `cleanup_failed` (an orphan sweep failed mid-way, or `.socket/` or one of its `blobs/`, `diffs/`, `packages/` directories is a symlink, which is never swept), `cargo_cache_patch_kept` (see above), and the reinstall advisories of the vendored reverts (`vendor_bun_reinstall_required`, `vendor_vlt_reinstall_required`, `vendor_pypi_reinstall_required`; a revert's other warnings, such as `vendor_lock_entry_removed` or a drift keep's, are not repeated). A pass skipped at the lock (`lock_held` | `lock_io`) has no `gc` and no events, only a `gc_skipped` warning whose detail ends with the code. Human mode prints `GC: skipped: .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one). A drift keep is an entry the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so its `verified` reverts may over-promise what a wet run will actually reclaim. `scan` queries the patch API in `--batch-size` chunks. Authenticated runs POST `/v0/orgs/{slug}/patches/batch`; token-less runs POST `{proxy}/patch/batch` on the public proxy and degrade to per-package `GET /patch/by-package/:purl` requests in two cases: the deployed proxy predates the batch endpoint (legacy proxies answer the POST with their `400 "Unsupported endpoint"` catch-all), or the all-or-nothing batch validation rejects the chunk (e.g. a crawled PURL type the server doesn't recognize, such as `pkg:jsr/…` — the per-package path tolerates those individually, preserving the pre-batch scan semantics). Rate limits and over-capacity 503s surface instead of silently degrading. diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index 49fbe2912..2ea97871a 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -47,11 +47,12 @@ pub(super) struct GcSummary { /// its whole mutating half (`lock_held` or `lock_io`). Never set in /// preview mode. skipped: Option<(&'static str, String)>, - /// Post-revert/prune rewrites that failed (`vendor_state_write_failed` - /// / `manifest_write_failed`) and orphan sweeps that could not finish - /// (`cleanup_failed`: the removed counts above are what it did - /// reclaim). The mutations already happened on disk, so the stale - /// record is reported, not the pass failed. Also the vendored reverts' + /// A post-revert ledger rewrite that failed (`vendor_state_write_failed`: + /// the reverts already happened on disk, so the stale record is + /// reported, not the pass failed), a manifest prune that could not be + /// written (`manifest_write_failed`: nothing was pruned or swept), and + /// orphan sweeps that could not finish (`cleanup_failed`: the removed + /// counts above are what it did reclaim). Also the vendored reverts' /// reinstall advisories, and `manifest_invalid` / `manifest_unreadable` /// when the manifest half could not load the manifest. warnings: Vec<(&'static str, String)>, @@ -258,32 +259,64 @@ pub(super) async fn run_apply_gc( Err(e) => return GcSummary::manifest_unloadable(vendor_gc, manifest_path, &e), }; let mut prunable = detect_prunable(&manifest, scanned_purls, vendored); + keep_project_entries(common, &mut prunable).await; let kept = keep_patched_cargo_copies(common, &manifest, socket_dir, &mut prunable).await; for purl in &prunable { manifest.patches.remove(purl); } - let mut write_failure = None; if !prunable.is_empty() { - // The entries stay listed as pruned: they are gone from the - // in-memory copy the sweep below works from. if let Err(e) = write_manifest(manifest_path, &manifest).await { - write_failure = Some(( + // The manifest on disk still names every entry, so nothing was + // pruned and every blob is still referenced: no sweep. + let mut gc = GcSummary::vendor_only(vendor_gc); + gc.warnings.extend(kept); + gc.warnings.push(( "manifest_write_failed", format!( - "pruned {} but could not update {}: {e}", + "could not prune {}: updating {} failed ({e}); nothing was removed", plural(prunable.len(), "manifest entry", "manifest entries"), manifest_path.display() ), )); + return gc; } } let mut gc = run_gc(&manifest, prunable, socket_dir, /*dry_run=*/ false).await; gc.absorb_vendor_gc(vendor_gc); gc.warnings.extend(kept); - gc.warnings.extend(write_failure); gc } +/// Under global scope, drop from `prunable` every entry the `--cwd` project +/// still accounts for. +/// +/// Both scopes share one manifest, and a global run crawls only the global +/// installs. Against that crawl alone every record of the project reads as +/// uninstalled, and pruning it deletes the blobs the project's `apply` and +/// `rollback` need. What the project accounts for is what its own `--prune` +/// judges against: its installed packages and the ones only its lockfiles +/// list (its vendored entries are exempt already). +async fn keep_project_entries(common: &GlobalArgs, prunable: &mut Vec) { + if !common.is_global() || prunable.is_empty() { + return; + } + let project = GlobalArgs { + global: false, + global_prefix: None, + ..common.clone() + }; + let (installed, _, _) = + crate::ecosystem_dispatch::crawl_ecosystems(&project.crawler_options(), None).await; + let ctx = crate::commands::context::ProjectContext::new(&project); + let locked = &ctx.locks().await.entries; + let accounted: HashSet = installed + .iter() + .map(|pkg| PurlKey::new(&pkg.purl)) + .chain(locked.iter().map(|entry| PurlKey::new(&entry.purl))) + .collect(); + prunable.retain(|purl| !accounted.contains(&PurlKey::new(purl))); +} + /// Whether the vendored-state ledger exists. Checked before the lock, /// whose acquire would create `.socket/`. async fn has_ledger(common: &GlobalArgs) -> bool { @@ -407,6 +440,7 @@ async fn preview_apply_gc( } } let mut prunable = detect_prunable(&manifest, scanned_purls, vendored); + keep_project_entries(common, &mut prunable).await; let _ = keep_patched_cargo_copies(common, &manifest, socket_dir, &mut prunable).await; // The sweep derives the referenced set from this in-memory manifest. for purl in &prunable { diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index c00440980..29b03ca07 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -774,6 +774,147 @@ async fn scan_prune_only_wet_removes_orphans() { ); } +/// A manifest record with one patched file, and its two blobs on disk. +fn write_record_with_blobs(root: &Path, purls: &[&str]) { + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let mut patches = serde_json::Map::new(); + for (i, purl) in purls.iter().enumerate() { + let (before, after) = (format!("{:064x}", 0xb0 + i), format!("{:064x}", 0xa0 + i)); + for blob in [&before, &after] { + std::fs::write(socket.join("blobs").join(blob), "blob").unwrap(); + } + patches.insert( + purl.to_string(), + serde_json::json!({ + "uuid": format!("5555555{i}-5555-4555-8555-555555555555"), + "exportedAt": "2024-01-01T00:00:00Z", + "files": { "index.js": { "beforeHash": before, "afterHash": after } }, + "vulnerabilities": {}, + "description": "record", "license": "MIT", "tier": "free" + }), + ); + } + std::fs::write( + socket.join("manifest.json"), + serde_json::json!({ "patches": patches }).to_string(), + ) + .unwrap(); +} + +fn manifest_purls(root: &Path) -> Vec { + let body = std::fs::read_to_string(root.join(".socket/manifest.json")).unwrap(); + let manifest: serde_json::Value = serde_json::from_str(&body).unwrap(); + let mut purls: Vec = manifest["patches"] + .as_object() + .unwrap() + .keys() + .cloned() + .collect(); + purls.sort(); + purls +} + +fn blob_count(root: &Path) -> usize { + std::fs::read_dir(root.join(".socket/blobs")).map_or(0, |dir| dir.count()) +} + +/// Both scopes share `/.socket/manifest.json`, and a global run crawls +/// only the global installs. Its prune must not read the project's records +/// as uninstalled: dropping them deletes the blobs the project's `apply` +/// and `rollback` need. Only the entry neither scope accounts for goes. +#[tokio::test] +#[serial] +async fn scan_global_prune_keeps_the_projects_entries() { + let server = MockServer::start().await; + mock_batch_empty(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + write_root_package_json(&project); + write_npm_package(&project, "project-only", "1.0.0"); + // Lockfile-listed, not installed: the project's own prune keeps it. + std::fs::write( + project.join("package-lock.json"), + r#"{ "name": "in-proc-scan-test", "version": "0.0.0", "lockfileVersion": 3, + "packages": { + "": { "name": "in-proc-scan-test", "version": "0.0.0" }, + "node_modules/project-only": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/project-only/-/project-only-1.0.0.tgz" }, + "node_modules/locked-only": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/locked-only/-/locked-only-1.0.0.tgz" } + } }"#, + ) + .unwrap(); + write_record_with_blobs( + &project, + &[ + "pkg:npm/project-only@1.0.0", + "pkg:npm/locked-only@1.0.0", + "pkg:npm/installed-nowhere@1.0.0", + ], + ); + let global_root = tmp.path().join("global/lib"); + write_npm_package(&global_root, "global-pkg", "2.0.0"); + let global = global_root.join("node_modules"); + + for dry_run in [true, false] { + let mut args = default_args(&project); + args.common.api_url = Some(server.uri()); + args.common.global_prefix = Some(global.clone()); + args.common.dry_run = dry_run; + args.prune = true; + assert_eq!(run_scrubbed(args).await, 0); + } + + assert_eq!( + manifest_purls(&project), + ["pkg:npm/locked-only@1.0.0", "pkg:npm/project-only@1.0.0"], + "a global prune drops only what neither scope accounts for" + ); + assert_eq!(blob_count(&project), 4, "the kept records keep their blobs"); +} + +/// A prune whose manifest rewrite fails has pruned nothing: the manifest on +/// disk still names every entry, so their blobs must survive the sweep. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn scan_prune_keeps_blobs_when_the_manifest_cannot_be_rewritten() { + use std::os::unix::fs::PermissionsExt; + let server = MockServer::start().await; + mock_batch_empty(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "still-installed", "1.0.0"); + write_record_with_blobs(tmp.path(), &["pkg:npm/orphan@1.0.0"]); + let socket = tmp.path().join(".socket"); + // The lock file exists already, so the read-only directory refuses only + // the manifest's staged rewrite. `blobs/` stays writable. + std::fs::write(socket.join("apply.lock"), "").unwrap(); + std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o555)).unwrap(); + let read_only = std::fs::write(socket.join("probe"), "").is_err(); + + let mut args = default_args(tmp.path()); + args.common.api_url = Some(server.uri()); + args.prune = true; + let code = run_scrubbed(args).await; + std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o755)).unwrap(); + if !read_only { + return; // root ignores the mode bits; nothing to observe + } + + assert_eq!(code, 0, "a GC that could not run does not fail the scan"); + assert_eq!(manifest_purls(tmp.path()), ["pkg:npm/orphan@1.0.0"]); + assert_eq!( + blob_count(tmp.path()), + 2, + "the entry is still in the manifest, so its blobs are still referenced" + ); +} + // --------------------------------------------------------------------------- // --sync (== --mode agent --prune) // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-core/src/manifest/cleanup_blobs.rs b/crates/socket-patch-core/src/manifest/cleanup_blobs.rs index 53fd485ec..ecd36b415 100644 --- a/crates/socket-patch-core/src/manifest/cleanup_blobs.rs +++ b/crates/socket-patch-core/src/manifest/cleanup_blobs.rs @@ -3,6 +3,7 @@ use std::path::Path; use crate::api::blob_fetcher::ArtifactNoun; use crate::manifest::schema::{PatchManifest, PatchRecord}; +use crate::utils::containment::ensure_unlinked; /// Result of a blob cleanup operation. #[derive(Debug, Default)] @@ -77,17 +78,33 @@ impl ArtifactReferences { /// Sweep each artifact directory independently so a failed pass does not /// stop another. Callers report partial counts and cleanup warnings. + /// + /// A directory reached through a link (`.socket` itself or the artifact + /// directory under it) is refused, on a dry run too: socket-patch never + /// creates a link there, so its target is someone else's tree, and + /// every file in it would read as an orphan. pub async fn sweep(&self, socket_dir: &Path, dry_run: bool) -> ArtifactSweep { + let root = socket_dir.parent().unwrap_or(socket_dir); + let pass = |name: &str| { + let dir = socket_dir.join(name); + ensure_unlinked(root, &dir, "sweep").map(|()| dir) + }; ArtifactSweep { - blobs: cleanup_dir(&socket_dir.join("blobs"), dry_run, |name| { - self.blobs.contains(name) - }) - .await, + blobs: match pass("blobs") { + Ok(dir) => cleanup_dir(&dir, dry_run, |name| self.blobs.contains(name)).await, + Err(refusal) => Err(refusal), + }, // Nothing writes or reads legacy diff or package archives any // more (v5 fetches patch content as per-file blobs only), so // every file in either directory is an orphan. - diffs: cleanup_dir(&socket_dir.join("diffs"), dry_run, |_| false).await, - packages: cleanup_dir(&socket_dir.join("packages"), dry_run, |_| false).await, + diffs: match pass("diffs") { + Ok(dir) => cleanup_dir(&dir, dry_run, |_| false).await, + Err(refusal) => Err(refusal), + }, + packages: match pass("packages") { + Ok(dir) => cleanup_dir(&dir, dry_run, |_| false).await, + Err(refusal) => Err(refusal), + }, } } } @@ -447,6 +464,61 @@ mod tests { .is_err()); } + /// A `.socket/` level that is a link is not socket-patch's own state + /// (a checkout can commit `.socket/packages -> ../../elsewhere`): the + /// sweep must refuse it, wet and dry, instead of deleting the target's + /// files as orphans. The passes over real directories still run. + #[cfg(unix)] + #[tokio::test] + async fn sweep_refuses_a_linked_artifact_dir_and_spares_its_target() { + use std::os::unix::fs::symlink; + let tmp = tempfile::tempdir().unwrap(); + let victim = tmp.path().join("victim"); + std::fs::create_dir_all(&victim).unwrap(); + std::fs::write(victim.join("notes.txt"), "keep me").unwrap(); + let socket_dir = tmp.path().join("project/.socket"); + std::fs::create_dir_all(socket_dir.join("blobs")).unwrap(); + std::fs::write(socket_dir.join("blobs").join(ORPHAN_HASH), "orphan").unwrap(); + + for linked in ["packages", "diffs"] { + symlink(&victim, socket_dir.join(linked)).unwrap(); + for dry_run in [true, false] { + let sweep = ArtifactReferences::active(&PatchManifest::new()) + .sweep(&socket_dir, dry_run) + .await; + let refused = match linked { + "packages" => sweep.packages, + _ => sweep.diffs, + }; + let err = refused.expect_err("a linked artifact dir is refused"); + assert!( + err.to_string() + .contains(crate::utils::containment::LINKED_LEVEL_MARKER), + "{err}" + ); + assert!( + victim.join("notes.txt").exists(), + "{linked} dry_run={dry_run}" + ); + assert!(sweep.blobs.is_ok(), "the real blobs dir is still swept"); + } + std::fs::remove_file(socket_dir.join(linked)).unwrap(); + } + + // `.socket` itself linked: every pass is refused. + let shared = tmp.path().join("shared-socket"); + std::fs::create_dir_all(shared.join("blobs")).unwrap(); + std::fs::write(shared.join("blobs").join(ORPHAN_HASH), "theirs").unwrap(); + let other = tmp.path().join("other"); + std::fs::create_dir_all(&other).unwrap(); + symlink(&shared, other.join(".socket")).unwrap(); + let sweep = ArtifactReferences::active(&PatchManifest::new()) + .sweep(&other.join(".socket"), false) + .await; + assert!(sweep.blobs.is_err() && sweep.diffs.is_err() && sweep.packages.is_err()); + assert!(shared.join("blobs").join(ORPHAN_HASH).exists()); + } + /// #893: the beforeHash blobs of a patch still in the manifest are its /// only local restore data, so the sweep keeps them beside the /// afterHash blobs; only unreferenced files go. From 6675d3ef52367266411eeefc8bb2a587edd29e61 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 10 Nov 2026 11:58:48 -0500 Subject: [PATCH 004/111] Wrap help text, explain the patch modes, and accept everyday ecosystem names First-contact fixes from the 2026-11-10 usability review. None changes what a command does. * Help wraps to the terminal, capped at 100 columns. Lines ran to 564 characters in `--help` and 501 in the short `-h` summaries, which broke the two-column layout in an 80-column terminal. A new test bounds every help page's line width. * Root help defines the three patch modes. It used "hosted", "vendored" and "agent-mode" without saying what they are; the only definitions were under `--mode` in `scan --help`. * `--ecosystems` and socket.yml `patches.ecosystems` accept the names users reach for. The CLI prints "Found 5 packages (5 go)" and "(61 python)" but rejected `-e go` and `-e python`, and socket.yml answered `go` with "did you mean `gem`?". Both now resolve through one `Ecosystem::from_name`: the slugs in any case, the display names, and common registry, language and package-manager names. The value is normalized to the slug at parse time, so later comparisons are unchanged. `--ecosystems` help lists the slugs. * The npm launcher explains a missing platform package. With `npm install --omit=optional` it printed `spawnSync .../socket-patch-darwin-arm64 ENOENT`; it now names the package, why npm skipped it and how to get the binary. An unsupported platform lists the supported ones. * README no longer opens by saying it documents a prerelease that may differ from what the install commands fetch. 5.0.0 shipped on 2026-10-12, and the note is copied into the npm and crates.io pages. Co-Authored-By: Claude Fable 5.2 --- Cargo.lock | 11 ++++ Cargo.toml | 2 +- README.md | 5 +- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- crates/socket-patch-cli/src/args.rs | 22 +++++--- crates/socket-patch-cli/src/lib.rs | 10 ++++ .../tests/help_text_hygiene.rs | 38 +++++++++++-- .../socket-patch-core/src/crawlers/types.rs | 54 +++++++++++++++++++ .../src/policy/socket_yml.rs | 27 +++++++--- docs/configuration.md | 2 +- npm/socket-patch/bin/socket-patch | 40 ++++++++++++-- npm/socket-patch/bin/socket-patch.test.mjs | 32 +++++++++++ 12 files changed, 218 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9527cc78a..25d1f4b22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,6 +220,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size", ] [[package]] @@ -2081,6 +2082,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index 2f20a92cd..acbbe4672 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ repository = "https://github.com/SocketDev/socket-patch" [workspace.dependencies] socket-patch-core = { path = "crates/socket-patch-core", version = "=5.1.0" } -clap = { version = "=4.5.60", features = ["derive", "env"] } +clap = { version = "=4.5.60", features = ["derive", "env", "wrap_help"] } serde = { version = "=1.0.228", features = ["derive"] } serde_json = { version = "=1.0.149", features = ["preserve_order", "raw_value"] } sha2 = "=0.10.9" diff --git a/README.md b/README.md index e7fc7f4b9..72ad6e1e3 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,7 @@ without waiting for an upstream release or upgrading the dependency. Socket Patch updates dependency files to use patched packages, which you install with your normal package manager. -> This branch documents the v5 prerelease. Installation commands below select the -> latest published release, which may have different behavior. To try this branch, -> [build from source](docs/development.md#build). Existing users should read the -> [v5 migration guide](docs/migrating-to-v5.md). +> Upgrading from v4? Read the [v5 migration guide](docs/migrating-to-v5.md). ## Installation diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 259b5dbfd..675832299 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -96,7 +96,7 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo | `--api-token` | — | `SOCKET_API_TOKEN` | (none) | string | Auth token (absence selects the public proxy) | | `--org` | `-o` | `SOCKET_ORG_SLUG` | (auto-resolve) | string | Org slug. Resolved once per run from the token (`GET /v0/organizations`) when omitted; if that fails, the whole run uses the public proxy anonymously (free patches only) and warns. | | `--proxy-url` | — | `SOCKET_PROXY_URL` | `https://patches-api.socket.dev` | string | Public proxy when no token | -| `--ecosystems` | `-e` | `SOCKET_ECOSYSTEMS` | (all) | CSV → `Vec` | Restrict to these ecosystems | +| `--ecosystems` | `-e` | `SOCKET_ECOSYSTEMS` | (all) | CSV → `Vec` | Restrict to these ecosystems: `npm`, `pypi`, `cargo`, `gem`, `golang`, `maven`, `composer`, `nuget`, `deno`. Names are case-insensitive, and the names output prints (`python`, `ruby`, `go`, `php`) and everyday ones (`pip`, `rubygems`, `rust`, `crates`, `java`, `gradle`, `sbt`, `dotnet`, `packagist`, `jsr`, and `node`/`pnpm`/`yarn`/`bun`/`vlt` for `npm`) are accepted and normalized to the slug; `socket.yml` `patches.ecosystems` takes the same names (additive/MINOR). Anything else is a usage error listing the slugs | | `--vendor-source` | — | `SOCKET_VENDOR_SOURCE` | **`service`** | enum: `service` \| `auto` (alias) | How `vendor` acquires the installable artifact (see "Prebuilt vendor artifacts") | | `--maven-config` | — | — | (recorded choice, else `auto`) | enum: `auto` \| `none` | Maven reactor vendoring: write the repository tail (`auto`) or use only the fallback file repository (`none`). The choice persists in the vendor ledger. | | `--vendor-url` | — | `SOCKET_VENDOR_URL` | (active API/proxy base) | string | Base host for the vendoring-service package-reference request | diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 32119be10..b82810432 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -14,10 +14,12 @@ use socket_patch_core::crawlers::Ecosystem; use socket_patch_core::telemetry::TelemetryAuth; use socket_patch_core::vendor::{VendorServiceConfig, VendorSource}; -/// Reject unknown ecosystems before they can produce a misleading empty scan. +/// Reject unknown ecosystems before they can produce a misleading empty +/// scan, and spell a recognized one (`Ecosystem::from_name`: `go`, +/// `python`, `NPM`) the one way every later comparison expects. fn parse_supported_ecosystem(s: &str) -> Result { - if Ecosystem::all().iter().any(|e| e.cli_name() == s) { - return Ok(s.to_string()); + if let Some(ecosystem) = Ecosystem::from_name(s) { + return Ok(ecosystem.cli_name().to_string()); } let supported = Ecosystem::all() .iter() @@ -115,7 +117,8 @@ pub struct GlobalArgs { #[arg(help_heading = GLOBAL_OPTIONS, long = "proxy-url", env = "SOCKET_PROXY_URL")] pub proxy_url: Option, - /// Restrict to these ecosystems (comma-separated). + /// Restrict to these ecosystems (comma-separated): npm, pypi, cargo, + /// gem, golang, maven, composer, nuget, deno. #[arg( help_heading = GLOBAL_OPTIONS, long = "ecosystems", @@ -753,7 +756,7 @@ mod tests { #[test] fn ecosystem_rejection_messages() { let supported = "npm, pypi, cargo, gem, golang, maven, composer, nuget, deno"; - for bad in ["bogus", "NPM", "py-pi"] { + for bad in ["bogus", "npm pypi", "py-pi"] { assert_eq!( parse_supported_ecosystem(bad), Err(format!( @@ -1252,7 +1255,8 @@ mod tests { } /// `parse_supported_ecosystem` accepts every supported ecosystem name - /// and returns it verbatim. + /// and returns it verbatim; the names a run prints (`go`, `python`) and + /// other spellings come back as the flag's own name. #[test] fn parse_supported_ecosystem_accepts_supported_names() { for e in Ecosystem::all() { @@ -1262,7 +1266,13 @@ mod tests { Ok(name.to_string()), "{name:?} is a supported ecosystem and must be accepted", ); + assert_eq!( + parse_supported_ecosystem(e.display_name()), + Ok(name.to_string()) + ); } + assert_eq!(parse_supported_ecosystem("NPM"), Ok("npm".to_string())); + assert_eq!(parse_supported_ecosystem("pip"), Ok("pypi".to_string())); } /// End-to-end through clap: `--ecosystems` splits on commas, validates each diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index 8bff7d1eb..e98b813f7 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -31,10 +31,20 @@ use socket_patch_core::utils::target::is_uuid_shaped; about = "Apply security fixes to the dependency versions you already use", version, propagate_version = true, + // Help wraps to the terminal, and to 100 columns when it is wider or + // not a terminal. + max_term_width = 100, after_help = "Quick start:\n \ socket-patch scan --dry-run Preview patches and dependency-file changes\n \ socket-patch scan Apply patches, then run your package manager's install\n \ socket-patch vex -O vex.json Generate OpenVEX after installing\n\n\ + Patch modes (scan --mode, get --mode):\n \ + hosted Default. Lockfiles point at Socket-built patched packages, which your\n \ + package manager installs; nothing extra runs after an install\n \ + vendored Patched packages are committed under .socket/vendor/; installs need no\n \ + Socket service\n \ + agent Patches are recorded in .socket/ and written into installed packages;\n \ + run `socket-patch apply` after every install\n\n\ Use 'socket-patch -h' for common options, or '--help' for all options." )] pub struct Cli { diff --git a/crates/socket-patch-cli/tests/help_text_hygiene.rs b/crates/socket-patch-cli/tests/help_text_hygiene.rs index e0bb485c8..4816c6487 100644 --- a/crates/socket-patch-cli/tests/help_text_hygiene.rs +++ b/crates/socket-patch-cli/tests/help_text_hygiene.rs @@ -235,19 +235,47 @@ fn vendor_and_repair_summaries_read_as_one_line() { == " repair Restore agent or vendored patch artifacts and clean up unused ones"), "{text}" ); + // Help wraps to the terminal width, so compare the paragraph's words. let repair = long_help(&["repair"]); - assert!( - repair.starts_with( - "Restore agent or vendored patch artifacts and clean up unused ones\n\n\ - Downloads missing agent patch data and redownloads missing or corrupt \ + let mut paragraphs = repair.split("\n\n").map(|p| p.replace('\n', " ")); + assert_eq!( + paragraphs.next().as_deref(), + Some("Restore agent or vendored patch artifacts and clean up unused ones"), + "{repair}" + ); + assert_eq!( + paragraphs.next().as_deref(), + Some( + "Downloads missing agent patch data and redownloads missing or corrupt \ vendored artifacts using the existing vendor ledger, then deletes \ unreferenced artifacts. A lost `.socket/vendor/state.json` cannot be \ - reconstructed; restore it from version control.\n" + reconstructed; restore it from version control." ), "{repair}" ); } +/// No help page prints a line wider than 100 columns: long flag +/// descriptions wrap instead of running off the terminal's edge. +#[test] +fn help_lines_wrap_at_100_columns() { + let mut cmd = socket_patch_cli::cli_command(); + cmd.build(); + let mut pages = vec![ + ("-h".to_string(), cmd.render_help().to_string()), + ("--help".to_string(), cmd.render_long_help().to_string()), + ]; + for sub in cmd.get_subcommands_mut() { + let name = sub.get_name().to_string(); + pages.push((format!("{name} -h"), sub.render_help().to_string())); + pages.push((format!("{name} --help"), sub.render_long_help().to_string())); + } + for (page, text) in pages { + let widest = text.lines().map(|l| l.chars().count()).max().unwrap_or(0); + assert!(widest <= 100, "{page}: a {widest}-column line\n{text}"); + } +} + /// `socket-patch --update --version` names the public binary, not the /// hidden `self-update` parse target. #[test] diff --git a/crates/socket-patch-core/src/crawlers/types.rs b/crates/socket-patch-core/src/crawlers/types.rs index 47af48ec3..f131128ee 100644 --- a/crates/socket-patch-core/src/crawlers/types.rs +++ b/crates/socket-patch-core/src/crawlers/types.rs @@ -96,6 +96,30 @@ impl Ecosystem { _ => self.cli_name(), } } + + /// The ecosystem a user-typed name means, in any letter case: its + /// `--ecosystems` name, the name messages print for it + /// ([`Self::display_name`], so what a run prints can be typed back), or + /// the everyday name of its registry, language or package manager. + pub fn from_name(name: &str) -> Option { + let name = name.trim().to_ascii_lowercase(); + Some(match name.as_str() { + "node" | "pnpm" | "yarn" | "bun" | "vlt" => Ecosystem::Npm, + "pip" => Ecosystem::Pypi, + "rust" | "crates" | "crates.io" => Ecosystem::Cargo, + "rubygems" | "bundler" => Ecosystem::Gem, + "java" | "gradle" | "sbt" => Ecosystem::Maven, + "packagist" => Ecosystem::Composer, + "dotnet" => Ecosystem::Nuget, + "jsr" => Ecosystem::Deno, + _ => { + return Self::all() + .iter() + .copied() + .find(|eco| name == eco.cli_name() || name == eco.display_name()) + } + }) + } } /// Represents a package discovered during crawling. @@ -148,6 +172,36 @@ impl Default for CrawlerOptions { mod tests { use super::*; + /// Every name a run prints for an ecosystem can be typed back, in any + /// case, and so can the everyday names; nothing else resolves. + #[test] + fn from_name_accepts_cli_display_and_everyday_names() { + for eco in Ecosystem::all().iter().copied() { + assert_eq!(Ecosystem::from_name(eco.cli_name()), Some(eco)); + assert_eq!(Ecosystem::from_name(eco.display_name()), Some(eco)); + assert_eq!( + Ecosystem::from_name(&eco.cli_name().to_uppercase()), + Some(eco) + ); + } + for (name, eco) in [ + ("go", Ecosystem::Golang), + ("Python", Ecosystem::Pypi), + (" pip ", Ecosystem::Pypi), + ("rubygems", Ecosystem::Gem), + ("rust", Ecosystem::Cargo), + ("gradle", Ecosystem::Maven), + ("dotnet", Ecosystem::Nuget), + ("pnpm", Ecosystem::Npm), + ("jsr", Ecosystem::Deno), + ] { + assert_eq!(Ecosystem::from_name(name), Some(eco), "{name}"); + } + for unknown in ["", "bogus", "py-pi", "npm,pypi"] { + assert_eq!(Ecosystem::from_name(unknown), None, "{unknown}"); + } + } + #[test] fn test_from_purl_npm() { assert_eq!( diff --git a/crates/socket-patch-core/src/policy/socket_yml.rs b/crates/socket-patch-core/src/policy/socket_yml.rs index a3a16c582..9a406be67 100644 --- a/crates/socket-patch-core/src/policy/socket_yml.rs +++ b/crates/socket-patch-core/src/policy/socket_yml.rs @@ -612,12 +612,13 @@ fn patches_block(node: &Node) -> Result { .to_string(), )); } - let known: Vec<&str> = Ecosystem::all().iter().map(|e| e.cli_name()).collect(); let mut out = Vec::with_capacity(list.len()); for (i, entry) in list.iter().enumerate() { - let lower = entry.trim().to_lowercase(); - if !known.contains(&lower.as_str()) { - let hint = did_you_mean(&lower, known.iter().copied()) + // The same names `--ecosystems` takes. + let Some(ecosystem) = Ecosystem::from_name(entry) else { + let known: Vec<&str> = + Ecosystem::all().iter().map(|e| e.cli_name()).collect(); + let hint = did_you_mean(entry.trim(), known.iter().copied()) .map(|k| format!(" (did you mean `{k}`?)")) .unwrap_or_default(); return Err(( @@ -628,8 +629,8 @@ fn patches_block(node: &Node) -> Result { known.join(", ") ), )); - } - out.push(lower); + }; + out.push(ecosystem.cli_name().to_string()); } block.ecosystems = Some(out); } @@ -871,6 +872,20 @@ mod tests { assert_eq!(b.max_new_patches, Some(5)); } + /// `ecosystems` takes the names `--ecosystems` takes, stored as slugs. + #[test] + fn ecosystems_accept_the_names_the_flag_accepts() { + let b = block("version: 2\npatches:\n ecosystems: [go, Python, rubygems]\n"); + assert_eq!( + b.ecosystems, + Some(vec![ + "golang".to_string(), + "pypi".to_string(), + "gem".to_string() + ]) + ); + } + #[test] fn defaults_when_absent() { for text in [ diff --git a/docs/configuration.md b/docs/configuration.md index 6a8e1cbb7..10af7f284 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,7 +78,7 @@ validate that block. | Key under `patches` | Meaning | | --- | --- | | `enabled` | `false` reports candidates without changing patches or running prune | -| `ecosystems` | Limit selection to ecosystem slugs from the [support matrix](ecosystems.md) | +| `ecosystems` | Limit selection to ecosystem slugs from the [support matrix](ecosystems.md). Like `--ecosystems`, it also accepts common names (`go`, `python`, `pip`, `rubygems`, `rust`, `dotnet`, ...) in any case | | `includePaths` / `ignorePaths` | Include or exclude project roots by their dependency-file paths | | `packages` / `ignorePackages` | Include or exclude package names or PURLs, optionally with exact versions | | `minSeverity` | Minimum severity: `critical`, `high`, `medium` (`moderate`), or `low`; omit for no floor | diff --git a/npm/socket-patch/bin/socket-patch b/npm/socket-patch/bin/socket-patch index 1e686b8b7..5bf3b8d9b 100755 --- a/npm/socket-patch/bin/socket-patch +++ b/npm/socket-patch/bin/socket-patch @@ -72,11 +72,33 @@ function runFirstUsable(binPaths, args, { return 1; } +const STANDALONE_INSTALL = "curl -fsSL https://install.socket.dev/patch | sh"; + +function unsupportedPlatformMessage(key) { + return [ + `socket-patch: no prebuilt binary for this platform (${key}).`, + `Supported: ${Object.keys(PLATFORMS).join(", ")}.`, + "To build from source: cargo install socket-patch-cli", + ].join("\n"); +} + +// No platform package is installed and there is no bundled binary: npm +// skipped the optional dependency, which a bare ENOENT on an internal +// path does not say. +function missingPlatformPackageMessage(candidates) { + return [ + `socket-patch: the platform package ${candidates.join(" or ")} is not installed.`, + "npm skips it under --omit=optional (or --no-optional), and when the lockfile was", + "written on another platform without it. Reinstall with optional dependencies, or", + `use the standalone installer on macOS or Linux: ${STANDALONE_INSTALL}`, + ].join("\n"); +} + function main() { const key = `${process.platform} ${process.arch}`; const candidates = PLATFORMS[key]; if (!candidates) { - console.error(`Unsupported platform: ${key}`); + console.error(unsupportedPlatformMessage(key)); process.exit(1); } @@ -93,7 +115,12 @@ function main() { const localBin = process.platform === "win32" ? `socket-patch-${key.replace(" ", "-")}.exe` : `socket-patch-${key.replace(" ", "-")}`; - binPaths.push(path.join(__dirname, localBin)); + const bundled = path.join(__dirname, localBin); + if (!fs.existsSync(bundled)) { + console.error(missingPlatformPackageMessage(candidates)); + process.exit(1); + } + binPaths.push(bundled); } process.exit(runFirstUsable(binPaths, process.argv.slice(2))); @@ -102,5 +129,12 @@ function main() { if (require.main === module) { main(); } else { - module.exports = { PLATFORMS, detectLibc, orderCandidates, runFirstUsable }; + module.exports = { + PLATFORMS, + detectLibc, + orderCandidates, + runFirstUsable, + unsupportedPlatformMessage, + missingPlatformPackageMessage, + }; } diff --git a/npm/socket-patch/bin/socket-patch.test.mjs b/npm/socket-patch/bin/socket-patch.test.mjs index 02c69df75..c3445ce1c 100644 --- a/npm/socket-patch/bin/socket-patch.test.mjs +++ b/npm/socket-patch/bin/socket-patch.test.mjs @@ -157,6 +157,38 @@ describe("npm wrapper libc selection (#974)", () => { assert.match(logs[0], /ENOENT/); }); + // `npm install --omit=optional` leaves the launcher with no platform + // package; it must say so, not report ENOENT on an internal path. + it("explains a missing platform package and how to get the binary", () => { + const root = mkdtempSync(join(tmpdir(), "sp-wrapper-missing-")); + try { + const binDir = join(root, "node_modules", "@socketsecurity", "socket-patch", "bin"); + mkdirSync(binDir, { recursive: true }); + writeFileSync(join(binDir, "socket-patch"), readFileSync(wrapperPath)); + const run = spawnSync(process.execPath, [join(binDir, "socket-patch"), "--version"], { + encoding: "utf8", + }); + assert.equal(run.status, 1); + const key = `${process.platform} ${process.arch}`; + if (PLATFORMS[key]) { + assert.match(run.stderr, /platform package .* is not installed/); + assert.ok(run.stderr.includes(PLATFORMS[key][0]), run.stderr); + assert.match(run.stderr, /--omit=optional/); + } else { + assert.match(run.stderr, /no prebuilt binary for this platform/); + } + assert.doesNotMatch(run.stderr, /ENOENT/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("names the supported platforms on an unsupported one", () => { + const message = wrapper.unsupportedPlatformMessage("freebsd x64"); + assert.match(message, /freebsd x64/); + for (const key of EXPECTED_KEYS) assert.ok(message.includes(key), key); + }); + it("propagates the exit status of a binary that ran", () => { const status = wrapper.runFirstUsable(["/gnu", "/musl"], [], { spawn: () => ({ status: 3 }), From 28a2fbc3221b996420428648de153931bc7e191b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 10 Nov 2026 12:07:11 -0500 Subject: [PATCH 005/111] Make repair verify blobs and fail when it cannot restore one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `repair` only checked that each referenced blob existed. A blob whose bytes changed on disk (a `core.autocrlf` checkout of a committed `.socket/blobs/`, a truncated file) read as "present": repair said "All blobs are present locally" and every `apply` failed on it with "Hash verification failed before patch. Expected …, Got …", which names neither the file nor a remedy (NT-D4, PE-18). And `repair --offline` with missing blobs printed a warning, said "Repair complete." and exited 0 (`--json`: `success`, no event), while the next `apply --offline` failed and told the user to run `repair` (CA-D05). repair now re-hashes every referenced blob it finds (afterHash and beforeHash) against its git-sha256 name and treats a mismatch like a missing blob: downloaded again and renamed over the bad entry, which a failed download leaves as it was. Offline nothing can be fetched, so each missing or corrupt blob is a `failed` event (`offline_missing_sources` / `blob_corrupt`, `details.hash`), an `Error:` list on stderr that `--silent` does not mute, a closing "Repair finished with errors" line and exit 1, as the contract's `--offline` rule ("fail loudly when a required local source is missing") already said. apply and rollback keep hashing a blob only when they are about to write its bytes. The refusal is now typed, so the caller that fed the writer a cached blob reports "blob .socket/blobs/ is corrupt … Run `socket-patch repair`" without hashing anything again. The repair suites' fixtures wrote placeholder bytes under a made-up hash; they now use the real hash of their content. Verified with the core unit tests (corrupt-blob detection, the apply and rollback messages), repair's unit tests and the `repair`, `e2e_safety_cow` and `in_process_remove_repair_lifecycle` binaries. Co-Authored-By: Claude Fable 5.2 --- crates/socket-patch-cli/CLI_CONTRACT.md | 7 +- .../socket-patch-cli/src/commands/repair.rs | 390 ++++++++++++++---- .../socket-patch-cli/tests/e2e_safety_cow.rs | 7 + .../tests/repair/repair_invariants.rs | 180 +++++++- .../tests/repair/repair_render.rs | 152 +++++-- .../socket-patch-core/src/api/blob_fetcher.rs | 121 +++++- crates/socket-patch-core/src/patch/apply.rs | 154 ++++++- crates/socket-patch-core/src/patch/npm_bin.rs | 8 +- .../socket-patch-core/src/patch/rollback.rs | 18 +- docs/usage.md | 2 +- 10 files changed, 881 insertions(+), 158 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 259b5dbfd..58bcb84b5 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -24,7 +24,7 @@ For task-oriented guidance, start with [usage](../../docs/usage.md), | `apply` | — | Agent mode: apply patches from the local manifest | | `rollback` | — | **Full-state rollback (v5.0, MAJOR)**: restore original files AND unwind vendored lockfile wiring / restore hosted pins to their upstream registry entries, remove the rolled-back entries from the manifest, and GC their blobs/archives; takes optional variadic positional `targets` (PURL \| UUID \| package name \| path glob). See [Rollback command contract](#rollback-command-contract-v50) | | `remove` | — | Restore and remove one patch across hosted, vendored, and agent state; requires positional `identifier`. | -| `repair` | — | Download missing agent blobs, redownload missing/corrupt vendored artifacts (never re-synthesizing a lost ledger), and clean up unused ones: a blob no manifest patch references (the afterHash and beforeHash blobs of every manifest patch are kept, so an offline `rollback` still has its originals; `scan --prune` keeps the same set) and every obsolete diff or package archive (refuses with `lock_held` when a live process holds the lock; see "Lock lifecycle" below) | +| `repair` | — | Download missing agent blobs and replace corrupt ones (every referenced blob on disk is re-hashed against its git-sha256 name), redownload missing/corrupt vendored artifacts (never re-synthesizing a lost ledger), and clean up unused ones: a blob no manifest patch references (the afterHash and beforeHash blobs of every manifest patch are kept, so an offline `rollback` still has its originals; `scan --prune` keeps the same set) and every obsolete diff or package archive (refuses with `lock_held` when a live process holds the lock; see "Lock lifecycle" below) | Rows are in `--help` order (v5.0): the hosted/vendored workflow (`scan` → `vex` → `vendor`, with `list` to inspect), then the agent-mode (in-place patching) commands. @@ -120,7 +120,7 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo **`--download-mode` removed (v5.0, MAJOR).** The global `--download-mode` flag and its `SOCKET_DOWNLOAD_MODE` binding are gone, with no alias: passing the flag is a usage error (exit 2) and the env var is ignored. Patch content is always fetched as per-file blobs (`.socket/blobs/`); `.socket/diffs/` archives are obsolete, never read, and removed by the cleanup sweeps. -`--offline` means the same thing on every command (v3.0): never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --mode vendored` too: offline vendored staging is `vendor --offline`'s job. +`--offline` means the same thing on every command (v3.0): never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2), and a referenced blob that is missing or corrupt, which only a download can restore, fails the run: one `failed` event per blob (`offline_missing_sources` / `blob_corrupt`), exit 1, never "Repair complete." (the cleanup pass still runs). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --mode vendored` too: offline vendored staging is `vendor --offline`'s job. The in-place apply paths (`apply`, agent-mode `get` / `scan`, and their nested apply calls) reject files matching neither `beforeHash` nor `afterHash` by @@ -1654,6 +1654,7 @@ command's integration tests check its output. | `offline_missing_sources` / `sources_download_failed` | apply run-level `warnings[]` | apply (additive): the patch sources were unavailable — `--offline` with no local source, or the download left a patch with no source — so nothing was attempted. The envelope keeps its pinned shape (`partialFailure`, empty `events[]`, zero summary, no top-level `error`); the warning is its machine-readable reason (the human path prints the staging `Error:` line on stderr instead, even under `--silent`). | | `paid_required` | `skipped` + status=`paidRequired` | get: every patch found needs a paid plan and the caller's token isn't entitled (exit 0); one `skipped` event per patch, `details.tier`. `get ` on the public proxy reports it both for a `tier: "paid"` view and for the proxy's 403 refusal, whose event then carries only `uuid` (no `purl`: the proxy never named it). `scan` never reports it: it leaves paid patches out of the selection and counts them in `paidPatches` / `canAccessPaidPatches`. | | `download_failed` | `failed` | repair/get/scan: network or 404 on patch fetch. | +| `offline_missing_sources` / `blob_corrupt` | `failed` (artifact-level: `details.{hash,state}`, no purl) | repair `--offline`: a blob the manifest references is absent (`state: "missing"`; `afterHash` blobs only, as `beforeHash` blobs are fetched on demand) or on disk with bytes that no longer hash to its name (`state: "corrupt"`: a line-ending-converting checkout, a truncated file; `afterHash` and `beforeHash` blobs). Nothing is fetched offline, so each is a failure of the run (exit 1; human: `Error: N blobs are missing|corrupt (offline mode - not downloading):` on stderr, even under `--silent`); a corrupt file is left in place. Online, both kinds are downloaded (a corrupt entry is replaced whole) and the `downloaded` / dry-run `verified` carrier adds `details.corrupt` (how many were corrupt) when non-zero. `apply` and `rollback` name a corrupt blob in their `apply_failed` / `rollback_failed` error (`blob .socket/blobs/ is corrupt … Run \`socket-patch repair\``); they hash a blob only when about to write its bytes. | | `cleanup_failed` | `skipped` (warning) | repair: an orphan-sweep pass (blobs, diff or package archives) failed mid-way (e.g. permission error). The run continues and exits 0; human mode carries the warning on stderr (not muted by `--silent`). v5.0: `rollback`'s default GC and `remove`'s GC surface the same condition in their run-level `warnings[]` (`remove`: also on stderr in human mode) — same posture, never affects the exit. | | `rollback_failed` | `failed` | remove/rollback: file restore could not complete (rollback: no blocking file names a more specific code). | | `already_original` | `skipped` | rollback (v5.0): every file of the copy already matches its `beforeHash`. | @@ -1919,7 +1920,7 @@ outcome for a selected record, rather than inferring a write from exit 0. | `apply` | `Applied` · `Skipped` (already_patched / package_not_installed / vendored) · `Failed` · `Verified` (dry-run) | | `vendor` | `Applied` (= vendored; `command` routes) · `Rebuilt` (a reused artifact restored) · `Skipped` (refusals, warnings, unsupported ecosystems) · `Failed` · `Removed` (reconcile + `--revert`) · `Verified` (dry-run) | | `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`; hosted pins (v5.0: one per `(purl, uuid)` the lockfiles wire) additionally carry `details.mode: "hosted"` and `details.lockfiles: []` (no `details.ledger` — hosted mode keeps no ledger; the human listing labels them `Mode: hosted (wired in )`), both additive and absent on manifest entries; v5.0: vendor-ledger records carry `details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` the same way, and the human listing labels them `Mode: vendored (recorded in .socket/vendor/state.json)`; a `state.json` that cannot be read or parsed degrades to nothing-to-consult with the stderr line `Warning: unreadable vendor ledger (); its vendored patches are not listed` — muted by `--silent`, exit unchanged) | -| `repair` | `Downloaded` (or `Verified` on dry-run; `details: {count, downloadMode: "file"}` — `downloadMode` is always `"file"` since v5.0 removed the diff download path) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run; every vendored-phase event carries `details.mode: "vendored"`) · `Skipped` (vendor_uuid_mismatch, cleanup_failed) · artifact-level `Removed` (or `Verified`) GC carrier (`details.count`, `details.checked`, `bytes`) · `Failed` events · top-level `gc` (absent under `--download-only`) | +| `repair` | `Downloaded` (or `Verified` on dry-run; `details: {count, downloadMode: "file"}` — `downloadMode` is always `"file"` since v5.0 removed the diff download path; `details.corrupt` when some of the blobs replaced corrupt cache entries) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run; every vendored-phase event carries `details.mode: "vendored"`) · `Skipped` (vendor_uuid_mismatch, cleanup_failed) · artifact-level `Removed` (or `Verified`) GC carrier (`details.count`, `details.checked`, `bytes`) · `Failed` events (`download_failed`; `--offline`: one `offline_missing_sources` / `blob_corrupt` per blob it could not restore) · top-level `gc` (absent under `--download-only`) | | `vex` | `Verified` (one per attested subcomponent) · `Skipped` (omissions) — only under `--json --output` | | `remove` | `Removed` (per purl; `Verified` on dry-run) · `RolledBack` (one per installed copy the in-place rollback restored; `summary.rolledBack`) · vendored/hosted-leg `Removed`/`Skipped` events with `details.mode` · artifact-level `Removed`/`Verified` GC carrier (`details: {count, checked}`, `bytes`; only when the sweep removed something — v5.0, MAJOR: replaces `details.blobsRemoved` / `archivesRemoved` / `rolledBack`) · top-level `gc` (absent under `--preserve-state`) · `cleanup_failed` warnings | | `rollback` | `RolledBack` (agent copies; vendored / hosted unwinds with `details.mode`) · `Verified` (dry-run previews, manifest removals included) · `Skipped` (already_original / no_files / package_not_installed) · `Failed` (per copy or leg; artifact-level `hosted_write_failed` / `hosted_wiring_contested`) · `Removed` (manifest entries, `details.manifest: true`) · top-level `gc` (absent under `--preserve-state`) | diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 4bbb3008f..f0f5e2875 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -1,7 +1,7 @@ use clap::Args; use socket_patch_core::api::blob_fetcher::{ - fetch_missing_blobs, format_fetch_failures, format_fetch_successes, get_missing_blobs, - ArtifactNoun, BLOB, DIFF_ARCHIVE, PACKAGE_ARCHIVE, + fetch_blobs_replacing, format_fetch_failures, format_fetch_successes, get_corrupt_blobs, + get_missing_blobs, ArtifactNoun, BLOB, DIFF_ARCHIVE, PACKAGE_ARCHIVE, }; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::manifest::cleanup_blobs::{ @@ -264,9 +264,13 @@ fn format_id_list(ids: &[String], cap: usize) -> Vec { lines } -/// `Found 2 missing blobs` / `Found 1 missing blob`. -fn format_found_missing(n: usize) -> String { - format!("Found {}", BLOB.count(n).replacen(' ', " missing ", 1)) +/// `Found 2 missing blobs` / `Found 1 corrupt blob`. +fn format_found(n: usize, fault: BlobFault) -> String { + format!( + "Found {}", + BLOB.count(n) + .replacen(' ', &format!(" {} ", fault.word()), 1) + ) } /// Why a hosted-only project has nothing to repair (the JSON skip @@ -290,17 +294,60 @@ fn format_nothing_missing( } } -/// The `--offline` warning (stderr) for artifacts that cannot be fetched. -fn format_offline_warning(ids: &[String]) -> String { +/// Why a referenced blob has to be downloaded. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BlobFault { + /// No `.socket/blobs/` entry. + Missing, + /// The entry's bytes no longer hash to its name. + Corrupt, +} + +impl BlobFault { + /// `"missing"` / `"corrupt"`, as printed and as `details.state`. + fn word(self) -> &'static str { + match self { + BlobFault::Missing => "missing", + BlobFault::Corrupt => "corrupt", + } + } + + /// The `errorCode` of a blob `--offline` could not restore. + fn offline_code(self) -> &'static str { + match self { + BlobFault::Missing => "offline_missing_sources", + BlobFault::Corrupt => "blob_corrupt", + } + } +} + +/// The `--offline` error (stderr) for blobs with `fault` that cannot be +/// fetched. +fn format_offline_error(ids: &[String], fault: BlobFault) -> String { let verb = if ids.len() == 1 { "is" } else { "are" }; let mut lines = vec![format!( - "Warning: {} {verb} missing (offline mode - not downloading):", - BLOB.count(ids.len()) + "Error: {} {verb} {} (offline mode - not downloading):", + BLOB.count(ids.len()), + fault.word() )]; lines.extend(format_id_list(ids, OFFLINE_LIST_CAP)); lines.join("\n") } +/// The `failed` event for one blob `--offline` could not restore. +fn offline_unrestored_event(hash: &str, fault: BlobFault) -> PatchEvent { + PatchEvent::artifact(PatchAction::Failed) + .with_error( + fault.offline_code(), + format!( + "blob {hash} is {} and --offline is set; re-run `socket-patch repair` \ + without --offline to download it", + fault.word() + ), + ) + .with_details(serde_json::json!({ "hash": hash, "state": fault.word() })) +} + /// The cleanup phase's summary: one result block per kind that had /// something to remove; otherwise a single line saying what was checked. fn format_cleanup_summary(results: &[(ArtifactNoun, CleanupResult)], dry_run: bool) -> String { @@ -324,16 +371,34 @@ fn format_cleanup_summary(results: &[(ArtifactNoun, CleanupResult)], dry_run: bo format_all_in_use(&checked, total) } -/// The closing line of a human repair run. `other_failure` is a failure -/// recorded elsewhere in the run (a vendored artifact that could not be -/// rebuilt): the run exits 1, so it must not close on "Repair complete.". -fn format_final_line(download_failed: usize, other_failure: bool, dry_run: bool) -> String { +/// The closing line of a human repair run. `offline_unrestored` counts the +/// missing or corrupt blobs `--offline` left as they were. `other_failure` +/// is a failure recorded elsewhere in the run (a vendored artifact that +/// could not be rebuilt): the run exits 1, so it must not close on "Repair +/// complete.". +fn format_final_line( + download_failed: usize, + offline_unrestored: usize, + other_failure: bool, + dry_run: bool, +) -> String { if download_failed > 0 { let verb = if download_failed == 1 { "was" } else { "were" }; format!( "Repair finished with errors: {} {verb} not downloaded.", BLOB.count(download_failed) ) + } else if offline_unrestored > 0 { + format!( + "Repair finished with errors: {} could not be restored in offline mode. \ + Re-run without --offline to download {}.", + BLOB.count(offline_unrestored), + if offline_unrestored == 1 { + "it" + } else { + "them" + } + ) } else if other_failure { "Repair finished with errors.".to_string() } else if dry_run { @@ -343,51 +408,67 @@ fn format_final_line(download_failed: usize, other_failure: bool, dry_run: bool) } } -/// What one download pass did: how many artifacts were missing, and how -/// many of them it downloaded or failed to. +/// What one download pass did: how many blobs it had to fetch (the missing +/// ones and the corrupt ones), and how many of them it downloaded or +/// failed to. #[derive(Default)] struct DownloadPass { - missing: usize, + wanted: usize, downloaded: usize, failed: usize, + /// `--offline`: every blob of the pass, none of which was fetched. + offline_unrestored: Vec<(String, BlobFault)>, } -/// Step 1's pass over `missing` (non-empty), the `afterHash` blobs `m` -/// references: the `--offline` warning, the `--dry-run` preview, or the -/// download into `blobs_path` and its result lines. +/// Step 1's pass over the blobs to fetch (at least one): the `missing` +/// `afterHash` blobs and the `corrupt` cached ones, both sorted. The +/// `--offline` errors, the `--dry-run` preview, or the download into +/// `blobs_path` (a corrupt entry is replaced whole) and its result lines. async fn download_pass( args: &RepairArgs, client: &mut Option, - m: &socket_patch_core::manifest::schema::PatchManifest, missing: &[String], + corrupt: &[String], blobs_path: &Path, ) -> DownloadPass { let quiet = args.common.json || args.common.silent; + let wanted: Vec = missing.iter().chain(corrupt).cloned().collect(); let mut pass = DownloadPass { - missing: missing.len(), + wanted: wanted.len(), ..DownloadPass::default() }; + let faults = [(missing, BlobFault::Missing), (corrupt, BlobFault::Corrupt)]; if args.common.offline { - if !quiet { - eprintln!("{}", format_offline_warning(missing)); + for (ids, fault) in faults { + // The run exits 1 over these, so they print even under + // `--silent` (`--json` runs carry them in the envelope). + if !ids.is_empty() && !args.common.json { + eprintln!("{}", format_offline_error(ids, fault)); + } + pass.offline_unrestored + .extend(ids.iter().map(|id| (id.clone(), fault))); } return pass; } if !quiet { - println!("{}", format_found_missing(missing.len())); + for (ids, fault) in faults { + if !ids.is_empty() { + println!("{}", format_found(ids.len(), fault)); + } + } } if args.common.dry_run { if !quiet { println!(); println!("Would download:"); - for line in format_id_list(missing, DRY_RUN_LIST_CAP) { + for line in format_id_list(&wanted, DRY_RUN_LIST_CAP) { println!("{line}"); } } return pass; } let mut status = crate::ui::StatusLine::stderr(args.common.json, args.common.silent); - status.set(format!("Downloading {}...", BLOB.count(missing.len()))); + status.set(format!("Downloading {}...", BLOB.count(wanted.len()))); if client.is_none() { *client = Some( get_api_client_with_overrides(args.common.api_client_overrides()) @@ -396,7 +477,7 @@ async fn download_pass( ); } let client = client.as_ref().expect("client built just above"); - let fetch_result = fetch_missing_blobs(m, blobs_path, client, None).await; + let fetch_result = fetch_blobs_replacing(&wanted, blobs_path, client, None).await; status.finish(); pass.downloaded = fetch_result.downloaded; pass.failed = fetch_result.failed; @@ -510,31 +591,52 @@ async fn repair_inner( setup: m.setup.clone(), } }); - let missing_artifacts: Vec = match &scoped_manifest { - None => Vec::new(), - Some(m) => get_missing_blobs(m, &blobs_path) - .await - .into_iter() - .collect(), + // A blob that is on disk is not thereby usable: one whose bytes no + // longer hash to its name (a `core.autocrlf` checkout, a truncated + // file) fails every `apply`, so it is fetched again like a missing one. + let (missing_artifacts, corrupt_artifacts) = match &scoped_manifest { + None => (Vec::new(), Vec::new()), + Some(m) => { + let sorted = |set: std::collections::HashSet| { + let mut ids: Vec = set.into_iter().collect(); + ids.sort(); + ids + }; + ( + sorted(get_missing_blobs(m, &blobs_path).await), + sorted(get_corrupt_blobs(m, &blobs_path).await), + ) + } }; + let nothing_to_fetch = missing_artifacts.is_empty() && corrupt_artifacts.is_empty(); // Whether stdout already carries a line, so the blank separators - // between sections never open the output (the offline warning goes - // to stderr). - let mut stdout_started = !args.common.offline || missing_artifacts.is_empty(); - let primary = match scoped_manifest.as_ref() { - Some(m) if !missing_artifacts.is_empty() => { - download_pass(args, client, m, &missing_artifacts, &blobs_path).await - } - _ => { - if !quiet { - println!("{}", format_nothing_missing(manifest.as_ref())); - } - DownloadPass::default() + // between sections never open the output (the offline errors go to + // stderr). + let mut stdout_started = !args.common.offline || nothing_to_fetch; + let primary = if nothing_to_fetch { + if !quiet { + println!("{}", format_nothing_missing(manifest.as_ref())); } + DownloadPass::default() + } else { + download_pass( + args, + client, + &missing_artifacts, + &corrupt_artifacts, + &blobs_path, + ) + .await }; - let missing_count = primary.missing; + let wanted_count = primary.wanted; downloaded_count += primary.downloaded; download_failed_count += primary.failed; + // `--offline` never contacts the network, so a blob it found missing or + // corrupt stays that way and the next `apply --offline` fails on it: + // each is a failure of this run, not a note. + for (hash, fault) in &primary.offline_unrestored { + env.record(offline_unrestored_event(hash, *fault)); + } // Step 1.5: vendored artifacts — health-check the ledger and re-vendor // missing/corrupt artifacts through the vendored backend (the patch @@ -629,7 +731,12 @@ async fn repair_inner( // so a piped stdout never ends in a stray blank line when the // line itself goes to stderr. let other_failure = matches!(env.status, Status::PartialFailure | Status::Error); - let line = format_final_line(download_failed_count, other_failure, args.common.dry_run); + let line = format_final_line( + download_failed_count, + primary.offline_unrestored.len(), + other_failure, + args.common.dry_run, + ); if download_failed_count > 0 || other_failure { if stdout_started { eprintln!(); @@ -648,22 +755,26 @@ async fn repair_inner( // `PatchEvent::artifact` form (no PURL/UUID). // // Only the online path downloads (or, in dry-run, *would* download). - // In offline mode nothing is fetched even when artifacts are missing, - // so don't record a download/would-download event there — that would - // contradict the human-readable path, which only prints a warning. - if downloaded_count > 0 || (!args.common.offline && args.common.dry_run && missing_count > 0) { + // In offline mode nothing is fetched, so there is no download or + // would-download event: each blob it could not restore is a `failed` + // event of its own, recorded above. + if downloaded_count > 0 || (!args.common.offline && args.common.dry_run && wanted_count > 0) { let (action, count) = if args.common.dry_run { - (PatchAction::Verified, missing_count) + (PatchAction::Verified, wanted_count) } else { (PatchAction::Downloaded, downloaded_count) }; - env.record( - PatchEvent::artifact(action).with_details(serde_json::json!({ - "count": count, - // Always "file"; kept for the event's shape. - "downloadMode": "file", - })), - ); + let mut details = serde_json::json!({ + "count": count, + // Always "file"; kept for the event's shape. + "downloadMode": "file", + }); + // How many of the pass's blobs were on disk but corrupt (the human + // path's "Found N corrupt blobs"); absent when none were. + if !corrupt_artifacts.is_empty() { + details["corrupt"] = corrupt_artifacts.len().into(); + } + env.record(PatchEvent::artifact(action).with_details(details)); } if download_failed_count > 0 { env.record(PatchEvent::artifact(PatchAction::Failed).with_error( @@ -746,7 +857,7 @@ mod tests { "files": { "package/index.js": { "beforeHash": "0000000000000000000000000000000000000000000000000000000000000000", - "afterHash": "1111111111111111111111111111111111111111111111111111111111111111" + "afterHash": "ec6300ae5bdb6ca9433c068514962f8f983e55f32803176f77d3e80a6345f147" } }, "vulnerabilities": {}, @@ -757,8 +868,19 @@ mod tests { } }"#; + /// The manifest's one `afterHash`: the git-sha256 of [`REFERENCED_BYTES`], + /// so a blob written with them verifies. const REFERENCED_HASH: &str = - "1111111111111111111111111111111111111111111111111111111111111111"; + "ec6300ae5bdb6ca9433c068514962f8f983e55f32803176f77d3e80a6345f147"; + const REFERENCED_BYTES: &[u8] = b"kept"; + + #[test] + fn referenced_hash_names_the_referenced_bytes() { + assert_eq!( + socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes(REFERENCED_BYTES), + REFERENCED_HASH + ); + } /// Write a `.socket/manifest.json` under `root` and return the socket dir. fn make_socket(root: &Path) -> PathBuf { @@ -808,10 +930,35 @@ mod tests { }) } - /// `--offline` skips the download phase: a missing artifact is a - /// warning, never a download or would-download event, dry run or not. + /// The `failed` events for blobs `--offline` could not restore, as + /// `(errorCode, details.hash, details.state)`. + fn unrestored(env: &Envelope) -> Vec<(String, String, String)> { + env.events + .iter() + .filter(|e| e.action == PatchAction::Failed) + .map(|e| { + let detail = |key: &str| { + e.details + .as_ref() + .and_then(|d| d.get(key)) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string() + }; + ( + e.error_code.clone().unwrap_or_default(), + detail("hash"), + detail("state"), + ) + }) + .collect() + } + + /// `--offline` cannot fetch a missing blob, and the next + /// `apply --offline` fails on it: the run is a failure that names the + /// blob, never a download or would-download event, dry run or not. #[tokio::test] - async fn offline_missing_artifact_records_no_download_event() { + async fn offline_missing_blob_fails_the_run_and_is_named() { for dry_run in [false, true] { let tmp = tempfile::tempdir().unwrap(); let socket = make_socket(tmp.path()); @@ -830,10 +977,78 @@ mod tests { env.events ); assert_eq!(counts.downloaded, 0); - assert_eq!(env.status, Status::Success); + assert_eq!(env.status, Status::PartialFailure, "dry_run={dry_run}"); + assert_eq!( + unrestored(&env), + vec![( + "offline_missing_sources".to_string(), + REFERENCED_HASH.to_string(), + "missing".to_string() + )], + "dry_run={dry_run}" + ); } } + /// A blob that is on disk with the wrong bytes (here the CRLF form a + /// `core.autocrlf` checkout leaves) is not "present": offline it is a + /// named failure, and the bad file is left for an online repair. + #[tokio::test] + async fn offline_corrupt_blob_fails_the_run_and_is_named() { + let tmp = tempfile::tempdir().unwrap(); + let socket = make_socket(tmp.path()); + write_blob(&socket, REFERENCED_HASH, b"ke\r\npt"); + + let args = offline_args(tmp.path()); + let (env, counts) = + repair_inner(&args, &socket.join("manifest.json"), &mut None, Vec::new()) + .await + .expect("repair_inner"); + + assert_eq!(counts.downloaded, 0); + assert_eq!(env.status, Status::PartialFailure); + assert_eq!( + unrestored(&env), + vec![( + "blob_corrupt".to_string(), + REFERENCED_HASH.to_string(), + "corrupt".to_string() + )] + ); + assert_eq!( + std::fs::read(socket.join("blobs").join(REFERENCED_HASH)).unwrap(), + b"ke\r\npt", + "offline repair never deletes what it cannot replace" + ); + } + + /// An online dry run previews the re-download of a corrupt blob like + /// that of a missing one, and says how many were corrupt. + #[tokio::test] + async fn online_dry_run_previews_the_redownload_of_a_corrupt_blob() { + let tmp = tempfile::tempdir().unwrap(); + let socket = make_socket(tmp.path()); + write_blob(&socket, REFERENCED_HASH, b"truncat"); + let mut args = offline_args(tmp.path()); + args.common.offline = false; + args.common.dry_run = true; + + let (env, _counts) = + repair_inner(&args, &socket.join("manifest.json"), &mut None, Vec::new()) + .await + .expect("repair_inner"); + + let preview = env + .events + .iter() + .find(|e| e.action == PatchAction::Verified) + .and_then(|e| e.details.clone()) + .expect("a would-download event"); + assert_eq!(preview["count"], 1); + assert_eq!(preview["corrupt"], 1); + assert_eq!(env.status, Status::Success); + } + /// The online dry-run path *should* still preview the download — this /// pins that the offline gate didn't over-correct. We can't hit the /// network here, but `repair_inner`'s dry-run branch records the event @@ -1059,19 +1274,27 @@ mod tests { #[test] fn found_missing_line() { - assert_eq!(format_found_missing(1), "Found 1 missing blob"); - assert_eq!(format_found_missing(12), "Found 12 missing blobs"); + assert_eq!(format_found(1, BlobFault::Missing), "Found 1 missing blob"); + assert_eq!( + format_found(12, BlobFault::Missing), + "Found 12 missing blobs" + ); + assert_eq!(format_found(2, BlobFault::Corrupt), "Found 2 corrupt blobs"); } #[test] - fn offline_warning_singular_and_plural() { + fn offline_error_singular_and_plural() { + assert_eq!( + format_offline_error(&ids(&["a"]), BlobFault::Missing), + "Error: 1 blob is missing (offline mode - not downloading):\n - a" + ); assert_eq!( - format_offline_warning(&ids(&["a"])), - "Warning: 1 blob is missing (offline mode - not downloading):\n - a" + format_offline_error(&ids(&["b", "a"]), BlobFault::Missing), + "Error: 2 blobs are missing (offline mode - not downloading):\n - a\n - b" ); assert_eq!( - format_offline_warning(&ids(&["b", "a"])), - "Warning: 2 blobs are missing (offline mode - not downloading):\n - a\n - b" + format_offline_error(&ids(&["a"]), BlobFault::Corrupt), + "Error: 1 blob is corrupt (offline mode - not downloading):\n - a" ); } @@ -1140,26 +1363,37 @@ mod tests { #[test] fn final_line_reflects_failures_and_dry_run() { - assert_eq!(format_final_line(0, false, false), "Repair complete."); + assert_eq!(format_final_line(0, 0, false, false), "Repair complete."); assert_eq!( - format_final_line(0, false, true), + format_final_line(0, 0, false, true), "Dry run: no changes made." ); assert_eq!( - format_final_line(1, false, false), + format_final_line(1, 0, false, false), "Repair finished with errors: 1 blob was not downloaded." ); assert_eq!( - format_final_line(2, true, false), + format_final_line(2, 0, true, false), "Repair finished with errors: 2 blobs were not downloaded." ); + // Blobs `--offline` left missing or corrupt (exit 1), dry run or not. + assert_eq!( + format_final_line(0, 1, true, false), + "Repair finished with errors: 1 blob could not be restored in offline mode. \ + Re-run without --offline to download it." + ); + assert_eq!( + format_final_line(0, 3, true, true), + "Repair finished with errors: 3 blobs could not be restored in offline mode. \ + Re-run without --offline to download them." + ); // A failed vendored rebuild (exit 1) never closes on "complete". assert_eq!( - format_final_line(0, true, false), + format_final_line(0, 0, true, false), "Repair finished with errors." ); assert_eq!( - format_final_line(0, true, true), + format_final_line(0, 0, true, true), "Repair finished with errors." ); } diff --git a/crates/socket-patch-cli/tests/e2e_safety_cow.rs b/crates/socket-patch-cli/tests/e2e_safety_cow.rs index 96334fb4a..ce05ca6d3 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_cow.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_cow.rs @@ -577,6 +577,13 @@ fn apply_failure_does_not_cow_or_modify() { err.contains("Hash verification failed before patch"), "failure must be the pre-write hash-verification gate, got error: {err:?}" ); + // Re-running `apply` can never fix a blob with the wrong bytes: the + // error names the cache entry and the command that replaces it. + assert!( + err.contains(&format!(".socket/blobs/{claimed_after_hash} is corrupt")) + && err.contains("`socket-patch repair`"), + "failure must name the corrupt blob and its remedy, got error: {err:?}" + ); // Content unchanged on both sides of the hardlink. assert_eq!(git_sha256_file(&fx.index_js()), git_sha256(ORIGINAL_BYTES)); diff --git a/crates/socket-patch-cli/tests/repair/repair_invariants.rs b/crates/socket-patch-cli/tests/repair/repair_invariants.rs index af5f31964..f4050ba51 100644 --- a/crates/socket-patch-cli/tests/repair/repair_invariants.rs +++ b/crates/socket-patch-cli/tests/repair/repair_invariants.rs @@ -49,7 +49,7 @@ const MANIFEST_JSON: &str = r#"{ "files": { "package/index.js": { "beforeHash": "0000000000000000000000000000000000000000000000000000000000000000", - "afterHash": "1111111111111111111111111111111111111111111111111111111111111111" + "afterHash": "765b5c40e14fd7c5d6a2b8063c4aa7dc6571e09f6817f8572f2cc9febf51a44b" } }, "vulnerabilities": {}, @@ -60,7 +60,11 @@ const MANIFEST_JSON: &str = r#"{ } }"#; -const REFERENCED_HASH: &str = "1111111111111111111111111111111111111111111111111111111111111111"; +/// The manifest's one `afterHash`: the git-sha256 of [`REFERENCED_BYTES`], +/// so the blob the fixtures write under it verifies (`repair` re-hashes +/// every referenced blob it finds). +const REFERENCED_HASH: &str = "765b5c40e14fd7c5d6a2b8063c4aa7dc6571e09f6817f8572f2cc9febf51a44b"; +const REFERENCED_BYTES: &[u8] = b"patched content"; fn make_socket_dir(root: &Path) -> PathBuf { let socket = root.join(".socket"); @@ -328,7 +332,7 @@ fn repair_offline_with_no_orphans_succeeds_quietly() { // orphans, nothing to download (offline), nothing to clean up. let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); - write_blob(&socket, REFERENCED_HASH, b"patched content"); + write_blob(&socket, REFERENCED_HASH, REFERENCED_BYTES); let (code, stdout) = run_repair(tmp.path(), &[]); assert_eq!(code, 0, "expected exit 0; stdout=\n{stdout}"); @@ -359,7 +363,7 @@ fn repair_offline_removes_orphan_blob() { // referenced one. let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); - write_blob(&socket, REFERENCED_HASH, b"patched content"); + write_blob(&socket, REFERENCED_HASH, REFERENCED_BYTES); let orphan_hash = "deadbeef".repeat(8); // 64 chars write_blob(&socket, &orphan_hash, b"orphaned content"); @@ -410,7 +414,7 @@ fn repair_dry_run_does_not_remove_orphan_blob() { // With `--dry-run`, the orphan should be REPORTED but stay on disk. let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); - write_blob(&socket, REFERENCED_HASH, b"patched content"); + write_blob(&socket, REFERENCED_HASH, REFERENCED_BYTES); let orphan_hash = "cafebabe".repeat(8); write_blob(&socket, &orphan_hash, b"orphaned content"); @@ -477,7 +481,7 @@ fn repair_download_only_skips_cleanup() { // the test stays hermetic (no network). let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); - write_blob(&socket, REFERENCED_HASH, b"patched content"); + write_blob(&socket, REFERENCED_HASH, REFERENCED_BYTES); let orphan_hash = "feedface".repeat(8); write_blob(&socket, &orphan_hash, b"orphaned content"); @@ -538,7 +542,7 @@ fn repair_cleanup_failure_is_reported_in_json_and_silent_modes() { let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); - write_blob(&socket, REFERENCED_HASH, b"kept"); + write_blob(&socket, REFERENCED_HASH, REFERENCED_BYTES); let orphan = "0badf00d".repeat(8); // 64 chars, not referenced write_blob(&socket, &orphan, b"orphan bytes"); let blobs_dir = socket.join("blobs"); @@ -648,7 +652,7 @@ fn take_external_lock(socket_dir: &Path) -> std::fs::File { fn repair_deletes_leftover_lock_file_on_success() { let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); - write_blob(&socket, REFERENCED_HASH, b"patched content"); + write_blob(&socket, REFERENCED_HASH, REFERENCED_BYTES); std::fs::write(socket.join("apply.lock"), b"leftover").expect("stage stale lock"); let (code, stdout) = run_repair(tmp.path(), &[]); @@ -666,7 +670,7 @@ fn repair_deletes_leftover_lock_file_on_success() { fn repair_deletes_probe_created_lock_file() { let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); - write_blob(&socket, REFERENCED_HASH, b"patched content"); + write_blob(&socket, REFERENCED_HASH, REFERENCED_BYTES); assert!(!socket.join("apply.lock").exists()); let (code, stdout) = run_repair(tmp.path(), &[]); @@ -685,7 +689,7 @@ fn repair_deletes_probe_created_lock_file() { fn repair_dry_run_also_removes_leftover_lock_file() { let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); - write_blob(&socket, REFERENCED_HASH, b"patched content"); + write_blob(&socket, REFERENCED_HASH, REFERENCED_BYTES); std::fs::write(socket.join("apply.lock"), b"leftover").expect("stage stale lock"); let (code, stdout) = run_repair(tmp.path(), &["--dry-run"]); @@ -909,6 +913,152 @@ async fn repair_online_downloads_missing_blob() { assert_eq!(format!("{}", blob_hits[0].method), "GET"); } +/// A blob that is on disk with the wrong bytes (a `core.autocrlf` checkout +/// of a committed `.socket/blobs/`, a truncated file) used to read as +/// "present": `repair` said all blobs were there and `apply` failed on it +/// forever. `repair` re-hashes every referenced blob and downloads a +/// mismatching one again, replacing it — the before blob as well as the +/// after blob. +#[tokio::test] +async fn repair_online_redownloads_corrupt_blobs() { + let patched = b"patched\ncontent\n"; + let original = b"original\ncontent\n"; + let after_hash = git_sha256(patched); + let before_hash = git_sha256(original); + + let mock = MockServer::start().await; + for (hash, content) in [(&after_hash, &patched[..]), (&before_hash, &original[..])] { + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/blob/{hash}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(content.to_vec())) + .expect(1) + .mount(&mock) + .await; + } + + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + let manifest = MANIFEST_JSON + .replace(REFERENCED_HASH, &after_hash) + .replace(&"0".repeat(64), &before_hash); + std::fs::write(socket.join("manifest.json"), manifest).unwrap(); + // The CRLF form of the after blob and a truncated before blob. + write_blob(&socket, &after_hash, b"patched\r\ncontent\r\n"); + write_blob(&socket, &before_hash, &original[..5]); + + let run = |extra: &[&str]| { + let mut args = vec!["repair"]; + args.extend_from_slice(extra); + let out = socket_cmd(tmp.path()) + .args(&args) + .env("SOCKET_API_URL", mock.uri()) + .env("SOCKET_API_TOKEN", "fake-token-for-test") + .env("SOCKET_ORG_SLUG", ORG_SLUG) + .output() + .expect("run socket-patch"); + ( + out.status.code(), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) + }; + + // A dry run says what it found and changes nothing. + let (code, stdout, stderr) = run(&["--dry-run"]); + assert_eq!(code, Some(0), "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains("Found 2 corrupt blobs") && stdout.contains("Would download:"), + "stdout={stdout}" + ); + assert_eq!( + std::fs::read(socket.join("blobs").join(&after_hash)).unwrap(), + b"patched\r\ncontent\r\n" + ); + + let (code, stdout, stderr) = run(&["--json"]); + assert_eq!(code, Some(0), "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["summary"]["downloaded"], 1, "one aggregate event: {v}"); + let downloaded = v["events"] + .as_array() + .unwrap() + .iter() + .find(|e| e["action"] == "downloaded") + .unwrap_or_else(|| panic!("no downloaded event: {v}")); + assert_eq!(downloaded["details"]["count"], 2, "{v}"); + assert_eq!(downloaded["details"]["corrupt"], 2, "{v}"); + + assert_eq!( + std::fs::read(socket.join("blobs").join(&after_hash)).unwrap(), + patched, + "the corrupt after blob was replaced" + ); + assert_eq!( + std::fs::read(socket.join("blobs").join(&before_hash)).unwrap(), + original, + "the corrupt before blob was replaced" + ); + + // Healed: a second run has nothing to fetch (the mocks expect one GET + // each) and says so. + let (code, stdout, stderr) = run(&[]); + assert_eq!(code, Some(0), "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains("All blobs are present locally."), + "stdout={stdout}" + ); +} + +/// A corrupt blob whose re-download fails stays on disk (a failed download +/// never deletes), and the run is a failure: `repair` must not say +/// "complete" over a blob `apply` will refuse. +#[tokio::test] +async fn repair_reports_a_corrupt_blob_it_could_not_redownload() { + let patched = b"patched\ncontent\n"; + let after_hash = git_sha256(patched); + // No blob route: the fetch 404s. + let mock = MockServer::start().await; + + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write( + socket.join("manifest.json"), + MANIFEST_JSON.replace(REFERENCED_HASH, &after_hash), + ) + .unwrap(); + write_blob(&socket, &after_hash, b"patched\r\ncontent\r\n"); + + let out = socket_cmd(tmp.path()) + .args(["repair"]) + .env("SOCKET_API_URL", mock.uri()) + .env("SOCKET_API_TOKEN", "fake-token-for-test") + .env("SOCKET_ORG_SLUG", ORG_SLUG) + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(1), + "stdout={stdout}; stderr={stderr}" + ); + assert!(stdout.contains("Found 1 corrupt blob"), "stdout={stdout}"); + assert!( + stderr.contains("Failed to download 1 blob") + && stderr.contains("Repair finished with errors"), + "stderr={stderr}" + ); + assert!(!stdout.contains("Repair complete."), "stdout={stdout}"); + assert_eq!( + std::fs::read(socket.join("blobs").join(&after_hash)).unwrap(), + b"patched\r\ncontent\r\n", + "a failed download leaves the entry as it was" + ); +} + #[test] fn repair_honors_manifest_path_override() { // Put the manifest somewhere other than `.socket/manifest.json` and @@ -918,6 +1068,9 @@ fn repair_honors_manifest_path_override() { let custom_dir = tmp.path().join("custom"); std::fs::create_dir_all(&custom_dir).unwrap(); std::fs::write(custom_dir.join("patches.json"), MANIFEST_JSON).unwrap(); + // The manifest's blob sits beside it, so the offline run has nothing + // to fetch (a missing blob fails an offline repair). + write_blob(&custom_dir, REFERENCED_HASH, REFERENCED_BYTES); // Negative control: with NO `.socket/manifest.json` and no override, // repair must fail to find a manifest. This proves the success below is @@ -951,10 +1104,11 @@ fn repair_honors_manifest_path_override() { let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap(); assert_eq!(v["command"], "repair"); assert_eq!(v["status"], "success"); - // The override manifest references one blob with no blob on disk, but - // offline mode fetches nothing and there are no orphans to remove. + // The override manifest's one blob is on disk beside it and there are + // no orphans to remove. assert_eq!(v["summary"]["removed"], 0); assert_eq!(v["summary"]["downloaded"], 0); + assert!(custom_dir.join("blobs").join(REFERENCED_HASH).exists()); } /// Regression: download failures are ERRORS, and `--silent` suppresses @@ -1045,7 +1199,7 @@ fn repair_silent_suppresses_human_stdout() { let socket = make_socket_dir(tmp.path()); // Keep the referenced blob (survives) plus an orphan (swept) so cleanup // has something to announce in the non-silent control. - write_blob(&socket, REFERENCED_HASH, b"kept"); + write_blob(&socket, REFERENCED_HASH, REFERENCED_BYTES); let orphan = "deadbeef".repeat(8); // 64 hex chars, not referenced write_blob(&socket, &orphan, b"orphan bytes"); diff --git a/crates/socket-patch-cli/tests/repair/repair_render.rs b/crates/socket-patch-cli/tests/repair/repair_render.rs index a8b11985d..3e8b88730 100644 --- a/crates/socket-patch-cli/tests/repair/repair_render.rs +++ b/crates/socket-patch-cli/tests/repair/repair_render.rs @@ -53,7 +53,7 @@ const MANIFEST_JSON: &str = r#"{ "files": { "package/index.js": { "beforeHash": "0000000000000000000000000000000000000000000000000000000000000000", - "afterHash": "1111111111111111111111111111111111111111111111111111111111111111" + "afterHash": "765b5c40e14fd7c5d6a2b8063c4aa7dc6571e09f6817f8572f2cc9febf51a44b" } }, "vulnerabilities": {}, @@ -64,7 +64,11 @@ const MANIFEST_JSON: &str = r#"{ } }"#; -const REFERENCED_HASH: &str = "1111111111111111111111111111111111111111111111111111111111111111"; +/// The manifest's one `afterHash`: the git-sha256 of [`REFERENCED_BYTES`], +/// so the blob the fixtures write under it verifies (`repair` re-hashes +/// every referenced blob it finds). +const REFERENCED_HASH: &str = "765b5c40e14fd7c5d6a2b8063c4aa7dc6571e09f6817f8572f2cc9febf51a44b"; +const REFERENCED_BYTES: &[u8] = b"patched content"; const REFERENCED_UUID: &str = "11111111-1111-4111-8111-111111111111"; fn make_socket_dir(root: &Path) -> PathBuf { @@ -210,7 +214,7 @@ fn repair_failed_human_mode_prints_error_to_stderr() { fn repair_all_present_human_mode_prints_summary() { let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); - write_blob(&socket, REFERENCED_HASH, b"patched content"); + write_blob(&socket, REFERENCED_HASH, REFERENCED_BYTES); let out = socket_cmd(tmp.path()) .args(["repair", "--offline"]) @@ -237,52 +241,114 @@ fn repair_all_present_human_mode_prints_summary() { ); } -/// The offline warning lists at most 5 missing ids and folds the rest into -/// " ... and N more". With 12 missing file artifacts that's 5 items + 7. +/// `repair --offline` cannot fetch a missing blob, and the next +/// `apply --offline` fails on it and says to run `repair`: the run exits 1 +/// and never closes on "Repair complete.". The error lists at most 5 +/// missing ids and folds the rest into " ... and N more" (12 missing: 5 +/// items + 7), and prints even under `--silent`. #[test] -fn repair_offline_warning_truncates_missing_list_after_five() { +fn repair_offline_missing_blobs_fail_and_list_at_most_five() { let tmp = tempfile::tempdir().expect("tempdir"); write_twelve_file_manifest(tmp.path()); // No blobs on disk → all 12 afterHashes are missing. + for silent in [false, true] { + let mut args = vec!["repair", "--offline"]; + if silent { + args.push("--silent"); + } + let out = socket_cmd(tmp.path()) + .args(&args) + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + // The error (and its list) is diagnostic output: stderr. + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(1), + "blobs an offline repair cannot restore fail the run; stdout=\n{stdout}" + ); + assert!( + stderr.contains("Error: 12 blobs are missing (offline mode - not downloading):"), + "the error header must carry the full missing count; stderr=\n{stderr}" + ); + assert!( + !stdout.contains("Error") && !stdout.contains("Repair complete."), + "stdout must neither carry the error nor claim completion; stdout=\n{stdout}" + ); + let items = item_lines(&stderr); + assert_eq!( + items.len(), + 5, + "the offline error must list exactly 5 missing ids; stderr=\n{stderr}" + ); + for item in &items { + // " - " + 12 truncated chars + "..." — the ids are 64-hex here. + assert_eq!( + item.len(), + " - ".len() + 12 + 3, + "each listed id must be truncated to 12 chars + ellipsis; got {item:?}" + ); + } + assert!( + stderr.contains(" ... and 7 more"), + "the overflow line must report the remaining 12 - 5 = 7 ids; stderr=\n{stderr}" + ); + assert_eq!( + stderr.contains( + "Repair finished with errors: 12 blobs could not be restored in offline mode. \ + Re-run without --offline to download them." + ), + !silent, + "the closing line is the human summary, muted by --silent; stderr=\n{stderr}" + ); + assert!( + !tmp.path().join(".socket/apply.lock").exists(), + "the failed run still removes its lock" + ); + } +} + +/// The `--json` twin: one `failed` event per blob the offline run could +/// not restore (`offline_missing_sources`, `details.hash`), and +/// `partialFailure` / exit 1. It used to be `success` with no event. +#[test] +fn repair_offline_missing_blobs_json_names_each_blob() { + let tmp = tempfile::tempdir().expect("tempdir"); + write_twelve_file_manifest(tmp.path()); + let out = socket_cmd(tmp.path()) - .args(["repair", "--offline"]) + .args(["repair", "--offline", "--json"]) .output() .expect("run socket-patch"); let stdout = String::from_utf8_lossy(&out.stdout); - // The warning (and its list) is diagnostic output: stderr. - let stderr = String::from_utf8_lossy(&out.stderr); - assert_eq!( - out.status.code(), - Some(0), - "offline missing artifacts are a warning, not a failure; stdout=\n{stdout}" - ); - assert!( - stderr.contains("Warning: 12 blobs are missing (offline mode - not downloading):"), - "the warning header must carry the full missing count; stderr=\n{stderr}" - ); - assert!( - !stdout.contains("Warning"), - "the warning must not pollute stdout; stdout=\n{stdout}" - ); - let items = item_lines(&stderr); - assert_eq!( - items.len(), - 5, - "the offline warning must list exactly 5 missing ids; stdout=\n{stdout}" - ); - for item in &items { - // " - " + 12 truncated chars + "..." — the ids are 64-hex here. - assert_eq!( - item.len(), - " - ".len() + 12 + 3, - "each listed id must be truncated to 12 chars + ellipsis; got {item:?}" - ); - } - assert!( - stderr.contains(" ... and 7 more"), - "the overflow line must report the remaining 12 - 5 = 7 ids; stderr=\n{stderr}" - ); + assert_eq!(out.status.code(), Some(1), "stdout=\n{stdout}"); + let env = parse_json_envelope(&stdout); + assert_eq!(json_string(&env, "status"), Some("partialFailure")); + let failed: Vec<&serde_json::Value> = env["events"] + .as_array() + .expect("events") + .iter() + .filter(|e| e["action"] == "failed") + .collect(); + assert_eq!(failed.len(), 12, "one failed event per missing blob: {env}"); + let mut hashes: Vec<&str> = failed + .iter() + .map(|e| { + assert_eq!(e["errorCode"], "offline_missing_sources", "{e}"); + assert_eq!(e["details"]["state"], "missing", "{e}"); + assert!( + e["error"].as_str().is_some_and(|m| m.contains("--offline")), + "{e}" + ); + e["details"]["hash"].as_str().expect("details.hash") + }) + .collect(); + hashes.dedup(); + let expected: Vec = (1..=12u32).map(|i| format!("{i:02}").repeat(32)).collect(); + assert_eq!(hashes, expected, "sorted, one per blob"); + assert_eq!(env["summary"]["failed"], 12); } /// The dry-run preview lists at most 10 missing ids and folds the rest into @@ -340,7 +406,7 @@ fn repair_dry_run_preview_truncates_missing_list_after_ten() { fn repair_removes_orphan_archives_human_mode_prints_relabeled_summary() { let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); - write_blob(&socket, REFERENCED_HASH, b"kept"); + write_blob(&socket, REFERENCED_HASH, REFERENCED_BYTES); write_archive(&socket, "diffs", REFERENCED_UUID, b"kept-diff"); write_archive(&socket, "packages", REFERENCED_UUID, b"kept-package"); const ORPHAN_DIFF: &str = "99999999-9999-4999-8999-999999999999"; @@ -420,7 +486,7 @@ fn repair_removes_orphan_archives_human_mode_prints_relabeled_summary() { fn repair_archive_cleanup_failure_warns_and_continues() { let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); - write_blob(&socket, REFERENCED_HASH, b"kept"); + write_blob(&socket, REFERENCED_HASH, REFERENCED_BYTES); std::fs::write(socket.join("diffs"), b"not a dir").expect("stage diffs-as-file"); const ORPHAN_PKG: &str = "77777777-7777-4777-8777-777777777777"; write_archive(&socket, "packages", ORPHAN_PKG, b"orphan pkg bytes"); @@ -515,7 +581,7 @@ fn repair_exits_zero_and_stays_quiet_when_lock_file_unremovable() { let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); - write_blob(&socket, REFERENCED_HASH, b"kept"); + write_blob(&socket, REFERENCED_HASH, REFERENCED_BYTES); std::fs::write(socket.join("apply.lock"), b"leftover").expect("stage stale lock"); std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o555)) .expect("chmod .socket read-only"); diff --git a/crates/socket-patch-core/src/api/blob_fetcher.rs b/crates/socket-patch-core/src/api/blob_fetcher.rs index 6cf67cb63..6d7e796fc 100644 --- a/crates/socket-patch-core/src/api/blob_fetcher.rs +++ b/crates/socket-patch-core/src/api/blob_fetcher.rs @@ -3,8 +3,9 @@ use std::path::Path; use crate::api::client::{ApiClient, ApiError, BinaryBody}; use crate::hash::git_sha256::{compute_git_sha256_from_bytes, compute_git_sha256_from_reader}; -use crate::manifest::operations::get_after_hash_blobs; +use crate::manifest::operations::{get_after_hash_blobs, get_before_hash_blobs}; use crate::manifest::schema::PatchManifest; +use crate::patch::apply::is_valid_blob_hash; /// Result of fetching a single blob. #[derive(Debug, Clone)] @@ -49,6 +50,50 @@ pub async fn get_missing_blobs(manifest: &PatchManifest, blobs_path: &Path) -> H missing } +/// The cached blobs `manifest` references (`afterHash` and `beforeHash`) +/// that are on disk but no longer hash to their git-sha256 name: truncated, +/// rewritten by a line-ending-converting checkout (`core.autocrlf`), or +/// unreadable. [`get_missing_blobs`] only tests for presence, so such an +/// entry is trusted until `apply` or `rollback` refuses its bytes, on every +/// run. +/// +/// Hashes every present blob, so this is `repair`'s check: the apply path +/// verifies a blob only when it is about to write its bytes. +pub async fn get_corrupt_blobs(manifest: &PatchManifest, blobs_path: &Path) -> HashSet { + let mut referenced = get_after_hash_blobs(manifest); + referenced.extend(get_before_hash_blobs(manifest)); + let mut corrupt = HashSet::new(); + + for hash in referenced { + // Never join an unvalidated manifest string onto the directory; + // apply and rollback refuse such a hash by name. + if !is_valid_blob_hash(&hash) { + continue; + } + let blob_path = blobs_path.join(&hash); + // A missing entry is `get_missing_blobs`' to report. One that is + // not a regular file (a planted link, a directory) is refused by + // name where it is read, and a download never writes through it. + let Ok(meta) = tokio::fs::symlink_metadata(&blob_path).await else { + continue; + }; + if !meta.is_file() { + continue; + } + let intact = match tokio::fs::File::open(&blob_path).await { + Ok(file) => compute_git_sha256_from_reader(meta.len(), file) + .await + .is_ok_and(|actual| blob_hash_matches(&hash, &actual)), + Err(_) => false, + }; + if !intact { + corrupt.insert(hash); + } + } + + corrupt +} + /// Download all missing `afterHash` blobs referenced in the manifest. /// `on_progress` is called before each download with /// `(hash, 1-based index, total)`. @@ -71,6 +116,22 @@ pub async fn fetch_missing_blobs( download_entries(&hashes, blobs_path, client, on_progress).await } +/// Download the blobs `hashes` whether or not an entry is already on disk: +/// each download is staged, verified against its name and renamed over the +/// entry, so a corrupt one ([`get_corrupt_blobs`]) is replaced whole and a +/// failed download leaves it as it was. +pub async fn fetch_blobs_replacing( + hashes: &[String], + blobs_path: &Path, + client: &ApiClient, + on_progress: Option<&OnProgress>, +) -> FetchMissingBlobsResult { + if hashes.is_empty() { + return FetchMissingBlobsResult::default(); + } + download_entries(hashes, blobs_path, client, on_progress).await +} + /// Download the blobs `hashes` (rollback's `beforeHash` set). Blobs that /// already exist on disk are counted in `skipped`. pub async fn fetch_blobs_by_hash( @@ -334,8 +395,9 @@ fn guard_cache_entry(dest: &Path) -> std::io::Result<()> { /// never held in memory whole. /// /// [`get_missing_blobs`] only tests for presence, so a torn entry left by -/// an in-place write would be trusted forever; stage+rename makes the final -/// path either the complete bytes or absent. +/// an in-place write would be trusted until a `repair` re-hashed it +/// ([`get_corrupt_blobs`]); stage+rename makes the final path either the +/// complete bytes or what was there before. /// /// Deliberately LIGHTER than [`crate::utils::fs::atomic_write_bytes`] (no /// file fsync, no dir fsync, `.socket-dl-` prefix): these are re-downloadable @@ -579,6 +641,59 @@ mod tests { assert!(missing.is_empty()); } + /// A present blob is corrupt exactly when its bytes do not hash to its + /// name: the CRLF checkout and the truncation are caught for both the + /// after and the before blob, while an intact, a missing and an + /// unreferenced entry are not. + #[tokio::test] + async fn corrupt_blobs_are_the_present_ones_that_do_not_hash_to_their_name() { + let dir = tempfile::tempdir().unwrap(); + let blobs_path = dir.path().join("blobs"); + tokio::fs::create_dir_all(&blobs_path).await.unwrap(); + + let intact = b"line one\nline two\n"; + let crlf = b"patched\nbytes\n"; + let truncated = b"the original file\n"; + let h_intact = compute_git_sha256_from_bytes(intact); + let h_crlf = compute_git_sha256_from_bytes(crlf); + let h_truncated = compute_git_sha256_from_bytes(truncated); + let h_missing = "c".repeat(64); + + let mut manifest = make_manifest_with_hashes(&[&h_intact, &h_crlf, &h_missing]); + manifest + .patches + .get_mut("pkg:npm/test@1.0.0") + .unwrap() + .files + .get_mut("package/file0.js") + .unwrap() + .before_hash = h_truncated.clone(); + + tokio::fs::write(blobs_path.join(&h_intact), intact) + .await + .unwrap(); + tokio::fs::write(blobs_path.join(&h_crlf), b"patched\r\nbytes\r\n") + .await + .unwrap(); + tokio::fs::write(blobs_path.join(&h_truncated), &truncated[..4]) + .await + .unwrap(); + // Unreferenced junk is the sweep's business, not this check's. + tokio::fs::write(blobs_path.join("d".repeat(64)), b"junk") + .await + .unwrap(); + + let corrupt = get_corrupt_blobs(&manifest, &blobs_path).await; + assert_eq!( + corrupt, + HashSet::from([h_crlf, h_truncated]), + "only the present, referenced, mismatching blobs" + ); + assert!(get_missing_blobs(&manifest, &blobs_path) + .await + .contains(&h_missing)); + } + fn ok(id: &str) -> BlobFetchResult { BlobFetchResult { hash: id.to_string(), diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs index c0693b16f..b129e5e86 100644 --- a/crates/socket-patch-core/src/patch/apply.rs +++ b/crates/socket-patch-core/src/patch/apply.rs @@ -106,6 +106,17 @@ impl<'a> PatchSources<'a> { mem_blobs: None, } } + + /// The message for a write of blob `hash`'s bytes that failed with `e` + /// ([`blob_write_error`]). An overlay blob is not a cache file, so its + /// error stands as it is. + fn write_error(&self, e: &std::io::Error, hash: &str) -> String { + if self.mem_blobs.is_some_and(|m| m.contains_key(hash)) { + e.to_string() + } else { + blob_write_error(e, hash) + } + } } /// Result of applying patches to a single package. @@ -449,6 +460,59 @@ async fn variant_installed_at( false } +/// Bytes that do not hash to the hash they are recorded under: what +/// [`apply_file_patch_at`] (and npm's bin-form twin) refuses before any +/// disk write. Typed, so a caller that fed it a cached blob can say that +/// the blob is what is wrong ([`blob_write_error`]). +#[derive(Debug)] +struct ContentHashMismatch { + expected: String, + actual: String, +} + +impl std::fmt::Display for ContentHashMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Hash verification failed before patch. Expected: {}, Got: {}", + self.expected, self.actual + ) + } +} + +impl std::error::Error for ContentHashMismatch {} + +/// The refusal of content that hashes to `actual`, not `expected`. +pub(crate) fn content_hash_mismatch(expected: &str, actual: String) -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + ContentHashMismatch { + expected: expected.to_string(), + actual, + }, + ) +} + +/// The message for a write of the cached blob `blob_hash` that failed with +/// `e`. When the blob's own bytes were refused for not hashing to its name +/// (a line-ending-converting checkout, a truncated file), the cache entry +/// is what is wrong and re-running the command cannot help: name the file +/// and the command that replaces it. The mismatch was already computed by +/// the refused write, so a healthy run never hashes a blob for this. +pub(crate) fn blob_write_error(e: &std::io::Error, blob_hash: &str) -> String { + match e + .get_ref() + .and_then(|inner| inner.downcast_ref::()) + { + Some(mismatch) if mismatch.expected == blob_hash => format!( + "Hash verification failed before patch: blob .socket/blobs/{blob_hash} is corrupt \ + (its content hashes to {}). Run `socket-patch repair` to download it again.", + mismatch.actual + ), + _ => e.to_string(), + } +} + /// Apply a patch to a single file. /// /// **Permission policy** (per the user-visible contract — patched @@ -507,13 +571,7 @@ pub(crate) async fn apply_file_patch_at( // upstream blob errors out before any disk write. let content_hash = compute_git_sha256_from_bytes(patched_content); if content_hash != expected_hash { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!( - "Hash verification failed before patch. Expected: {}, Got: {}", - expected_hash, content_hash - ), - )); + return Err(content_hash_mismatch(expected_hash, content_hash)); } // Snapshot pre-patch metadata so `restore_file_permissions` can @@ -1321,7 +1379,7 @@ async fn apply_package_patch_at( { Ok(write) => write, Err(e) => { - result.error = Some(e.to_string()); + result.error = Some(sources.write_error(&e, &file_info.after_hash)); return result; } }; @@ -1349,7 +1407,7 @@ async fn apply_package_patch_at( )), }); } - result.error = Some(e.to_string()); + result.error = Some(sources.write_error(&e, &file_info.after_hash)); return result; } } @@ -2175,6 +2233,84 @@ mod tests { assert_eq!(content, original); } + /// A cached blob whose bytes changed (here the CRLF form a + /// `core.autocrlf` checkout leaves) fails the package without writing, + /// and the error names the cache entry and the command that replaces + /// it: re-running `apply` can never fix it. + #[tokio::test] + async fn test_apply_package_patch_corrupt_blob_names_the_blob_and_repair() { + let pkg_dir = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + + let original = b"original\ncontent\n"; + let patched = b"patched\ncontent\n"; + let before_hash = compute_git_sha256_from_bytes(original); + let after_hash = compute_git_sha256_from_bytes(patched); + + tokio::fs::write(pkg_dir.path().join("index.js"), original) + .await + .unwrap(); + tokio::fs::write( + blobs_dir.path().join(&after_hash), + b"patched\r\ncontent\r\n", + ) + .await + .unwrap(); + + let mut files = HashMap::new(); + files.insert( + "index.js".to_string(), + PatchFileInfo { + before_hash, + after_hash: after_hash.clone(), + }, + ); + + let result = apply_package_patch( + crate::utils::repo_root::ReadScope::Workspace, + "pkg:npm/test@1.0.0", + pkg_dir.path(), + &files, + &PatchSources::blobs_only(blobs_dir.path()), + false, + MismatchPolicy::Warn, + ) + .await; + + assert!(!result.success); + let err = result.error.expect("a corrupt blob is an error"); + assert!( + err.contains(&format!(".socket/blobs/{after_hash}")), + "the error names the blob file: {err}" + ); + assert!( + err.contains("is corrupt") && err.contains("`socket-patch repair`"), + "the error says what is wrong and what fixes it: {err}" + ); + assert_eq!( + tokio::fs::read(pkg_dir.path().join("index.js")) + .await + .unwrap(), + original, + "nothing was written" + ); + } + + /// Only a mismatch against the blob's own name is the blob's fault: a + /// write refused for any other reason keeps its own message. + #[test] + fn blob_write_error_blames_the_blob_only_for_its_own_mismatch() { + let hash = "a".repeat(64); + let mismatch = content_hash_mismatch(&hash, "b".repeat(64)); + assert!(blob_write_error(&mismatch, &hash).contains("is corrupt")); + assert_eq!( + blob_write_error(&mismatch, &"c".repeat(64)), + mismatch.to_string() + ); + let denied = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"); + assert_eq!(blob_write_error(&denied, &hash), "denied"); + } + #[tokio::test] async fn test_apply_package_patch_all_already_patched() { let pkg_dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/patch/npm_bin.rs b/crates/socket-patch-core/src/patch/npm_bin.rs index afdac2868..7ffae7e30 100644 --- a/crates/socket-patch-core/src/patch/npm_bin.rs +++ b/crates/socket-patch-core/src/patch/npm_bin.rs @@ -49,11 +49,9 @@ pub(crate) fn fixed_for_write( }; let content_hash = compute_git_sha256_from_bytes(content); if content_hash != expected_hash { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!( - "Hash verification failed before patch. Expected: {expected_hash}, Got: {content_hash}" - ), + return Err(crate::patch::apply::content_hash_mismatch( + expected_hash, + content_hash, )); } let hash = compute_git_sha256_from_bytes(&fixed); diff --git a/crates/socket-patch-core/src/patch/rollback.rs b/crates/socket-patch-core/src/patch/rollback.rs index e9b86a87e..9739650bc 100644 --- a/crates/socket-patch-core/src/patch/rollback.rs +++ b/crates/socket-patch-core/src/patch/rollback.rs @@ -829,7 +829,10 @@ async fn rollback_package_patch_at( { Ok(write) => write, Err(e) => { - result.error = Some(e.to_string()); + result.error = Some(crate::patch::apply::blob_write_error( + &e, + &file_info.before_hash, + )); return result; } }; @@ -858,7 +861,10 @@ async fn rollback_package_patch_at( )), }); } - result.error = Some(e.to_string()); + result.error = Some(crate::patch::apply::blob_write_error( + &e, + &file_info.before_hash, + )); return result; } } @@ -2905,7 +2911,7 @@ mod tests { files.insert( "index.js".to_string(), PatchFileInfo { - before_hash, + before_hash: before_hash.clone(), after_hash, }, ); @@ -2927,6 +2933,12 @@ mod tests { err.contains("Hash verification failed"), "unexpected error: {err}" ); + // It names the cache entry and the command that replaces it. + assert!( + err.contains(&format!(".socket/blobs/{before_hash}")) + && err.contains("`socket-patch repair`"), + "unexpected error: {err}" + ); // The patched file must NOT have been overwritten with the bad bytes. assert_eq!( tokio::fs::read(pkg_dir.path().join("index.js")) diff --git a/docs/usage.md b/docs/usage.md index bf3753018..b25a012fc 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -222,7 +222,7 @@ The removed `setup` command is covered in the [migration guide](migrating-to-v5. | `rollback [NAME\|PURL\|UUID\|PATH]...` | Restore selected patches, or all patches when no target is given, and remove their local state | | `remove ` | Restore and remove matching patches; a name or versionless PURL selects every recorded version | | `vendor --revert` | Undo vendoring from its recorded edits and remove the vendored artifacts | -| `repair` | Restore missing agent patch data or missing/corrupt vendored artifacts using existing records, and clean unused data | +| `repair` | Restore missing or corrupt agent patch data and vendored artifacts using existing records, and clean unused data | | `scan --mode agent --prune` | Patch discovered packages and remove records for dependencies that left the project | Use `--dry-run` to preview. `rollback --preserve-state` and From 7ad3962ea0e61ac5976584237a7eb07185bc4606 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 10 Nov 2026 12:16:05 -0500 Subject: [PATCH 006/111] Roll back a hosted uv lock whose only registry package is the patched one Hosted rollback rebuilds a uv.lock entry from PyPI in the shape another registry package of the lock shows: which registry it names, and which artifact fields this uv records. A project with one dependency has no such package, so `socket-patch rollback` failed with "no sibling registry package shows the registry and artifact fields the lock records" and left pyproject.toml and uv.lock hosted. The hosted scan wires such a project without complaint, so the only way back was `git checkout`. A `[[package]]` lock (uv 0.2.35 and later, script locks included) says what a sibling would in its header. With no sibling, the entry is now rebuilt on PyPI's default index with the fields uv writes at the lock's `revision`: `url`, `hash` and `size` with no revision and at revision 1, those plus `upload-time` at revisions 2 and 3. The run reports it with an `upstream_uv_entry_rebuilt` warning naming the entry, the fields and the `uv lock --check` to run. It still refuses, with nothing written, when the revision is newer than 3 (its fields are unknown here) and when a uv setting or environment variable names a package index, since the entry's registry is then not known to be PyPI. A pylock or a uv 0.2 `[[distribution]]` lock with no sibling refuses as before: nothing in them tells their writers' shapes apart. A refused uv pin's remedy now names every file the hosted rewrite edited. Discovery lists only the lock, so the advice was `git checkout -- uv.lock`, which leaves the `[tool.uv.sources]` URL in pyproject.toml (or the script) for the next `uv lock` to wire back in. The paired file is named when it carries the pin's patch. Verified with real uv. A `uv init` + `uv add "urllib3==1.26.18"` project taken through the real hosted rewriter: the 5.1.0 binary refuses it as reported; with this change rollback exits 0, both files are byte-identical to what uv wrote, `uv lock --check` passes and `uv sync --frozen` installs urllib3 1.26.18. The same cycle is byte-identical on uv 0.2.37, 0.3.5, 0.4.30, 0.5.31, 0.6.14, 0.7.0, 0.8.0, 0.8.4, 0.9.30, 0.10.0, 0.11.19 and 0.12.17. uv 0.6.15-0.6.17 spell the revision 2 timestamp `upload_time`; their entry comes back as `upload-time`, which uv 0.6.17 accepts (`lock --check` and `sync --frozen`), as 0.7.0 and later accept `upload_time`. A script lock with one dependency round-trips too (`uv lock --check --script`). The real-uv e2e driver accepted either a restore or a refusal for its one-dependency lanes. It now requires the project, constraints and script lanes to restore byte for byte on every `[[package]]` lock (not byte-exact on 0.6.15-0.6.17). `e2e_redirect_uv_build` passes those lanes on the 16 uv releases of the CI matrix, 0.1.45 through 0.12.17. Co-Authored-By: Claude Fable 5.2 --- crates/socket-patch-cli/CLI_CONTRACT.md | 5 +- .../tests/vex_e2e_common/uv.rs | 30 +- .../src/patch/redirect/upstream/mod.rs | 47 ++- .../src/patch/redirect/upstream/uv.rs | 196 +++++++++- .../tests/upstream_restore_golden.rs | 355 ++++++++++++++++-- docs/testing/uv-compatibility.md | 17 + 6 files changed, 593 insertions(+), 57 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 259b5dbfd..cf87d377e 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1319,13 +1319,13 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem * **vlt** — `vlt-lock.json`: slot [2] from the registry's `dist.integrity`, slot [3] per the lock's own convention (see the vlt hosted-mode contract); every hosted instance of the pin together. * **cargo** — `Cargo.lock` back on crates.io (source + the sparse index's checksum, `SOCKET_CRATES_INDEX`). When the lock already holds a crates.io block for the same `name@version` (a later dependent locked its own copy, #679), the Socket block merges into it instead: it is dropped (with a v1 lock's `[metadata]` line), and dependents' references take the spelling cargo writes, so no duplicate block is left that cargo cannot parse (#863), and no index lookup is needed; every `Cargo.toml` declaration loses its `registry = "socket-patch-"` pin (the shorthand the rewriter produced collapses back); every `[registries.socket-patch-]` block no manifest or lock still references leaves the project cargo config — including a superseded patch generation's block an earlier re-pin left behind (#864). A declaration it cannot unpin refuses. * **golang** — the hosted `replace` (in the root `go.mod`, an in-root workspace member's `go.mod`, or `go.work`) and the socket module's sum lines (its `go.sum`, `go.work.sum`, and for a `go.work` pin each in-root member's `go.sum`) go; the upstream module's two go.sum lines come back: for a `go.mod` pin into the `go.sum` beside it; for a `go.work` pin, which leaves the members' pair in place, only when no sum file holds them, into each sum file that held the socket pair, else `go.work.sum`. A `go.work.sum` left empty is removed; hashed from the module proxy (`SOCKET_GOPROXY`, else `GOPROXY` / `GONOPROXY` / `GOPRIVATE` as go reads them) and cross-checked against the checksum database (`SOCKET_GOSUMDB_URL`, else `sum.golang.org` unless `GOSUMDB=off` / `GONOSUMDB` / `GOPRIVATE` say go would not ask it). A `replace` the user had before the hosted run is not recorded anywhere, so the restore lands on the plain upstream module. - * **pypi** — `Pipfile.lock`, `requirements.txt` (+ in-root `-r` includes), Hatch PEP 508 direct references (`pyproject.toml` / `hatch.toml`), `poetry.lock`, `pdm.lock`, `uv.lock`, PEP 723 script locks and PEP 751 `pylock*.toml` (+ the paired `pyproject.toml` / script metadata): hashes re-derived from PyPI's JSON API (`SOCKET_PYPI_JSON_API`). A restored `requirements.txt` line gets `--hash` options only when the file is in pip's hash-checking mode. The mode is read off the file's other requirement lines (an `-e` / `--editable` line means unhashed). When every requirement is a hosted pin, it is read off the hosted line itself (`--hash` vs a `#sha256=` url fragment) (#410). Refused: a `static_urls` `pdm.lock` whose project installs from an index other than PyPI (a `[[tool.pdm.source]]` in the sibling `pyproject.toml`, a project `pypi.url` (`.pdm.toml`, then `pdm.toml`), or, with neither, another package's file URL in the lock off PyPI's file host), since restoring PyPI's file URLs would make `pdm sync` bypass that index (#413); a `pdm.lock` without `cross_platform`, or a uv / script / pylock lock, whose release has a wheel that is not pure Python 3 (which files the lock keeps is not re-derivable); a uv lock whose options filter files (`exclude-newer`, `no-binary`, `no-build`, or an `exclude-newer-package` cutoff for the restored package; one of `false` exempts it from the global cutoff here and in pylocks, as in uv) (#1604); a pylock from `uv pip compile` whose header command line (`--no-build`, `--only-binary`, `--no-binary`, `--exclude-newer`, `--exclude-newer-package`) or uv settings (the nearest `uv.toml`, else a pyproject's `[tool.uv]` / `[tool.uv.pip]`, then the user's and the system's `uv.toml` unless `UV_NO_CONFIG` is set, merged as uv does with `[pip]` over the top level; none under `--no-config`, and any `--config-file` refuses; `UV_CONFIG_FILE` or `UV_EXCLUDE_NEWER` set in the restoring environment also refuses, since the header does not record uv's environment) filter the restored package's files, or whose header records no `uv pip compile` command line (`--no-header`, `UV_CUSTOM_COMPILE_COMMAND`), since uv 0.9.16 records only the files those filters let through (#1613); a uv lock whose other registry packages name no registry, several, or one other than PyPI's simple index; a pylock whose other registry packages show neither an `index` nor (as `uv pip compile` writes them) only PyPI files with none, which restores the entry without an `index` too. Restored artifact fields keep the spelling the lock's other entries show, including the `upload_time` that uv 0.6.15–0.6.17 write. A uv 0.2 `[[distribution]]` lock (uv 0.1.45–0.2.34) is restored like a `[[package]]` one, in the grammar the hosted rewrite wrote the entry in, which is the entry's own: its source keeps its spelling (a string `"direct+…"` back to `"registry+…"`, uv ≤ 0.2.17, or an inline table), its artifacts their grammar (`[distribution.sdist]` / `[[distribution.wheel]]` sub-tables, uv ≤ 0.2.5, or inline), and dependents' references, inline or `[[distribution.dependencies]]` tables, are pointed back. Refused there: an entry with an sdist and inline `dependencies` when no other registry entry of the lock carries both (uv 0.2.18–0.2.22 and 0.2.23–0.2.34 order them differently; hosted mode refuses to wire such an entry, `redirect_uv_lock_unsupported`); and, for the inline-artifact grammar, an `exclude-newer` cutoff the lock does not record: the project's settings (`uv.toml`, else the pyproject's `[tool.uv]`), the user's or system `uv.toml`, or `UV_EXCLUDE_NEWER` (uv 0.2.12–0.2.27 apply these without recording them; hosted mode refuses to wire a lock whose project sets one). A cutoff uv 0.2.28–0.2.33 recorded at the lock's top level refuses like an `[options]` one. A cutoff passed to uv 0.2.6–0.2.27 only on the command line leaves no trace and cannot be detected. A restored pylock entry's `upload-time`s are whole seconds, as uv writes them, unless the lock's other entries show fractions. Its artifacts come back in the TOML spelling the other entries use: uv's inline `wheels = [{ … }]`, or the standard tables `pip lock` writes (`[[packages.wheels]]` with a `[packages.wheels.hashes]` sub-table, `[packages.sdist]`). A `pip lock` file (`created-by = "pip"`) records only the artifact pip selected, so the entry is restored with only the release's wheel (its sdist when it has none), and a release with several wheels is refused. A transitive `override-dependencies` entry hosted mode added is removed (`upstream_uv_override_removed`). Hosted mode keeps no ledger, so it marks the entry it adds with a `# socket-patch hosted: …` comment line above it and removes only a marked entry; a user's own `==` override (which the hosted rewrite reuses rather than adding a second one) is kept, and the lock's `[manifest] overrides` record of it gets its specifier back. + * **pypi** — `Pipfile.lock`, `requirements.txt` (+ in-root `-r` includes), Hatch PEP 508 direct references (`pyproject.toml` / `hatch.toml`), `poetry.lock`, `pdm.lock`, `uv.lock`, PEP 723 script locks and PEP 751 `pylock*.toml` (+ the paired `pyproject.toml` / script metadata): hashes re-derived from PyPI's JSON API (`SOCKET_PYPI_JSON_API`). A restored `requirements.txt` line gets `--hash` options only when the file is in pip's hash-checking mode. The mode is read off the file's other requirement lines (an `-e` / `--editable` line means unhashed). When every requirement is a hosted pin, it is read off the hosted line itself (`--hash` vs a `#sha256=` url fragment) (#410). Refused: a `static_urls` `pdm.lock` whose project installs from an index other than PyPI (a `[[tool.pdm.source]]` in the sibling `pyproject.toml`, a project `pypi.url` (`.pdm.toml`, then `pdm.toml`), or, with neither, another package's file URL in the lock off PyPI's file host), since restoring PyPI's file URLs would make `pdm sync` bypass that index (#413); a `pdm.lock` without `cross_platform`, or a uv / script / pylock lock, whose release has a wheel that is not pure Python 3 (which files the lock keeps is not re-derivable); a uv lock whose options filter files (`exclude-newer`, `no-binary`, `no-build`, or an `exclude-newer-package` cutoff for the restored package; one of `false` exempts it from the global cutoff here and in pylocks, as in uv) (#1604); a pylock from `uv pip compile` whose header command line (`--no-build`, `--only-binary`, `--no-binary`, `--exclude-newer`, `--exclude-newer-package`) or uv settings (the nearest `uv.toml`, else a pyproject's `[tool.uv]` / `[tool.uv.pip]`, then the user's and the system's `uv.toml` unless `UV_NO_CONFIG` is set, merged as uv does with `[pip]` over the top level; none under `--no-config`, and any `--config-file` refuses; `UV_CONFIG_FILE` or `UV_EXCLUDE_NEWER` set in the restoring environment also refuses, since the header does not record uv's environment) filter the restored package's files, or whose header records no `uv pip compile` command line (`--no-header`, `UV_CUSTOM_COMPILE_COMMAND`), since uv 0.9.16 records only the files those filters let through (#1613); a uv lock whose other registry packages name several registries, or one other than PyPI's simple index; a uv 0.2 `[[distribution]]` lock with no other registry package; a pylock whose other registry packages show neither an `index` nor (as `uv pip compile` writes them) only PyPI files with none, which restores the entry without an `index` too. A `[[package]]` uv lock (uv 0.2.35 and later, script locks included) in which every registry package is hosted — a project with one dependency — has no other entry to show its registry or the artifact fields uv records, so the entry is rebuilt on PyPI's default index with the fields uv writes at the lock's `revision` (`url`, `hash`, `size` with no `revision` and at revision 1; those and `upload-time` at revisions 2 and 3), which is byte for byte what `uv lock` wrote except on uv 0.6.15–0.6.17, whose `upload_time` spelling comes back as the `upload-time` they also read; the run warns `upstream_uv_entry_rebuilt`. That rebuild is refused when the lock's `revision` is newer than 3, or when uv is configured with a package index, since the entry's registry is then not known to be PyPI: `index`, `index-url`, `extra-index-url`, `find-links` or `no-index` in the paired `pyproject.toml` / script `[tool.uv]`, in the nearest `uv.toml` (else pyproject `[tool.uv]`) or the user's or system `uv.toml`, or `UV_INDEX` / `UV_DEFAULT_INDEX` / `UV_INDEX_URL` / `UV_EXTRA_INDEX_URL` / `UV_FIND_LINKS` / `UV_NO_INDEX` set in the restoring environment. Restored artifact fields keep the spelling the lock's other entries show, including the `upload_time` that uv 0.6.15–0.6.17 write. A uv 0.2 `[[distribution]]` lock (uv 0.1.45–0.2.34) is restored like a `[[package]]` one, in the grammar the hosted rewrite wrote the entry in, which is the entry's own: its source keeps its spelling (a string `"direct+…"` back to `"registry+…"`, uv ≤ 0.2.17, or an inline table), its artifacts their grammar (`[distribution.sdist]` / `[[distribution.wheel]]` sub-tables, uv ≤ 0.2.5, or inline), and dependents' references, inline or `[[distribution.dependencies]]` tables, are pointed back. Refused there: an entry with an sdist and inline `dependencies` when no other registry entry of the lock carries both (uv 0.2.18–0.2.22 and 0.2.23–0.2.34 order them differently; hosted mode refuses to wire such an entry, `redirect_uv_lock_unsupported`); and, for the inline-artifact grammar, an `exclude-newer` cutoff the lock does not record: the project's settings (`uv.toml`, else the pyproject's `[tool.uv]`), the user's or system `uv.toml`, or `UV_EXCLUDE_NEWER` (uv 0.2.12–0.2.27 apply these without recording them; hosted mode refuses to wire a lock whose project sets one). A cutoff uv 0.2.28–0.2.33 recorded at the lock's top level refuses like an `[options]` one. A cutoff passed to uv 0.2.6–0.2.27 only on the command line leaves no trace and cannot be detected. A restored pylock entry's `upload-time`s are whole seconds, as uv writes them, unless the lock's other entries show fractions. Its artifacts come back in the TOML spelling the other entries use: uv's inline `wheels = [{ … }]`, or the standard tables `pip lock` writes (`[[packages.wheels]]` with a `[packages.wheels.hashes]` sub-table, `[packages.sdist]`). A `pip lock` file (`created-by = "pip"`) records only the artifact pip selected, so the entry is restored with only the release's wheel (its sdist when it has none), and a release with several wheels is refused. A transitive `override-dependencies` entry hosted mode added is removed (`upstream_uv_override_removed`). Hosted mode keeps no ledger, so it marks the entry it adds with a `# socket-patch hosted: …` comment line above it and removes only a marked entry; a user's own `==` override (which the hosted rewrite reuses rather than adding a second one) is kept, and the lock's `[manifest] overrides` record of it gets its specifier back. * **gem** — `Gemfile.lock` / `gems.locked` + `Gemfile` / `gems.rb`: the spec moves back into the upstream `GEM` section (or the Socket remote leaves a merged section), the `source "" do … end` block is undone (also when a superseding hosted re-scan on a lock with no `CHECKSUMS` section has moved that block to the newer patch of the same name and version while the lock still pins the older one; several such blocks are refused), the `CHECKSUMS` entry is re-pinned from the rubygems.org compact index (`SOCKET_RUBYGEMS_URL`) and the `DEPENDENCIES` pin loses its `!`, unless the declaration comes back inside the user's own `source "…" do` block (Bundler writes the `!` for any dependency declared in one, rubygems.org included), where the `!` stays. The declaration's original constraint is not recorded, so it comes back as the exact pin `gem "", ""`. A transitive gem (one the manifest never declared) gets an appended block with a blank line before it; the restore removes that block, its blank line and the `DEPENDENCIES` entry, so the pair comes back byte for byte. An appended block with no blank line before it (written by a release before this one) can't be told apart from an in-place rewrite, so it still comes back as the exact pin. Refused: an ambiguous upstream section, an upstream remote other than rubygems.org. The manifest pair can't make a committed bundler cache upstream: a `-.gem` left in Bundler's cache dir that isn't the upstream archive is named by `upstream_gem_stale_cache`, never deleted. * **composer** — `composer.lock`: `dist` and the deleted `source` block from packagist's composer v2 metadata (`SOCKET_PACKAGIST_URL`). Refused unless the entry is packagist-sourced and packagist still serves the lock's `dist.reference` for the version. * **maven** — `pom.xml` (the `-socket.` version suffix, the added `` / `` entry) and the `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` lines hosted mode writes: **no network**, so it restores under `--offline` too. `.mvn` files holding anything else keep the resolver lines (`maven_trusted_checksums_left`). * **nuget** — `nuget.config` loses the `socket-patch-` source and its exact-id mapping; every lock entry of the id at the pinned version, in every lock the root config governs (member projects' and `packages..lock.json` included), gets nuget.org's `contentHash` back (`SOCKET_NUGET_URL`). Refused when the restored config would not resolve the id from nuget.org alone. A config hosted mode created from scratch is kept (`nuget_default_config_left`). * Any other file wiring a pin refuses it (`socket-patch cannot re-derive the upstream entry in `). -* **Refusals.** `--offline` refuses every pin whose restore needs a registry lookup (all but maven), as does a registry that does not answer or no longer describes the entry. A refused pin writes nothing; its message is `cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` — for a `bun.lock` / `bun.lockb` followed by `, then run `bun install --force` (a plain `bun install` keeps the patched copy)`, since Bun's hoisted linker does not re-extract a package whose entry returns to the registry copy of the same `name@version` (#764) — human `Error: Cannot restore …` on stderr (even under `--silent`), JSON a `failed` `hosted_restore_refused` event, and exit 1 (`remove`: the `hosted_revert_failed` error). A write failure after every pin resolved is one artifact-level (no `purl`) `failed` `hosted_write_failed` event. +* **Refusals.** `--offline` refuses every pin whose restore needs a registry lookup (all but maven), as does a registry that does not answer or no longer describes the entry. A refused pin writes nothing; its message is `cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` — `` are the files wiring the pin and, for a `uv.lock` or a script lock, the paired `pyproject.toml` / script when it carries the pin's `[tool.uv.sources]` URL (a checkout of the lock alone would leave that source live); for a `bun.lock` / `bun.lockb` followed by `, then run `bun install --force` (a plain `bun install` keeps the patched copy)`, since Bun's hoisted linker does not re-extract a package whose entry returns to the registry copy of the same `name@version` (#764) — human `Error: Cannot restore …` on stderr (even under `--silent`), JSON a `failed` `hosted_restore_refused` event, and exit 1 (`remove`: the `hosted_revert_failed` error). A write failure after every pin resolved is one artifact-level (no `purl`) `failed` `hosted_write_failed` event. * **Output.** Human `Restored to its upstream registry entry` / `Would restore to its upstream registry entry` (`--dry-run`). vlt: the stale installed copies of restored nodes are removed afterwards, as before (`--no-vlt-install-cleanup` keeps them). ### JSON envelope (v5.0, MAJOR: the unified envelope) @@ -1687,6 +1687,7 @@ command's integration tests check its output. | `hosted_resolution_orphaned` | rollback/remove `warnings[]`; list `warnings[]` (human: `Warning: …` on stderr) | yarn berry (#1203): the root `package.json` keeps a Socket-hosted `resolutions` selector that no live `yarn.lock` entry resolves and no lock descriptor matches (left behind by `yarn remove` / `yarn up`). `rollback` / `remove` removed it (the lock is untouched; an emptied `resolutions` object is dropped). uv (#1445): `pyproject.toml` (or a script's PEP 723 block) keeps a hosted `override-dependencies` pin and its `[tool.uv.sources]` URL for a transitive package the lock no longer holds (left behind by `uv remove ` / `uv lock --upgrade-package `). `rollback` / `remove` removed both lines and the lock's `[manifest]` record of them (emptied tables are dropped). `list` only reports either and names the scoped `remove ` that retires it alone. Never flips the exit. | | `upstream_registry_fallback` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore: a yarn berry, pnpm or vlt entry is restored from the version document of the registry the project resolves it against (yarn berry: `npmScopes` / `YARN_NPM_REGISTRY_SERVER` / `npmRegistryServer` from the `.yarnrc.yml` chain, as above, the pnpm lock's sibling `.npmrc` `registry` / `@scope:registry` or pnpm-workspace.yaml `registry` / `registries`, vlt's node registry); that registry could not be read (e.g. it needs credentials) or, for yarn berry, could not be determined (an rc value referencing an environment variable that is set, or an unset one with no default; a flow-style `npmScopes`), so the default registry's document was used and the restored tarball URL may not be the mirror's. | | `upstream_gem_stale_cache` | rollback/remove `warnings[]`; a vendored unwind's rollback `warnings[]`, remove / `vendor --revert` / `vendor` (manifest reconcile) advisory events and `scan --prune` `gc.warnings[]`; vendor advisory event (takeover) | upstream restore (#1260), or a wet vendored gem unwind (`remove`, `rollback`, `vendor --revert`, `scan --prune`, `vendor`'s reconcile) that puts the gem back on its upstream entry (#1594; a gem locked from a remote other than rubygems.org with no `CHECKSUMS` sha is not judged): a restored gem's `-.gem` is still in Bundler's cache dir (`cache_path`, default `vendor/cache`, resolved as for the Gem stale-install guard) and its sha256 is not the upstream one (the restored `CHECKSUMS` entry, else the rubygems.org compact index), or it could not be checked (`--offline`, a registry error). Bundler installs from that dir first, so a `bundle cache` taken while the hosted pin was live makes every later install fail on the upstream checksum (exit 37) or, on bundler < 2.6 frozen installs, keep installing the patched bytes. The detail names the file. Remedy: delete it, then run `bundle cache` to cache the upstream gem in its place (or `bundle install` if the project does not commit its cache; with the cache dir committed, a frozen install reads only the cache). Read-only: the restore never deletes it. Not raised for an archive whose sha256 matches upstream. The takeover's copy adds that the vendored copy is installed instead while the gem stays vendored, and that the vendored unwind names the archive again. | +| `upstream_uv_entry_rebuilt` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore: every registry package of a `uv.lock` / script lock was hosted, so no other entry showed the registry or the artifact fields uv records, and the restored entries were rebuilt from PyPI on the default index with the fields uv writes at the lock's `revision` (see "Hosted unwind coverage"). The detail names the lock, the entries, the fields and the check to run (`uv lock --check`, for a script lock `uv lock --check --script