From 278a3879bac0ff9d3014b95d6fc92d20cf5c80a8 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 13:28:04 -0400 Subject: [PATCH 01/44] refactor(core): consolidate guarded file reads and drop redundant stats - manifest/operations.rs: parse the manifest straight from the string (serde_json::from_str) instead of building an intermediate Value and cloning it into PatchManifest; classify errors with is_data() so the two historical message prefixes ("Invalid manifest" / "Failed to parse manifest JSON") are preserved. read_manifest now goes through the shared FIFO-guarded utils::fs::read_regular_to_string helper. - utils/fs.rs: entry_is_dir uses the DirEntry's cached file type and only stats the resolved path for symlinks, removing one stat per entry on every crawler directory walk while keeping the documented follow-symlinks contract. - utils/socket_cli_config.rs: delete the private read_regular_file twin in favour of utils::fs::read_regular_to_bytes_sync. Full workspace suite green (239 suites, 7317 passed, 0 failed). Co-Authored-By: Claude Fable 5.1 --- .../src/manifest/operations.rs | 59 ++++++------------- crates/socket-patch-core/src/utils/fs.rs | 11 +++- .../src/utils/socket_cli_config.rs | 34 +---------- 3 files changed, 28 insertions(+), 76 deletions(-) diff --git a/crates/socket-patch-core/src/manifest/operations.rs b/crates/socket-patch-core/src/manifest/operations.rs index 87aaee42..0de2a4fe 100644 --- a/crates/socket-patch-core/src/manifest/operations.rs +++ b/crates/socket-patch-core/src/manifest/operations.rs @@ -38,11 +38,16 @@ pub fn get_before_hash_blobs(manifest: &PatchManifest) -> HashSet { blobs } -/// Validate a parsed JSON value as a PatchManifest. -/// Returns Ok(manifest) if valid, or Err(message) if invalid. -fn validate_manifest(value: &serde_json::Value) -> Result { - serde_json::from_value::(value.clone()) - .map_err(|e| format!("Invalid manifest: {}", e)) +/// Parse and validate a manifest directly, without an intermediate JSON tree. +fn parse_manifest(content: &str) -> Result { + serde_json::from_str(content).map_err(|e| { + let context = if e.is_data() { + "Invalid manifest" + } else { + "Failed to parse manifest JSON" + }; + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{context}: {e}")) + }) } /// Read and parse a manifest from the filesystem. @@ -51,38 +56,12 @@ fn validate_manifest(value: &serde_json::Value) -> Result pub async fn read_manifest( path: impl AsRef, ) -> Result, std::io::Error> { - let path = path.as_ref(); - - // Guarded open: a plain `read_to_string` open(2)s a FIFO squatting the - // manifest path with `O_RDONLY` and waits forever for a writer, wedging - // every manifest consumer (`apply` runs from install hooks, so this - // hangs `npm install` with no output). Non-regular files fail fast with - // `InvalidInput` instead; a missing file keeps mapping to `Ok(None)`. - let (mut file, metadata) = match crate::utils::fs::open_regular_file(path).await { - Ok(pair) => pair, + let content = match crate::utils::fs::read_regular_to_string(path.as_ref()).await { + Ok(content) => content, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(e), }; - let mut content = String::with_capacity(metadata.len() as usize); - { - use tokio::io::AsyncReadExt; - file.read_to_string(&mut content).await?; - } - - let parsed: serde_json::Value = match serde_json::from_str(&content) { - Ok(v) => v, - Err(e) => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("Failed to parse manifest JSON: {}", e), - )) - } - }; - - match validate_manifest(&parsed) { - Ok(manifest) => Ok(Some(manifest)), - Err(e) => Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)), - } + parse_manifest(&content).map(Some) } /// Write a manifest to the filesystem with pretty-printed JSON. @@ -256,7 +235,7 @@ mod tests { } #[test] - fn test_validate_manifest_valid() { + fn test_parse_manifest_valid() { let json = serde_json::json!({ "patches": { "pkg:npm/test@1.0.0": { @@ -271,24 +250,24 @@ mod tests { } }); - let result = validate_manifest(&json); + let result = parse_manifest(&json.to_string()); assert!(result.is_ok()); let manifest = result.unwrap(); assert_eq!(manifest.patches.len(), 1); } #[test] - fn test_validate_manifest_invalid() { + fn test_parse_manifest_invalid() { let json = serde_json::json!({ "patches": "not-an-object" }); - let result = validate_manifest(&json); + let result = parse_manifest(&json.to_string()); assert!(result.is_err()); } #[test] - fn test_validate_manifest_missing_fields() { + fn test_parse_manifest_missing_fields() { let json = serde_json::json!({ "patches": { "pkg:npm/test@1.0.0": { @@ -297,7 +276,7 @@ mod tests { } }); - let result = validate_manifest(&json); + let result = parse_manifest(&json.to_string()); assert!(result.is_err()); } diff --git a/crates/socket-patch-core/src/utils/fs.rs b/crates/socket-patch-core/src/utils/fs.rs index 580a62c7..b92f9f0c 100644 --- a/crates/socket-patch-core/src/utils/fs.rs +++ b/crates/socket-patch-core/src/utils/fs.rs @@ -63,10 +63,15 @@ pub(crate) async fn list_dir_entries(path: &Path) -> Vec { /// like `symlink_metadata`), so a symlink pointing at a directory /// would wrongly report `false`. To honor the documented /// symlink-following contract — which crawlers like deno/python/ruby -/// rely on for symlinked package directories — we stat the resolved -/// `entry.path()` via [`is_dir`], which does follow links. +/// rely on for symlinked package directories — symlinks are resolved through +/// [`is_dir`]. Ordinary entries use their cached file type, avoiding an extra +/// stat for every directory visited by a crawler. pub(crate) async fn entry_is_dir(entry: &DirEntry) -> bool { - is_dir(&entry.path()).await + match entry.file_type().await { + Ok(kind) if kind.is_symlink() => is_dir(&entry.path()).await, + Ok(kind) => kind.is_dir(), + Err(_) => false, + } } /// Check whether `path` is a directory, following symlinks. diff --git a/crates/socket-patch-core/src/utils/socket_cli_config.rs b/crates/socket-patch-core/src/utils/socket_cli_config.rs index f939f966..c4fe0116 100644 --- a/crates/socket-patch-core/src/utils/socket_cli_config.rs +++ b/crates/socket-patch-core/src/utils/socket_cli_config.rs @@ -164,38 +164,6 @@ fn parse_config_bytes(raw: &[u8]) -> Result { }) } -/// Read the config bytes, requiring a regular file — the sync twin of -/// [`open_regular_file`](crate::utils::fs::open_regular_file). `load()` runs -/// synchronously during API-client construction, and a plain `open(2)` of a -/// FIFO planted at the config path waits forever for a writer, wedging every -/// networked command before it can do any work. `O_NONBLOCK` makes the open -/// return immediately (it has no effect on regular-file reads); the -/// handle-based `is_file` check then rejects FIFOs/devices/directories so -/// the caller warns and treats the file as absent. -fn read_regular_file(path: &std::path::Path) -> std::io::Result> { - use std::io::Read; - #[cfg(unix)] - let mut file = { - use std::os::unix::fs::OpenOptionsExt; - std::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NONBLOCK) - .open(path)? - }; - #[cfg(not(unix))] - let mut file = std::fs::File::open(path)?; - let metadata = file.metadata()?; - if !metadata.is_file() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("{} is not a regular file", path.display()), - )); - } - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut bytes)?; - Ok(bytes) -} - /// Read the config from disk: the first candidate whose file exists wins. /// `None` covers every failure path; a present-but-unusable file warns and /// stops the probe — falling through to a stale lower-priority file would @@ -203,7 +171,7 @@ fn read_regular_file(path: &std::path::Path) -> std::io::Result> { /// fires once per process.) fn read_from_disk() -> Option { for path in config_json_paths() { - let raw = match read_regular_file(&path) { + let raw = match crate::utils::fs::read_regular_to_bytes_sync(&path) { Ok(raw) => raw, Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, Err(e) => { From 225bcd4362ab2e3f001935d4cf15d5e809779594 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 18:56:12 -0400 Subject: [PATCH 02/44] fix(lock): apply.lock never outlives the command that holds it (D1) acquire() now owns the whole lock lifecycle: it creates a missing .socket/ itself (idempotently, inside the retry loop), opens-or-creates apply.lock, takes the OS lock, and verifies via a same_file::Handle that the handle it locked is still the file the path names (dev+ino / volume+index); a mismatch or missing file is an orphan left by a releaser and is retried, never honored. LockGuard::drop unlinks apply.lock WHILE still holding the lock, closes the handle, then best-effort remove_dir()s an otherwise-empty .socket/ (gated to a dir literally named .socket so --manifest-path user dirs are never deleted). A failed acquire prunes the empty dir it may have created. Transient outcomes of racing a releaser's cleanup are retried on their own bounds instead of surfacing as lock_io: open/mkdir failures while the parent is gone (ENOENT, and macOS EINVAL on O_CREAT in a just-rmdir'd dir), std create_dir_all's AlreadyExists-for-a-vanished-dir TOCTOU, and Windows delete-pending codes 5/32/303 (5 ms x 40 grace, independent of --lock-timeout). Waiters drop the File before every backoff sleep so they never prolong a delete-pending window. Only fs2 contention consumes the deadline. lock_cli: acquire_or_emit no longer fails on a missing .socket/; new pub(crate) lock_failure() is the single LockError -> (errorCode, message) rendering for other lock sites to adopt. Tests pin the new protocol: file present while held / gone after drop, empty .socket/ pruned, non-empty left alone, non-.socket dirs kept, squatting file -> Io, orphaned-inode holder never blocks, two-thread acquire/release hammer never double-holds and leaves no residue, waiter-vs-fresh acquire (Ok, Ok) is now a hard failure. e2e_safety_lock asserts apply.lock is gone after every apply run and the manifest survives; apply_invariants' snapshot no longer excludes apply.lock. Adds same-file = "=1.0.6" as a direct core dependency (already in Cargo.lock via walkdir). Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 1 + Cargo.toml | 1 + .../socket-patch-cli/src/commands/lock_cli.rs | 157 +++- .../tests/apply_invariants.rs | 12 +- .../socket-patch-cli/tests/cli_parse_main.rs | 6 +- .../socket-patch-cli/tests/e2e_safety_lock.rs | 82 +- crates/socket-patch-core/Cargo.toml | 1 + .../socket-patch-core/src/patch/apply_lock.rs | 815 +++++++++++++----- 8 files changed, 774 insertions(+), 301 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e790bce2..bfe94398 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1723,6 +1723,7 @@ dependencies = [ "qbsdiff", "regex", "reqwest", + "same-file", "self-replace", "semver", "serde", diff --git a/Cargo.toml b/Cargo.toml index bae07947..a94d6051 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ tar = "=0.4.46" flate2 = "=1.1.9" zip = { version = "=8.6.0", default-features = false, features = ["deflate"] } fs2 = "=0.4.3" +same-file = "=1.0.6" libc = "=0.2.182" semver = "=1.0.27" self-replace = "=1.5.0" diff --git a/crates/socket-patch-cli/src/commands/lock_cli.rs b/crates/socket-patch-cli/src/commands/lock_cli.rs index 8354c4e1..33e9d6bd 100644 --- a/crates/socket-patch-cli/src/commands/lock_cli.rs +++ b/crates/socket-patch-cli/src/commands/lock_cli.rs @@ -31,15 +31,15 @@ use crate::json_envelope::{Command, Envelope, EnvelopeError}; /// try-once shape. Positive values wait with a 100 ms backoff — /// see `socket_patch_core::patch::apply_lock::acquire`. /// -/// A leftover `apply.lock` from a crashed run never contends: the -/// kernel released the dead holder's advisory lock along with its -/// file handle, so the acquire reclaims the file in place. `Held` -/// therefore always means a *live* process. The file is never -/// unlinked here — an unlink defeats mutual exclusion, because a -/// competitor (live holder or mid-acquire racer) can keep or take an -/// advisory lock on the orphaned inode while a fresh acquire locks -/// its replacement. The only sanctioned deletion is `repair`'s final -/// cleanup, which runs after its own guard is released. +/// The lock's whole lifecycle lives in the core guard: `acquire` +/// creates a missing `.socket/` itself, and the returned guard's drop +/// unlinks `apply.lock` while still holding the lock, releases it, and +/// prunes an otherwise-empty `.socket/` — so no command leaves a lock +/// file (or a bare `.socket/`) behind, and this wrapper never has to +/// touch the file. A leftover from a crashed run never contends: the +/// kernel released the dead holder's advisory lock along with its file +/// handle, so the acquire reclaims the file in place and removes it on +/// exit. `Held` therefore always means a *live* process. pub(crate) fn acquire_or_emit( socket_dir: &Path, command: Command, @@ -49,25 +49,34 @@ pub(crate) fn acquire_or_emit( ) -> Result { match acquire(socket_dir, timeout) { Ok(guard) => Ok(guard), - Err(LockError::Held) => { - emit( - command, - json, - dry_run, - "lock_held", - &held_message(timeout), - Hint::Wait, - ); - Err(1) - } - Err(LockError::Io { path, source }) => { - let msg = format!("failed to open lock file at {}: {}", path.display(), source); - emit(command, json, dry_run, "lock_io", &msg, Hint::None); + Err(err) => { + let hint = match err { + LockError::Held => Hint::Wait, + LockError::Io { .. } => Hint::None, + }; + let (code, message) = lock_failure(&err, timeout); + emit(command, json, dry_run, code, &message, hint); Err(1) } } } +/// The one `LockError` → (`errorCode`, message) mapping every lock +/// site renders: `Held` → `lock_held` with the wait budget spelled out +/// by [`held_message`], `Io` → `lock_io` naming the path and the OS +/// error. Callers that build their own envelope (the scan/vendor step, +/// GC, hosted) use this rather than re-deriving the strings, so the +/// contention text and the waited clause cannot drift between commands. +pub(crate) fn lock_failure(err: &LockError, timeout: Duration) -> (&'static str, String) { + match err { + LockError::Held => ("lock_held", held_message(timeout)), + LockError::Io { path, source } => ( + "lock_io", + format!("failed to open lock file at {}: {}", path.display(), source), + ), + } +} + /// Human-readable description of a `lock_held` contention for the given /// wait budget. A zero budget means the historical non-blocking /// try-once, so we omit the "(waited …)" clause entirely. @@ -165,17 +174,33 @@ mod tests { assert_eq!(code, 1); } + /// A missing `.socket/` is not an error: `acquire` creates it, and + /// the guard's drop removes the lock file and the now-empty + /// directory again, so a lock-only run leaves no trace. #[test] - fn acquire_or_emit_returns_one_when_socket_dir_missing() { + fn acquire_or_emit_creates_missing_socket_dir_and_prunes_it_on_drop() { let dir = tempfile::tempdir().unwrap(); - let code = acquire_or_emit( - &dir.path().join("nope"), - Command::Apply, - false, - false, - Duration::ZERO, - ) - .unwrap_err(); + let socket = dir.path().join(".socket"); + assert!(!socket.exists()); + + let guard = acquire_or_emit(&socket, Command::Apply, false, false, Duration::ZERO).unwrap(); + assert!(socket.join("apply.lock").is_file()); + + drop(guard); + assert!(!socket.join("apply.lock").exists()); + assert!(!socket.exists(), "empty .socket/ must be pruned on release"); + } + + /// A file squatting where `.socket/` should be still surfaces as + /// `lock_io` / exit 1 — the acquire-mkdirs change did not turn + /// genuine faults into silent successes. + #[test] + fn acquire_or_emit_returns_one_when_socket_dir_is_a_file() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join(".socket"); + std::fs::write(&socket, b"squatter").unwrap(); + let code = + acquire_or_emit(&socket, Command::Apply, false, false, Duration::ZERO).unwrap_err(); assert_eq!(code, 1); } @@ -208,27 +233,32 @@ mod tests { /// A leftover lock file from a crashed run never contends — the /// kernel released the dead holder's advisory lock along with its - /// file handle, so a plain acquire reclaims the file in place. - /// This is the fact that made `--break-lock` redundant (and, with - /// it, the `unlock` subcommand): there is no stale-lock state a - /// user ever needs to clear before running a mutating command. + /// file handle, so a plain acquire reclaims the file in place and + /// the guard's drop removes it. This is the fact that made + /// `--break-lock` redundant (and, with it, the `unlock` + /// subcommand): there is no stale-lock state a user ever needs to + /// clear before running a mutating command. #[test] fn acquire_or_emit_reclaims_stale_leftover_file() { let dir = tempfile::tempdir().unwrap(); // Pre-stage a lock file with no holder — simulates the // post-crash leftover scenario. - std::fs::write(dir.path().join("apply.lock"), b"").unwrap(); + std::fs::write(dir.path().join("apply.lock"), b"leftover").unwrap(); let guard = acquire_or_emit(dir.path(), Command::Apply, false, false, Duration::ZERO).unwrap(); - // The file persists (never unlinked here) and we hold the lock: - // a competitor's acquire is contended while the guard is live. + // The reclaimed file is the live lock while the guard is held: a + // competitor's acquire is contended. assert!(dir.path().join("apply.lock").is_file()); assert!(matches!( acquire(dir.path(), Duration::ZERO), Err(LockError::Held) )); drop(guard); + assert!( + !dir.path().join("apply.lock").exists(), + "the reclaimed leftover is removed on release" + ); } /// Regression guard carried over from the `--break-lock` era: the @@ -238,22 +268,26 @@ mod tests { /// re-acquired: a competitor that flocked (or had merely *opened*) /// the file before the unlink kept a valid lock on the orphaned /// inode while the re-acquire locked a fresh one — two live holders - /// at once. `acquire_or_emit` never unlinks: the acquire's guard is - /// the lock. + /// at once. Today every guard drop unlinks the file, so this is the + /// live stress test of the core protocol that makes that safe: + /// unlink WHILE holding the lock, and re-check the locked handle's + /// identity against the path after every successful lock. /// /// The competitor thread increments a shared holder count only /// while it genuinely holds the OS lock, as does the main thread /// for the guard `acquire_or_emit` hands back. With real mutual /// exclusion the count can never exceed 1, so the test is /// deterministic-green on correct code; under a buggy unlink window - /// the hammer lands in the gap within a handful of iterations. + /// the hammer lands in the gap within a handful of iterations. The + /// lock dir is a `.socket/` so every release also prunes the + /// directory and every acquire recreates it. #[test] fn acquire_or_emit_preserves_mutual_exclusion() { use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; let dir = tempfile::tempdir().unwrap(); - let lock_dir = dir.path().to_path_buf(); + let lock_dir = dir.path().join(".socket"); let holders = Arc::new(AtomicUsize::new(0)); let violated = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false)); @@ -301,8 +335,41 @@ mod tests { assert!( !violated.load(Ordering::SeqCst), - "two processes held the apply lock at once: \ - the lock file must never be unlinked by the acquire path" + "two processes held the apply lock at once: the acquire path must never \ + unlink, and a release must not orphan a competitor's open handle \ + (unlink under the lock + post-lock identity check)" + ); + assert!( + !lock_dir.join("apply.lock").exists() && !lock_dir.exists(), + "no lock residue may outlive the last holder" + ); + } + + /// `lock_failure` is the single rendering every lock site shares: + /// `Held` carries the waited clause, `Io` names the path and the OS + /// error under `lock_io`. + #[test] + fn lock_failure_maps_both_variants() { + assert_eq!( + lock_failure(&LockError::Held, Duration::from_secs(2)), + ( + "lock_held", + "another socket-patch process is operating in this directory (waited 2s)" + .to_string() + ) + ); + let io = LockError::Io { + path: std::path::PathBuf::from(".socket/apply.lock"), + source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"), + }; + let (code, message) = lock_failure(&io, Duration::ZERO); + assert_eq!(code, "lock_io"); + assert_eq!( + message, + format!( + "failed to open lock file at {}: denied", + std::path::Path::new(".socket/apply.lock").display() + ) ); } diff --git a/crates/socket-patch-cli/tests/apply_invariants.rs b/crates/socket-patch-cli/tests/apply_invariants.rs index eea408d3..d1c36a1e 100644 --- a/crates/socket-patch-cli/tests/apply_invariants.rs +++ b/crates/socket-patch-cli/tests/apply_invariants.rs @@ -72,17 +72,13 @@ fn write_project(root: &Path) { /// each file's relative path and bytes into a single SHA-256 so any /// change — adding, removing, or rewriting a file — flips the digest. /// -/// Excludes `apply.lock` (advisory lock file created by `apply` / -/// `rollback` / `repair` / `remove`). That file is deliberate -/// ephemeral session state — not patch content — and persists by -/// design so subsequent runs can re-flock the same inode without a -/// create race. The "apply is read-only against .socket/" invariant -/// is about the patch payload (manifest, blobs, diffs, packages), -/// not session metadata. +/// Nothing is excluded: the advisory `apply.lock` that `apply` takes +/// exists only while the command holds it (the guard unlinks it on +/// exit), so a snapshot taken after the run must not see it either. A +/// lock file surviving a run is itself a regression this hash catches. fn dir_hash(dir: &Path) -> String { let mut files: Vec<(PathBuf, Vec)> = Vec::new(); collect_files(dir, dir, &mut files); - files.retain(|(rel, _)| rel.file_name().and_then(|n| n.to_str()) != Some("apply.lock")); files.sort_by(|a, b| a.0.cmp(&b.0)); let mut hasher = Sha256::new(); for (rel, bytes) in files { diff --git a/crates/socket-patch-cli/tests/cli_parse_main.rs b/crates/socket-patch-cli/tests/cli_parse_main.rs index d4b2b81d..f0a3b1d4 100644 --- a/crates/socket-patch-cli/tests/cli_parse_main.rs +++ b/crates/socket-patch-cli/tests/cli_parse_main.rs @@ -168,8 +168,10 @@ fn repair_subcommand_parses() { #[test] fn unlock_subcommand_is_removed() { - // BREAKING (4.0): the `unlock` subcommand was folded into `repair` - // (which now deletes the leftover `apply.lock` after finishing). + // BREAKING (4.0): the `unlock` subcommand was removed. A leftover + // lock never blocks acquisition (the OS releases a dead holder's + // advisory lock) and every mutating command now unlinks its own + // `apply.lock` on exit, so there is no stale-lock state to clear. // Pin the removal so the name can't quietly come back half-wired. let err = expect_err(parse(&["socket-patch", "unlock"])); assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand); diff --git a/crates/socket-patch-cli/tests/e2e_safety_lock.rs b/crates/socket-patch-cli/tests/e2e_safety_lock.rs index b89ee558..37cfb3ba 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_lock.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_lock.rs @@ -237,14 +237,33 @@ fn lock_released_after_external_drop() { code, 1, "partialFailure against an absent package exits 1.\nstderr:\n{stderr}" ); + // The binary reclaimed the file the test created and removed it on + // exit — the lock never outlives the command that held it. + assert_no_lock_residue(&socket_dir); } -/// The lock file is intentionally not deleted on guard drop — -/// keeping the inode lets subsequent apply runs re-flock without a -/// create race. Verify the file is still there after a successful -/// apply, and that re-acquiring still works. +/// Every run that takes the lock removes `apply.lock` on exit, while +/// leaving the real `.socket/` state (here: the manifest) alone. The +/// guard unlinks the file under the lock and prunes only an EMPTY +/// `.socket/`, so a project with a manifest keeps its directory. +fn assert_no_lock_residue(socket_dir: &Path) { + assert!( + !socket_dir.join("apply.lock").exists(), + "apply.lock must not outlive the run that held it" + ); + assert!( + socket_dir.join("manifest.json").is_file(), + "the manifest is real state and must survive a lock release" + ); +} + +/// The lock file exists only while a command holds it: each run creates +/// `apply.lock` on demand, unlinks it (under the lock) on exit, and the +/// next run creates it afresh. Verify the file is gone after each +/// completed apply, that the manifest survives, and that re-acquiring +/// still works. #[test] -fn lock_file_persists_across_runs() { +fn lock_file_is_removed_after_each_run() { let dir = tempfile::tempdir().unwrap(); let socket_dir = dir.path().join(".socket"); setup_socket_dir(&socket_dir); @@ -256,27 +275,17 @@ fn lock_file_persists_across_runs() { "apply.lock must not exist before the first run" ); - // First run: must acquire (not lock_held) and create the file. + // First run: must acquire (not lock_held), create the file, and + // remove it again on exit. let (_code1, stdout1, _stderr1) = run(dir.path(), &["apply", "--json"]); assert_lock_acquired(&parse_json_envelope(&stdout1)); + assert_no_lock_residue(&socket_dir); - // Lock file should persist after the run completes (inode kept so - // subsequent acquires don't race on create). - assert!( - socket_dir.join("apply.lock").is_file(), - "apply.lock should persist between runs" - ); - - // Second run must still be able to acquire (file exists, but no - // one holds the OS lock) — full envelope check, not a substring. + // Second run recreates the file, acquires, and removes it again — + // full envelope check, not a substring. let (_code2, stdout2, _stderr2) = run(dir.path(), &["apply", "--json"]); assert_lock_acquired(&parse_json_envelope(&stdout2)); - - // And the file is still there afterwards. - assert!( - socket_dir.join("apply.lock").is_file(), - "apply.lock should still persist after the second run" - ); + assert_no_lock_residue(&socket_dir); } /// Multiple real `socket-patch apply` subprocesses contending for the @@ -329,10 +338,13 @@ fn two_apply_subprocesses_serialize() { assert_eq!(json_string(&env, "status"), Some("error")); } - // Release and re-run — must now succeed in acquiring. + // Release and re-run — must now succeed in acquiring. The refused + // children never held a guard, so none of them unlinked the file the + // test created; the run that finally acquires it removes it. drop(external); let (_code2, stdout2, _) = run(dir.path(), &["apply", "--json"]); assert_lock_acquired(&parse_json_envelope(&stdout2)); + assert_no_lock_residue(&socket_dir); } /// Sanity check that doesn't actually depend on the binary: confirm @@ -361,18 +373,21 @@ fn helper_lock_is_actually_exclusive() { } /// `apply` against a pre-staged lock file (no live holder) reclaims -/// the file in place and proceeds with the apply pass — no flag -/// needed. Mirrors the OS-level scenario: a previous run crashed and -/// left `apply.lock` behind, but the kernel released the dead -/// holder's flock, so a fresh acquire sails through. This fact is -/// what made `--break-lock` (and the `unlock` subcommand) redundant. +/// the file in place, proceeds with the apply pass, and removes the +/// file on exit — no flag needed. Mirrors the OS-level scenario: a +/// previous run crashed and left `apply.lock` behind, but the kernel +/// released the dead holder's flock, so a fresh acquire sails through. +/// This fact is what made `--break-lock` (and the `unlock` subcommand) +/// redundant, and the exit-time unlink means the leftover does not +/// even survive the next run. #[test] -fn stale_lock_file_does_not_block_apply() { +fn stale_lock_file_is_reclaimed_then_removed() { let dir = tempfile::tempdir().unwrap(); let socket_dir = dir.path().join(".socket"); setup_socket_dir(&socket_dir); - // Pre-stage a lock file but DON'T hold an OS lock. - std::fs::write(socket_dir.join("apply.lock"), b"").unwrap(); + // Pre-stage a lock file but DON'T hold an OS lock. Non-empty bytes + // so the reclaim is visibly "this file", not a fresh create. + std::fs::write(socket_dir.join("apply.lock"), b"leftover").unwrap(); let (code, stdout, stderr) = run(dir.path(), &["apply", "--json"]); let env = parse_json_envelope(&stdout); @@ -386,11 +401,8 @@ fn stale_lock_file_does_not_block_apply() { code, 1, "apply that ran the pipeline to partialFailure must exit 1.\nstderr:\n{stderr}" ); - // The inode is kept for subsequent acquires. - assert!( - socket_dir.join("apply.lock").is_file(), - "apply.lock should still exist after the run" - ); + // The reclaimed leftover is gone; the manifest is untouched. + assert_no_lock_residue(&socket_dir); } /// `apply --lock-timeout=1` against a held lock waits up to 1s diff --git a/crates/socket-patch-core/Cargo.toml b/crates/socket-patch-core/Cargo.toml index 2a1d5d4c..26b97ca4 100644 --- a/crates/socket-patch-core/Cargo.toml +++ b/crates/socket-patch-core/Cargo.toml @@ -25,6 +25,7 @@ qbsdiff = { workspace = true } tar = { workspace = true } flate2 = { workspace = true } fs2 = { workspace = true } +same-file = { workspace = true } tempfile = { workspace = true } zip = { workspace = true } base64 = { workspace = true } diff --git a/crates/socket-patch-core/src/patch/apply_lock.rs b/crates/socket-patch-core/src/patch/apply_lock.rs index abd19f6f..3216b3dd 100644 --- a/crates/socket-patch-core/src/patch/apply_lock.rs +++ b/crates/socket-patch-core/src/patch/apply_lock.rs @@ -1,44 +1,87 @@ //! Advisory file lock used to serialize mutating operations against a //! single `.socket/` directory. //! -//! Apply, rollback, repair, and remove can each rewrite manifest state -//! and on-disk package files. Two of them running at once against the -//! same project — common when a dev runs `socket-patch apply` while CI -//! triggers a deploy hook, or when `apply` and a `repair` are stacked -//! by a wrapper script — race on every file write. The lock turns -//! that race into a clean refusal: the second invocation reports -//! `lock_held` and exits non-zero, leaving the first to finish. +//! Apply, rollback, repair, remove, vendor and the hosted/vendored scan +//! flows can each rewrite manifest state and on-disk package files. Two +//! of them running at once against the same project — common when a dev +//! runs `socket-patch apply` while CI triggers a deploy hook, or when +//! `apply` and a `repair` are stacked by a wrapper script — race on +//! every file write. The lock turns that race into a clean refusal: the +//! second invocation reports `lock_held` and exits non-zero, leaving the +//! first to finish. //! -//! The lock file lives at `<.socket>/apply.lock`. It is created on -//! demand (the parent `.socket/` directory must exist first; callers -//! get a clear error otherwise) and is retained by the mutating -//! commands across runs — the file handle drop releases the OS-level -//! advisory lock, but the inode sticks around for next time. That -//! keeps the lock idempotent across restarts and avoids a race where -//! two callers create the lock file at the same time. Callers must -//! never unlink a lock they hold (or one a live process might hold): -//! a competitor keeping or taking an advisory lock on the orphaned -//! inode while a fresh acquire locks its replacement defeats mutual -//! exclusion. The one sanctioned deletion is `socket-patch repair`, -//! which removes the leftover file as its final housekeeping step — -//! after releasing its own guard — so a finished repair leaves a -//! clean `.socket/` tree. A leftover file from a crashed run needs no -//! removal to unblock anything: the kernel released the dead -//! process's advisory lock with its file handle, so the next acquire -//! reclaims the file in place. +//! # Lifecycle //! -//! Locking is advisory (`flock(2)` on Unix, `LockFileEx` on Windows -//! via the `fs2` crate). Non-cooperating writers (a user shelling -//! `rm -rf .socket/`) are not stopped — but every socket-patch -//! mutating command honors the lock, which is what matters in -//! practice. +//! The lock file lives at `<.socket>/apply.lock` and exists only while a +//! command holds the lock: +//! +//! * [`acquire`] creates `socket_dir` itself (idempotently, inside the +//! retry loop), opens-or-creates `apply.lock`, takes the OS lock, and +//! then verifies that the handle it locked is still the file the path +//! names ([`same_file::Handle`] identity: device + inode on Unix, +//! volume serial + file index on Windows). A mismatch means a releaser +//! unlinked the file between our open and our lock, so the handle is +//! an orphan: we drop it and retry against whatever the path names now. +//! * [`LockGuard`]'s drop unlinks `apply.lock` WHILE STILL HOLDING the +//! lock, then closes the handle (releasing the lock), then best-effort +//! removes an otherwise-empty `.socket/`. Unlinking under the lock is +//! what makes the unlink safe: any waiter that already opened this +//! inode fails the identity check once it finally locks it, instead of +//! becoming a second live holder alongside whoever locked the +//! replacement file. +//! +//! So no command leaves `apply.lock` behind, and a project that had no +//! `.socket/` before a run has none after it unless the run wrote real +//! state there. A leftover file from a crashed run needs no removal to +//! unblock anything — the kernel released the dead process's advisory +//! lock with its file handle — so the next acquire reclaims it in place +//! and removes it on exit. `Held` therefore always means a live process. +//! +//! # Windows +//! +//! `DeleteFile` on an open file succeeds, but the name stays +//! delete-pending until the last handle closes, and every open of that +//! name in the meantime fails with `ERROR_ACCESS_DENIED` (5), +//! `ERROR_SHARING_VIOLATION` (32) or `ERROR_DELETE_PENDING` (303). Two +//! measures keep that window short and harmless: a waiter never holds +//! the file open across its backoff sleep, and those three codes get a +//! short fixed grace (5 ms × 40, independent of the caller's timeout) +//! before they surface as `Io`. std's default share mode already +//! includes `FILE_SHARE_DELETE`, so a holder can unlink the file while +//! waiters have it open. +//! +//! Locking is advisory (`flock(2)` on Unix, `LockFileEx` on Windows via +//! the `fs2` crate). Non-cooperating writers (a user shelling +//! `rm -rf .socket/`) are not stopped — but every socket-patch mutating +//! command honors the lock, which is what matters in practice. +use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use fs2::FileExt; +use same_file::Handle; use thiserror::Error; +const LOCK_FILE_NAME: &str = "apply.lock"; +const SOCKET_DIR_NAME: &str = ".socket"; + +/// Longest single backoff sleep while waiting on a live holder. +const BACKOFF_CAP: Duration = Duration::from_millis(100); + +/// Consecutive "the file vanished under us" retries (open `NotFound`, or +/// a post-lock identity mismatch) before giving up with `Io`. Each one +/// is a releaser pruning `.socket/` between two of our steps; they cost +/// no sleep and are not contention, so they are bounded by count rather +/// than by `timeout`. +const VANISHED_LIMIT: u32 = 16; + +/// Windows delete-pending grace: `attempts × sleep`, independent of +/// `timeout` (a zero-timeout try-once still waits it out, because the +/// name is free, just not reusable yet). +const DELETE_PENDING_ATTEMPTS: u32 = 40; +const DELETE_PENDING_SLEEP: Duration = Duration::from_millis(5); + /// Errors surfaced when acquiring the apply lock. #[derive(Debug, Error)] pub enum LockError { @@ -47,8 +90,11 @@ pub enum LockError { #[error("another socket-patch process is operating in this directory")] Held, - /// We could not create or open the lock file (typically a missing - /// `.socket/` directory or a permissions problem). + /// We could not create `socket_dir`, or could not open or lock the + /// lock file (a file squatting on `.socket/`, a directory squatting + /// on `apply.lock`, a permissions problem, a filesystem without + /// advisory locks, …). `path` is the directory for a `create_dir` + /// failure and the lock file otherwise. #[error("failed to open lock file at {path:?}: {source}")] Io { path: PathBuf, @@ -59,30 +105,79 @@ pub enum LockError { /// RAII guard for the apply lock. /// -/// Drop releases the OS-level advisory lock. There is no explicit -/// `unlock()` API on purpose — Rust's drop guarantees are simpler to -/// reason about than a `?`-fallible unlock path. +/// Drop unlinks `apply.lock` while still holding the lock, releases the +/// OS-level advisory lock by closing the handle, and then best-effort +/// removes an otherwise-empty `.socket/` (see the module doc). There is +/// no fallible `unlock()` API on purpose — Rust's drop guarantees are +/// simpler to reason about than a `?`-fallible unlock path; [`release`] +/// exists only to name an early drop. +/// +/// [`release`]: LockGuard::release #[derive(Debug)] #[must_use = "the lock is released when this guard is dropped"] pub struct LockGuard { - // The std::fs::File holds the OS handle whose drop releases the - // lock; we keep it alive for the guard's lifetime. Field is unused - // by name but its Drop side effect is the entire point. - _file: std::fs::File, + // `Some` for the guard's whole life. `Drop` clears it so the handle + // closes (releasing the lock) BETWEEN unlinking the file and pruning + // the directory: Windows only lets go of the name once the last + // handle is gone, so the rmdir has to come after the close. + handle: Option, + path: PathBuf, + socket_dir: PathBuf, +} + +impl LockGuard { + /// Release the lock now — unlink, close, prune — instead of at the + /// end of the guard's scope. + pub fn release(self) { + drop(self); + } +} + +impl Drop for LockGuard { + fn drop(&mut self) { + // R1: unlink while still holding the lock. `NotFound` (a + // non-cooperating `rm`) and every other error are ignored: a + // leftover file is harmless and reclaimed by the next acquire. + let _ = std::fs::remove_file(&self.path); + // R2: close the handle; the OS releases the advisory lock. + self.handle = None; + // R3: prune an otherwise-empty `.socket/`. Non-recursive, so it + // fails harmlessly when anything else lives there — including a + // concurrent acquirer's freshly created `apply.lock`. + prune_empty_socket_dir(&self.socket_dir); + } +} + +/// Best-effort `remove_dir` of `socket_dir`, gated to a directory +/// literally named `.socket`: `--manifest-path` can point the lock at an +/// arbitrary user directory, and the lock must never delete one of those +/// just because it happened to be empty. +fn prune_empty_socket_dir(socket_dir: &Path) { + if socket_dir + .file_name() + .is_some_and(|name| name == SOCKET_DIR_NAME) + { + let _ = std::fs::remove_dir(socket_dir); + } } /// Try to acquire the apply lock at `/apply.lock`. /// /// `timeout = Duration::ZERO` makes this a non-blocking try-once. Any /// positive `timeout` re-tries with a 100 ms backoff until the lock -/// becomes available or the budget elapses. +/// becomes available or the budget elapses. Only genuine contention (a +/// live holder) consumes the budget; the transient outcomes of racing a +/// releaser's cleanup — the directory or file vanishing between two of +/// our steps, Windows delete-pending opens — are retried on their own +/// small fixed bounds so a zero-timeout caller still gets its one honest +/// attempt. /// -/// The lock file is created on demand. Its parent (`socket_dir`) must -/// already exist — apply and friends create `.socket/` separately -/// during `setup`, and we don't want lock acquisition to silently -/// create directories on a misconfigured path. +/// `socket_dir` is created on demand (idempotently, inside the retry +/// loop, because a finished holder prunes an empty `.socket/` on exit). +/// A failed acquire prunes it again if it is still empty, so a refused +/// lock leaves no residue. pub fn acquire(socket_dir: &Path, timeout: Duration) -> Result { - let path = socket_dir.join("apply.lock"); + let path = socket_dir.join(LOCK_FILE_NAME); // Use `checked_add` so an astronomically large `timeout` (the flag // is a user-supplied `u64` of seconds — e.g. `--lock-timeout` / @@ -93,37 +188,16 @@ pub fn acquire(socket_dir: &Path, timeout: Duration) -> Result return Ok(LockGuard { _file: file }), - // Only a genuine "someone else holds it" signal counts as - // contention and feeds the retry/`Held` path. Any other - // failure (ENOLCK, EBADF, a filesystem that doesn't support - // advisory locks, EACCES on a pre-existing read-only lock - // file, …) is a real I/O fault: surface it immediately as - // `Io` rather than busy-sleeping for the whole budget and - // then mislabelling it as `Held`. See `is_lock_contended`. - Err(ref e) if is_lock_contended(e) => { + // One mkdir → open → lock → identity-check attempt, in its own + // function so the file handle is closed before any sleep below: + // a waiter parked with the file open would prolong a Windows + // delete-pending window for everyone. + match attempt(&path, socket_dir) { + Attempt::Acquired(guard) => return Ok(guard), + Attempt::Contended => { let now = Instant::now(); // A `None` deadline (timeout overflowed `Instant`) never // elapses; otherwise give up once the budget is spent. @@ -134,24 +208,175 @@ pub fn acquire(socket_dir: &Path, timeout: Duration) -> Result 0 // here (now < deadline); with no deadline, just use the - // full 100 ms quantum. - let cap = Duration::from_millis(100); + // full quantum. let sleep_for = match deadline { - Some(d) => (d - now).min(cap), - None => cap, + Some(d) => (d - now).min(BACKOFF_CAP), + None => BACKOFF_CAP, }; std::thread::sleep(sleep_for); } - Err(source) => { - return Err(LockError::Io { - path: path.clone(), - source, - }); + Attempt::Vanished => { + vanished += 1; + if vanished > VANISHED_LIMIT { + let source = std::io::Error::new( + ErrorKind::NotFound, + "lock file kept vanishing while acquiring it", + ); + return Err(fail(socket_dir, path, source)); + } + } + Attempt::DeletePending(source) => { + delete_pending += 1; + if delete_pending > DELETE_PENDING_ATTEMPTS { + return Err(fail(socket_dir, path, source)); + } + std::thread::sleep(DELETE_PENDING_SLEEP); } + Attempt::Fault { path, source } => return Err(fail(socket_dir, path, source)), } } } +/// Outcome of one mkdir → open → lock → identity-check attempt. Every +/// variant but `Acquired` has already closed its file handle. +enum Attempt { + Acquired(LockGuard), + /// A live holder has the lock (the `fs2` contention sentinel). + Contended, + /// The directory or file disappeared between two of our steps, or + /// the handle we locked is an orphan: a releaser ran its cleanup. + Vanished, + /// Windows: the name is still delete-pending from a releaser's + /// unlink; free, but not reusable until its last handle closes. + DeletePending(std::io::Error), + /// A genuine I/O fault at `path` — surface immediately, never as + /// `Held`. + Fault { + path: PathBuf, + source: std::io::Error, + }, +} + +fn attempt(path: &Path, socket_dir: &Path) -> Attempt { + // 1. The directory, idempotently. Inside every attempt on purpose: + // a releaser may have pruned it since the last one. + match std::fs::create_dir_all(socket_dir) { + Ok(()) => {} + // std's `create_dir_all` stats the path after `EEXIST` to decide + // whether the existing entry is a directory; a releaser pruning + // it between those two syscalls makes that stat fail and the + // call report `AlreadyExists` for a directory that is now gone. + // Re-check: a non-directory squatting on the path is a fault; + // anything else (still a directory, or vanished again) is left + // to the open below, which reports a missing parent as + // `Vanished`. + Err(e) if e.kind() == ErrorKind::AlreadyExists => { + if std::fs::metadata(socket_dir).is_ok_and(|md| !md.is_dir()) { + return Attempt::Fault { + path: socket_dir.to_path_buf(), + source: e, + }; + } + } + Err(e) if is_delete_pending(&e) => return Attempt::DeletePending(e), + Err(e) => { + return Attempt::Fault { + path: socket_dir.to_path_buf(), + source: e, + } + } + } + + // 2a. Open (or create) the lock file. `create(true)` is idempotent + // if it already exists; we never write to the file, only lock it. + let file = match std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + { + Ok(file) => file, + Err(e) => return open_failure(e, path, socket_dir), + }; + + // 2b. Only a genuine "someone else holds it" signal counts as + // contention. Any other failure (ENOLCK, EBADF, a filesystem that + // doesn't support advisory locks, EACCES on a read-only lock file, + // …) is a real I/O fault: surface it immediately rather than + // busy-sleeping for the whole budget and then mislabelling it as + // `Held`. See `is_lock_contended`. + match file.try_lock_exclusive() { + Ok(()) => {} + Err(ref e) if is_lock_contended(e) => return Attempt::Contended, + Err(source) => { + return Attempt::Fault { + path: path.to_path_buf(), + source, + } + } + } + + // 2c. Identity check: is the handle we locked still the file the + // path names? `from_file` consumes the `File` (it needs the fstat + // identity), so the guard keeps the `Handle`; `as_file` would give + // the `File` back if anyone ever needed it. Dropping `held` on the + // mismatch arms releases the orphan's lock. + let held = match Handle::from_file(file) { + Ok(held) => held, + Err(source) => { + return Attempt::Fault { + path: path.to_path_buf(), + source, + } + } + }; + match Handle::from_path(path) { + Ok(now) if now == held => Attempt::Acquired(LockGuard { + handle: Some(held), + path: path.to_path_buf(), + socket_dir: socket_dir.to_path_buf(), + }), + // The path names a replacement: a releaser unlinked the inode we + // locked between our open and our lock, and a newcomer created + // the next file. + Ok(_) => Attempt::Vanished, + Err(e) => open_failure(e, path, socket_dir), + } +} + +/// Classify a failed open (or identity probe) of the lock file. A +/// missing file or parent is a releaser's cleanup racing us (retry); +/// so is any other failure while the parent directory is gone — macOS +/// reports an `O_CREAT` open inside a directory that was rmdir'd a +/// moment ago as `EINVAL` rather than `ENOENT`. Windows delete-pending +/// codes get their grace; everything else is a genuine fault. +fn open_failure(e: std::io::Error, path: &Path, socket_dir: &Path) -> Attempt { + if e.kind() == ErrorKind::NotFound { + return Attempt::Vanished; + } + if is_delete_pending(&e) { + return Attempt::DeletePending(e); + } + if matches!(std::fs::metadata(socket_dir), Err(ref m) if m.kind() == ErrorKind::NotFound) { + return Attempt::Vanished; + } + Attempt::Fault { + path: path.to_path_buf(), + source: e, + } +} + +/// Build the `Io` error for a failed acquire, first pruning the empty +/// `.socket/` this call may have created so a refused lock leaves no +/// residue behind. (`remove_dir` is non-recursive: a lock file we +/// created but could not lock keeps the directory, on purpose — we must +/// never unlink a file we do not hold the lock on.) +fn fail(socket_dir: &Path, path: PathBuf, source: std::io::Error) -> LockError { + prune_empty_socket_dir(socket_dir); + LockError::Io { path, source } +} + /// Distinguish "the lock is held by someone else" from a real I/O /// failure of `try_lock_exclusive`. /// @@ -166,18 +391,39 @@ fn is_lock_contended(err: &std::io::Error) -> bool { err.raw_os_error() == fs2::lock_contended_error().raw_os_error() } +/// Windows only: is this open/identity-probe error the delete-pending +/// window of a just-released lock (`ERROR_ACCESS_DENIED` 5, +/// `ERROR_SHARING_VIOLATION` 32, `ERROR_DELETE_PENDING` 303)? Always +/// false elsewhere — those numbers mean unrelated errnos on Unix. Only +/// applied to the open and identity-probe paths, never to the lock +/// call: `LockFileEx` contention is a different code (33) and must keep +/// feeding the `Held`/deadline logic. +fn is_delete_pending(err: &std::io::Error) -> bool { + cfg!(windows) && matches!(err.raw_os_error(), Some(5) | Some(32) | Some(303)) +} + #[cfg(test)] mod tests { use super::*; - /// Lock file is created on demand and the first acquisition succeeds. + /// A `.socket/` under a fresh tempdir — the shape production uses, + /// and the name the guard's prune step is gated on. + fn socket_dir(dir: &tempfile::TempDir) -> PathBuf { + dir.path().join(".socket") + } + + /// Lock file exists while held and is gone once the guard drops. #[test] fn first_acquire_succeeds() { let dir = tempfile::tempdir().unwrap(); - let guard = acquire(dir.path(), Duration::ZERO).unwrap(); - // Lock file must exist on disk. - assert!(dir.path().join("apply.lock").is_file()); + let socket = socket_dir(&dir); + let guard = acquire(&socket, Duration::ZERO).unwrap(); + assert!(socket.join("apply.lock").is_file()); drop(guard); + assert!( + !socket.join("apply.lock").exists(), + "drop must unlink the lock file" + ); } /// Second concurrent acquire returns `LockError::Held` when the @@ -185,35 +431,92 @@ mod tests { #[test] fn second_concurrent_acquire_is_held() { let dir = tempfile::tempdir().unwrap(); - let _first = acquire(dir.path(), Duration::ZERO).unwrap(); - let err = acquire(dir.path(), Duration::ZERO).unwrap_err(); + let socket = socket_dir(&dir); + let _first = acquire(&socket, Duration::ZERO).unwrap(); + let err = acquire(&socket, Duration::ZERO).unwrap_err(); assert!(matches!(err, LockError::Held)); } - /// After the first guard drops, a fresh acquire succeeds. + /// After the first guard drops (which also unlinks the file and + /// prunes the directory), a fresh acquire recreates both and + /// succeeds. #[test] fn drop_releases_lock() { let dir = tempfile::tempdir().unwrap(); + let socket = socket_dir(&dir); { - let _g = acquire(dir.path(), Duration::ZERO).unwrap(); + let _g = acquire(&socket, Duration::ZERO).unwrap(); } // guard dropped here - let again = acquire(dir.path(), Duration::ZERO); + let again = acquire(&socket, Duration::ZERO); assert!(again.is_ok()); } - /// Missing socket directory surfaces as `LockError::Io` with the - /// original `NotFound` underneath. + /// `acquire` creates a missing `.socket/` itself, and the guard's + /// drop removes both the lock file and the now-empty directory — + /// a lock-only run leaves the project exactly as it found it. + #[test] + fn acquire_creates_missing_socket_dir_and_prunes_it_on_drop() { + let dir = tempfile::tempdir().unwrap(); + let socket = socket_dir(&dir); + assert!(!socket.exists()); + + let guard = acquire(&socket, Duration::ZERO).unwrap(); + assert!(socket.join("apply.lock").is_file()); + + drop(guard); + assert!(!socket.join("apply.lock").exists()); + assert!( + !socket.exists(), + "an otherwise-empty .socket/ must be pruned on release" + ); + } + + /// The prune is non-recursive: a `.socket/` holding real state keeps + /// everything but the lock file. + #[test] + fn drop_leaves_non_empty_socket_dir_alone() { + let dir = tempfile::tempdir().unwrap(); + let socket = socket_dir(&dir); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write(socket.join("manifest.json"), b"{}").unwrap(); + + let guard = acquire(&socket, Duration::ZERO).unwrap(); + drop(guard); + + assert!(!socket.join("apply.lock").exists()); + assert!(socket.join("manifest.json").is_file()); + assert!(socket.is_dir()); + } + + /// The directory prune is gated to a directory literally named + /// `.socket`: `--manifest-path` can aim the lock at any user + /// directory, which the guard must not delete even when empty. + #[test] + fn drop_prunes_only_a_dir_named_socket() { + let dir = tempfile::tempdir().unwrap(); + let custom = dir.path().join("custom"); + let guard = acquire(&custom, Duration::ZERO).unwrap(); + assert!(custom.join("apply.lock").is_file()); + drop(guard); + assert!(!custom.join("apply.lock").exists()); + assert!(custom.is_dir(), "a user-named lock dir must survive"); + } + + /// A regular file squatting where `.socket/` should be is an `Io` + /// naming the directory — never `Held`, and the squatter is left + /// untouched. #[test] - fn missing_socket_dir_surfaces_io() { + fn file_squatting_on_socket_dir_surfaces_io() { let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("does-not-exist"); - let err = acquire(&missing, Duration::ZERO).unwrap_err(); + let socket = socket_dir(&dir); + std::fs::write(&socket, b"not a directory").unwrap(); + + let err = acquire(&socket, Duration::from_millis(250)).unwrap_err(); match err { - LockError::Io { source, .. } => { - assert_eq!(source.kind(), std::io::ErrorKind::NotFound); - } - _ => panic!("expected Io error, got {:?}", err), + LockError::Io { path, .. } => assert_eq!(path, socket), + LockError::Held => panic!("a squatting file is an I/O fault, not contention"), } + assert_eq!(std::fs::read(&socket).unwrap(), b"not a directory"); } /// Non-zero timeout waits then errors `Held` when the lock never @@ -221,9 +524,10 @@ mod tests { #[test] fn timeout_held() { let dir = tempfile::tempdir().unwrap(); - let _first = acquire(dir.path(), Duration::ZERO).unwrap(); + let socket = socket_dir(&dir); + let _first = acquire(&socket, Duration::ZERO).unwrap(); let start = Instant::now(); - let err = acquire(dir.path(), Duration::from_millis(250)).unwrap_err(); + let err = acquire(&socket, Duration::from_millis(250)).unwrap_err(); let elapsed = start.elapsed(); assert!(matches!(err, LockError::Held)); // We waited at least the budget (with some slack for the @@ -271,15 +575,35 @@ mod tests { } } + /// The delete-pending grace is Windows-only and never overlaps the + /// contention sentinel: on Windows codes 5/32/303 qualify and + /// `ERROR_LOCK_VIOLATION` (33) does not; elsewhere nothing does + /// (5 is EIO and 32 is EPIPE on Unix). + #[test] + fn delete_pending_classifier_is_windows_only_and_excludes_contention() { + use std::io::Error; + + assert!(!is_delete_pending(&fs2::lock_contended_error())); + assert!(!is_delete_pending(&Error::from(ErrorKind::NotFound))); + for code in [5, 32, 303] { + assert_eq!( + is_delete_pending(&Error::from_raw_os_error(code)), + cfg!(windows), + "os error {code}" + ); + } + } + /// A non-blocking (`ZERO`) acquire on a contended lock returns /// `Held` essentially immediately — it must not pay the 100 ms /// backoff sleep before giving up. #[test] fn zero_timeout_does_not_sleep_before_held() { let dir = tempfile::tempdir().unwrap(); - let _first = acquire(dir.path(), Duration::ZERO).unwrap(); + let socket = socket_dir(&dir); + let _first = acquire(&socket, Duration::ZERO).unwrap(); let start = Instant::now(); - let err = acquire(dir.path(), Duration::ZERO).unwrap_err(); + let err = acquire(&socket, Duration::ZERO).unwrap_err(); let elapsed = start.elapsed(); assert!(matches!(err, LockError::Held)); assert!( @@ -298,30 +622,35 @@ mod tests { #[test] fn overflowing_timeout_does_not_panic_when_free() { let dir = tempfile::tempdir().unwrap(); + let socket = socket_dir(&dir); // Would panic ("overflow when adding duration to instant") under // the old `Instant::now() + timeout`. - let guard = acquire(dir.path(), Duration::from_secs(u64::MAX)).unwrap(); - assert!(dir.path().join("apply.lock").is_file()); + let guard = acquire(&socket, Duration::from_secs(u64::MAX)).unwrap(); + assert!(socket.join("apply.lock").is_file()); drop(guard); + assert!(!socket.join("apply.lock").exists()); } /// Regression companion: with an overflowing (effectively infinite) /// timeout AND a contended lock, `acquire` must *wait* — not panic /// and not give up — and then succeed once the holder releases. /// Proves both the no-overflow-panic fix and that a `None` deadline - /// never spuriously elapses into `Held`. + /// never spuriously elapses into `Held`. The holder's release also + /// unlinks the file and prunes `.socket/`, so the parked waiter has + /// to recreate both — the mkdir-inside-the-loop path. #[test] fn overflowing_timeout_waits_then_acquires_on_release() { use std::sync::Arc; let dir = Arc::new(tempfile::tempdir().unwrap()); - let held = acquire(dir.path(), Duration::ZERO).unwrap(); + let socket = socket_dir(&dir); + let held = acquire(&socket, Duration::ZERO).unwrap(); // Release the lock a little while after the waiter starts. let dir2 = Arc::clone(&dir); let releaser = std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(150)); - drop(held); // releases the OS lock + drop(held); // unlinks, releases the OS lock, prunes .socket/ // Keep the tempdir alive until the waiter has acquired. std::thread::sleep(Duration::from_millis(200)); drop(dir2); @@ -331,139 +660,199 @@ mod tests { // panics before ever sleeping. With the fix it waits indefinitely // and acquires once `held` drops above. let start = Instant::now(); - let guard = acquire(dir.path(), Duration::from_secs(u64::MAX)).unwrap(); + let guard = acquire(&socket, Duration::from_secs(u64::MAX)).unwrap(); let waited = start.elapsed(); assert!( waited >= Duration::from_millis(100), "should have waited for the holder to release, waited {:?}", waited ); + assert!(socket.join("apply.lock").is_file()); drop(guard); + assert!(!socket.exists(), "last guard out prunes .socket/"); releaser.join().unwrap(); } - /// Regression: a waiter parked in the retry loop must not keep - /// locking the *old* inode across `repair`'s sanctioned lock-file - /// deletion. + /// A waiter parked in the retry loop must never end up holding the + /// lock alongside the next command once the holder releases. /// - /// `repair` drops its guard and then unlinks `apply.lock` as its - /// final housekeeping step. It justifies that with a "residual - /// window of microseconds" between the drop and the unlink — true - /// for a fresh acquire (open, then immediately flock), but false - /// for a waiter: `acquire` used to open the lock file exactly once, - /// *before* the loop, then re-flock that same handle for the whole - /// `--lock-timeout` budget. So a waiter parked for minutes would - /// eventually flock the unlinked, orphaned inode and report success - /// while the next command created a fresh `apply.lock` and locked - /// that — two simultaneous holders of the "exclusive" apply lock, - /// i.e. exactly the concurrent manifest/package-file corruption the - /// lock exists to prevent. Re-opening the path on every retry keeps - /// the waiter honest about whatever file `apply.lock` names now. - /// - /// The choreography below can lose benign races on a loaded runner - /// (observed on macOS and Windows CI), so it retries: the - /// regressed bug double-holds on essentially every iteration, while - /// the benign losses need an unlucky deschedule and almost never - /// repeat. One clean iteration proves the re-open behavior; a full - /// run of iterations without one is statistically the bug. + /// The holder's drop unlinks `apply.lock` under the lock and then + /// releases; a fresh try-once acquire follows at once and takes the + /// lock on a brand-new inode. The waiter may have opened the OLD + /// inode before the unlink: if it locks that orphan, the post-lock + /// identity check must reject it (the path names a different file + /// now, or nothing) and send it back around the loop, where it either + /// wins the free window itself or sees the fresh holder and reports + /// `Held`. Both are correct; two live guards at once is the bug this + /// pins, and — unlike the pre-identity-check protocol, which merely + /// called that window "vanishingly rare" — it is now impossible, so + /// every iteration asserts it outright. #[test] - fn waiter_does_not_lock_orphaned_inode_after_lock_file_deleted() { + fn waiter_does_not_lock_orphaned_inode_after_holder_release() { use std::sync::mpsc; const ATTEMPTS: usize = 5; - let mut benign = Vec::new(); for _ in 0..ATTEMPTS { let dir = tempfile::tempdir().unwrap(); - let lock_path = dir.path().join("apply.lock"); + let socket = socket_dir(&dir); + let lock_path = socket.join("apply.lock"); - // A `repair` run holds the lock; this is the inode the + // A mutating command holds the lock; this is the inode the // waiter will open below. - let repair_guard = acquire(dir.path(), Duration::ZERO).unwrap(); + let holder = acquire(&socket, Duration::ZERO).unwrap(); // The waiter: a concurrent `apply --lock-timeout 1` that - // parks in the retry loop while repair finishes. + // parks in the retry loop while the holder finishes. let (started_tx, started_rx) = mpsc::channel(); - let waiter_dir = dir.path().to_path_buf(); + let waiter_dir = socket.clone(); let waiter = std::thread::spawn(move || { started_tx.send(()).unwrap(); acquire(&waiter_dir, Duration::from_millis(600)) }); - // Let the waiter open the lock file and burn its first - // (contended) attempt, so its handle is on the pre-deletion - // inode. Being late here is harmless — it just means the - // waiter burns another attempt on the same handle. + // Let the waiter burn its first (contended) attempt. Being + // late here is harmless — it just burns another attempt. started_rx.recv().unwrap(); std::thread::sleep(Duration::from_millis(50)); - // repair's tail: release the guard, then unlink the lock - // file. The next mutating command comes along and takes the - // lock on a brand-new inode. - drop(repair_guard); - std::fs::remove_file(&lock_path).unwrap(); - let fresh = acquire(dir.path(), Duration::ZERO); + // The holder finishes (unlink under the lock, release, + // prune) and the next command takes the lock immediately. + drop(holder); + let fresh = acquire(&socket, Duration::ZERO); let waiter_result = waiter.join().unwrap(); - match (fresh, waiter_result) { - // The interleaving under test: the fresh acquire won - // the post-unlink window, and the waiter — re-opening - // the path every retry — saw the new inode held and - // gave up. Under the bug this outcome is unreachable - // (the waiter flocks its orphaned pre-loop handle and - // returns a guard), so one clean iteration is proof. - (Ok(_fresh_guard), Err(LockError::Held)) => return, - // Benign race: the waiter's retry landed between the - // unlink and the fresh acquire, while the lock was - // genuinely free — it recreated the file and is a - // legitimate sole holder, and the fresh try-once - // correctly reported Held. Mutual exclusion held; retry - // for the interleaving under test. - (Err(LockError::Held), Ok(_waiter_guard)) => { - benign.push("waiter won the free-lock window"); - } - // Both hold "the" lock at once. For the fixed, - // re-opening waiter this needs the sanctioned - // microsecond window between its open() and flock() - // straddling repair's drop+unlink — vanishingly rare - // twice. The old one-handle waiter lands here on every - // iteration, so repeats fail below. - (Ok(_fresh_guard), Ok(_waiter_guard)) => { - benign.push("double hold via the open->flock window"); - } - // Windows can keep an unlinked file delete-pending until - // its last handle closes. CreateFile then returns - // ERROR_ACCESS_DENIED (5), including when the waiter is - // reopening while the fresh acquire races that cleanup. - // Neither an I/O refusal nor Held grants a second lock. - // Retry this choreography; still require a clean Held - // iteration above, and never relax acquire's I/O errors. - // https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea + match (&fresh, &waiter_result) { + // The fresh acquire won and the waiter, re-checking the + // path every retry, saw the new inode held and gave up. + (Ok(_), Err(LockError::Held)) => {} + // The waiter's retry landed in the free window between + // the release and the fresh acquire: it recreated the + // file and is the legitimate sole holder, and the fresh + // try-once correctly reported Held. + (Err(LockError::Held), Ok(_)) => {} + (Ok(_), Ok(_)) => panic!( + "two live guards on the apply lock at once: the waiter locked \ + the orphaned pre-release inode and the identity check let it through" + ), + // Windows keeps an unlinked name delete-pending until its + // last handle closes; the grace in `acquire` should absorb + // that, but if a loaded runner outlasts it, an I/O refusal + // still grants nobody a second lock. #[cfg(windows)] (Ok(_) | Err(LockError::Held), Err(LockError::Io { source, .. })) | (Err(LockError::Io { source, .. }), Ok(_) | Err(LockError::Held)) - if source.raw_os_error() == Some(5) => - { - benign.push("open raced Windows delete-pending handle"); - } - #[cfg(windows)] - ( - Err(LockError::Io { source: first, .. }), - Err(LockError::Io { source: second, .. }), - ) if first.raw_os_error() == Some(5) && second.raw_os_error() == Some(5) => { - benign.push("both opens raced Windows delete-pending handle"); - } + if is_delete_pending(source) => {} (fresh, waiter_result) => panic!( "unexpected lock outcome: fresh={:?} waiter={:?}", - fresh.map(|_| "Ok(guard)"), - waiter_result.map(|_| "Ok(guard)") + fresh.as_ref().map(|_| "Ok(guard)"), + waiter_result.as_ref().map(|_| "Ok(guard)") ), } + + // Whoever held it, releasing leaves nothing behind. + drop(fresh); + drop(waiter_result); + assert!( + !lock_path.exists(), + "apply.lock must not outlive its holders" + ); + assert!( + !socket.exists(), + "an otherwise-empty .socket/ must be pruned" + ); + } + } + + /// The lock binds to the file the path names NOW: a guard left + /// holding an orphaned inode (a non-cooperating `rm` + `touch` + /// replaced the file under it) neither blocks a fresh acquire nor + /// gets confused on its own drop. Unix-only: Windows cannot replace + /// a name that another handle keeps delete-pending. + #[cfg(unix)] + #[test] + fn orphaned_inode_holder_does_not_block_the_path() { + let dir = tempfile::tempdir().unwrap(); + let socket = socket_dir(&dir); + let lock_path = socket.join("apply.lock"); + + let orphan = acquire(&socket, Duration::ZERO).unwrap(); + // Replace the lock file behind the holder's back. + std::fs::remove_file(&lock_path).unwrap(); + std::fs::File::create(&lock_path).unwrap(); + + // The replacement is unlocked, so a fresh acquire takes it even + // though `orphan` still holds the old inode. + let fresh = acquire(&socket, Duration::ZERO).unwrap(); + assert!(lock_path.is_file()); + + drop(fresh); + assert!(!lock_path.exists()); + assert!(!socket.exists()); + // The orphan's drop finds nothing to unlink or prune and must not + // panic. + drop(orphan); + assert!(!socket.exists()); + } + + /// Two threads hammering acquire/release on one `.socket/` — every + /// release unlinking the file and pruning the directory, every + /// acquire recreating both — must never observe two live guards and + /// must end with no lock file and no directory. This is the live + /// stress test of the unlink-under-lock + identity-check protocol + /// and of the mkdir-inside-the-loop / vanished-file retries. + #[test] + fn concurrent_acquire_release_never_double_holds_and_leaves_no_residue() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + + const ITERATIONS: usize = 200; + + let dir = tempfile::tempdir().unwrap(); + let socket = socket_dir(&dir); + let holders = Arc::new(AtomicUsize::new(0)); + let violated = Arc::new(AtomicBool::new(false)); + + let workers: Vec<_> = (0..2) + .map(|_| { + let socket = socket.clone(); + let holders = Arc::clone(&holders); + let violated = Arc::clone(&violated); + std::thread::spawn(move || { + let mut faults = Vec::new(); + for _ in 0..ITERATIONS { + match acquire(&socket, Duration::ZERO) { + Ok(guard) => { + if holders.fetch_add(1, Ordering::SeqCst) != 0 { + violated.store(true, Ordering::SeqCst); + } + std::thread::yield_now(); + holders.fetch_sub(1, Ordering::SeqCst); + drop(guard); + } + // Refusal (the other thread holds) is a + // correct outcome; only a double hold or an + // I/O fault is a failure. + Err(LockError::Held) => {} + Err(e @ LockError::Io { .. }) => faults.push(e.to_string()), + } + } + faults + }) + }) + .collect(); + + let mut faults = Vec::new(); + for worker in workers { + faults.extend(worker.join().unwrap()); } - panic!( - "waiter must not acquire the apply lock while another holder is live \ - (it locked the orphaned pre-deletion inode): no clean iteration in \ - {ATTEMPTS} attempts — {benign:?}" + + assert!( + !violated.load(Ordering::SeqCst), + "two threads held the apply lock at once" ); + assert!(faults.is_empty(), "acquire hit I/O faults: {faults:?}"); + assert!(!socket.join("apply.lock").exists()); + assert!(!socket.exists(), "the last release must prune .socket/"); } /// mkfifo(2) directly, not the /usr/bin/mkfifo binary: spawning a child @@ -486,7 +875,7 @@ mod tests { /// A non-contention `try_lock_exclusive` fault must surface as /// `LockError::Io` immediately — not busy-sleep the whole timeout /// budget and then come out mislabelled as `Held` (the documented - /// contract of the second `Err` arm in `acquire`). + /// contract of the `Fault` arm in `attempt`). /// /// Induced for real, with no fault-injection seam: a FIFO planted at /// `apply.lock` opens fine with `O_RDWR` (the process is both reader @@ -518,9 +907,9 @@ mod tests { fs2::lock_contended_error().raw_os_error() ); } - LockError::Held => panic!( - "a genuine flock fault must not be mislabelled as contention" - ), + LockError::Held => { + panic!("a genuine flock fault must not be mislabelled as contention") + } } // The fault arm returns without ever entering the retry/backoff // path: nowhere near the 5 s budget (the old funnel-everything- @@ -530,6 +919,9 @@ mod tests { "Io fault must not burn the retry budget, took {:?}", elapsed ); + // A failed acquire never unlinks a file it does not hold the + // lock on. + assert!(lock_path.exists(), "the squatting FIFO must survive"); } /// Companion in try-once mode: `timeout = ZERO` on a faulting lock @@ -552,9 +944,9 @@ mod tests { fs2::lock_contended_error().raw_os_error() ); } - LockError::Held => panic!( - "try-once mode must not mislabel a genuine flock fault as Held" - ), + LockError::Held => { + panic!("try-once mode must not mislabel a genuine flock fault as Held") + } } } @@ -565,9 +957,10 @@ mod tests { #[test] fn wait_respects_deadline_without_full_quantum_overshoot() { let dir = tempfile::tempdir().unwrap(); - let _first = acquire(dir.path(), Duration::ZERO).unwrap(); + let socket = socket_dir(&dir); + let _first = acquire(&socket, Duration::ZERO).unwrap(); let start = Instant::now(); - let err = acquire(dir.path(), Duration::from_millis(150)).unwrap_err(); + let err = acquire(&socket, Duration::from_millis(150)).unwrap_err(); let elapsed = start.elapsed(); assert!(matches!(err, LockError::Held)); assert!( From 74b79632643743dbbcaa8d0bd4b4a510c5711328 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 18:41:43 -0400 Subject: [PATCH 03/44] refactor(core): shared .socket prune helper, ledger dedupe, sweep hygiene (D4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - utils/socket_dir.rs (new): prune_empty_dirs / remove_file_and_prune / remove_tree_and_prune — one "delete, then rmdir the now-empty parents up to but excluding " implementation, confined to stop_dir's subtree, non-recursive and best-effort; write_json_ledger — pretty JSON + trailing newline + create_dir_all + atomic write, skipping a byte-identical ledger already on disk. - redirect/state.rs: persist_redirect_state now prunes an emptied .socket/vendor/ (the observed residue after a hosted rollback); the unlink error still propagates first. save_redirect_state goes through write_json_ledger (idempotent hosted re-runs no longer churn the committed ledger). CorruptRedirectState gains `unreadable`: an I/O failure or a directory/FIFO squatting the path is reported as "cannot be read" and is never quarantined — quarantine stays for malformed JSON. read_ledger_bytes twin deleted in favour of utils::fs::read_regular_to_bytes. - vendor/state.rs: save_state uses the shared helpers (delete + prune, eco-husk backstop kept), read_state_bytes twin deleted, detached/record docs describe the manifest-free vendored posture, PatchRecord import used. - manifest/cleanup_blobs.rs: cleanup_dir drops the redundant pre-stat (read_dir NotFound is the missing-dir signal), keeps sweeping past a per-file unlink failure (counting only removed files, returning the first error after the pass), closes the ReadDir handle and removes the emptied store directory on a wet run — no more empty .socket/blobs|diffs| packages husks. "No blobs directory found" wording corrected to "No blobs to clean up." (also true for an existing empty dir). - utils/fs.rs: open_regular_file and both async readers are one spawn_blocking hop over the single sync guard; atomic_write_bytes_as folds its five identical stage-cleanup arms into commit_stage; module doc for entry_is_dir updated. - constants.rs: SOCKET_DIR const; stale "currently 3.x" doc dropped. - socket_cli_config.rs: env_flag -> pub env_truthy (doc lists the real vocabulary) so the CLI's byte-identical copy can be deleted. - operations.rs: tests pin the "Invalid manifest" / "Failed to parse manifest JSON" prefix split and the duplicate-struct-field rejection. Co-Authored-By: Claude Fable 5.1 --- crates/socket-patch-core/src/constants.rs | 11 +- .../src/manifest/cleanup_blobs.rs | 108 ++++++- .../src/manifest/operations.rs | 40 ++- .../src/patch/redirect/state.rs | 236 ++++++++++++--- crates/socket-patch-core/src/utils/fs.rs | 146 +++++----- crates/socket-patch-core/src/utils/mod.rs | 1 + .../src/utils/socket_cli_config.rs | 16 +- .../socket-patch-core/src/utils/socket_dir.rs | 274 ++++++++++++++++++ crates/socket-patch-core/src/vendor/state.rs | 114 ++++---- 9 files changed, 764 insertions(+), 182 deletions(-) create mode 100644 crates/socket-patch-core/src/utils/socket_dir.rs diff --git a/crates/socket-patch-core/src/constants.rs b/crates/socket-patch-core/src/constants.rs index 3535f550..a2c96397 100644 --- a/crates/socket-patch-core/src/constants.rs +++ b/crates/socket-patch-core/src/constants.rs @@ -1,3 +1,8 @@ +/// The project-relative state directory every socket-patch file lives under +/// (manifest, blob/archive stores, the vendor and hosted ledgers, the apply +/// lock). Empty-directory prunes climb up to — never past — this level. +pub const SOCKET_DIR: &str = ".socket"; + /// Default path for the patch manifest file relative to the project root. pub const DEFAULT_PATCH_MANIFEST_PATH: &str = ".socket/manifest.json"; @@ -10,9 +15,9 @@ pub const DEFAULT_SOCKET_API_URL: &str = "https://api.socket.dev"; /// User-Agent header value for API requests. /// /// The version segment is derived from the crate version at compile time so it -/// tracks the published release (currently `3.x`) instead of drifting from a -/// hardcoded literal. Server-side analytics and any minimum-version gating rely -/// on this reporting the real version. +/// tracks the published release instead of drifting from a hardcoded literal. +/// Server-side analytics and any minimum-version gating rely on this reporting +/// the real version. pub(crate) const USER_AGENT: &str = concat!("SocketPatchCLI/", env!("CARGO_PKG_VERSION")); #[cfg(test)] diff --git a/crates/socket-patch-core/src/manifest/cleanup_blobs.rs b/crates/socket-patch-core/src/manifest/cleanup_blobs.rs index 9eebbbc4..09eddab6 100644 --- a/crates/socket-patch-core/src/manifest/cleanup_blobs.rs +++ b/crates/socket-patch-core/src/manifest/cleanup_blobs.rs @@ -17,23 +17,38 @@ pub struct CleanupResult { /// /// Walks `dir`, treats it as authoritative socket-patch state (so any /// regular non-hidden file is considered for removal), and asks -/// `is_used(filename) -> bool` whether each file should be kept. +/// `is_used(filename) -> bool` whether each file should be kept. A missing +/// `dir` yields an empty result; every other I/O error on the directory +/// itself propagates. A wet sweep that empties the directory removes it too +/// (non-recursively — kept files, hidden files or subdirectories keep it), so +/// a fully rolled-back project leaves no empty `blobs/`, `diffs/` or +/// `packages/` husk behind. +/// +/// Per-file unlink failures do not abort the sweep: every other orphan is +/// still attempted, only files actually removed are counted, and the first +/// failure is returned once the pass is complete. async fn cleanup_dir bool>( dir: &Path, dry_run: bool, is_used: F, ) -> Result { - if tokio::fs::metadata(dir).await.is_err() { - return Ok(CleanupResult::default()); - } - - let mut read_dir = tokio::fs::read_dir(dir).await?; + let mut read_dir = match tokio::fs::read_dir(dir).await { + Ok(read_dir) => read_dir, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(CleanupResult::default()); + } + Err(e) => return Err(e), + }; let mut entries = Vec::new(); while let Some(entry) = read_dir.next_entry().await? { entries.push(entry); } + // Close the enumeration handle before the directory itself may be + // removed below (Windows refuses to delete a directory with one open). + drop(read_dir); let mut result = CleanupResult::default(); + let mut first_error = None; for entry in &entries { let file_name_str = entry.file_name().to_string_lossy().to_string(); @@ -61,14 +76,23 @@ async fn cleanup_dir bool>( if is_used(&file_name_str) { continue; } + if !dry_run { + if let Err(e) = tokio::fs::remove_file(&path).await { + first_error.get_or_insert(e); + continue; + } + } result.blobs_removed += 1; result.bytes_freed += metadata.len(); result.removed_blobs.push(file_name_str); - if !dry_run { - tokio::fs::remove_file(&path).await?; - } } + if let Some(e) = first_error { + return Err(e); + } + if !dry_run { + let _ = tokio::fs::remove_dir(dir).await; + } Ok(result) } @@ -123,7 +147,8 @@ pub async fn cleanup_unused_archives( /// Formats the cleanup result for human-readable output. pub fn format_cleanup_result(result: &CleanupResult, dry_run: bool) -> String { if result.blobs_checked == 0 { - return "No blobs directory found, nothing to clean up.".to_string(); + // Absent directory, or one holding no regular non-hidden files. + return "No blobs to clean up.".to_string(); } if result.blobs_removed == 0 { @@ -338,6 +363,14 @@ mod tests { assert!(tokio::fs::metadata(blobs_dir.join(AFTER_HASH_1)) .await .is_ok()); + + // A dry run never touches the directory either, even when every + // file in it would go. + let result = cleanup_unused_blobs(&PatchManifest::new(), &blobs_dir, true) + .await + .unwrap(); + assert_eq!(result.blobs_removed, 2); + assert!(blobs_dir.is_dir(), "dry run keeps the directory"); } #[tokio::test] @@ -360,6 +393,50 @@ mod tests { .unwrap(); assert_eq!(result.blobs_removed, 2); + // A wet sweep that orphaned everything leaves no empty `blobs/` husk + // behind (the residue a full agent-mode rollback used to leave). + assert!( + !blobs_dir.exists(), + "an emptied store directory is removed with its last orphan" + ); + assert!(dir.path().exists(), "only the store dir itself goes"); + } + + /// A per-file unlink failure must not abort the sweep: the remaining + /// orphans are still attempted, and the error is returned only after the + /// pass. Pinned on Unix by a read-only store dir — every unlink fails, + /// so the error propagates and nothing is counted as removed. + #[cfg(unix)] + #[tokio::test] + async fn test_cleanup_unlink_failure_propagates_after_the_pass() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let blobs_dir = dir.path().join("blobs"); + tokio::fs::create_dir_all(&blobs_dir).await.unwrap(); + tokio::fs::write(blobs_dir.join(ORPHAN_HASH), "orphan") + .await + .unwrap(); + tokio::fs::write(blobs_dir.join(BEFORE_HASH_1), "orphan too") + .await + .unwrap(); + std::fs::set_permissions(&blobs_dir, std::fs::Permissions::from_mode(0o555)).unwrap(); + if std::fs::File::create(blobs_dir.join("probe")).is_ok() { + let _ = std::fs::set_permissions(&blobs_dir, std::fs::Permissions::from_mode(0o755)); + eprintln!("skipping: running as root, 0555 does not block unlinks"); + return; + } + + let result = cleanup_unused_blobs(&create_test_manifest(), &blobs_dir, false).await; + std::fs::set_permissions(&blobs_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + + assert_eq!( + result.unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied, + "a failed unlink is still reported as the sweep's error" + ); + assert!(blobs_dir.join(ORPHAN_HASH).exists()); + assert!(blobs_dir.join(BEFORE_HASH_1).exists()); + assert!(blobs_dir.is_dir(), "a non-empty store dir is never removed"); } #[tokio::test] @@ -388,8 +465,11 @@ mod tests { assert_eq!(format_bytes(1073741824), "1.00 GB"); } + /// Zero checked covers BOTH an absent store dir and an existing one that + /// holds no regular non-hidden files, so the wording must be true for + /// either — it never claims the directory was not found. #[test] - fn test_format_cleanup_result_no_blobs_dir() { + fn test_format_cleanup_result_nothing_checked() { let result = CleanupResult { blobs_checked: 0, blobs_removed: 0, @@ -398,7 +478,7 @@ mod tests { }; assert_eq!( format_cleanup_result(&result, false), - "No blobs directory found, nothing to clean up." + "No blobs to clean up." ); } @@ -626,7 +706,8 @@ mod tests { #[tokio::test] async fn test_cleanup_empty_existing_dir_checks_nothing() { // An existing-but-empty directory must report zero checked (no entries - // to consider), distinct from a populated one. + // to consider), distinct from a populated one — and, being an empty + // husk, a wet sweep removes it. let dir = tempfile::tempdir().unwrap(); let blobs_dir = dir.path().join("blobs"); tokio::fs::create_dir_all(&blobs_dir).await.unwrap(); @@ -637,6 +718,7 @@ mod tests { assert_eq!(result.blobs_checked, 0); assert_eq!(result.blobs_removed, 0); + assert!(!blobs_dir.exists(), "an empty store dir is pruned"); } #[cfg(unix)] diff --git a/crates/socket-patch-core/src/manifest/operations.rs b/crates/socket-patch-core/src/manifest/operations.rs index 0de2a4fe..846ecdaa 100644 --- a/crates/socket-patch-core/src/manifest/operations.rs +++ b/crates/socket-patch-core/src/manifest/operations.rs @@ -256,14 +256,50 @@ mod tests { assert_eq!(manifest.patches.len(), 1); } + /// Well-formed JSON that violates the schema is a DATA error: it keeps + /// the historical "Invalid manifest" prefix (the syntax class below keeps + /// "Failed to parse manifest JSON"); both are `InvalidData`. The split + /// is decided by `serde_json::Error::is_data`, so pin it here — no + /// external test does. #[test] fn test_parse_manifest_invalid() { let json = serde_json::json!({ "patches": "not-an-object" }); - let result = parse_manifest(&json.to_string()); - assert!(result.is_err()); + let err = parse_manifest(&json.to_string()).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert!( + err.to_string().starts_with("Invalid manifest: "), + "schema errors keep the data-class prefix: {err}" + ); + + let err = parse_manifest("{ not json").unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert!( + err.to_string() + .starts_with("Failed to parse manifest JSON: "), + "syntax errors keep the parse-class prefix: {err}" + ); + } + + /// Deserializing straight into the struct rejects a REPEATED struct field + /// (the old `Value` round-trip silently kept the last value). A + /// hand-edited manifest with two `uuid` keys in one record is malformed + /// and classified as a data error. The fixture is a raw string on purpose: + /// `json!` would collapse the duplicate before the parser ever saw it. + #[test] + fn test_parse_manifest_rejects_duplicate_struct_field() { + let raw = r#"{"patches":{"pkg:npm/a@1.0.0":{"uuid":"x","uuid":"y", + "exportedAt":"t","files":{},"vulnerabilities":{}, + "description":"","license":"MIT","tier":"free"}}}"#; + let err = parse_manifest(raw).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert!( + err.to_string() + .starts_with("Invalid manifest: duplicate field `uuid`"), + "{err}" + ); } #[test] diff --git a/crates/socket-patch-core/src/patch/redirect/state.rs b/crates/socket-patch-core/src/patch/redirect/state.rs index cae641ef..97cdf8a6 100644 --- a/crates/socket-patch-core/src/patch/redirect/state.rs +++ b/crates/socket-patch-core/src/patch/redirect/state.rs @@ -16,8 +16,10 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use super::FileEdit; +use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; -use crate::utils::fs::atomic_write_bytes; +use crate::utils::fs::read_regular_to_bytes; +use crate::utils::socket_dir::{remove_file_and_prune, write_json_ledger}; /// Repo-relative path of the redirect ledger. pub const REDIRECT_STATE_REL: &str = ".socket/vendor/redirect-state.json"; @@ -78,6 +80,12 @@ pub struct CorruptRedirectState { pub detail: String, /// Where [`CorruptRedirectState::quarantine`] moved the file, when it did. pub quarantined_to: Option, + /// True when the ledger could not be READ (an I/O error, or a directory / + /// FIFO squatting the path) rather than parsed. The bytes on disk may be + /// perfectly valid revert data — or not a file at all — so + /// [`CorruptRedirectState::quarantine`] leaves them where they are and the + /// message asks for the I/O problem to be fixed, not for JSON repair. + pub unreadable: bool, } impl CorruptRedirectState { @@ -85,8 +93,16 @@ impl CorruptRedirectState { /// later run can overwrite the revert data it may still hold. Never /// clobbers an existing `.corrupt` file (an earlier quarantine may hold /// older revert data); on any failure the original file simply stays put - /// — the caller's hard error already prevents overwriting it. + /// — the caller's hard error already prevents overwriting it. An + /// UNREADABLE ledger is never moved: it is not known to be malformed. + /// + /// The quarantine file is the one sanctioned `.socket/vendor/` residue: + /// the empty-directory prunes are non-recursive and leave both it and the + /// directory in place until the user resolves it. pub async fn quarantine(&mut self) { + if self.unreadable { + return; + } let target = match self.path.parent() { Some(parent) => parent.join("redirect-state.json.corrupt"), None => return, @@ -102,6 +118,18 @@ impl CorruptRedirectState { impl std::fmt::Display for CorruptRedirectState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.unreadable { + return write!( + f, + "the redirect ledger {} cannot be read ({}); it may hold the \ + pre-redirect lockfile values a future revert needs, so it was \ + left in place and will not be overwritten. Fix the file's \ + permissions (or move a stray directory or special file at that \ + path aside), then re-run.", + self.path.display(), + self.detail + ); + } write!( f, "the redirect ledger {} is malformed ({}); it records the \ @@ -137,18 +165,25 @@ impl std::error::Error for CorruptRedirectState {} /// still holds (see the type's docs). Read-only consumers may degrade a /// malformed ledger to "nothing to consult", but must surface it; the hosted /// writer must abort. +/// +/// The bytes come from the (untrusted) project tree through the FIFO-safe +/// [`read_regular_to_bytes`] — non-blocking on Unix, rejecting FIFOs / +/// devices / directories — so a planted special file fails loudly instead of +/// wedging every flow that consults the ledger (scan, vex, list, vendor) on +/// an `open(2)` that waits forever for a writer. pub async fn load_redirect_state( project_root: &Path, ) -> Result, CorruptRedirectState> { let path = project_root.join(REDIRECT_STATE_REL); - let bytes = match read_ledger_bytes(&path).await { + let bytes = match read_regular_to_bytes(&path).await { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => { return Err(CorruptRedirectState { path, - detail: format!("unreadable: {e}"), + detail: e.to_string(), quarantined_to: None, + unreadable: true, }); } }; @@ -158,39 +193,23 @@ pub async fn load_redirect_state( path, detail: format!("invalid JSON: {e}"), quarantined_to: None, + unreadable: false, }), } } -/// Read the ledger bytes from the (untrusted) project tree. Opens via -/// [`open_regular_file`](crate::utils::fs::open_regular_file) — non-blocking -/// on Unix, rejecting FIFOs/devices/directories — so a planted special file -/// fails loudly instead of wedging every flow that consults the ledger -/// (scan, vex, list, vendor) on a FIFO `open(2)` that waits forever for a -/// writer; same guard as package_json discovery and the npm/composer/ -/// python/ruby crawlers. -async fn read_ledger_bytes(path: &Path) -> std::io::Result> { - use tokio::io::AsyncReadExt; - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut bytes).await?; - Ok(bytes) -} - /// Persist the redirect ledger atomically (stage + fsync + rename, the same /// hardened writer the sibling vendor ledger uses). A bare `fs::write` /// truncates the target first, so a crash or `ENOSPC` mid-write would tear -/// the only store of the pre-redirect originals a future revert needs. +/// the only store of the pre-redirect originals a future revert needs. A +/// byte-identical ledger already on disk (an idempotent hosted re-run) is +/// left untouched. Always a write, never a delete — see +/// [`persist_redirect_state`] for the emptied-ledger rule. pub async fn save_redirect_state( project_root: &Path, state: &RedirectState, ) -> std::io::Result<()> { - let path = project_root.join(REDIRECT_STATE_REL); - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - let json = serde_json::to_string_pretty(state).map_err(std::io::Error::other)?; - atomic_write_bytes(&path, format!("{json}\n").as_bytes()).await + write_json_ledger(&project_root.join(REDIRECT_STATE_REL), state).await } /// `pkg:/@` → `(, )`; the name keeps any @@ -321,19 +340,21 @@ pub fn drop_superseded_purl(state: &mut RedirectState, purl: &str) -> bool { /// Persist the redirect ledger via [`save_redirect_state`]'s atomic writer. /// An EMPTY ledger (no edits, no records) is DELETED instead: a residual /// empty file would keep takeover-overlap detection and VEX reading a ledger -/// that asserts nothing. +/// that asserts nothing. The delete then prunes a now-empty `.socket/vendor/` +/// (best-effort, non-recursive — the vendor ledger, artifacts or a `.corrupt` +/// quarantine keep it), so a fully unwound hosted project leaves no residue +/// below `.socket/` itself, which the lock guard owns. A failed unlink +/// propagates before any prune. pub async fn persist_redirect_state( project_root: &Path, state: &RedirectState, ) -> std::io::Result<()> { if state.edits.is_empty() && state.records.is_empty() { - let path = project_root.join(REDIRECT_STATE_REL); - match tokio::fs::remove_file(&path).await { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(e), - } - return Ok(()); + return remove_file_and_prune( + &project_root.join(REDIRECT_STATE_REL), + &project_root.join(SOCKET_DIR), + ) + .await; } save_redirect_state(project_root, state).await } @@ -1058,10 +1079,55 @@ mod tests { let _ = std::fs::OpenOptions::new().write(true).open(&fifo); panic!("load_redirect_state must complete promptly with a FIFO ledger"); }; - let err = result.unwrap_err(); + let mut err = result.unwrap_err(); assert_eq!(err.path, fifo, "the error must name the planted path"); + assert!(err.unreadable, "a non-regular file is an I/O problem"); // The pure load never mutates the project — the FIFO stays put. assert!(fifo.exists()); + // And neither does the quarantine: a file we could not read is not + // known to be malformed, so it is never moved aside. + err.quarantine().await; + assert!(err.quarantined_to.is_none()); + assert!(fifo.exists()); + assert!(!dir.join("redirect-state.json.corrupt").exists()); + } + + /// An I/O failure (here: a directory squatting the ledger path) is + /// classified as UNREADABLE, not malformed: the message names the I/O + /// problem and does not tell the user to "repair its JSON", and + /// `quarantine` refuses to move the path aside. + #[tokio::test] + async fn load_unreadable_ledger_is_not_quarantined_or_called_malformed() { + let tmp = tempfile::tempdir().unwrap(); + let squatter = tmp.path().join(REDIRECT_STATE_REL); + tokio::fs::create_dir_all(&squatter).await.unwrap(); + + let mut err = load_redirect_state(tmp.path()).await.unwrap_err(); + assert!(err.unreadable); + let message = err.to_string(); + assert!( + message.contains("cannot be read") && message.contains("redirect-state.json"), + "unreadable wording names the file and the I/O class: {message}" + ); + assert!( + !message.contains("malformed") && !message.contains("repair its JSON"), + "an unreadable ledger must not be described as malformed: {message}" + ); + err.quarantine().await; + assert!(err.quarantined_to.is_none()); + assert!(squatter.is_dir(), "the squatting path is left in place"); + assert!(!tmp + .path() + .join(".socket/vendor/redirect-state.json.corrupt") + .exists()); + + // The malformed classification is unchanged: parse failures still + // say so and still quarantine. + tokio::fs::remove_dir(&squatter).await.unwrap(); + tokio::fs::write(&squatter, b"{ torn").await.unwrap(); + let err = load_redirect_state(tmp.path()).await.unwrap_err(); + assert!(!err.unreadable); + assert!(err.to_string().contains("malformed")); } /// A record carrying an EMPTY uuid (a hand-repaired ledger — a workflow @@ -1137,6 +1203,11 @@ mod tests { .await .unwrap(); assert!(text.ends_with('\n'), "ledger keeps its trailing newline"); + assert_eq!( + text, + format!("{}\n", serde_json::to_string_pretty(&state).unwrap()), + "wire bytes: pretty JSON plus one trailing newline" + ); // The atomic writer must not leave its stage file behind. let mut entries = tokio::fs::read_dir(tmp.path().join(".socket/vendor")) .await @@ -1150,6 +1221,46 @@ mod tests { } } + /// An idempotent hosted re-run re-saves the ledger it just loaded. A + /// byte-identical ledger must not be re-staged and renamed over (mtime + /// churn on a committed file, a needless fsync): with the parent made + /// read-only the identical save still succeeds — nothing is written — + /// while a changed ledger still has to write, and fails. + #[cfg(unix)] + #[tokio::test] + async fn save_skips_a_byte_identical_ledger() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let mut state = RedirectState::new(); + state + .records + .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); + save_redirect_state(tmp.path(), &state).await.unwrap(); + + let dir = tmp.path().join(".socket/vendor"); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap(); + if std::fs::File::create(dir.join("probe")).is_ok() { + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)); + let _ = std::fs::remove_file(dir.join("probe")); + eprintln!("skipping: running as root, 0555 does not block writes"); + return; + } + + let identical = save_redirect_state(tmp.path(), &state).await; + state + .records + .insert("pkg:npm/minimist@1.2.2".to_string(), sample_record()); + let changed = save_redirect_state(tmp.path(), &state).await; + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + + assert!(identical.is_ok(), "identical bytes: no write attempted"); + assert_eq!( + changed.unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied, + "changed bytes still go through the (here refused) atomic write" + ); + } + /// Persisting an EMPTY state into a project with no ledger must succeed /// as a pure no-op: the delete-instead-of-write path tolerates NotFound /// (a fresh project has nothing to delete) and must not scaffold @@ -1170,6 +1281,61 @@ mod tests { ); } + /// Emptying the ledger deletes it AND prunes the now-empty + /// `.socket/vendor/` it lived in — the residue a hosted rollback used to + /// leave — but never `.socket/` itself (the lock guard owns that level). + #[tokio::test] + async fn persist_empty_state_prunes_the_emptied_vendor_dir() { + let tmp = tempfile::tempdir().unwrap(); + let mut state = RedirectState::new(); + state + .records + .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); + save_redirect_state(tmp.path(), &state).await.unwrap(); + // Something else lives in `.socket/` (the lock, a manifest…). + tokio::fs::write(tmp.path().join(".socket/apply.lock"), b"") + .await + .unwrap(); + + persist_redirect_state(tmp.path(), &RedirectState::new()) + .await + .unwrap(); + + assert!(!tmp.path().join(REDIRECT_STATE_REL).exists()); + assert!( + !tmp.path().join(".socket/vendor").exists(), + "an emptied hosted ledger leaves no .socket/vendor/ husk" + ); + assert!( + tmp.path().join(".socket").exists(), + ".socket/ itself is never pruned here" + ); + } + + /// The prune is non-recursive: a `.corrupt` quarantine (the one + /// sanctioned residue) or the sibling vendor ledger keeps `.socket/vendor/`. + #[tokio::test] + async fn persist_empty_state_keeps_vendor_dir_with_siblings() { + let tmp = tempfile::tempdir().unwrap(); + let mut state = RedirectState::new(); + state + .records + .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); + save_redirect_state(tmp.path(), &state).await.unwrap(); + let dir = tmp.path().join(".socket/vendor"); + tokio::fs::write(dir.join("redirect-state.json.corrupt"), b"older") + .await + .unwrap(); + + persist_redirect_state(tmp.path(), &RedirectState::new()) + .await + .unwrap(); + + assert!(!dir.join("redirect-state.json").exists()); + assert!(dir.join("redirect-state.json.corrupt").exists()); + assert!(dir.exists(), "a non-empty vendor dir is kept"); + } + /// A FAILED delete of the emptied ledger (anything but NotFound) must /// propagate, never report success: callers treat `Ok` as "the ledger no /// longer asserts anything", and a swallowed error would leave a live diff --git a/crates/socket-patch-core/src/utils/fs.rs b/crates/socket-patch-core/src/utils/fs.rs index b92f9f0c..fb55549f 100644 --- a/crates/socket-patch-core/src/utils/fs.rs +++ b/crates/socket-patch-core/src/utils/fs.rs @@ -22,10 +22,12 @@ //! //! # Symlinks //! -//! `entry_is_dir` follows symlinks (uses `metadata()`, not -//! `symlink_metadata()`), matching the historical behavior of the -//! crawlers (pnpm's content-addressed store relies on resolving -//! symlinks into `node_modules/.pnpm/*`). +//! `entry_is_dir` follows symlinks: ordinary entries answer from the +//! `DirEntry`'s cached file type (no extra stat), and symlink entries are +//! resolved through [`is_dir`] (follow-links `metadata()`), so a link to a +//! directory reports `true` — matching the historical behavior of the +//! crawlers (pnpm's content-addressed store relies on resolving symlinks +//! into `node_modules/.pnpm/*`). use std::path::{Path, PathBuf}; @@ -117,52 +119,48 @@ pub(crate) async fn is_file(path: &Path) -> bool { /// FIFOs/devices/directories with `InvalidInput` instead of reading /// them (on some platforms a directory reads as zero bytes, which /// would otherwise be silently hashed as the empty blob). +/// +/// One blocking-pool hop: the open + fstat run together in +/// [`open_regular_file_sync`], the single copy of the guard. pub(crate) async fn open_regular_file( path: &Path, ) -> std::io::Result<(tokio::fs::File, std::fs::Metadata)> { - #[cfg(unix)] - let file = tokio::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NONBLOCK) - .open(path) - .await?; - #[cfg(not(unix))] - let file = tokio::fs::File::open(path).await?; - - let metadata = file.metadata().await?; - if !metadata.is_file() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("{} is not a regular file", path.display()), - )); - } - Ok((file, metadata)) + let path = path.to_path_buf(); + let (file, metadata) = asyncify(move || open_regular_file_sync(&path)).await?; + Ok((tokio::fs::File::from_std(file), metadata)) } -/// Read a regular file to a `String` through [`open_regular_file`]: the -/// FIFO-safe reader (non-blocking open, fstat regular-file check on the -/// opened descriptor) that the ecosystem modules had each re-declared -/// privately. Follows a symlink to a regular file; a FIFO, directory or -/// socket fails fast with `InvalidInput` instead of wedging in open(2). -/// `pub` so the CLI crate's raw `read_to_string` sites can share it. +/// Read a regular file to a `String` through the FIFO-safe opener +/// (non-blocking open, fstat regular-file check on the opened descriptor) +/// that the ecosystem modules had each re-declared privately. Follows a +/// symlink to a regular file; a FIFO, directory or socket fails fast with +/// `InvalidInput` instead of wedging in open(2). Open + read are one +/// blocking-pool hop (like tokio's own `fs::read_to_string`). `pub` so the +/// CLI crate's raw `read_to_string` sites can share it. pub async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) + let path = path.to_path_buf(); + asyncify(move || read_regular_to_string_sync(&path)).await } /// Read a binary regular file through the same FIFO-safe opener as text /// lockfiles. A malformed or non-regular lockfile never blocks discovery. pub async fn read_regular_to_bytes(path: &Path) -> std::io::Result> { - use tokio::io::AsyncReadExt as _; + let path = path.to_path_buf(); + asyncify(move || read_regular_to_bytes_sync(&path)).await +} - let (mut file, metadata) = open_regular_file(path).await?; - let mut content = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut content).await?; - Ok(content) +/// Run one blocking filesystem operation on tokio's blocking pool — the +/// same shape as tokio's internal `asyncify`, including its mapping of a +/// panicked/cancelled task to an `io::Error`. +async fn asyncify(f: F) -> std::io::Result +where + F: FnOnce() -> std::io::Result + Send + 'static, + T: Send + 'static, +{ + match tokio::task::spawn_blocking(f).await { + Ok(result) => result, + Err(_) => Err(std::io::Error::other("background task failed")), + } } /// True when `path` ITSELF is a symbolic link (lstat; the link target is not @@ -208,9 +206,9 @@ pub fn read_regular_to_bytes_sync(path: &Path) -> std::io::Result> { Ok(content) } -/// Blocking twin of [`open_regular_file`]: `O_NONBLOCK` open on Unix, then -/// the handle-based regular-file check, so the two sync readers above share -/// one guard instead of re-declaring it. +/// The one regular-file guard: `O_NONBLOCK` open on Unix, then the +/// handle-based regular-file check. The async [`open_regular_file`] and every +/// reader above run this on the blocking pool. fn open_regular_file_sync(path: &Path) -> std::io::Result<(std::fs::File, std::fs::Metadata)> { #[cfg(unix)] let file = { @@ -367,57 +365,59 @@ async fn atomic_write_bytes_as( .unwrap_or_else(|| "file".to_string()); let stage = parent.join(format!(".socket-stage-{}-{}", stem, uuid::Uuid::new_v4())); - let mut file = tokio::fs::OpenOptions::new() + // `create_new` failing leaves no stage to clean up; every step after it + // does, so they share one error arm. + let file = tokio::fs::OpenOptions::new() .write(true) .create_new(true) .open(&stage) .await?; - - use tokio::io::AsyncWriteExt; - if let Err(e) = file.write_all(content).await { + if let Err(e) = commit_stage(file, content, perms, &stage, path).await { let _ = tokio::fs::remove_file(&stage).await; return Err(e); } + + // The rename only updated the parent directory entry; fsync the directory + // so the rename itself survives a crash. Best-effort, Unix only. + #[cfg(unix)] + { + if let Ok(dir) = tokio::fs::File::open(parent).await { + let _ = dir.sync_all().await; + } + } + + Ok(()) +} + +/// Write, flush, fsync, (re-mode) and close the stage, then rename it over +/// `path`. Takes the handle by value so it is closed before the rename +/// (Windows refuses to rename an open file) and before the caller's +/// error-path unlink of the stage. +async fn commit_stage( + mut file: tokio::fs::File, + content: &[u8], + perms: Option, + stage: &Path, + path: &Path, +) -> std::io::Result<()> { + use tokio::io::AsyncWriteExt; + file.write_all(content).await?; // `write_all` only buffers into tokio's background writer, and // `sync_all` stores an in-flight write error back into the handle // instead of returning it — this flush is the only point where a // failed stage write (ENOSPC, EIO, quota) actually surfaces. Without // it the truncated stage would be renamed over the intact target. - if let Err(e) = file.flush().await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - if let Err(e) = file.sync_all().await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } + file.flush().await?; + file.sync_all().await?; // Set the preserved mode on the stage *before* the rename so the file // never appears at the destination with the wrong bits, even briefly. // The content is already written through the open handle, so a // restrictive mode (0400, 0000) cannot fail the write. if let Some(p) = perms { - if let Err(e) = file.set_permissions(p).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } + file.set_permissions(p).await?; } drop(file); - - if let Err(e) = tokio::fs::rename(&stage, path).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - - // The rename only updated the parent directory entry; fsync the directory - // so the rename itself survives a crash. Best-effort, Unix only. - #[cfg(unix)] - { - if let Ok(dir) = tokio::fs::File::open(parent).await { - let _ = dir.sync_all().await; - } - } - - Ok(()) + tokio::fs::rename(stage, path).await } #[cfg(test)] diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index fea8b05d..910dcd0a 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -10,6 +10,7 @@ pub mod python_lock; pub mod python_script; pub(crate) mod serde; pub mod socket_cli_config; +pub mod socket_dir; pub(crate) mod toml_edit_ext; pub mod uri; diff --git a/crates/socket-patch-core/src/utils/socket_cli_config.rs b/crates/socket-patch-core/src/utils/socket_cli_config.rs index c4fe0116..cc94651c 100644 --- a/crates/socket-patch-core/src/utils/socket_cli_config.rs +++ b/crates/socket-patch-core/src/utils/socket_cli_config.rs @@ -49,11 +49,13 @@ fn env_non_empty(name: &str) -> Option { std::env::var(name).ok().filter(|v| !v.is_empty()) } -/// Truthy check for the config-layer toggles (`SOCKET_NO_CONFIG`, -/// `SOCKET_NO_API_TOKEN`). Accepts the same affirmative vocabulary as the -/// CLI's `parse_bool_flag` (`1`/`true`/`yes`/`on`, case-insensitive); -/// anything else — including unset and empty — is false. -fn env_flag(name: &str) -> bool { +/// Truthy check for an opt-in/opt-out environment toggle (`SOCKET_NO_CONFIG`, +/// `SOCKET_NO_API_TOKEN`, the CLI's `SOCKET_NO_UPDATE_CHECK`, …). Accepts the +/// same affirmative vocabulary as the CLI's `parse_bool_flag` +/// (`1`/`true`/`yes`/`on`/`y`/`t`, trimmed, case-insensitive); anything else +/// — including unset and empty — is false. `pub` so the CLI shares this one +/// vocabulary instead of re-declaring it. +pub fn env_truthy(name: &str) -> bool { matches!( std::env::var(name) .unwrap_or_default() @@ -66,14 +68,14 @@ fn env_flag(name: &str) -> bool { /// `SOCKET_NO_CONFIG` — disable the socket-cli config fallback layer. pub fn is_config_disabled() -> bool { - env_flag("SOCKET_NO_CONFIG") + env_truthy("SOCKET_NO_CONFIG") } /// `SOCKET_NO_API_TOKEN` — ignore ambient API tokens (env var and /// socket-cli config); only an explicit `--api-token` flag authenticates. /// Mirrors socket-cli's `SOCKET_CLI_NO_API_TOKEN` (aliased in the CLI). pub fn no_api_token_veto() -> bool { - env_flag("SOCKET_NO_API_TOKEN") + env_truthy("SOCKET_NO_API_TOKEN") } /// Candidate config file paths, most-preferred first, mirroring diff --git a/crates/socket-patch-core/src/utils/socket_dir.rs b/crates/socket-patch-core/src/utils/socket_dir.rs new file mode 100644 index 00000000..252206dc --- /dev/null +++ b/crates/socket-patch-core/src/utils/socket_dir.rs @@ -0,0 +1,274 @@ +//! Residue-free removal of socket-patch-owned state under `.socket/`. +//! +//! Every reversal path (an emptied ledger, a swept blob store, a reverted +//! vendored unit) used to hand-roll "delete the file, then `remove_dir` the +//! parents I created" — or forgot to, leaving empty `.socket/vendor/`, +//! `.socket/vendor//` or `.socket/blobs/` husks behind. The helpers here +//! are the one implementation: delete, then climb the now-empty parents up +//! to but EXCLUDING `stop_dir` (normally the project's `.socket/`, which the +//! lock guard owns). +//! +//! Every prune is best-effort and non-recursive: `remove_dir` refuses a +//! non-empty directory, so anything still living there — vendored +//! artifacts, a sibling ecosystem's copies, the manifest, `apply.lock`, a +//! `redirect-state.json.corrupt` quarantine (the one sanctioned residue) — +//! keeps the directory and stops the climb. The climb is also confined to +//! `stop_dir`'s subtree, so a caller mistake can never rmdir its way out of +//! the project. + +use std::path::Path; + +use serde::Serialize; + +use super::fs::{atomic_write_bytes, read_regular_to_bytes}; + +/// Best-effort: remove `dir` when it is empty, then each ancestor while it is +/// empty, stopping before `stop_dir` (never removed) or at the first +/// directory that is not empty. A missing `dir` (already unwound wholesale) +/// continues to its parents — they may be husks this run created. Nothing +/// outside `stop_dir`'s subtree is ever touched. +pub async fn prune_empty_dirs(dir: &Path, stop_dir: &Path) { + let mut level = Some(dir); + while let Some(d) = level { + if d == stop_dir || !d.starts_with(stop_dir) { + return; + } + match tokio::fs::remove_dir(d).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return, + } + level = d.parent(); + } +} + +/// Remove the file at `path` (a missing file is fine), then prune its +/// now-empty parents up to but excluding `stop_dir`. Any unlink error other +/// than NotFound propagates BEFORE any pruning: a read-only parent leaves the +/// file — and the caller's fail-closed error — exactly where they were. +pub async fn remove_file_and_prune(path: &Path, stop_dir: &Path) -> std::io::Result<()> { + match tokio::fs::remove_file(path).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + if let Some(parent) = path.parent() { + prune_empty_dirs(parent, stop_dir).await; + } + Ok(()) +} + +/// Remove the tree at `dir` (a missing tree is fine; read-only directory +/// modes are relaxed like [`remove_tree`](crate::patch::copy_tree::remove_tree)), +/// then prune its now-empty parents up to but excluding `stop_dir`. The +/// per-unit vendored revert: `.socket/vendor///` goes, then the +/// `/` and `vendor/` levels when that was their last unit. A removal +/// error propagates unchanged (callers surface it verbatim) and skips the +/// prune — the tree is still there. +pub async fn remove_tree_and_prune(dir: &Path, stop_dir: &Path) -> std::io::Result<()> { + crate::patch::copy_tree::remove_tree(dir).await?; + if let Some(parent) = dir.parent() { + prune_empty_dirs(parent, stop_dir).await; + } + Ok(()) +} + +/// Persist a committed JSON ledger: pretty-printed with a trailing newline +/// (deterministic bytes), parent directory created on demand, staged + +/// fsync'd + renamed via [`atomic_write_bytes`]. A ledger already holding +/// these exact bytes is left untouched — an idempotent re-run must not churn +/// the mtime of a committed file or pay a needless fsync. The comparison +/// reads through the FIFO-safe opener; any read error (absent, unreadable, +/// not a regular file) simply falls through to the write, so failure paths +/// are exactly those of a plain write. +pub(crate) async fn write_json_ledger(path: &Path, value: &T) -> std::io::Result<()> { + let mut bytes = serde_json::to_vec_pretty(value).map_err(std::io::Error::other)?; + bytes.push(b'\n'); + if matches!(read_regular_to_bytes(path).await, Ok(existing) if existing == bytes) { + return Ok(()); + } + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + atomic_write_bytes(path, &bytes).await +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The climb removes every empty level below `stop_dir`, never + /// `stop_dir` itself, and continues past a level that is already gone. + #[tokio::test] + async fn prune_climbs_empty_levels_and_stops_before_stop_dir() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join(".socket"); + let uuid_dir = socket.join("vendor/npm/uuid"); + tokio::fs::create_dir_all(&uuid_dir).await.unwrap(); + + // Start one level BELOW a dir that does not exist: NotFound must not + // end the climb (the unwind paths remove the uuid dir wholesale + // before pruning). + prune_empty_dirs(&uuid_dir.join("gone"), &socket).await; + + assert!(!socket.join("vendor").exists(), "every empty level pruned"); + assert!(socket.exists(), "stop_dir is never removed"); + assert!(tmp.path().exists()); + } + + /// A non-empty level keeps itself and everything above it. + #[tokio::test] + async fn prune_stops_at_the_first_non_empty_level() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join(".socket"); + let eco = socket.join("vendor/npm"); + tokio::fs::create_dir_all(eco.join("a")).await.unwrap(); + tokio::fs::write(eco.join("sibling.tgz"), b"x") + .await + .unwrap(); + + prune_empty_dirs(&eco.join("a"), &socket).await; + + assert!(!eco.join("a").exists(), "the empty leaf goes"); + assert!(eco.join("sibling.tgz").exists(), "siblings are untouched"); + assert!(eco.exists() && socket.join("vendor").exists()); + } + + /// A `dir` outside `stop_dir`'s subtree is refused outright — the climb + /// can never rmdir its way out of the project. + #[tokio::test] + async fn prune_never_leaves_the_stop_dir_subtree() { + let tmp = tempfile::tempdir().unwrap(); + let elsewhere = tmp.path().join("elsewhere/empty"); + tokio::fs::create_dir_all(&elsewhere).await.unwrap(); + + prune_empty_dirs(&elsewhere, &tmp.path().join(".socket")).await; + + assert!(elsewhere.exists(), "an out-of-subtree dir is left alone"); + } + + #[tokio::test] + async fn remove_file_and_prune_unlinks_then_climbs() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join(".socket"); + let ledger = socket.join("vendor/redirect-state.json"); + tokio::fs::create_dir_all(ledger.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&ledger, b"{}").await.unwrap(); + + remove_file_and_prune(&ledger, &socket).await.unwrap(); + assert!(!socket.join("vendor").exists()); + assert!(socket.exists()); + + // A missing file is fine, and still prunes a stale empty parent. + tokio::fs::create_dir_all(socket.join("vendor")) + .await + .unwrap(); + remove_file_and_prune(&ledger, &socket).await.unwrap(); + assert!(!socket.join("vendor").exists()); + } + + /// The unlink error propagates verbatim and nothing is pruned — the + /// caller's fail-closed error keeps the file exactly where it was. + #[cfg(unix)] + #[tokio::test] + async fn remove_file_and_prune_propagates_unlink_errors_before_pruning() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join(".socket"); + let dir = socket.join("vendor"); + let ledger = dir.join("state.json"); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(&ledger, b"{}").await.unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap(); + if std::fs::File::create(dir.join("probe")).is_ok() { + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)); + eprintln!("skipping: running as root, 0555 does not block writes"); + return; + } + + let err = remove_file_and_prune(&ledger, &socket).await.unwrap_err(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + assert!(ledger.exists(), "a failed unlink leaves the file in place"); + } + + #[tokio::test] + async fn remove_tree_and_prune_removes_the_unit_and_its_empty_parents() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join(".socket"); + let uuid_dir = socket.join("vendor/npm/uuid"); + tokio::fs::create_dir_all(uuid_dir.join("package")) + .await + .unwrap(); + tokio::fs::write(uuid_dir.join("package/index.js"), b"x") + .await + .unwrap(); + // A sibling unit under another ecosystem keeps `vendor/`. + let other = socket.join("vendor/pypi/other"); + tokio::fs::create_dir_all(&other).await.unwrap(); + + remove_tree_and_prune(&uuid_dir, &socket).await.unwrap(); + + assert!(!socket.join("vendor/npm").exists(), "unit + eco husk gone"); + assert!(other.exists() && socket.join("vendor").exists()); + + // Missing tree: still Ok, still prunes. + remove_tree_and_prune(&other, &socket).await.unwrap(); + remove_tree_and_prune(&other, &socket).await.unwrap(); + assert!(!socket.join("vendor").exists()); + assert!(socket.exists()); + } + + #[derive(Serialize)] + struct Ledger { + version: u32, + entries: Vec, + } + + /// Bytes are pretty JSON plus a trailing newline; the parent is created + /// on demand; an identical ledger is not rewritten (its inode survives). + #[tokio::test] + async fn write_json_ledger_is_deterministic_and_skips_identical_bytes() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".socket/vendor/state.json"); + let ledger = Ledger { + version: 1, + entries: vec!["a".into()], + }; + write_json_ledger(&path, &ledger).await.unwrap(); + let text = tokio::fs::read_to_string(&path).await.unwrap(); + assert_eq!( + text, + format!("{}\n", serde_json::to_string_pretty(&ledger).unwrap()) + ); + + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let before = std::fs::metadata(&path).unwrap().ino(); + write_json_ledger(&path, &ledger).await.unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().ino(), + before, + "an identical ledger must not be re-staged and renamed over" + ); + let changed = Ledger { + version: 1, + entries: vec!["a".into(), "b".into()], + }; + write_json_ledger(&path, &changed).await.unwrap(); + assert_ne!( + std::fs::metadata(&path).unwrap().ino(), + before, + "a changed ledger is atomically replaced" + ); + } + // No stage litter either way. + for entry in std::fs::read_dir(path.parent().unwrap()).unwrap() { + let name = entry.unwrap().file_name().to_string_lossy().into_owned(); + assert!(!name.starts_with(".socket-stage-"), "litter: {name}"); + } + } +} diff --git a/crates/socket-patch-core/src/vendor/state.rs b/crates/socket-patch-core/src/vendor/state.rs index 83b3a74f..54f7ba9c 100644 --- a/crates/socket-patch-core/src/vendor/state.rs +++ b/crates/socket-patch-core/src/vendor/state.rs @@ -28,9 +28,11 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; -use crate::utils::fs::atomic_write_bytes; +use crate::utils::fs::{atomic_write_bytes, read_regular_to_bytes}; use crate::utils::serde::serialize_sorted; +use crate::utils::socket_dir::{prune_empty_dirs, remove_file_and_prune, write_json_ledger}; use super::path::VENDOR_DIR; @@ -246,19 +248,24 @@ pub struct VendorEntry { /// pypi/pipenv extras. #[serde(default, skip_serializing_if = "Option::is_none")] pub pipenv: Option, - /// True when vendored without a manifest record (`scan --vendor - /// --detached`). The manifest reconcile must not revert such an entry — - /// it is never "dropped from the manifest" because it was never in it; - /// [`VendorEntry::record`] is the verification source instead. + /// True when vendored WITHOUT a manifest record — the posture of every + /// `scan` / `get --mode vendored` entry (vendored mode writes no + /// `.socket/manifest.json`); only the manifest-driven standalone + /// `vendor` command records `false`. The manifest reconcile must not + /// revert a detached entry — it is never "dropped from the manifest" + /// because it was never in it; [`VendorEntry::record`] is the + /// verification source instead. Always serialized when true so older + /// readers keep the same exemption. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub detached: bool, /// The embedded patch record for detached entries (afterHashes, - /// vulnerabilities, description, tier) — present iff `detached`. Trust - /// class: the same committed-file trust as `.socket/manifest.json`; the - /// artifact is still re-verified against these afterHashes and - /// `checked_artifact_path`'s uuid cross-checks before any disk access. + /// vulnerabilities, description, tier) — present iff `detached`, and the + /// ONLY verification source for such an entry. Trust class: the same + /// committed-file trust as `.socket/manifest.json`; the artifact is still + /// re-verified against these afterHashes and `checked_artifact_path`'s + /// uuid cross-checks before any disk access. #[serde(default, skip_serializing_if = "Option::is_none")] - pub record: Option, + pub record: Option, } /// The ledger. @@ -446,9 +453,15 @@ fn state_path(project_root: &Path) -> PathBuf { /// instead of bricking every vendor-adjacent command (`remove`, `vendor`, /// `repair`) with `vendor_state_unreadable`. Such a file carries no vendor /// data by construction, so nothing is guessed. +/// +/// The bytes come from the (untrusted) project tree through the FIFO-safe +/// [`read_regular_to_bytes`] — non-blocking on Unix, rejecting FIFOs / +/// devices / directories — so a planted special file fails loudly instead of +/// wedging every vendor-adjacent command on an `open(2)` that waits forever +/// for a writer; same guard as the sibling redirect ledger. pub async fn load_state(project_root: &Path) -> std::io::Result { let path = state_path(project_root); - match read_state_bytes(&path).await { + match read_regular_to_bytes(&path).await { Ok(bytes) => serde_json::from_slice(&bytes).or_else(|e| { if let Ok(value) = serde_json::from_slice::(&bytes) { if value.get("mode").is_some() && value.get("entries").is_none() { @@ -465,48 +478,30 @@ pub async fn load_state(project_root: &Path) -> std::io::Result { } } -/// Read the ledger bytes from the (untrusted) project tree. Opens via -/// [`open_regular_file`](crate::utils::fs::open_regular_file) — non-blocking -/// on Unix, rejecting FIFOs/devices/directories — so a planted special file -/// fails loudly instead of wedging every vendor-adjacent command (`vendor`, -/// `remove`, `repair`) on a FIFO `open(2)` that waits forever for a writer; -/// same guard as the sibling redirect ledger. -async fn read_state_bytes(path: &Path) -> std::io::Result> { - use tokio::io::AsyncReadExt; - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut bytes).await?; - Ok(bytes) -} - /// Persist the ledger atomically with sorted keys + 2-space indent + trailing -/// newline (deterministic bytes — the file is committed). An EMPTY ledger -/// deletes `state.json` and prunes `.socket/vendor/` when that leaves it -/// empty, so a fully-reverted project carries no vendor residue. +/// newline (deterministic bytes — the file is committed; a byte-identical +/// ledger is not rewritten). An EMPTY ledger deletes `state.json` and prunes +/// `.socket/vendor/` when that leaves it empty, so a fully-reverted project +/// carries no vendor residue below `.socket/` itself (the lock guard's +/// level). A failed unlink propagates before any prune. pub async fn save_state(project_root: &Path, state: &VendorState) -> std::io::Result<()> { let path = state_path(project_root); - if state.entries.is_empty() { - match tokio::fs::remove_file(&path).await { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(e), - } - // Prune now-empty ecosystem levels, then .socket/vendor itself. - // `remove_dir` is non-recursive: a dir still holding artifacts (or - // anything we don't own) fails harmlessly and is kept. - let vendor_root = project_root.join(VENDOR_DIR); - for eco in super::path::ECOSYSTEM_DIRS { - let _ = tokio::fs::remove_dir(vendor_root.join(eco)).await; - } - let _ = tokio::fs::remove_dir(&vendor_root).await; - return Ok(()); + if !state.entries.is_empty() { + return write_json_ledger(&path, state).await; } - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; + let socket_dir = project_root.join(SOCKET_DIR); + // Delete the ledger; a read-only parent surfaces here, before anything + // is pruned. + remove_file_and_prune(&path, &socket_dir).await?; + // Backstop for ecosystem-level husks left by per-unit reverts that did + // not prune their own parents. `remove_dir` is non-recursive: a dir + // still holding artifacts (or anything we don't own) is kept, and then + // so is `.socket/vendor/`. + let vendor_root = project_root.join(VENDOR_DIR); + for eco in super::path::ECOSYSTEM_DIRS { + prune_empty_dirs(&vendor_root.join(eco), &socket_dir).await; } - let mut bytes = serde_json::to_vec_pretty(state).map_err(std::io::Error::other)?; - bytes.push(b'\n'); - atomic_write_bytes(&path, &bytes).await + Ok(()) } /// The informational marker written inside each vendored unit @@ -1101,12 +1096,24 @@ mod tests { save_state(root, &state).await.unwrap(); assert!(root.join(VENDOR_STATE_REL).exists()); + // An empty ecosystem husk left by a per-unit revert goes too. + tokio::fs::create_dir_all(root.join(".socket/vendor/npm")) + .await + .unwrap(); + // `.socket/` holds something else (the lock, a manifest…). + tokio::fs::write(root.join(".socket/apply.lock"), b"") + .await + .unwrap(); state.entries.clear(); save_state(root, &state).await.unwrap(); assert!(!root.join(VENDOR_STATE_REL).exists()); assert!( !root.join(VENDOR_DIR).exists(), - ".socket/vendor pruned when empty" + ".socket/vendor (and its empty eco husks) pruned when empty" + ); + assert!( + root.join(SOCKET_DIR).exists(), + ".socket/ itself is never pruned here" ); // But a vendor dir that still holds artifacts is NOT pruned. @@ -1129,6 +1136,15 @@ mod tests { ); } + /// The ledger path is spelled as a literal (a `const` cannot be built + /// from another with `concat!`); pin it to the directory constant it + /// re-spells so the two can never drift apart. + #[test] + fn vendor_state_rel_lives_directly_under_vendor_dir() { + assert_eq!(VENDOR_STATE_REL, format!("{VENDOR_DIR}/state.json")); + assert!(VENDOR_DIR.starts_with(&format!("{SOCKET_DIR}/"))); + } + #[tokio::test] async fn marker_writes_atomically() { let tmp = tempfile::tempdir().unwrap(); From 99876255e3a1643f1f64949b262578307bc49c6a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 18:52:08 -0400 Subject: [PATCH 04/44] refactor(core/redirect): shared guarded atomic staging, override gating, nuget/cargo-lock fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosted-redirect reverts (per-purl takeover + whole-ledger replay): - New redirect/staged.rs shared by takeover.rs and replay.rs: FIFO-safe read_rel (read_regular_to_string), staged_read, and one flush_staged with the not-a-regular-file guard on every path (text, binary, delete), atomic_write_bytes_preserving_mode for text AND bun.lockb (replay's plain writer reset the mode), and the emptied-parent prune scoped to sub-directories only. takeover.rs read_rel/write_rel were raw read_to_string/fs::write (FIFO wedge, write-through-symlink, torn lockfile on ENOSPC); replay.rs text flush was a truncating fs::write. - takeover.rs: find_record_key/drop_claimed dedupe the per-purl ledger bookkeeping; package-lock.json is read once (attribution text reused by the JSON replay); registry-uuid regex hoisted to a LazyLock static; per-edit FileEdit clones become borrows; dead CargoRedirectRevert alias removed; dry_run docstrings now describe the in-memory ledger claim. - replay.rs: HatchDocument arm no longer stages a byte-identical write / editedFiles credit when the file is already at its original. Rewriters (mod.rs): - registry_override_of_kind unifies the five override-kind gatings: absent AND foreign-kind overrides both warn the ecosystem's missing-override code (nuget/gem/golang previously skipped silently). - composer/gem/maven grants with no composer.lock / Gemfile(.lock) / pom.xml now warn redirect_composer_no_lockfile / redirect_gem_no_gemfile / redirect_maven_no_pom (once per run; a Gradle-only project keeps its snippet path) instead of vanishing. - nuget: a present-but-corrupt packages.lock.json warns redirect_nuget_lock_unparseable and skips (was silently treated as absent); the re-run probe reads the parsed keys (hand-normalized spellings no longer duplicate the source); a nuget.config authored from scratch records action "added". - plan_cargo_lock: same-name+version twin blocks are line-anchored and disambiguated — exactly one twin at the target index is ours (re-run beside a crates.io copy is a no-op), otherwise the dep is skipped with redirect_cargo_lock_pkg_ambiguous instead of repointing the first hit. - Fixed regexes hoisted to LazyLock statics (cargo toml/lock, composer, nuget, maven dependency block); maven tag scan is a plain find; yarn berry probe is is_berry_lock(); maven pom edited in place via as_mut. - Dispatcher: pub fn pdm_drives(files); withhold() borrows the override set unless a pdm/pipenv veto applies (two unconditional clones gone); requirements::rewrite called directly (pass-through wrapper deleted); rewrite_hatch overlays only pyproject.toml/hatch.toml. - serialize_json: expect() instead of swallowing an Err into "\n". Tests: write_failure_at_flush now makes the parent dir read-only (rename ignores the target's mode); new takeover FIFO/symlink/mode twins, replay mode + hatch-already-original tests, bun.lockb mode assertion, nuget unparseable/hand-normalized/added tests, cargo twin-block tests, composer/gem/maven no-file tests; foreign-kind and gem override tests pin the warn-everywhere policy. Co-Authored-By: Claude Fable 5.1 --- .../src/patch/redirect/bun_binary.rs | 24 + .../src/patch/redirect/mod.rs | 741 +++++++++++++----- .../src/patch/redirect/replay.rs | 188 ++--- .../src/patch/redirect/staged.rs | 120 +++ .../src/patch/redirect/takeover.rs | 327 ++++---- 5 files changed, 982 insertions(+), 418 deletions(-) create mode 100644 crates/socket-patch-core/src/patch/redirect/staged.rs diff --git a/crates/socket-patch-core/src/patch/redirect/bun_binary.rs b/crates/socket-patch-core/src/patch/redirect/bun_binary.rs index 71dbcf07..16083595 100644 --- a/crates/socket-patch-core/src/patch/redirect/bun_binary.rs +++ b/crates/socket-patch-core/src/patch/redirect/bun_binary.rs @@ -303,6 +303,17 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let bytes = result.binary_files["bun.lockb"].clone(); std::fs::write(dir.path().join("bun.lockb"), &bytes).unwrap(); + // The whole-ledger replay restores the binary lock through the + // mode-preserving atomic writer, like the per-purl path. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + dir.path().join("bun.lockb"), + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + } let mut ledger = state(result.edits); let replay = revert_remaining_redirect_edits(dir.path(), &mut ledger.clone(), false).await; assert!(replay.refusals.is_empty(), "{:?}", replay.refusals); @@ -310,6 +321,19 @@ mod tests { std::fs::read(dir.path().join("bun.lockb")).unwrap(), FIXTURE ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(dir.path().join("bun.lockb")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600, + "bun.lockb keeps its mode across the replay" + ); + } let mut drift = BunLockb::parse(&bytes).unwrap(); let first_id = ledger.edits[0].key.as_ref().unwrap().parse().unwrap(); drift diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 6bc03c01..4ffa2a5a 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -14,7 +14,9 @@ //! `preserve_order` (2-space pretty + trailing newline) to match the TS //! `JSON.stringify(v, null, 2) + '\n'`. +use std::borrow::Cow; use std::collections::BTreeMap; +use std::sync::LazyLock; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -32,6 +34,7 @@ mod pnpm; mod poetry; mod replay; mod requirements; +mod staged; mod state; mod takeover; pub use replay::{revert_remaining_redirect_edits, GroupRefusal, ReplayOutcome}; @@ -41,7 +44,7 @@ pub use state::{ }; pub use takeover::{ redirect_revert_supported, revert_cargo_redirect_purl, revert_npm_redirect_purl, - revert_redirect_purl, CargoRedirectRevert, RedirectRevert, + revert_redirect_purl, RedirectRevert, }; /// One ecosystem's integrity hashes (mirrors the TS `PatchArtifactIntegrity`). @@ -215,12 +218,23 @@ fn full_name(dep: &DepOverride) -> String { /// (2-space pretty via serde_json, key order preserved by `preserve_order`, /// `/` unescaped). fn serialize_json(value: &Value) -> String { + // A `Value` into an in-memory buffer cannot fail; swallowing an `Err` + // into an empty string would truncate the user's lockfile to "\n". format!( "{}\n", - serde_json::to_string_pretty(value).unwrap_or_default() + serde_json::to_string_pretty(value).expect("serde_json::Value serializes infallibly") ) } +/// The dep's registry override when it is of `kind`. `None` for an absent +/// AND for a foreign-kind override alike — neither can drive this +/// ecosystem's rewrite, so every rewriter warns its missing-override code +/// for both: a granted dep the rewriter cannot honor must be SAID, never +/// silently dropped from the redirected count. +fn registry_override_of_kind<'a>(dep: &'a DepOverride, kind: &str) -> Option<&'a RegistryOverride> { + dep.registry_override.as_ref().filter(|ov| ov.kind == kind) +} + /// Run every rewriter and merge the results (each owns distinct files). pub fn rewrite_registry_redirect( files: &BTreeMap, @@ -254,6 +268,37 @@ pub fn pipenv_reserialized_around_reference( pipenv::reserialized_around_reference(live, ours) } +/// Whether `pdm.lock` is the project's PyPI install driver: present, with no +/// `uv.lock` or `poetry.lock` beside it (mirroring the vendored flavor +/// precedence uv > poetry > pdm > pipenv). A leftover `pdm.lock` beside one +/// of those neither blocks nor is attested through them. `pub` so the CLI's +/// hosted confirmation gate can share the predicate instead of re-deriving it. +pub fn pdm_drives(files: &BTreeMap) -> bool { + files.contains_key("pdm.lock") + && !files.contains_key("uv.lock") + && !files.contains_key("poetry.lock") +} + +/// `overrides` minus the deps whose patch uuid is in `refused` — borrowed +/// untouched when nothing was refused (the common case), cloned only when a +/// veto actually applies. +fn withhold<'a>( + overrides: &'a [DepOverride], + refused: &std::collections::BTreeSet, +) -> Cow<'a, [DepOverride]> { + if refused.is_empty() { + Cow::Borrowed(overrides) + } else { + Cow::Owned( + overrides + .iter() + .filter(|dep| !refused.contains(&dep.patch_uuid)) + .cloned() + .collect(), + ) + } +} + pub fn rewrite_registry_redirect_with_pipenv_version( files: &BTreeMap, overrides: &[DepOverride], @@ -262,43 +307,25 @@ pub fn rewrite_registry_redirect_with_pipenv_version( ) -> RewriteResult { let mut result = RewriteResult::default(); // pdm runs FIRST, but only when `pdm.lock` is the project's PyPI install - // driver: a `uv.lock` or `poetry.lock` beside it takes precedence (mirroring - // the vendored flavor precedence uv > poetry > pdm > pipenv), so a leftover - // `pdm.lock` neither blocks nor is attested through them. When pdm does - // drive, a patch it refuses is withheld from every other pypi rewriter so a - // sibling `Pipfile.lock` / `requirements.txt` cannot attest a patch the - // installing lock will never honor. - let pdm_drives = files.contains_key("pdm.lock") - && !files.contains_key("uv.lock") - && !files.contains_key("poetry.lock"); - if pdm_drives { + // driver (see [`pdm_drives`]). When it does, a patch it refuses is + // withheld from every other pypi rewriter so a sibling `Pipfile.lock` / + // `requirements.txt` cannot attest a patch the installing lock will never + // honor. + if pdm_drives(files) { pdm::rewrite(files, overrides, &mut result); } - let usable: Vec<_> = overrides - .iter() - .filter(|dep| !result.refused_pdm_uuids.contains(&dep.patch_uuid)) - .cloned() - .collect(); - let overrides = if pdm_drives { - usable.as_slice() - } else { - overrides - }; + let overrides = withhold(overrides, &result.refused_pdm_uuids); // Pipenv next: a CONFLICT in a live Pipfile.lock vetoes the sibling pypi // rewriters too (see `pipenv::rewrite`). - pipenv::rewrite(files, overrides, pipenv_major, &mut result); - let overrides: Vec<_> = overrides - .iter() - .filter(|dep| !result.refused_pipenv_uuids.contains(&dep.patch_uuid)) - .cloned() - .collect(); - let overrides = overrides.as_slice(); + pipenv::rewrite(files, &overrides, pipenv_major, &mut result); + let overrides = withhold(&overrides, &result.refused_pipenv_uuids); + let overrides: &[DepOverride] = &overrides; rewrite_npm_lock(files, overrides, &mut result); rewrite_pnpm_lock(files, overrides, &mut result); rewrite_yarn_classic(files, overrides, &mut result); rewrite_yarn_berry(files, overrides, &mut result); rewrite_bun_lock(files, overrides, &mut result); - rewrite_pypi_requirements(files, overrides, &mut result); + requirements::rewrite(files, overrides, &mut result); rewrite_hatch(files, overrides, &mut result); rewrite_uv_lock(files, overrides, python_metadata, &mut result); poetry::rewrite_poetry(files, overrides, &mut result); @@ -339,7 +366,13 @@ fn rewrite_hatch( .extend(result.confirmed_requirements_uuids.iter().cloned()); return; } - let mut current = files.clone(); + // Overlay only the two documents the hatch planner reads, so the second + // pypi dep sees the first dep's rewritten pyproject — without cloning + // every candidate lockfile in `files` for it. + let mut current: BTreeMap = ["pyproject.toml", "hatch.toml"] + .into_iter() + .filter_map(|k| files.get(k).map(|v| (k.to_owned(), v.clone()))) + .collect(); for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") { let Some(hash) = dep.integrity.sha256.as_ref().filter(|hash| { @@ -642,15 +675,6 @@ fn rewrite_npm_v2_deps( changed } -// ── pip requirements.txt ──────────────────────────────────────────────────── -fn rewrite_pypi_requirements( - files: &BTreeMap, - overrides: &[DepOverride], - result: &mut RewriteResult, -) { - requirements::rewrite(files, overrides, result); -} - // ── cargo (Cargo.toml + .cargo/config.toml + Cargo.lock) ───────────────────── // // TRANSACTIONAL per dependency: a dep is redirected ONLY if its Cargo.toml pin @@ -692,20 +716,13 @@ fn rewrite_cargo( let (mut toml_changed, mut lock_changed, mut config_changed) = (false, false, false); for dep in &cargo { - let Some(ov) = &dep.registry_override else { + let Some(ov) = registry_override_of_kind(dep, "cargo-sparse") else { result.warnings.push(RewriteWarning { code: "redirect_cargo_missing_override".into(), detail: format!("{} has no cargo-sparse registry override", dep.name), }); continue; }; - if ov.kind != "cargo-sparse" { - result.warnings.push(RewriteWarning { - code: "redirect_cargo_missing_override".into(), - detail: format!("{} has no cargo-sparse registry override", dep.name), - }); - continue; - } // Service-supplied strings are interpolated into raw TOML (a section // header, a quoted value) and into Cargo.lock — validate them against // their exact expected grammars BEFORE any write, mirroring the @@ -828,6 +845,18 @@ fn rewrite_cargo( }); continue; } + CargoLockPlan::Ambiguous => { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_lock_pkg_ambiguous".into(), + detail: format!( + "Cargo.lock holds more than one [[package]] for {}@{} (several \ + sources) and none of them is the socket registry copy; cannot \ + tell which to repoint — dependency skipped (nothing rewritten)", + dep.name, dep.version + ), + }); + continue; + } } } else { LockCommit::Absent @@ -1069,26 +1098,44 @@ enum CargoTomlAction { /// `socket-patch-` pin is superseded in place, and any occurrence that /// cannot be handled refuses the whole dep. Nothing is applied unless every /// occurrence resolves. +// The Cargo.toml planner's fixed probes, compiled once: `plan_cargo_toml` +// runs once per cargo dep, and a regex compile per probe per dep is pure +// waste on a manifest with many patched crates. +static CARGO_TOML_HEADER_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^\[([^\]]+)\]\s*(?:#.*)?$").expect("static section-header regex is valid") +}); +static CARGO_TOML_PACKAGE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"\bpackage\s*=\s*"([^"]*)""#).expect("static package-key regex is valid") +}); +static CARGO_TOML_REGISTRY_VAL_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"\bregistry\s*=\s*"([^"]*)""#).expect("static registry-value regex is valid") +}); +static CARGO_TOML_REGISTRY_KEY_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"\bregistry\s*=").expect("static registry-key probe regex is valid") +}); +static CARGO_TOML_REGISTRY_INDEX_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"\bregistry-index\s*=").expect("static registry-index probe regex is valid") +}); +static CARGO_TOML_WORKSPACE_KEY_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"\bworkspace\s*=").expect("static workspace-key probe regex is valid") +}); +static CARGO_TOML_PATH_GIT_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"\b(?:path|git)\s*=").expect("static path/git probe regex is valid") +}); + fn plan_cargo_toml( content: &str, crate_name: &str, reg: &str, ) -> Result { let lines: Vec<&str> = content.split('\n').collect(); - let header_re = - Regex::new(r"^\[([^\]]+)\]\s*(?:#.*)?$").expect("static section-header regex is valid"); - let package_re = - Regex::new(r#"\bpackage\s*=\s*"([^"]*)""#).expect("static package-key regex is valid"); - let registry_val_re = - Regex::new(r#"\bregistry\s*=\s*"([^"]*)""#).expect("static registry-value regex is valid"); - let registry_key_re = - Regex::new(r"\bregistry\s*=").expect("static registry-key probe regex is valid"); - let registry_index_re = - Regex::new(r"\bregistry-index\s*=").expect("static registry-index probe regex is valid"); - let workspace_key_re = - Regex::new(r"\bworkspace\s*=").expect("static workspace-key probe regex is valid"); - let path_git_re = - Regex::new(r"\b(?:path|git)\s*=").expect("static path/git probe regex is valid"); + let header_re: &Regex = &CARGO_TOML_HEADER_RE; + let package_re: &Regex = &CARGO_TOML_PACKAGE_RE; + let registry_val_re: &Regex = &CARGO_TOML_REGISTRY_VAL_RE; + let registry_key_re: &Regex = &CARGO_TOML_REGISTRY_KEY_RE; + let registry_index_re: &Regex = &CARGO_TOML_REGISTRY_INDEX_RE; + let workspace_key_re: &Regex = &CARGO_TOML_WORKSPACE_KEY_RE; + let path_git_re: &Regex = &CARGO_TOML_PATH_GIT_RE; // A pending occurrence: what was found, resolved to an action in pass 2 // (workspace-inheriting entries need the whole file scanned first). @@ -1448,6 +1495,16 @@ fn plan_cargo_toml( }) } +static CARGO_LOCK_SOURCE_LINE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?m)^source = "[^"]*"$"#).expect("static lock source-line regex is valid") +}); +static CARGO_LOCK_CHECKSUM_LINE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?m)^checksum = "[^"]*"$"#).expect("static lock checksum-line regex is valid") +}); +static CARGO_LOCK_AFTER_SOURCE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?m)^(source = "[^"]*"\n)"#).expect("static source-line anchor regex is valid") +}); + fn plan_cargo_lock( content: &str, crate_name: &str, @@ -1458,43 +1515,68 @@ fn plan_cargo_lock( // Rust's regex has NO lookahead, so bound the [[package]] block by string // search: from its header to the next `\n[[package]]` (or EOF), so the // trailing bytes after the block (incl. the final newline) are preserved. - let head = format!("[[package]]\nname = \"{crate_name}\"\nversion = \"{version}\"\n"); - let Some(block_start) = content.find(&head) else { - return CargoLockPlan::NotFound; - }; - let body_start = block_start + head.len(); - let mut block_end = match content[body_start..].find("\n[[package]]") { - Some(rel) => body_start + rel, - None => content.len(), - }; - // Exclude trailing newline(s) from the block region so the recorded + // Trailing newline(s) are excluded from the block region so the recorded // original/new strings stop after the last content byte (mirrors the TS // rewriter's `(?=\n*$)` lookahead), while the file keeps its trailing // newline (it stays outside the replaced region). - while block_end > body_start && content.as_bytes()[block_end - 1] == b'\n' { - block_end -= 1; - } + let block_end_after = |body_start: usize| -> usize { + let mut block_end = match content[body_start..].find("\n[[package]]") { + Some(rel) => body_start + rel, + None => content.len(), + }; + while block_end > body_start && content.as_bytes()[block_end - 1] == b'\n' { + block_end -= 1; + } + block_end + }; + let head = format!("[[package]]\nname = \"{crate_name}\"\nversion = \"{version}\"\n"); + // Every line-anchored header for this name@version. A Cargo.lock may + // legitimately hold TWO blocks for one name@version from different + // sources — after a redirect, a transitive crates.io copy resolves beside + // the socket-registry copy, and cargo sorts the crates.io block FIRST — + // so the first hit alone would repoint the wrong twin. + let heads: Vec = content + .match_indices(head.as_str()) + .map(|(at, _)| at) + .filter(|&at| at == 0 || content.as_bytes()[at - 1] == b'\n') + .collect(); + let block_start = match heads.as_slice() { + [] => return CargoLockPlan::NotFound, + [only] => *only, + twins => { + // Exactly one twin already at the target index is OURS (a re-run + // over a redirected lock); anything else cannot be attributed and + // the dep is skipped transactionally. + let target_source = format!("source = \"{index_url}\""); + let mut ours = twins.iter().copied().filter(|&at| { + let body_start = at + head.len(); + content[body_start..block_end_after(body_start)] + .lines() + .any(|line| line == target_source) + }); + match (ours.next(), ours.next()) { + (Some(at), None) => at, + _ => return CargoLockPlan::Ambiguous, + } + } + }; + let body_start = block_start + head.len(); + let block_end = block_end_after(body_start); let original = content[block_start..block_end].to_string(); let mut body = content[body_start..block_end].to_string(); - let source_re = - Regex::new(r#"(?m)^source = "[^"]*"$"#).expect("static lock source-line regex is valid"); - if source_re.is_match(&body) { - body = source_re + if CARGO_LOCK_SOURCE_LINE_RE.is_match(&body) { + body = CARGO_LOCK_SOURCE_LINE_RE .replace(&body, format!("source = \"{index_url}\"").as_str()) .to_string(); } else { body = format!("source = \"{index_url}\"\n{body}"); } - let checksum_re = Regex::new(r#"(?m)^checksum = "[^"]*"$"#) - .expect("static lock checksum-line regex is valid"); - if checksum_re.is_match(&body) { - body = checksum_re + if CARGO_LOCK_CHECKSUM_LINE_RE.is_match(&body) { + body = CARGO_LOCK_CHECKSUM_LINE_RE .replace(&body, format!("checksum = \"{cksum}\"").as_str()) .to_string(); } else { - let after_source = Regex::new(r#"(?m)^(source = "[^"]*"\n)"#) - .expect("static source-line anchor regex is valid"); - body = after_source + body = CARGO_LOCK_AFTER_SOURCE_RE .replace(&body, format!("${{1}}checksum = \"{cksum}\"\n").as_str()) .to_string(); } @@ -1528,6 +1610,10 @@ enum CargoLockPlan { }, AlreadyRedirected, NotFound, + /// Several `[[package]]` blocks for the name@version (multi-source twins) + /// and not exactly one of them at the target index — which twin is ours + /// cannot be decided, so the caller warns AND skips the dep entirely. + Ambiguous, } struct CargoConfigPlan { @@ -1868,10 +1954,7 @@ fn rewrite_yarn_classic( return; } let raw = &files["yarn.lock"]; - if Regex::new(r"(?m)^__metadata:") - .expect("static __metadata probe regex is valid") - .is_match(raw) - { + if is_berry_lock(raw) { return; // yarn-berry — not classic } // CRLF locks (core.autocrlf Windows checkouts — yarn v1 parses them fine) @@ -2045,6 +2128,13 @@ fn rewrite_yarn_classic( /// can reproduce offline; matches the vendored backend's `SUPPORTED_CACHE_KEY`. const YARN_BERRY_SUPPORTED_CACHE_KEY: &str = "10c0"; +/// A yarn.lock is berry (v2+) when it carries the `__metadata:` header block; +/// anything else is a classic v1 lock. Shared by both yarn rewriters so the +/// ownership split cannot drift. +fn is_berry_lock(content: &str) -> bool { + content.lines().any(|line| line.starts_with("__metadata:")) +} + /// The `cacheKey:` value from the `__metadata` block (berry writes it unquoted: /// ` cacheKey: 10c0`), mirroring the vendored backend's `berry_field`. fn berry_cache_key(content: &str) -> Option { @@ -2104,10 +2194,7 @@ fn rewrite_yarn_berry( } let content = &files["yarn.lock"]; // The classic rewriter handles a v1 lock; berry stays out of its way. - if !Regex::new(r"(?m)^__metadata:") - .expect("static __metadata probe regex is valid") - .is_match(content) - { + if !is_berry_lock(content) { return; } @@ -2463,6 +2550,11 @@ fn rewrite_bun_lock( return; } // This API carries UTF-8 text. Binary callers must use the byte API. + // Pure-API guard only: the CLI (scan/hosted.rs) never puts `bun.lockb` + // in `files` — it strips the key and feeds the bytes to + // `rewrite_bun_binary` — so this arm is reached only by direct callers + // of `rewrite_registry_redirect` (the `npm/bun/lockb-only-refusal` + // golden and `bun_lock_warning_branches`). if files.contains_key("bun.lockb") && !files.contains_key("bun.lock") { result.warnings.push(RewriteWarning { code: "redirect_bun_lockb_bytes_required".into(), @@ -3013,6 +3105,15 @@ fn append_composer_shasum(block: &str, sha1: &str) -> String { ) } +static COMPOSER_DIST_TYPE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"("type": ")[^"]*(")"#).expect("static dist type regex is valid") +}); +static COMPOSER_DIST_URL_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"("url": ")[^"]*(")"#).expect("static dist url regex is valid")); +static COMPOSER_DIST_SHASUM_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"("shasum": ")[^"]*(")"#).expect("static dist shasum regex is valid") +}); + fn rewrite_composer_lock( files: &BTreeMap, overrides: &[DepOverride], @@ -3022,15 +3123,25 @@ fn rewrite_composer_lock( .iter() .filter(|o| o.ecosystem == "composer") .collect(); - if composer.is_empty() || !files.contains_key("composer.lock") { + if composer.is_empty() { + return; + } + // Parity with `redirect_npm_no_lockfile`: a granted dep the project has + // no lock to pin must be SAID, not silently dropped from the redirected + // count (a composer.json + installed vendor tree without a lock is + // discovered and granted like any other). + if !files.contains_key("composer.lock") { + result.warnings.push(RewriteWarning { + code: "redirect_composer_no_lockfile".into(), + detail: "no composer.lock present; composer redirect skipped".into(), + }); return; } const DIST_KEY: &str = "\"dist\": {"; let mut content = files["composer.lock"].clone(); - let type_re = Regex::new(r#"("type": ")[^"]*(")"#).expect("static dist type regex is valid"); - let url_re = Regex::new(r#"("url": ")[^"]*(")"#).expect("static dist url regex is valid"); - let shasum_re = - Regex::new(r#"("shasum": ")[^"]*(")"#).expect("static dist shasum regex is valid"); + let type_re: &Regex = &COMPOSER_DIST_TYPE_RE; + let url_re: &Regex = &COMPOSER_DIST_URL_RE; + let shasum_re: &Regex = &COMPOSER_DIST_SHASUM_RE; let mut changed = false; for dep in &composer { let composer_name = full_name(dep); @@ -3288,16 +3399,28 @@ fn insert_nuget_source(config: &str, key: &str, url: &str) -> Option { /// The `key` of every `` under `` (empty when there /// is no such element). Used to preserve resolution for non-patched packages /// when a `` is introduced. +// The open tag may carry whitespace (`` is valid XML NuGet +// parses); a literal match reads a real source list as "no sources" — +// duplicate nuget.org seed, missed catch-all fan-out — while the +// vendor/nuget_feed twin already tolerates the spelling. A self-closing +// `` has no close tag, so the regex (correctly) finds no +// children span. +static NUGET_PACKAGE_SOURCES_REGION_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?s)]*)?>(.*?)") + .expect("static packageSources region regex is valid") +}); +// Tolerates any attribute order, whitespace around `=`, and single-quoted +// values (all valid XML NuGet accepts): a real source the scan misses would +// read as "no sources", triggering a duplicate nuget.org seed and leaving the +// missed source out of the catch-all fan-out. `[^>]` keeps the match inside +// one element. +static NUGET_ADD_KEY_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"]*?key\s*=\s*(?:"([^"]+)"|'([^']+)')"#) + .expect("static add-key regex is valid") +}); + fn nuget_package_source_keys(config: &str) -> Vec { - // The open tag may carry whitespace (`` is valid XML - // NuGet parses); a literal match reads a real source list as "no - // sources" — duplicate nuget.org seed, missed catch-all fan-out — while - // the vendor/nuget_feed twin already tolerates the spelling. A - // self-closing `` has no close tag, so the regex - // (correctly) finds no children span. - let region_re = Regex::new(r"(?s)]*)?>(.*?)") - .expect("static packageSources region regex is valid"); - let scope = region_re + let scope = NUGET_PACKAGE_SOURCES_REGION_RE .captures(config) .map(|c| { c.get(1) @@ -3305,13 +3428,7 @@ fn nuget_package_source_keys(config: &str) -> Vec { .as_str() }) .unwrap_or(""); - // Tolerates any attribute order, whitespace around `=`, and single-quoted - // values (all valid XML NuGet accepts): a real source the scan misses - // would read as "no sources", triggering a duplicate nuget.org seed and - // leaving the missed source out of the catch-all fan-out. `[^>]` keeps - // the match inside one element. - Regex::new(r#"]*?key\s*=\s*(?:"([^"]+)"|'([^']+)')"#) - .expect("static add-key regex is valid") + NUGET_ADD_KEY_RE .captures_iter(scope) .map(|c| { c.get(1) @@ -3339,23 +3456,42 @@ fn rewrite_nuget( .get("nuget.config") .cloned() .unwrap_or_else(default_nuget_config); + // A config this run authors from scratch records its source edits as + // `added` — the spelling every other rewriter uses for a created file. + let source_action = if files.contains_key("nuget.config") { + "rewritten" + } else { + "added" + }; let mut config_changed = false; - let mut lock: Option = files - .get("packages.lock.json") - .and_then(|s| serde_json::from_str(s).ok()); + // A present-but-corrupt lock is strictly worse than a missing one: the + // source + mapping would land while the lock kept the upstream + // contentHash (NU1403 on restore) and the ledger claimed the redirect. + // Warn once and skip the whole nuget redirect before anything is planned + // (the npm twin does the same). An ABSENT lock is fine — config-only. + let mut lock: Option = match files.get("packages.lock.json") { + None => None, + Some(text) => match serde_json::from_str::(text) { + Ok(parsed) => Some(parsed), + Err(_) => { + result.warnings.push(RewriteWarning { + code: "redirect_nuget_lock_unparseable".into(), + detail: "packages.lock.json is not valid JSON; nuget redirect skipped".into(), + }); + return; + } + }, + }; let mut lock_changed = false; for dep in &nuget { - let Some(ov) = &dep.registry_override else { + let Some(ov) = registry_override_of_kind(dep, "nuget-v3") else { result.warnings.push(RewriteWarning { code: "redirect_nuget_missing_override".into(), detail: format!("{} has no nuget-v3 registry override", dep.name), }); continue; }; - if ov.kind != "nuget-v3" { - continue; - } let Some(sha512_sri) = dep.integrity.sha512.clone() else { result.warnings.push(RewriteWarning { code: "redirect_nuget_missing_sha512".into(), @@ -3374,7 +3510,14 @@ fn rewrite_nuget( .clone() .unwrap_or_else(|| dep.name.to_lowercase()); - if !config.contains(&format!("key=\"{reg}\"")) { + // Idempotency probe over the parsed `` keys — the + // same reader `add_nuget_source` fans the catch-all out with — so a + // hand-normalized spelling (`key = 'socket-patch-…'`) is recognized + // as already wired instead of being duplicated on a re-run. + if !nuget_package_source_keys(&config) + .iter() + .any(|key| key == ®) + { // A failed insert skips the WHOLE dep (no edit record, no lock // re-pin): a mapping without its source routes the patched id to // a source that was never defined, and a lock pinned at the @@ -3396,7 +3539,7 @@ fn rewrite_nuget( result.edits.push(FileEdit { path: "nuget.config".into(), kind: "redirect_nuget_source".into(), - action: "rewritten".into(), + action: source_action.into(), key: Some(reg.clone()), original: None, new: Some(json!({ "source": ov.index_url, "pattern": dep.name })), @@ -3582,12 +3725,10 @@ fn gem_index_url_pattern(dep: &DepOverride, index_url: &str) -> String { fn gem_spelling_residue(content: &str, deps: &[&DepOverride]) -> String { let mut residue = content.to_string(); for dep in deps { - let Some(ov) = &dep.registry_override else { + // Silent here: this is the residue helper, the rewrite loop warns. + let Some(ov) = registry_override_of_kind(dep, "rubygems-compact-index") else { continue; }; - if ov.kind != "rubygems-compact-index" { - continue; - } let block_re = Regex::new( &(String::from(r#"(?m)^source ""#) + &gem_index_url_pattern(dep, &ov.index_url) @@ -3893,18 +4034,16 @@ fn rewrite_gem( // that state earns the frozen-install caveat — a converged pair is // frozen-installable as written. let mut mixed_state = false; + let mut warned_no_gemfile = false; for dep in &gem { - let Some(ov) = &dep.registry_override else { + let Some(ov) = registry_override_of_kind(dep, "rubygems-compact-index") else { result.warnings.push(RewriteWarning { code: "redirect_gem_missing_override".into(), detail: format!("{} has no rubygems-compact-index override", dep.name), }); continue; }; - if ov.kind != "rubygems-compact-index" { - continue; - } // The URL is interpolated into the Gemfile's quoted source string and // the lock's `remote:` lines — gate it before any write, like the // cargo arm gates sparse index URLs. @@ -3930,6 +4069,22 @@ fn rewrite_gem( }); continue; }; + // Neither manifest nor lock in the chosen spelling: nothing to pin. + // Say so once (parity with `redirect_npm_no_lockfile`) instead of + // silently dropping the dep from the redirected count. A lock-only + // project keeps its own per-dep `redirect_gem_lock_without_source`. + if gemfile.is_none() && lock.is_none() { + if !warned_no_gemfile { + warned_no_gemfile = true; + result.warnings.push(RewriteWarning { + code: "redirect_gem_no_gemfile".into(), + detail: format!( + "no {gemfile_name} / {lock_name} present; gem redirect skipped" + ), + }); + } + continue; + } // Platform-suffixed CHECKSUMS siblings (`name (version-arm64-darwin) // sha256=`) mean bundler resolves platform-specific gems the patch @@ -4369,17 +4524,23 @@ struct MavenDependencyMatch { } /// Inner-text byte range of the first `…` inside `pom[from, to)`, or -/// None. Offsets are into the FULL `pom`. +/// None. Offsets are into the FULL `pom`. Plain substring search — the tags +/// are literals, and the leftmost open tag followed by the first close tag +/// after it is exactly what the lazy `(?s)(.*?)` regex matched, +/// without a regex compile per tag per `` block. fn maven_tag_inner_range(pom: &str, tag: &str, from: usize, to: usize) -> Option<(usize, usize)> { - let re = Regex::new(&format!("(?s)<{tag}>(.*?)")) - .expect("tag regex is valid — callers pass literal tag names"); - let caps = re.captures(&pom[from..to])?; - let inner = caps - .get(1) - .expect("tag regex always captures group 1 (inner text)"); - Some((from + inner.start(), from + inner.end())) + let hay = &pom[from..to]; + let open = format!("<{tag}>"); + let inner_start = hay.find(open.as_str())? + open.len(); + let inner_end = inner_start + hay[inner_start..].find(format!("").as_str())?; + Some((from + inner_start, from + inner_end)) } +static MAVEN_DEPENDENCY_BLOCK_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?s)]*>.*?") + .expect("static dependency-block regex is valid") +}); + /// Trimmed text of the first `…` inside `pom[from, to)`, or None. fn maven_tag_text_in(pom: &str, tag: &str, from: usize, to: usize) -> Option { maven_tag_inner_range(pom, tag, from, to).map(|(s, e)| pom[s..e].trim().to_string()) @@ -4398,10 +4559,8 @@ fn find_maven_dependency_matches( group_id: &str, artifact_id: &str, ) -> Vec { - let dep_re = Regex::new(r"(?s)]*>.*?") - .expect("static dependency-block regex is valid"); let mut matches = vec![]; - for m in dep_re.find_iter(pom) { + for m in MAVEN_DEPENDENCY_BLOCK_RE.find_iter(pom) { let (dep_open, dep_close) = (m.start(), m.end()); let g = maven_tag_text_in(pom, "groupId", dep_open, dep_close); let a = maven_tag_text_in(pom, "artifactId", dep_open, dep_close); @@ -4437,13 +4596,10 @@ fn rewrite_maven_pom( // (local-repo-relative path, bare sha256 hex) entries to merge in. let mut checksum_entries: Vec<(String, String)> = vec![]; let gradle_build_present = GRADLE_FILES.iter().any(|f| files.contains_key(*f)); + let mut warned_no_pom = false; for dep in &maven { - let ov = dep - .registry_override - .as_ref() - .filter(|ov| ov.kind == "maven2"); - let Some(ov) = ov else { + let Some(ov) = registry_override_of_kind(dep, "maven2") else { result.warnings.push(RewriteWarning { code: "redirect_maven_missing_override".into(), detail: format!("{} has no maven2 registry override", full_name(dep)), @@ -4482,9 +4638,20 @@ fn rewrite_maven_pom( }); } - if pom.is_none() { + // The pom for the rest of this iteration; edits land in place. + let Some(pom_text) = pom.as_mut() else { + // A Gradle-only project is legitimately pom-less — the snippet + // above IS its redirect path. Otherwise say why nothing landed + // (parity with `redirect_npm_no_lockfile`), once per run. + if !gradle_build_present && !warned_no_pom { + warned_no_pom = true; + result.warnings.push(RewriteWarning { + code: "redirect_maven_no_pom".into(), + detail: "no pom.xml present; maven redirect skipped".into(), + }); + } continue; - } + }; // Unique-per-patch repository id (valid chars: alnum, `-`, `_`, `.`). let repo_id = format!("socket-patch-{}", dep.patch_uuid); @@ -4493,9 +4660,6 @@ fn rewrite_maven_pom( // policy `fail`) exactly as before and warn that this is NOT // fail-closed. let Some(suffixed_version) = suffixed_version else { - let pom_text = pom - .as_ref() - .expect("pom is Some — the is_none() guard above continues"); // Verify-only inspection: warn when the redirect can't take effect. // Only the FIRST match matters here (legacy behavior). let matches = find_maven_dependency_matches(pom_text, &group_id, &artifact_id); @@ -4552,7 +4716,7 @@ fn rewrite_maven_pom( if pom_text.contains(&format!("{repo_id}")) { continue; } - pom = Some(insert_maven_repository(pom_text, &repo_id, &ov.index_url)); + *pom_text = insert_maven_repository(pom_text, &repo_id, &ov.index_url); pom_changed = true; result.edits.push(FileEdit { path: "pom.xml".into(), @@ -4568,12 +4732,7 @@ fn rewrite_maven_pom( // FAIL-CLOSED: pin the suffixed version explicitly. Scan every matching // , tracking depMgmt containment via the version presence // so we can tell a literal pin here from a version managed elsewhere. - let matches = find_maven_dependency_matches( - pom.as_ref() - .expect("pom is Some — the is_none() guard above continues"), - &group_id, - &artifact_id, - ); + let matches = find_maven_dependency_matches(pom_text, &group_id, &artifact_id); // An unsupported on any match: the single-jar repo can't serve // it — skip the whole dep (no version edit, no repo, no checksum). @@ -4627,12 +4786,7 @@ fn rewrite_maven_pom( .collect(); to_rewrite.sort_by(|a, b| b.0.cmp(&a.0)); for (start, end) in &to_rewrite { - let mut rebuilt = pom - .as_ref() - .expect("pom is Some — the is_none() guard above continues") - .clone(); - rebuilt.replace_range(*start..*end, &suffixed_version); - pom = Some(rebuilt); + pom_text.replace_range(*start..*end, &suffixed_version); pom_changed = true; pin_landed = true; result.edits.push(FileEdit { @@ -4666,13 +4820,12 @@ fn rewrite_maven_pom( // as a versioned match, so `versioned` is non-empty and this branch is // skipped (idempotent). if versioned.is_empty() { - pom = Some(insert_maven_dependency_management( - pom.as_ref() - .expect("pom is Some — the is_none() guard above continues"), + *pom_text = insert_maven_dependency_management( + pom_text, &group_id, &artifact_id, &suffixed_version, - )); + ); pom_changed = true; pin_landed = true; result.edits.push(FileEdit { @@ -4700,17 +4853,8 @@ fn rewrite_maven_pom( if !pin_landed { continue; } - if !pom - .as_ref() - .expect("pom is Some — the is_none() guard above continues") - .contains(&format!("{repo_id}")) - { - pom = Some(insert_maven_repository( - pom.as_ref() - .expect("pom is Some — the is_none() guard above continues"), - &repo_id, - &ov.index_url, - )); + if !pom_text.contains(&format!("{repo_id}")) { + *pom_text = insert_maven_repository(pom_text, &repo_id, &ov.index_url); pom_changed = true; result.edits.push(FileEdit { path: "pom.xml".into(), @@ -5015,7 +5159,7 @@ fn rewrite_golang( for dep in &golang { let fname = full_name(dep); - let Some(ov) = &dep.registry_override else { + let Some(ov) = registry_override_of_kind(dep, "goproxy") else { result.warnings.push(RewriteWarning { code: "redirect_golang_unsupported".into(), detail: format!( @@ -5026,9 +5170,6 @@ fn rewrite_golang( }); continue; }; - if ov.kind != "goproxy" { - continue; - } let (Some(rhs_module), Some(rhs_version)) = ( &ov.identifiers.go_module_path, &ov.identifiers.go_module_version, @@ -7936,6 +8077,88 @@ mod tests { /// A cargo dep whose override kind is not `cargo-sparse` warns (the TS /// twin's behavior) instead of vanishing silently. + #[test] + fn cargo_lock_multi_source_twins_refuse_ambiguous_and_skip_the_dep() { + // Two [[package]] blocks for one name@version from different sources + // (a crates.io copy beside a git copy), neither at the socket index: + // which twin is ours cannot be decided, so the dep is skipped + // transactionally with its own warning — repointing the first hit + // would desync the lock's qualified package ids. + let manifest = + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n"; + let lock = format!( + "version = 3\n\n\ + [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\n\ + source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\ + checksum = \"{}\"\n\n\ + [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\n\ + source = \"git+https://github.com/serde-rs/serde?rev=abc#abc\"\n", + "1".repeat(64) + ); + let mut files = BTreeMap::new(); + files.insert("Cargo.toml".to_string(), manifest.to_string()); + files.insert("Cargo.lock".to_string(), lock); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "ambiguous twins must write NOTHING: files={:?} edits={:?}", + r.files.keys(), + r.edits + ); + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_lock_pkg_ambiguous"], + "{:?}", + r.warnings + ); + assert!(r.confirmed_cargo_uuids.is_empty()); + } + + #[test] + fn cargo_lock_rerun_beside_a_crates_io_twin_is_a_noop() { + // After a redirect, a transitive crates.io copy of the crate can + // resolve beside the socket-registry copy — and cargo sorts the + // crates.io block FIRST. A re-run must recognize the socket copy as + // its own (already redirected: no edit, no warning) instead of + // repointing the crates.io twin into a duplicate block. + let files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + ); + let overrides = vec![cargo_sparse_override()]; + let first = rewrite_registry_redirect(&files, &overrides); + let redirected_lock = first.files.get("Cargo.lock").expect("lock redirected"); + let twin = format!( + "[[package]]\nname = \"serde\"\nversion = \"1.0.190\"\n\ + source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\ + checksum = \"{}\"\n\n", + "2".repeat(64) + ); + let with_twin = redirected_lock.replacen( + "[[package]]\nname = \"serde\"", + &format!("{twin}[[package]]\nname = \"serde\""), + 1, + ); + assert_eq!( + with_twin.matches("name = \"serde\"").count(), + 2, + "{with_twin}" + ); + let mut again = files.clone(); + for (name, content) in &first.files { + again.insert(name.clone(), content.clone()); + } + again.insert("Cargo.lock".to_string(), with_twin); + let second = rewrite_registry_redirect(&again, &overrides); + assert!( + second.files.is_empty() && second.edits.is_empty(), + "the socket twin is already redirected: files={:?} edits={:?}", + second.files.keys(), + second.edits + ); + assert!(second.warnings.is_empty(), "{:?}", second.warnings); + assert!(second.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + #[test] fn cargo_kind_mismatch_warns() { let files = cargo_files( @@ -11811,6 +12034,66 @@ packages: ); } + /// A composer grant against a project with no composer.lock is SAID + /// (parity with `redirect_npm_no_lockfile`), not silently dropped from the + /// redirected count. + #[test] + fn composer_without_lockfile_warns_and_skips() { + let mut files = BTreeMap::new(); + files.insert("composer.json".to_string(), "{}\n".to_string()); + let r = rewrite_registry_redirect(&files, &[composer_override("1.0.0")]); + assert!(r.files.is_empty() && r.edits.is_empty()); + assert_eq!( + warning_codes(&r), + vec!["redirect_composer_no_lockfile"], + "{:?}", + r.warnings + ); + } + + /// Neither Gemfile nor Gemfile.lock: one warning per run, however many + /// gem deps were granted (a lock-only project keeps its per-dep + /// `redirect_gem_lock_without_source` path). + #[test] + fn gem_without_gemfile_or_lock_warns_once_and_skips() { + let files = BTreeMap::new(); + let r = rewrite_registry_redirect( + &files, + &[ + gem_override("rails", "7.0.0"), + gem_override("rack", "3.0.0"), + ], + ); + assert!(r.files.is_empty() && r.edits.is_empty()); + assert_eq!( + warning_codes(&r), + vec!["redirect_gem_no_gemfile"], + "{:?}", + r.warnings + ); + assert!( + r.warnings[0].detail.contains("Gemfile / Gemfile.lock"), + "{}", + r.warnings[0].detail + ); + } + + /// No pom.xml and no Gradle build script: the maven grants are SAID once. + /// (A Gradle-only project is legitimately pom-less — its snippet IS the + /// redirect path; see `maven_pom_gradle_manual_snippet`.) + #[test] + fn maven_without_pom_or_gradle_warns_once_and_skips() { + let files = BTreeMap::new(); + let r = rewrite_registry_redirect(&files, &[maven_override(), maven_override()]); + assert!(r.files.is_empty() && r.edits.is_empty()); + assert_eq!( + warning_codes(&r), + vec!["redirect_maven_no_pom"], + "{:?}", + r.warnings + ); + } + /// A cargo grant against a files map with NO Cargo.toml at all (only a /// lock) is skipped fail-closed with the no-manifest flavor of the /// not-found warning — the lock alone can never pin the registry. @@ -12771,6 +13054,59 @@ packages: ); let kinds: Vec<&str> = r.edits.iter().map(|e| e.kind.as_str()).collect(); assert_eq!(kinds, vec!["redirect_nuget_source", "redirect_nuget_lock"]); + assert_eq!( + r.edits[0].action, "added", + "a nuget.config authored from scratch records `added`, like every \ + other created file: {:?}", + r.edits[0] + ); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + } + + /// A PRESENT but unparseable packages.lock.json refuses the whole nuget + /// redirect up front: landing the source + mapping while the lock kept + /// the upstream contentHash would NU1403 every restore, with the ledger + /// claiming the redirect. One warning per run (the lock is shared by + /// every nuget dep), mirroring `redirect_npm_lock_unparseable`. + #[test] + fn nuget_unparseable_lock_warns_once_and_skips_everything() { + let mut files = BTreeMap::new(); + files.insert("nuget.config".to_string(), default_nuget_config()); + files.insert("packages.lock.json".to_string(), "{ not json".to_string()); + let r = rewrite_registry_redirect(&files, &[nuget_override(), nuget_override()]); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "nothing may land over a corrupt lock: files={:?} edits={:?}", + r.files.keys(), + r.edits + ); + assert_eq!( + warning_codes(&r), + vec!["redirect_nuget_lock_unparseable"], + "{:?}", + r.warnings + ); + } + + /// The re-run probe reads the parsed `` keys, so a + /// hand-normalized spelling of the socket source (single quotes, spaces + /// around `=`) is recognized as already wired instead of being added a + /// second time. + #[test] + fn nuget_hand_normalized_source_key_is_not_duplicated_on_rerun() { + let mut files = BTreeMap::new(); + files.insert( + "nuget.config".to_string(), + "\n\n \n \n \n \n \n \n \n \n \n \n \n \n\n" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[nuget_override()]); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "the source is already wired: files={:?} edits={:?}", + r.files.keys(), + r.edits + ); assert!(r.warnings.is_empty(), "{:?}", r.warnings); } @@ -12809,6 +13145,14 @@ packages: && first.files.contains_key("packages.lock.json"), "anchor: the first pass rewrites both files" ); + assert!( + first + .edits + .iter() + .any(|e| e.kind == "redirect_nuget_source" && e.action == "rewritten"), + "an edit to a PRE-EXISTING nuget.config stays `rewritten`: {:?}", + first.edits + ); let mut again = files.clone(); for (name, content) in &first.files { again.insert(name.clone(), content.clone()); @@ -13560,7 +13904,7 @@ packages: /// the nuget and golang arms skip it rather than misinterpreting the /// override's fields (silently, matching the TS twin). #[test] - fn foreign_override_kind_is_skipped_by_nuget_and_golang() { + fn foreign_override_kind_warns_missing_override_for_nuget_and_golang() { let mut nuget = nuget_override(); nuget .registry_override @@ -13581,17 +13925,23 @@ packages: r.files.keys(), r.edits ); - assert!( - r.warnings.is_empty(), - "foreign kinds are skipped silently today: {:?}", + // A foreign kind is no more usable than an absent override, and every + // arm SAYS so with its missing-override code (`registry_override_of_kind`). + assert_eq!( + warning_codes(&r), + vec![ + "redirect_nuget_missing_override", + "redirect_golang_unsupported" + ], + "{:?}", r.warnings ); } - /// gems.rb + Gemfile twins with deps that carry NO compact-index override - /// (absent, or a foreign kind): the divergence residue skips those deps - /// (nothing of theirs to erase), the rewrite loop skips them too — the - /// absent override warns, the foreign kind is silent, nothing is written. + /// gems.rb + Gemfile twins with deps that carry NO usable compact-index + /// override (absent, or a foreign kind): the divergence residue skips + /// those deps (nothing of theirs to erase), the rewrite loop skips them + /// too — BOTH warn the missing override, nothing is written. #[test] fn gem_deps_without_compact_index_override_are_skipped() { let gemfile = "source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\n"; @@ -13615,7 +13965,10 @@ packages: ); assert_eq!( warning_codes(&r), - vec!["redirect_gem_missing_override"], + vec![ + "redirect_gem_missing_override", + "redirect_gem_missing_override" + ], "{:?}", r.warnings ); diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index 6da1e25d..02a707e9 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -26,6 +26,7 @@ //! refused group keeps both its edits and its records — the //! intermediate-but-coherent ledger a retry needs. The caller persists. +use super::staged::{flush_staged, staged_read, Staged, StagedBytes}; use super::state::RedirectState; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; @@ -227,43 +228,6 @@ fn safe_rel_path(path: &str) -> bool { && !path.split(['/', '\\']).any(|c| c == "..") } -/// FIFO-guarded read: a planted FIFO squatting a lockfile path must fail -/// fast (`InvalidInput`) instead of wedging the replay on a blocking open -/// — the same posture as every other raw read in the patch engine. -async fn read_rel(project_root: &Path, rel: &str) -> Result, String> { - use tokio::io::AsyncReadExt; - let path = project_root.join(rel); - let (mut file, _) = match crate::utils::fs::open_regular_file(&path).await { - Ok(pair) => pair, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => return Err(format!("read {rel}: {e}")), - }; - let mut content = String::new(); - file.read_to_string(&mut content) - .await - .map_err(|e| format!("read {rel}: {e}"))?; - Ok(Some(content)) -} - -/// Files the group's unwind has decided but not yet written: -/// `Some(content)` to write, `None` to delete. -type Staged = BTreeMap>; - -/// Native binary lockfiles staged after restoring their package snapshots. -/// The atomic writer prevents partial binary lockfile writes. -type StagedBytes = BTreeMap>; - -async fn staged_read( - staged: &Staged, - project_root: &Path, - rel: &str, -) -> Result, String> { - match staged.get(rel) { - Some(pending) => Ok(pending.clone()), - None => read_rel(project_root, rel).await, - } -} - /// Remove one inserted fragment, eating the separators the writer added /// around it. Position-based: several writers record the fragment WITHOUT /// the indentation they inserted it with (the gem DEPENDENCIES pin and @@ -362,7 +326,7 @@ pub async fn revert_remaining_redirect_edits( // Newest-first: chained re-redirects unwind through each step's // `new` -> `original` until the first run's insertion is removed. for &idx in indices.iter().rev() { - let edit = state.edits[idx].clone(); + let edit = &state.edits[idx]; let (_, inverse) = classify(&edit.kind, &edit.action); if !matches!(inverse, Inverse::NoopDrop | Inverse::Unsupported) && !safe_rel_path(&edit.path) @@ -395,7 +359,7 @@ pub async fn revert_remaining_redirect_edits( } })?, }; - super::bun_binary::restore(&content, &edit) + super::bun_binary::restore(&content, edit) } .await; match restored { @@ -438,7 +402,7 @@ pub async fn revert_remaining_redirect_edits( } Inverse::PipenvEntry => { let restored = match staged_read(&staged, project_root, &edit.path).await { - Ok(Some(content)) => super::pipenv::restore(&content, &edit) + Ok(Some(content)) => super::pipenv::restore(&content, edit) .map(|restored| (content, restored)), Ok(None) => Err(format!("{} no longer exists", edit.path)), Err(error) => Err(error), @@ -487,7 +451,14 @@ pub async fn revert_remaining_redirect_edits( if inverse == Inverse::HatchDocument { match crate::vendor::restore_python_document(&content, original, new) { Ok((restored, false)) => { - staged.insert(edit.path.clone(), Some(restored)); + // Already at its original (the restore + // short-circuits on `live == original`): no + // write, no `editedFiles` credit; the ledger + // edit still retires (mirrors the PipenvEntry + // arm). + if restored != content { + staged.insert(edit.path.clone(), Some(restored)); + } group_drops.insert(idx); } _ => { @@ -699,50 +670,16 @@ pub async fn revert_remaining_redirect_edits( } } - // Commit the group: flush staged files (unless dry-run), then mark - // its edits for dropping. A flush error refuses the group late — - // some files may already have landed (the same residual exposure - // the per-purl reverts document) — and keeps its ledger entries. + // Commit the group: flush staged files (unless dry-run) through the + // shared guarded atomic writer, then mark its edits for dropping. A + // flush error refuses the group late — some files may already have + // landed (the same residual exposure the per-purl reverts document) + // — and keeps its ledger entries. if !dry_run { - for (rel, pending) in &staged { - let path = project_root.join(rel); - // FIFO/device guard on the write side too: writing to a - // planted FIFO blocks forever. Refuse the group instead. - if let Ok(meta) = tokio::fs::symlink_metadata(&path).await { - if !meta.is_file() { - refuse(format!("{rel} is not a regular file"), &mut outcome); - refused_groups.insert(group); - continue 'group; - } - } - let write_result = match pending { - Some(content) => tokio::fs::write(&path, content).await, - None => match tokio::fs::remove_file(&path).await { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - other => other, - }, - }; - if let Err(e) = write_result { - refuse(format!("write {rel}: {e}"), &mut outcome); - refused_groups.insert(group); - continue 'group; - } - } - // Commit native binary package restores atomically. - for (rel, bytes) in &staged_bytes { - let path = project_root.join(rel); - if let Ok(meta) = tokio::fs::symlink_metadata(&path).await { - if !meta.is_file() { - refuse(format!("{rel} is not a regular file"), &mut outcome); - refused_groups.insert(group); - continue 'group; - } - } - if let Err(e) = crate::utils::fs::atomic_write_bytes(&path, bytes).await { - refuse(format!("write {rel}: {e}"), &mut outcome); - refused_groups.insert(group); - continue 'group; - } + if let Err(reason) = flush_staged(project_root, &staged, &staged_bytes).await { + refuse(reason, &mut outcome); + refused_groups.insert(group); + continue 'group; } } outcome.reverted_files.extend(staged.keys().cloned()); @@ -1870,6 +1807,7 @@ mod tests { ("redirect_maven_config", "created"), ("redirect_maven_trusted_checksums", "created"), ("redirect_nuget_source", "rewritten"), + ("redirect_nuget_source", "added"), ("redirect_nuget_lock", "rewritten"), ]; for (kind, action) in known { @@ -2319,10 +2257,11 @@ mod tests { use std::os::unix::fs::PermissionsExt; let dir = TempDir::new().unwrap(); write(dir.path(), "composer.lock", "https://patch.example/a\n").await; - let path = dir.path().join("composer.lock"); - let mut perms = std::fs::metadata(&path).unwrap().permissions(); - perms.set_mode(0o444); - std::fs::set_permissions(&path, perms).unwrap(); + // The flush is an atomic stage + rename, so a read-only TARGET no + // longer blocks it (rename needs only the parent): make the parent + // directory read-only so the stage file cannot be created. + let writable = std::fs::metadata(dir.path()).unwrap().permissions(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap(); let mut state = state_with( vec![edit( "composer.lock", @@ -2334,6 +2273,8 @@ mod tests { &[], ); let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + // Restore before asserting so the TempDir can clean up on failure. + std::fs::set_permissions(dir.path(), writable).unwrap(); assert_eq!(out.refusals.len(), 1, "{out:?}"); assert!( out.refusals[0].reason.starts_with("write composer.lock:"), @@ -2346,6 +2287,77 @@ mod tests { "https://patch.example/a\n", "the redirected fragment must still be present" ); + let litter: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with(".socket-stage-")) + .collect(); + assert!(litter.is_empty(), "no stage litter on failure: {litter:?}"); + } + + /// The text flush goes through the mode-preserving atomic writer: a + /// `0600` lockfile keeps its bits across the revert (the plain writer + /// would swap in a fresh umask-mode inode). + #[cfg(unix)] + #[tokio::test] + async fn flush_keeps_the_lockfile_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new().unwrap(); + write(dir.path(), "composer.lock", "https://patch.example/a\n").await; + let path = dir.path().join("composer.lock"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let mut state = state_with( + vec![edit( + "composer.lock", + "redirect_composer_dist", + "rewritten", + Some("https://upstream.example/a"), + Some("https://patch.example/a"), + )], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!( + read(dir.path(), "composer.lock").await, + "https://upstream.example/a\n" + ); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "the lockfile's mode must survive the atomic rewrite" + ); + } + + /// A Hatch document already at its recorded original (an interrupted + /// earlier revert, or a hand-fix) retires its ledger edit without a + /// byte-identical rewrite or an `editedFiles` credit — the same rule the + /// PipenvEntry and ReplaceFragment arms follow. + #[tokio::test] + async fn hatch_document_already_at_original_retires_without_a_write() { + let original = "[project]\nname = \"app\"\ndependencies = [\"one==1\"]\n"; + let redirected = + "[project]\nname = \"app\"\ndependencies = [\"one @ https://patch.example/one.whl\"]\n"; + let dir = TempDir::new().unwrap(); + write(dir.path(), "pyproject.toml", original).await; + let mut state = state_with( + vec![edit( + "pyproject.toml", + "redirect_hatch_document", + "rewritten", + Some(original), + Some(redirected), + )], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert!( + out.reverted_files.is_empty(), + "nothing was written: {out:?}" + ); + assert!(state.edits.is_empty(), "the edit still retires"); + assert_eq!(read(dir.path(), "pyproject.toml").await, original); } // ---------- record hold/drop per purl ecosystem ---------- diff --git a/crates/socket-patch-core/src/patch/redirect/staged.rs b/crates/socket-patch-core/src/patch/redirect/staged.rs new file mode 100644 index 00000000..5d6f0e4c --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/staged.rs @@ -0,0 +1,120 @@ +//! Staged, fail-closed file I/O shared by the hosted-redirect reverts — the +//! per-purl takeover ([`super::takeover`]) and the whole-ledger replay +//! ([`super::replay`]). +//! +//! Both reverts resolve every inverse against a STAGED view of the project +//! and let nothing reach disk until all of them have resolved, so a drift +//! refusal leaves the project byte-identical. This module is that staging +//! layer: FIFO-safe reads of untrusted project files, the staged view, and +//! one flush with the same guards on both sides (a symlink or FIFO squatting +//! a path refuses; every write is atomic and keeps the file's mode). + +use std::collections::BTreeMap; +use std::path::Path; + +/// Files a revert has decided but not yet written: `Some(content)` to +/// write, `None` to delete. +pub(super) type Staged = BTreeMap>; + +/// Native binary lockfiles staged after restoring their package snapshots. +pub(super) type StagedBytes = BTreeMap>; + +/// Read a project file, distinguishing missing (`Ok(None)`) from unreadable. +/// +/// FIFO-guarded: a planted FIFO, directory or device squatting a lockfile +/// path fails fast (`InvalidInput`) instead of wedging the revert on a +/// blocking open — the same posture as every other raw read in the patch +/// engine. Errors read `read : `. +pub(super) async fn read_rel(project_root: &Path, rel: &str) -> Result, String> { + match crate::utils::fs::read_regular_to_string(&project_root.join(rel)).await { + Ok(content) => Ok(Some(content)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("read {rel}: {e}")), + } +} + +/// Read a project file through the staged writes, so each unwind step sees +/// what the earlier steps decided. Both the re-redirect chain (a step's +/// `original` is the previous step's `new`) and the cargo registry block's +/// still-referenced probe depend on that view, and neither may depend on the +/// bytes having landed. +pub(super) async fn staged_read( + staged: &Staged, + project_root: &Path, + rel: &str, +) -> Result, String> { + match staged.get(rel) { + Some(pending) => Ok(pending.clone()), + None => read_rel(project_root, rel).await, + } +} + +/// Commit the staged files. Only reached once every inverse resolved, so a +/// drift refusal never gets here; an I/O fault partway through is the one +/// remaining way to stop mid-set, and it surfaces as `Err` naming the path +/// (`write : …` / `remove : …`) — some files may already have +/// landed, the residual exposure both reverts document. +/// +/// Every path is guarded on the write side too: a symlink or FIFO squatting +/// it refuses (` is not a regular file`) — a rename-over would replace +/// the link with a detached regular file, and writing into a FIFO blocks +/// forever — while a missing target is fine (the write creates it). Text and +/// binary content go through the atomic mode-preserving writer, so a crash +/// or `ENOSPC` mid-flush never leaves a torn lockfile and a `0600` lock keeps +/// its bits. A deleted file's now-empty parent directory (the `.cargo/` a +/// registry block was written into) is pruned best-effort; the project root +/// itself is never touched. +pub(super) async fn flush_staged( + project_root: &Path, + staged: &Staged, + staged_bytes: &StagedBytes, +) -> Result<(), String> { + for (rel, pending) in staged { + let path = project_root.join(rel); + refuse_non_regular(&path, rel).await?; + match pending { + Some(content) => { + crate::utils::fs::atomic_write_bytes_preserving_mode(&path, content.as_bytes()) + .await + .map_err(|e| format!("write {rel}: {e}"))?; + } + None => { + match tokio::fs::remove_file(&path).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("remove {rel}: {e}")), + } + let in_subdir = Path::new(rel) + .parent() + .is_some_and(|parent| !parent.as_os_str().is_empty()); + if in_subdir { + if let Some(parent) = path.parent() { + // `remove_dir` refuses a non-empty directory, so this + // only ever removes the husk the revert emptied. + let _ = tokio::fs::remove_dir(parent).await; + } + } + } + } + } + for (rel, bytes) in staged_bytes { + let path = project_root.join(rel); + refuse_non_regular(&path, rel).await?; + crate::utils::fs::atomic_write_bytes_preserving_mode(&path, bytes) + .await + .map_err(|e| format!("write {rel}: {e}"))?; + } + Ok(()) +} + +/// The write-side guard: `symlink_metadata` (never following a link) must +/// either fail — the target does not exist and the write creates it — or +/// describe a regular file. +async fn refuse_non_regular(path: &Path, rel: &str) -> Result<(), String> { + if let Ok(meta) = tokio::fs::symlink_metadata(path).await { + if !meta.is_file() { + return Err(format!("{rel} is not a regular file")); + } + } + Ok(()) +} diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index 76a18e0d..d9fe2a84 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -41,11 +41,14 @@ use std::collections::{BTreeMap, HashSet}; use std::path::Path; +use std::sync::LazyLock; +use regex::Regex; use serde_json::Value; use crate::utils::purl::{normalize_purl, parse_cargo_purl, strip_purl_qualifiers}; +use super::staged::{flush_staged, read_rel, staged_read, Staged, StagedBytes}; use super::state::RedirectState; use super::FileEdit; @@ -56,9 +59,6 @@ pub struct RedirectRevert { pub reverted_files: Vec, } -/// Pre-rename alias (the struct was cargo-only before the npm-family port). -pub type CargoRedirectRevert = RedirectRevert; - /// Does [`revert_redirect_purl`] have an implementation for this purl's /// ecosystem? Callers (the vendor dispatch loop's cross-mode takeover gate) /// must consult this instead of hardcoding `pkg:cargo/`. @@ -70,8 +70,14 @@ pub fn redirect_revert_supported(purl: &str) -> bool { /// drop that purl's record and edits from `state`. The caller persists the /// mutated ledger (see `persist_redirect_state`). Dispatches per ecosystem; /// purls outside [`redirect_revert_supported`] are refused (fail closed). +/// /// `dry_run` resolves every inverse and drift check exactly like a wet run -/// but writes nothing and leaves `state` untouched. +/// and skips ONLY the disk flush: the purl's record and edits are still +/// dropped from `state`, so a composed preview (the whole-ledger replay run +/// after the per-purl reverts inside one rollback) sees the post-claim +/// ledger. Callers pass a throwaway clone and never persist it on a dry run +/// (rollback.rs / vendor.rs do). Contrast `revert_remaining_redirect_edits`, +/// whose dry run leaves its `state` untouched. pub async fn revert_redirect_purl( project_root: &Path, state: &mut RedirectState, @@ -89,64 +95,39 @@ pub async fn revert_redirect_purl( } } -/// Read a project file, distinguishing missing (`Ok(None)`) from unreadable. -async fn read_rel(project_root: &Path, rel: &str) -> Result, String> { - match tokio::fs::read_to_string(project_root.join(rel)).await { - Ok(c) => Ok(Some(c)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(format!("read {rel}: {e}")), - } -} - -async fn write_rel(project_root: &Path, rel: &str, content: &str) -> Result<(), String> { - tokio::fs::write(project_root.join(rel), content) - .await - .map_err(|e| format!("write {rel}: {e}")) +/// The ledger record whose canonical purl (qualifiers stripped, +/// percent-decoded) matches `purl`: `(record key as stored, canonical purl)`. +/// Refused when the ledger records no hosted redirect for the purl. +fn find_record_key(state: &RedirectState, purl: &str) -> Result<(String, String), String> { + let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + let target = canon(purl); + let Some(record_key) = state.records.keys().find(|k| canon(k) == target).cloned() else { + return Err(format!( + "the redirect ledger records no hosted redirect for {purl}" + )); + }; + Ok((record_key, target)) } -/// Files the unwind has decided but not yet written: `Some(content)` to -/// write, `None` to remove. -type Staged = BTreeMap>; - -/// Read a project file through the staged writes, so each unwind step sees -/// what the earlier steps decided. Both the re-redirect chain (a step's -/// `original` is the previous step's `new`) and the registry block's -/// still-referenced probe depend on that view, and neither may depend on the -/// bytes having landed. -async fn staged_read( - staged: &Staged, - project_root: &Path, - rel: &str, -) -> Result, String> { - match staged.get(rel) { - Some(pending) => Ok(pending.clone()), - None => read_rel(project_root, rel).await, - } +/// Drop the claimed edits (by ledger index) and the purl's record from the +/// ledger — only after every inverse applied cleanly. The caller persists. +fn drop_claimed(state: &mut RedirectState, claimed: Vec, record_key: &str) { + let drop: HashSet = claimed.into_iter().collect(); + let mut idx = 0usize; + state.edits.retain(|_| { + let keep = !drop.contains(&idx); + idx += 1; + keep + }); + state.records.remove(record_key); } -/// Write the staged files. Only reached once every inverse resolved, so a -/// drift refusal never gets here; an I/O fault partway through is the one -/// remaining way to stop mid-set, and it surfaces as `Err` with the write -/// already reported by path. -async fn flush_staged(project_root: &Path, staged: &Staged) -> Result<(), String> { - for (rel, pending) in staged { - let Some(content) = pending else { - let path = project_root.join(rel); - match tokio::fs::remove_file(&path).await { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(format!("remove {rel}: {e}")), - } - // Best-effort: prune a now-empty `.cargo/` dir. - if let Some(parent) = path.parent() { - let _ = tokio::fs::remove_dir(parent).await; - } - continue; - }; - write_rel(project_root, rel, content).await?; - } - Ok(()) -} +/// `socket-patch-` registry names as they appear in Cargo.toml pins, +/// Cargo.lock sources and `[registries.…]` headers. +static SOCKET_REGISTRY_UUID: LazyLock = LazyLock::new(|| { + Regex::new(r"socket-patch-([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})") + .expect("static registry-uuid regex is valid") +}); /// Revert every hosted-redirect edit the ledger records for `purl` (a cargo /// package), then drop that purl's record and edits from `state`. The caller @@ -157,21 +138,15 @@ async fn flush_staged(project_root: &Path, staged: &Staged) -> Result<(), String /// `original`, and an intermediate edit whose `original` is already live is a /// no-op. `[registries.socket-patch-…]` blocks tied to this purl's uuids are /// removed only when nothing in Cargo.toml / Cargo.lock still references them. -/// `dry_run` resolves every inverse and drift check exactly like a wet run -/// but writes nothing and leaves `state` untouched. +/// `dry_run` skips only the disk flush; the in-memory ledger claim still +/// happens (see [`revert_redirect_purl`]). pub async fn revert_cargo_redirect_purl( project_root: &Path, state: &mut RedirectState, purl: &str, dry_run: bool, ) -> Result { - let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); - let target = canon(purl); - let Some(record_key) = state.records.keys().find(|k| canon(k) == target).cloned() else { - return Err(format!( - "the redirect ledger records no hosted redirect for {purl}" - )); - }; + let (record_key, target) = find_record_key(state, purl)?; let Some((name, version)) = parse_cargo_purl(&target) else { return Err(format!("not a cargo purl: {purl}")); }; @@ -188,13 +163,10 @@ pub async fn revert_cargo_redirect_purl( // claim another package's block). let mut uuids: HashSet = HashSet::new(); uuids.insert(state.records[&record_key].uuid.clone()); - let uuid_re = - regex::Regex::new(r"socket-patch-([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})") - .expect("static regex"); for e in state.edits.iter().filter(|e| is_wiring_edit(e)) { for v in [&e.original, &e.new] { if let Some(s) = v.as_ref().and_then(Value::as_str) { - for c in uuid_re.captures_iter(s) { + for c in SOCKET_REGISTRY_UUID.captures_iter(s) { uuids.insert(c[1].to_string()); } } @@ -223,7 +195,7 @@ pub async fn revert_cargo_redirect_purl( // previous step's `new`), and the registry-block removals — recorded // before their wiring edits — run last, after the references are gone. for &i in mine.iter().rev() { - let edit = state.edits[i].clone(); + let edit = &state.edits[i]; match edit.kind.as_str() { "redirect_cargo_toml_dep" | "redirect_cargo_lock_entry" => { let (Some(new), Some(orig)) = ( @@ -328,19 +300,10 @@ pub async fn revert_cargo_redirect_purl( // wet run would hand them. The caller owns the state clone and never // persists it on a dry run, so nothing durable changes. if !dry_run { - flush_staged(project_root, &staged).await?; + flush_staged(project_root, &staged, &StagedBytes::new()).await?; } - // Only after every inverse applied cleanly: drop this purl's edits and - // record from the ledger (the caller persists it). - let drop: HashSet = mine.into_iter().collect(); - let mut idx = 0usize; - state.edits.retain(|_| { - let keep = !drop.contains(&idx); - idx += 1; - keep - }); - state.records.remove(&record_key); + drop_claimed(state, mine, &record_key); Ok(out) } @@ -483,22 +446,16 @@ pub(super) fn hosted_url_names(url: &str, name: &str, version: &str) -> bool { /// Same fail-closed contract as [`revert_cargo_redirect_purl`]: every inverse /// is resolved against a staged view and NOTHING reaches disk until all of /// them have resolved, so a drift refusal leaves the project byte-identical -/// across ALL the files the ledger claims. `dry_run` resolves every inverse -/// and drift check exactly like a wet run but writes nothing and leaves -/// `state` untouched. +/// across ALL the files the ledger claims. `dry_run` skips only the disk +/// flush; the in-memory ledger claim still happens (see +/// [`revert_redirect_purl`]). pub async fn revert_npm_redirect_purl( project_root: &Path, state: &mut RedirectState, purl: &str, dry_run: bool, ) -> Result { - let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); - let target = canon(purl); - let Some(record_key) = state.records.keys().find(|k| canon(k) == target).cloned() else { - return Err(format!( - "the redirect ledger records no hosted redirect for {purl}" - )); - }; + let (record_key, target) = find_record_key(state, purl)?; let Some((name, version)) = parse_npm_purl(&target) else { return Err(format!("not an npm purl: {purl}")); }; @@ -506,16 +463,21 @@ pub async fn revert_npm_redirect_purl( let lock_key = format!("{name}@{version}"); // The package-lock/shrinkwrap files any `redirect_npm_lock_entry` edits - // touch, parsed once from disk: an ALIAS install (`npm i alias@npm:name`) - // keys its entry by the alias, so ownership is resolved through the - // entry's `name` field — exactly how the rewriter matched it (the rewrite - // never touches name/version, so the probe is symmetric). + // touch, read ONCE from disk: the raw text is kept (`disk_texts`) so the + // replay below never re-reads a lock this attribution pass already + // loaded, and the parse is used for ownership — an ALIAS install (`npm i + // alias@npm:name`) keys its entry by the alias, so ownership is resolved + // through the entry's `name` field, exactly how the rewriter matched it + // (the rewrite never touches name/version, so the probe is symmetric). + let mut disk_texts: BTreeMap> = BTreeMap::new(); let mut disk_locks: BTreeMap> = BTreeMap::new(); for e in &state.edits { - if e.kind == "redirect_npm_lock_entry" && !disk_locks.contains_key(&e.path) { - let parsed = read_rel(project_root, &e.path) - .await? - .and_then(|c| serde_json::from_str::(&c).ok()); + if e.kind == "redirect_npm_lock_entry" && !disk_texts.contains_key(&e.path) { + let text = read_rel(project_root, &e.path).await?; + let parsed = text + .as_deref() + .and_then(|c| serde_json::from_str::(c).ok()); + disk_texts.insert(e.path.clone(), text); disk_locks.insert(e.path.clone(), parsed); } } @@ -633,30 +595,35 @@ pub async fn revert_npm_redirect_purl( let mut out = RedirectRevert::default(); let mut staged: Staged = Staged::new(); - let mut binary: Option> = None; + let mut staged_bytes: StagedBytes = StagedBytes::new(); // Newest-first: the hosted flow appends edits, so reverse index order // unwinds re-redirect chains correctly (each step's `original` is the // previous step's `new`). for &i in mine.iter().rev() { - let edit = state.edits[i].clone(); + let edit = &state.edits[i]; if edit.kind == super::bun_binary::KIND { if edit.path != "bun.lockb" { return Err("unexpected binary lock edit path".into()); } - let metadata = tokio::fs::symlink_metadata(project_root.join("bun.lockb")) - .await - .map_err(|e| format!("cannot inspect bun.lockb: {e}"))?; - if !metadata.is_file() { - return Err("bun.lockb is not a regular file".into()); - } - let content = match binary.take() { - Some(v) => v, + let path = project_root.join("bun.lockb"); + let content = match staged_bytes.remove(&edit.path) { + Some(pending) => pending, None => { - crate::utils::fs::read_regular_to_bytes_sync(&project_root.join("bun.lockb")) + let metadata = tokio::fs::symlink_metadata(&path) + .await + .map_err(|e| format!("cannot inspect bun.lockb: {e}"))?; + if !metadata.is_file() { + return Err("bun.lockb is not a regular file".into()); + } + crate::utils::fs::read_regular_to_bytes(&path) + .await .map_err(|e| format!("cannot read bun.lockb: {e}"))? } }; - binary = Some(super::bun_binary::restore(&content, &edit)?); + staged_bytes.insert( + edit.path.clone(), + super::bun_binary::restore(&content, edit)?, + ); if !out.reverted_files.iter().any(|p| p == "bun.lockb") { out.reverted_files.push("bun.lockb".into()); } @@ -719,8 +686,16 @@ pub async fn revert_npm_redirect_purl( } } } else { - revert_npm_json_edit(project_root, &mut staged, &edit, &name, &version, &mut out) - .await?; + revert_npm_json_edit( + project_root, + &mut staged, + &disk_texts, + edit, + &name, + &version, + &mut out, + ) + .await?; } } @@ -732,27 +707,10 @@ pub async fn revert_npm_redirect_purl( // wet run would hand them. The caller owns the state clone and never // persists it on a dry run, so nothing durable changes. if !dry_run { - flush_staged(project_root, &staged).await?; - if let Some(bytes) = &binary { - crate::utils::fs::atomic_write_bytes_preserving_mode( - &project_root.join("bun.lockb"), - bytes, - ) - .await - .map_err(|e| format!("cannot write bun.lockb: {e}"))?; - } + flush_staged(project_root, &staged, &staged_bytes).await?; } - // Only after every inverse applied cleanly: drop this purl's edits and - // record from the ledger (the caller persists it). - let drop: HashSet = mine.into_iter().collect(); - let mut idx = 0usize; - state.edits.retain(|_| { - let keep = !drop.contains(&idx); - idx += 1; - keep - }); - state.records.remove(&record_key); + drop_claimed(state, mine, &record_key); Ok(out) } @@ -833,16 +791,24 @@ fn edit_references_package(edit: &FileEdit, name: &str, version: &str) -> bool { } /// Replay one recorded package-lock JSON edit (`redirect_npm_lock_entry` / -/// `redirect_npm_lock_dep`) through the staged view. +/// `redirect_npm_lock_dep`) through the staged view. `disk_texts` is the +/// attribution pass's read of the lock (`None` = missing on disk), consulted +/// before touching the disk again; `staged` still wins over both. async fn revert_npm_json_edit( project_root: &Path, staged: &mut Staged, + disk_texts: &BTreeMap>, edit: &FileEdit, name: &str, version: &str, out: &mut RedirectRevert, ) -> Result<(), String> { - let Some(content) = staged_read(staged, project_root, &edit.path).await? else { + let content = match (staged.get(&edit.path), disk_texts.get(&edit.path)) { + (Some(pending), _) => pending.clone(), + (None, Some(on_disk)) => on_disk.clone(), + (None, None) => read_rel(project_root, &edit.path).await?, + }; + let Some(content) = content else { return Err(format!( "{} no longer exists; cannot revert the recorded hosted redirect \ for {name}@{version}", @@ -1377,6 +1343,95 @@ mod tests { .to_string() } + // ---------- shared staging guards (twins of the replay's) ---------- + + /// A FIFO planted at a lockfile the ledger claims refuses fast (`read + /// : … not a regular file`) instead of wedging the takeover in a + /// blocking open; the ledger is untouched for a retry. + #[cfg(unix)] + #[tokio::test] + async fn npm_fifo_squatting_the_lock_refuses_instead_of_wedging() { + use std::os::unix::ffi::OsStrExt; + let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; + let root = tmp.path(); + let path = root.join("yarn.lock"); + tokio::fs::remove_file(&path).await.unwrap(); + let cpath = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(cpath.as_ptr(), 0o644) }, 0); + let edits_before = state.edits.len(); + let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect_err("a FIFO must refuse"); + assert!( + err.starts_with("read yarn.lock:") && err.contains("not a regular file"), + "{err}" + ); + assert_eq!(state.edits.len(), edits_before, "ledger untouched"); + assert!(state.records.contains_key(NPM_PURL), "record kept"); + } + + /// A symlinked lockfile reads fine (the opener follows it) but refuses + /// at flush: a rename-over would replace the link with a detached + /// regular file. Nothing is written and the ledger is untouched. + #[cfg(unix)] + #[tokio::test] + async fn npm_symlinked_lock_reads_fine_but_refuses_at_flush() { + let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; + let root = tmp.path(); + let redirected = tokio::fs::read_to_string(root.join("yarn.lock")) + .await + .unwrap(); + tokio::fs::rename(root.join("yarn.lock"), root.join("real.lock")) + .await + .unwrap(); + std::os::unix::fs::symlink(root.join("real.lock"), root.join("yarn.lock")).unwrap(); + let edits_before = state.edits.len(); + let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect_err("a symlinked lock must refuse"); + assert_eq!(err, "yarn.lock is not a regular file"); + assert_eq!( + tokio::fs::read_to_string(root.join("real.lock")) + .await + .unwrap(), + redirected, + "the symlink target must stay byte-identical" + ); + assert!( + std::fs::symlink_metadata(root.join("yarn.lock")) + .unwrap() + .file_type() + .is_symlink(), + "the link itself must survive" + ); + assert_eq!(state.edits.len(), edits_before, "ledger untouched"); + assert!(state.records.contains_key(NPM_PURL), "record kept"); + } + + /// The text flush is the mode-preserving atomic writer: a `0600` lock + /// keeps its bits through the takeover revert. + #[cfg(unix)] + #[tokio::test] + async fn npm_text_lock_revert_keeps_the_file_mode() { + use std::os::unix::fs::PermissionsExt; + let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; + let root = tmp.path(); + let path = root.join("yarn.lock"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) + .await + .expect("revert succeeds"); + assert_eq!( + tokio::fs::read_to_string(&path).await.unwrap(), + classic_pristine() + ); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "the lockfile's mode must survive the atomic rewrite" + ); + } + fn berry_pristine() -> String { "# This file is generated by running \"yarn install\" inside your project.\n\n\ __metadata:\n version: 8\n cacheKey: 10c0\n\n\ From 57db0edfd33abf0ee6eca2db5396713a68fddf78 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 18:59:41 -0400 Subject: [PATCH 05/44] core(patch): harden blob/archive inputs, single write site, rollback pnpm fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply.rs - Validate manifest-supplied strings before they become paths: afterHash must be a 64-hex blob hash (new pub is_valid_blob_hash) and uuid a plain path segment; the disk blob is read through read_blob_entry (lstat the ENTRY, refuse symlinks/FIFOs, FIFO-safe open) so a poisoned manifest or a planted blobs/ symlink can no longer read out of tree or leak a hash. - Archive/diff strategies are byte resolvers feeding ONE apply_file_patch_at write site: write I/O errors surface as themselves instead of falling through and misreporting "Failed to read blob". - Borrow the in-memory vendor blob overlay (Cow) instead of cloning per file. - Drop the break_hardlink_if_needed pass: rename-over in atomic_write_bytes already isolates shared inodes. Stat the parent once and hold a single DirWriteGuard (from_metadata) instead of two acquires + a mkdir wash. - verify_file_patch: the opener's NotFound|NotADirectory is the existence probe (is_missing_path); no separate metadata stat per verified file. - A post-rename chown failure is applied-with-warning (advisory on `error` alongside success), with the mode still restored last. - apply_file_patch is now a single-copy wrapper kept for the cargo checksum sidecar; the pnpm fan-out lives at package level only. rollback.rs - rollback_package_patch = rollback_package_patch_at + pnpm peer-variant fan-out mirroring apply: patch-added files are deleted in every store copy, an already-original primary still heals a patched twin, copy failures aggregate as "pnpm store copy … failed to roll back". - Restore writes through apply_file_patch_at (no per-file store scan) and reads the before blob through the shared read_blob_entry. - verify_file_rollback drops its metadata pre-probe the same way. Co-Authored-By: Claude Fable 5.1 --- crates/socket-patch-core/src/patch/apply.rs | 996 ++++++++++++------ .../socket-patch-core/src/patch/rollback.rs | 246 ++++- 2 files changed, 855 insertions(+), 387 deletions(-) diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs index 420de844..fa201b09 100644 --- a/crates/socket-patch-core/src/patch/apply.rs +++ b/crates/socket-patch-core/src/patch/apply.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::collections::HashMap; use std::path::Path; #[cfg(unix)] @@ -5,10 +6,10 @@ use std::path::PathBuf; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; -use crate::patch::cow::break_hardlink_if_needed; use crate::patch::diff::apply_diff; use crate::patch::file_hash::compute_file_git_sha256; use crate::patch::package::read_archive_filtered; +use crate::utils::fs::read_regular_to_bytes; /// Status of a file patch verification. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -126,6 +127,10 @@ pub struct ApplyResult { /// Per-file record of which source produced the patched bytes. Only /// populated for files in `files_patched`. pub applied_via: HashMap, + /// Why the package failed (`success == false`). On a SUCCESSFUL result + /// it is an advisory instead: files skipped under `--force`, or a + /// post-write ownership restore the caller was not privileged to make + /// (the bytes ARE patched — see `apply_file_patch_at`). pub error: Option, /// Ecosystem sidecar fixup outcome — a typed /// [`SidecarRecord`](crate::patch::sidecars::SidecarRecord) carrying @@ -173,6 +178,25 @@ pub(crate) fn is_safe_relative_subpath(normalized: &str) -> bool { .all(|c| matches!(c, Component::Normal(_) | Component::CurDir)) } +/// True when an open/stat error means the path resolves to no entry: +/// `NotFound`, or `NotADirectory` (a component of the path is a regular +/// file — the same "not there" a plain `metadata` probe reports). +pub(crate) fn is_missing_path(e: &std::io::Error) -> bool { + matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) +} + +/// A blob hash must be a 64-char hex SHA-256 — the only shape the CLI ever +/// writes under `.socket/blobs/`. Enforced wherever a manifest or API hash +/// becomes a filesystem path component: anything else (`../../x`, an +/// absolute path) would escape the blobs directory via `Path::join`. `pub` +/// so the CLI's download-side gates share the one definition. +pub fn is_valid_blob_hash(hash: &str) -> bool { + hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) +} + /// Verify a single file can be patched. pub async fn verify_file_patch( pkg_path: &Path, @@ -195,32 +219,32 @@ pub async fn verify_file_patch( let is_new_file = file_info.before_hash.is_empty(); - // Check if file exists - if tokio::fs::metadata(&filepath).await.is_err() { - // New files (empty beforeHash) are expected to not exist yet. - if is_new_file { + // Hash the file straight away — the opener's own NotFound is the + // existence probe (a separate `metadata` first would stat every + // verified file twice). + let current_hash = match compute_file_git_sha256(&filepath).await { + Ok(h) => h, + Err(e) if is_missing_path(&e) => { + // New files (empty beforeHash) are expected to not exist yet. + if is_new_file { + return VerifyResult { + file: file_name.to_string(), + status: VerifyStatus::Ready, + message: None, + current_hash: None, + expected_hash: None, + target_hash: Some(file_info.after_hash.clone()), + }; + } return VerifyResult { file: file_name.to_string(), - status: VerifyStatus::Ready, - message: None, + status: VerifyStatus::NotFound, + message: Some("File not found".to_string()), current_hash: None, expected_hash: None, - target_hash: Some(file_info.after_hash.clone()), + target_hash: None, }; } - return VerifyResult { - file: file_name.to_string(), - status: VerifyStatus::NotFound, - message: Some("File not found".to_string()), - current_hash: None, - expected_hash: None, - target_hash: None, - }; - } - - // Compute current hash - let current_hash = match compute_file_git_sha256(&filepath).await { - Ok(h) => h, Err(e) => { return VerifyResult { file: file_name.to_string(), @@ -372,20 +396,30 @@ pub async fn select_installed_variants( /// no-op; the read-only attribute is preserved on existing files and /// set on new files to honor the read-only-by-default policy. /// -/// Writes the patched content and verifies the resulting hash. +/// The in-memory bytes are hash-checked against `expected_hash` BEFORE +/// anything touches disk, then committed by stage + fsync + `rename(2)` +/// (`utils::fs::atomic_write_bytes`). The rename replaces only the +/// directory entry, which is also the copy-on-write isolation for shared +/// inodes: a hardlinked sibling (pnpm's content-addressable store, the +/// bun / uv caches, Go's module cache) keeps the old inode, and a symlink +/// into a store is replaced by a private regular file instead of being +/// written through. +/// +/// This variant writes to exactly the one package root it is given; the +/// pnpm peer-variant store copies are handled at package level by +/// `apply_package_patch` / `rollback_package_patch`, with a full verify per +/// copy. /// -/// This variant writes to exactly the one package root it is given. -/// External write paths (rollback's restore) go through -/// [`apply_file_patch`], which additionally fans the write out to every -/// pnpm peer-variant store copy of the package; `apply_package_patch` -/// handles those copies itself at package level (with full per-copy -/// verification) and therefore uses this single-copy variant directly. +/// Returns `Ok(Some(warning))` when the bytes are committed but a +/// best-effort post-write step failed — today only the ownership restore +/// (an unprivileged caller cannot `chown` the fresh inode back to another +/// uid/gid). The file IS patched; the caller reports the warning. pub(crate) async fn apply_file_patch_at( pkg_path: &Path, file_name: &str, patched_content: &[u8], expected_hash: &str, -) -> Result<(), std::io::Error> { +) -> Result, std::io::Error> { let normalized = normalize_file_path(file_name); // SECURITY: refuse to write through a key that escapes the package dir. if !is_safe_relative_subpath(normalized) { @@ -418,61 +452,47 @@ pub(crate) async fn apply_file_patch_at( // parent dir. let existing_meta = tokio::fs::metadata(&filepath).await.ok(); - // Create parent directories if needed (e.g., new files added by a patch). - // - // `create_dir_all` needs write permission on the FIRST existing - // ancestor of `parent` to materialize the missing chain. Go's module - // cache (and some Nix/Bazel layouts) mark package directories - // read-only (0o555), so a patch that adds a file under a not-yet- - // existing subdir would fail here with EACCES — and the - // `DirWriteGuard` below can't help, because it relaxes the immediate - // parent, which does not exist yet. Temporarily grant owner-write on - // the nearest existing ancestor for the duration of the mkdir, then - // restore it exactly. (When `parent` already exists this ancestor IS - // `parent`; the guard relax+restore is then a harmless wash before the - // dedicated `DirWriteGuard` below re-relaxes it for the write.) - if let Some(parent) = filepath.parent() { - let mkdir_guard = DirWriteGuard::acquire(nearest_existing_ancestor(parent).await).await; - let mkdir_result = tokio::fs::create_dir_all(parent).await; - mkdir_guard.restore().await; - mkdir_result?; - } - - // The atomic stage+rename below — and the copy-on-write break, which - // also stages a sibling file — need write permission on the *parent - // directory*, not just on the file. Go's module cache marks both its - // files (0o444) and its directories (0o555) read-only, so without - // this the stage-file creation fails with EACCES (where the old - // in-place write, like `rollback.rs`, only had to relax the file's - // own mode). Temporarily grant owner-write on the directory; the - // guard restores its exact mode below. - let dir_guard = DirWriteGuard::acquire(filepath.parent()).await; - - // Copy-on-write defense against pnpm / bazel / nix shared inodes. - // If `filepath` is a symlink into a content store, or a hardlink - // shared with other projects, give this project a private inode - // before we mutate. No-op on regular private files (single - // syscall). See `patch::cow`. - // - // Atomic write (`utils::fs::atomic_write_bytes`): stage in the - // parent directory, fsync, rename onto the target. POSIX - // `rename(2)` is atomic — observers see either the old bytes or - // the new bytes, never a truncated half-write. - // - // The stage file is created with the user's umask defaults - // (typically 0o644) — that's how we sidestep the "existing file - // is 0o444" problem the old in-place write had: we rename a fresh - // user-writable inode over the target instead of trying to open - // a read-only file for write. `restore_file_permissions` then - // re-applies the pre-patch mode + uid/gid to the new inode. - // - // Both steps run inside a closure so the directory mode is ALWAYS - // restored — even if a step errors — before the failure propagates. - let write_result = async { - break_hardlink_if_needed(&filepath).await?; - crate::utils::fs::atomic_write_bytes(&filepath, patched_content).await - } - .await; + // The stage+rename below needs write permission on the *parent + // directory*, not just on the file: Go's module cache (and some + // Nix/Bazel layouts) mark both files (0o444) and directories (0o555) + // read-only, so without a relax the stage-file creation fails with + // EACCES. ONE stat of the parent decides how: + // * an existing directory is relaxed (if read-only) for the duration + // of the write and put back exactly as found; + // * a missing parent (a patch adding a file under a new subdir) is + // materialized with `create_dir_all`, relaxing the nearest EXISTING + // ancestor for the mkdir only — a freshly created directory is + // owner-writable by construction, so the write needs no guard. A + // non-directory sitting where the parent should be takes the same + // path and fails inside `create_dir_all`, with the blocker's mode + // restored. + let dir_guard = match filepath.parent() { + Some(parent) => match tokio::fs::metadata(parent).await { + Ok(meta) if meta.is_dir() => DirWriteGuard::from_metadata(parent, &meta).await, + _ => { + let mkdir_guard = + DirWriteGuard::acquire(nearest_existing_ancestor(parent).await).await; + let mkdir_result = tokio::fs::create_dir_all(parent).await; + mkdir_guard.restore().await; + mkdir_result?; + DirWriteGuard::noop() + } + }, + None => DirWriteGuard::noop(), + }; + + // Atomic write (`utils::fs::atomic_write_bytes`): stage in the parent + // directory, fsync, rename onto the target. POSIX `rename(2)` is + // atomic — observers see either the old bytes or the new bytes, never + // a truncated half-write — and it swaps the directory entry only, so a + // shared inode (pnpm store hardlink, symlink into a cache) is left + // untouched rather than written through. The stage file is created + // with the user's umask defaults, which is how a 0o444 target is never + // opened for write: a fresh inode is renamed over it and + // `restore_file_permissions` re-applies the pre-patch mode + uid/gid. + // The directory mode is restored whether or not the write succeeded, + // before any failure propagates. + let write_result = crate::utils::fs::atomic_write_bytes(&filepath, patched_content).await; dir_guard.restore().await; write_result?; @@ -480,34 +500,24 @@ pub(crate) async fn apply_file_patch_at( // On Unix this includes chown back to the pre-patch uid/gid (or // to the parent dir's uid/gid for new files); on Windows we only // manage the readonly attribute. - restore_file_permissions(&filepath, existing_meta.as_ref()).await?; - - Ok(()) + restore_file_permissions(&filepath, existing_meta.as_ref()).await } -/// [`apply_file_patch_at`] plus pnpm peer-variant fan-out: after the write -/// to `pkg_path` succeeds, the same hash-verified bytes are written to -/// every OTHER physical store copy of the package -/// (`.pnpm/@(peerA…)/…` vs `(peerB…)/…` are distinct real -/// dirs, each runtime-loaded). This is the write path rollback's restore -/// uses, so rolling back a patch restores every copy the apply reached — -/// restoring only the resolver's single primary would leave a -/// still-patched twin behind. Each copy goes through the full hardened -/// pipeline (atomic stage+rename, per-copy hardlink break, permission -/// restore); a failed copy propagates as an error — fail closed, never -/// "done" with a copy left divergent. Non-pnpm layouts discover no copies -/// and behave exactly as before. +/// Single-copy [`apply_file_patch_at`] with the post-write warning dropped +/// — the entry point the cargo checksum sidecar writes through. The pnpm +/// peer-variant fan-out lives at package level (`apply_package_patch`, +/// `rollback_package_patch`), where every copy gets its own verify; a +/// `.cargo-checksum.json` can never live in a pnpm store, so the per-file +/// store discovery this wrapper used to run on every call was pure waste. pub(crate) async fn apply_file_patch( pkg_path: &Path, file_name: &str, patched_content: &[u8], expected_hash: &str, ) -> Result<(), std::io::Error> { - apply_file_patch_at(pkg_path, file_name, patched_content, expected_hash).await?; - for copy in crate::crawlers::npm_crawler::find_pnpm_peer_variant_copies(pkg_path).await { - apply_file_patch_at(©, file_name, patched_content, expected_hash).await?; - } - Ok(()) + apply_file_patch_at(pkg_path, file_name, patched_content, expected_hash) + .await + .map(|_ownership_warning| ()) } /// Guard that temporarily grants owner-write on a directory so the @@ -528,33 +538,52 @@ pub(crate) struct DirWriteGuard { } impl DirWriteGuard { + /// A guard that changed nothing; [`DirWriteGuard::restore`] is a no-op. + fn noop() -> Self { + #[cfg(unix)] + { + Self { relock: None } + } + #[cfg(not(unix))] + { + Self {} + } + } + pub(crate) async fn acquire(dir: Option<&Path>) -> Self { + match dir { + Some(dir) => match tokio::fs::metadata(dir).await { + Ok(meta) => Self::from_metadata(dir, &meta).await, + Err(_) => Self::noop(), + }, + None => Self::noop(), + } + } + + /// [`DirWriteGuard::acquire`] for a caller that has already stat'ed + /// `dir` — no second `metadata` call. + async fn from_metadata(dir: &Path, meta: &std::fs::Metadata) -> Self { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - if let Some(dir) = dir { - if let Ok(meta) = tokio::fs::metadata(dir).await { - let mode = meta.permissions().mode(); - // Owner-write bit missing → relax it, remembering the - // original mode so `restore` can re-lock the dir. - if mode & 0o200 == 0 { - let mut perms = meta.permissions(); - perms.set_mode(mode | 0o200); - if tokio::fs::set_permissions(dir, perms).await.is_ok() { - return Self { - relock: Some((dir.to_path_buf(), mode)), - }; - } - } + let mode = meta.permissions().mode(); + // Owner-write bit missing → relax it, remembering the original + // mode so `restore` can re-lock the dir. + if mode & 0o200 == 0 { + let mut perms = meta.permissions(); + perms.set_mode(mode | 0o200); + if tokio::fs::set_permissions(dir, perms).await.is_ok() { + return Self { + relock: Some((dir.to_path_buf(), mode)), + }; } } - Self { relock: None } } #[cfg(not(unix))] { - let _ = dir; - Self {} + let _ = (dir, meta); } + Self::noop() } pub(crate) async fn restore(self) { @@ -593,43 +622,55 @@ async fn nearest_existing_ancestor(path: &Path) -> Option<&Path> { /// * `pre_patch` = `None` → the file is new; inherit owner/group from /// the parent dir and set mode `0o444`. /// -/// Split out of `apply_file_patch` to keep that function readable and +/// The bytes are already committed when this runs (the rename is done), +/// so an ownership restore the caller is not privileged to perform — the +/// pre-patch owner is another uid/gid and we are not root (`chown(2)` +/// EPERM; a group-writable install populated by another user) — must not +/// turn an applied patch into a reported failure: the mode is still +/// restored and the failure comes back as a warning (`Ok(Some(..))`). A +/// failing mode restore is still an error. +/// +/// Split out of `apply_file_patch_at` to keep that function readable and /// to make the platform branching unit-testable. async fn restore_file_permissions( filepath: &Path, pre_patch: Option<&std::fs::Metadata>, -) -> Result<(), std::io::Error> { +) -> Result, std::io::Error> { #[cfg(unix)] { use std::os::unix::fs::{MetadataExt, PermissionsExt}; - match pre_patch { - Some(meta) => { - // Existing file: re-apply the original ownership FIRST, - // then the mode. Order matters — `chown(2)` clears the - // setuid/setgid bits for an unprivileged caller (even when - // the uid/gid are unchanged), so the chmod must run last - // to restore the mode bit-for-bit, setuid/setgid included. - let uid = meta.uid(); - let gid = meta.gid(); - chown_blocking(filepath.to_path_buf(), Some(uid), Some(gid)).await?; - let restored = std::fs::Permissions::from_mode(meta.mode()); - tokio::fs::set_permissions(filepath, restored).await?; - } + // Ownership FIRST, then the mode. Order matters — `chown(2)` clears + // the setuid/setgid bits for an unprivileged caller (even when the + // uid/gid are unchanged), so the chmod must run last to restore the + // mode bit-for-bit, setuid/setgid included. + let (owner, mode) = match pre_patch { + // Existing file: its original ownership and exact mode. + Some(meta) => (Some((meta.uid(), meta.gid())), meta.mode()), + // New file: inherit owner/group from the parent dir; read-only + // for all, like an unpacked tarball's package files. None => { - // New file. Inherit owner/group from the parent dir. - if let Some(parent) = filepath.parent() { - if let Ok(parent_meta) = tokio::fs::metadata(parent).await { - let uid = parent_meta.uid(); - let gid = parent_meta.gid(); - chown_blocking(filepath.to_path_buf(), Some(uid), Some(gid)).await?; - } - } - // Default new-file mode: read-only for all. - let readonly = std::fs::Permissions::from_mode(0o444); - tokio::fs::set_permissions(filepath, readonly).await?; + let parent_owner = match filepath.parent() { + Some(parent) => tokio::fs::metadata(parent) + .await + .ok() + .map(|m| (m.uid(), m.gid())), + None => None, + }; + (parent_owner, 0o444) + } + }; + let mut warning = None; + if let Some((uid, gid)) = owner { + if let Err(e) = chown_blocking(filepath.to_path_buf(), Some(uid), Some(gid)).await { + warning = Some(format!( + "{}: patched, but ownership could not be restored to uid {uid} gid {gid}: {e}", + filepath.display() + )); } } + tokio::fs::set_permissions(filepath, std::fs::Permissions::from_mode(mode)).await?; + Ok(warning) } #[cfg(windows)] @@ -650,11 +691,14 @@ async fn restore_file_permissions( } } } + Ok(None) } - let _ = filepath; - let _ = pre_patch; - Ok(()) + #[cfg(not(any(unix, windows)))] + { + let _ = (filepath, pre_patch); + Ok(None) + } } /// Synchronous `chown` wrapped to run on the blocking pool so we don't @@ -858,8 +902,12 @@ async fn apply_package_patch_at( _ => None, }; - // Apply patches to files that need it. For each file, try package - // archive first, then diff, then blob. + // Advisory notes from writes that committed but could not fully restore + // metadata (see `apply_file_patch_at`); reported on `error` alongside + // `success` — the success-with-note shape the `--force` skip uses. + let mut warnings: Vec = Vec::new(); + + // Apply patches to files that need it. for (file_name, file_info) in files { let verify_result = result.files_verified.iter().find(|v| v.file == *file_name); if let Some(vr) = verify_result { @@ -868,89 +916,63 @@ async fn apply_package_patch_at( } } - let normalized = normalize_file_path(file_name).to_string(); - - // ── Strategy 1: package archive ────────────────────────────── - if try_apply_from_archive( - package_entries.as_ref(), - &normalized, - pkg_path, - file_name, - file_info, - ) - .await + let normalized = normalize_file_path(file_name); + + // Resolve the patched bytes from the first applicable source, in + // order: package archive → per-file diff → in-memory blob overlay + // (the vendor flows stage there, so vendoring writes no + // `.socket/blobs` entries) → on-disk blob. An archive or diff + // candidate is applicable only when it hashes to `afterHash`; a + // stale or corrupt entry falls through, it is not an error. The + // blob is the universal fallback: failing to read it fails the + // file. The diff needs the pre-apply on-disk hash that + // `verify_file_patch` captured — under `--force` a HashMismatch is + // promoted to Ready but `current_hash` keeps the real value, so + // the diff still bails instead of producing garbage. + let current_hash = verify_result.and_then(|v| v.current_hash.as_deref()); + let (patched_content, via): (Cow<'_, [u8]>, AppliedVia) = if let Some(bytes) = + resolve_from_archive(package_entries.as_ref(), normalized, file_info) { - result.files_patched.push(file_name.clone()); - result - .applied_via - .insert(file_name.clone(), AppliedVia::Package); - continue; - } - - // ── Strategy 2: per-file diff ──────────────────────────────── - // Diffs only apply cleanly when the on-disk content actually - // hashes to `before_hash` — otherwise the bsdiff output won't - // match `after_hash`. We pass the pre-apply current_hash - // captured by `verify_file_patch` so `try_apply_from_diff` can - // skip the wasted decompress+apply work when --force is - // overriding a hash mismatch (force flips status to Ready but - // the underlying hash is still wrong). - let current_hash_for_diff = verify_result.and_then(|v| v.current_hash.as_deref()); - if try_apply_from_diff( + (Cow::Borrowed(bytes), AppliedVia::Package) + } else if let Some(bytes) = resolve_from_diff( diff_entries.as_ref(), - &normalized, + normalized, pkg_path, - file_name, file_info, - current_hash_for_diff, + current_hash, ) .await { - result.files_patched.push(file_name.clone()); - result - .applied_via - .insert(file_name.clone(), AppliedVia::Diff); - continue; - } - - // ── Strategy 3: per-file blob ──────────────────────────────── - // The in-memory overlay wins (vendor flows stage there — no - // `.socket/blobs` writes); the on-disk dir is the fallback. - let mem_hit = sources - .mem_blobs - .and_then(|m| m.get(&file_info.after_hash)) - .cloned(); - let patched_content = match mem_hit { - Some(content) => content, - None => { - let blob_path = sources.blobs_path.join(&file_info.after_hash); - match tokio::fs::read(&blob_path).await { - Ok(content) => content, - Err(e) => { - result.error = Some(format!( - "Failed to read blob {}: {}", - file_info.after_hash, e - )); - return result; - } + (Cow::Owned(bytes), AppliedVia::Diff) + } else if let Some(bytes) = sources.mem_blobs.and_then(|m| m.get(&file_info.after_hash)) { + (Cow::Borrowed(bytes.as_slice()), AppliedVia::Blob) + } else { + match read_blob(sources.blobs_path, &file_info.after_hash).await { + Ok(bytes) => (Cow::Owned(bytes), AppliedVia::Blob), + Err(msg) => { + result.error = Some(msg); + return result; } } }; - // Single-copy write: the public `apply_package_patch` wrapper fans - // out to pnpm peer-variant copies itself, with per-copy - // verification. - if let Err(e) = - apply_file_patch_at(pkg_path, file_name, &patched_content, &file_info.after_hash).await + // ONE write site for every source, so a write failure (EACCES on + // the stage, ENOSPC, a failed rename) is reported as what it is + // instead of masquerading as the next source's miss. Single copy: + // the public `apply_package_patch` wrapper fans out to pnpm + // peer-variant copies itself, with per-copy verification. + match apply_file_patch_at(pkg_path, file_name, &patched_content, &file_info.after_hash) + .await { - result.error = Some(e.to_string()); - return result; + Ok(warning) => warnings.extend(warning), + Err(e) => { + result.error = Some(e.to_string()); + return result; + } } result.files_patched.push(file_name.clone()); - result - .applied_via - .insert(file_name.clone(), AppliedVia::Blob); + result.applied_via.insert(file_name.clone(), via); } // Ecosystem sidecar fixup. Best-effort: a failing sidecar does @@ -993,106 +1015,116 @@ async fn apply_package_patch_at( } } + if !warnings.is_empty() { + result.error = Some(warnings.join("; ")); + } result.success = true; result } -/// Try to write the patched bytes from `package_entries[normalized_path]` -/// to disk, verifying the post-write hash. Returns `true` on success. -async fn try_apply_from_archive( - package_entries: Option<&HashMap>>, +/// Strategy 1 — package archive: the entry for `normalized_path`, when it +/// is present and hashes to `afterHash`. Anything else is "not +/// applicable" and the caller falls through to the next source. +fn resolve_from_archive<'e>( + package_entries: Option<&'e HashMap>>, normalized_path: &str, - pkg_path: &Path, - file_name: &str, file_info: &PatchFileInfo, -) -> bool { - let entries = match package_entries { - Some(e) => e, - None => return false, - }; - let bytes = match entries.get(normalized_path) { - Some(b) => b, - None => return false, - }; - if compute_git_sha256_from_bytes(bytes) != file_info.after_hash { - return false; - } - // Single-copy write: `apply_package_patch` fans out to pnpm - // peer-variant copies itself, with per-copy verification. - apply_file_patch_at(pkg_path, file_name, bytes, &file_info.after_hash) - .await - .is_ok() +) -> Option<&'e [u8]> { + let bytes = package_entries?.get(normalized_path)?; + (compute_git_sha256_from_bytes(bytes) == file_info.after_hash).then_some(bytes.as_slice()) } -/// Try to apply the bsdiff delta from `diff_entries[normalized_path]` to -/// the on-disk file at `pkg_path/normalized_path`. Bails out (returning -/// `false`) for any of: -/// * no diff entry, -/// * `current_hash` is missing or doesn't match `file_info.before_hash` -/// (this is the strong gate — even `--force` promoting a -/// HashMismatch to Ready will still bail here, because the on-disk -/// hash captured by `verify_file_patch` was the real, mismatched -/// value), -/// * `file_info.before_hash` is empty (new files), -/// * read/diff/verify/write failure. -async fn try_apply_from_diff( +/// Strategy 2 — per-file diff: apply the bsdiff delta for +/// `normalized_path` to the on-disk file and return the product. Not +/// applicable (`None`) when there is no delta, the entry is a new file +/// (nothing to diff against), `current_hash` is missing or is not the +/// `beforeHash` the delta was authored against — the strong gate: `--force` +/// promotes a HashMismatch to Ready but the captured on-disk hash is still +/// the real one — or the read, the delta or the product hash fails. +async fn resolve_from_diff( diff_entries: Option<&HashMap>>, normalized_path: &str, pkg_path: &Path, - file_name: &str, file_info: &PatchFileInfo, current_hash: Option<&str>, -) -> bool { - let entries = match diff_entries { - Some(e) => e, - None => return false, - }; - let delta = match entries.get(normalized_path) { - Some(d) => d, - None => return false, - }; - if file_info.before_hash.is_empty() { - // New files have no before content to diff against. - return false; +) -> Option> { + let delta = diff_entries?.get(normalized_path)?; + if file_info.before_hash.is_empty() || current_hash != Some(file_info.before_hash.as_str()) { + return None; } - // Strong invariant: only run the diff when on-disk bytes hash to - // exactly the `before_hash` the delta was authored against. This - // closes the force-mode loophole — `--force` flips VerifyStatus to - // Ready, but `current_hash` retains the original on-disk hash, so - // the comparison below still rejects. - match current_hash { - Some(h) if h == file_info.before_hash => {} - _ => return false, + let before_bytes = read_regular_to_bytes(&pkg_path.join(normalized_path)) + .await + .ok()?; + let patched = apply_diff(&before_bytes, delta).ok()?; + (compute_git_sha256_from_bytes(&patched) == file_info.after_hash).then_some(patched) +} + +/// Strategy 3 (on-disk half) — read `blobs_path/` fail-closed. +/// +/// SECURITY: `hash` comes from a committed `.socket/manifest.json` that the +/// install hook applies without user action, so it is validated as a blob +/// hash before it is joined (no traversal, no absolute path), and the +/// directory ENTRY must be a regular file ([`read_blob_entry`]): a symlink +/// planted at `blobs/` must not carry the read out of the blobs +/// directory (an out-of-tree read whose hash-mismatch error would leak the +/// target's content hash), and a FIFO or device must not hang or flood it. +/// The error is the user-facing message. +async fn read_blob(blobs_path: &Path, hash: &str) -> Result, String> { + if !is_valid_blob_hash(hash) { + return Err(format!( + "Refusing to read blob with invalid hash {hash:?} (expected 64 hex chars)" + )); } + read_blob_entry(&blobs_path.join(hash)).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::InvalidInput { + format!("Blob is not a regular file: {hash}") + } else { + format!("Failed to read blob {hash}: {e}") + } + }) +} - let on_disk_path = pkg_path.join(normalized_path); - let before_bytes = match tokio::fs::read(&on_disk_path).await { - Ok(b) => b, - Err(_) => return false, - }; - let patched = match apply_diff(&before_bytes, delta) { - Ok(p) => p, - Err(_) => return false, - }; - if compute_git_sha256_from_bytes(&patched) != file_info.after_hash { - return false; +/// Read a blobs-directory entry that must be a regular file: the ENTRY is +/// `lstat`ed first (a symlink is refused, never followed), then the open +/// goes through the FIFO-safe reader. A non-regular entry fails with +/// `InvalidInput`; every other error keeps its kind (`NotFound` for a +/// missing blob). Shared with rollback's before-blob read. +pub(crate) async fn read_blob_entry(blob_path: &Path) -> std::io::Result> { + let meta = tokio::fs::symlink_metadata(blob_path).await?; + if !meta.is_file() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{} is not a regular file", blob_path.display()), + )); } - // Single-copy write: `apply_package_patch` fans out to pnpm - // peer-variant copies itself, with per-copy verification. - apply_file_patch_at(pkg_path, file_name, &patched, &file_info.after_hash) - .await - .is_ok() + read_regular_to_bytes(blob_path).await +} + +/// True when a manifest `uuid` is safe to use as the archive file stem: a +/// non-empty run of ASCII alphanumerics, `-` and `_`. Every real +/// `xxxxxxxx-xxxx-…` patch id passes; a separator, `.`, NUL or anything +/// else that could change the joined path is refused. +fn is_safe_archive_uuid(uuid: &str) -> bool { + !uuid.is_empty() + && uuid + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } /// Open `/.tar.gz` (if it exists) and return its entries /// filtered to the patched files in `files`. Errors and missing files /// both yield `None` so the caller silently falls through to the next -/// strategy. +/// strategy. SECURITY: `uuid` comes from the committed manifest and is +/// used as a path component — anything but a plain single path segment +/// (`../../x`, an absolute path) is treated as "no archive", never joined. async fn load_archive_if_present( dir: &Path, uuid: &str, files: &HashMap, ) -> Option>> { + if !is_safe_archive_uuid(uuid) { + return None; + } let archive_path = dir.join(format!("{uuid}.tar.gz")); if tokio::fs::metadata(&archive_path).await.is_err() { return None; @@ -1181,7 +1213,7 @@ mod tests { #[tokio::test] async fn test_apply_file_patch_rejects_escaping_path() { - // apply_file_patch must refuse to write outside the package dir even if + // apply_file_patch_at must refuse to write outside the package dir even if // the (attacker-chosen) content hashes to the declared afterHash. let dir = tempfile::tempdir().unwrap(); let pkg = dir.path().join("site-packages"); @@ -1189,7 +1221,7 @@ mod tests { let content = b"pwned\n"; let after = compute_git_sha256_from_bytes(content); for key in ["../escape.txt", "../../etc/whatever", "/abs/whatever"] { - let res = apply_file_patch(&pkg, key, content, &after).await; + let res = apply_file_patch_at(&pkg, key, content, &after).await; assert!(res.is_err(), "must reject {key:?}"); assert!( res.unwrap_err().to_string().contains("Unsafe patch path"), @@ -1302,7 +1334,7 @@ mod tests { .await .unwrap(); - apply_file_patch(dir.path(), "index.js", patched, &patched_hash) + apply_file_patch_at(dir.path(), "index.js", patched, &patched_hash) .await .unwrap(); @@ -1318,7 +1350,7 @@ mod tests { .unwrap(); let result = - apply_file_patch(dir.path(), "index.js", b"patched content", "wrong_hash").await; + apply_file_patch_at(dir.path(), "index.js", b"patched content", "wrong_hash").await; assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.to_string().contains("Hash verification failed")); @@ -1334,7 +1366,7 @@ mod tests { let path = dir.path().join("index.js"); tokio::fs::write(&path, b"original").await.unwrap(); - let result = apply_file_patch(dir.path(), "index.js", b"patched", "deadbeef").await; + let result = apply_file_patch_at(dir.path(), "index.js", b"patched", "deadbeef").await; assert!(result.is_err()); // Original content untouched. @@ -1373,7 +1405,7 @@ mod tests { let patched = b"patched"; let patched_hash = compute_git_sha256_from_bytes(patched); - apply_file_patch(project.parent().unwrap(), "foo.js", patched, &patched_hash) + apply_file_patch_at(project.parent().unwrap(), "foo.js", patched, &patched_hash) .await .unwrap(); @@ -1403,7 +1435,7 @@ mod tests { .await .unwrap(); - apply_file_patch(dir.path(), "index.js", patched, &patched_hash) + apply_file_patch_at(dir.path(), "index.js", patched, &patched_hash) .await .unwrap(); @@ -1441,7 +1473,7 @@ mod tests { .await .unwrap(); - apply_file_patch(dir.path(), "bin.sh", patched, &patched_hash) + apply_file_patch_at(dir.path(), "bin.sh", patched, &patched_hash) .await .unwrap(); @@ -1471,7 +1503,7 @@ mod tests { let patched_hash = compute_git_sha256_from_bytes(patched); // File does not yet exist — this is the new-file path. - apply_file_patch(dir.path(), nested, patched, &patched_hash) + apply_file_patch_at(dir.path(), nested, patched, &patched_hash) .await .unwrap(); @@ -1509,7 +1541,7 @@ mod tests { tokio::fs::write(&path, original).await.unwrap(); let pre = tokio::fs::metadata(&path).await.unwrap(); - apply_file_patch(dir.path(), "index.js", patched, &patched_hash) + apply_file_patch_at(dir.path(), "index.js", patched, &patched_hash) .await .unwrap(); @@ -1520,7 +1552,7 @@ mod tests { /// Read-only package directory (Go's module cache marks both files /// 0o444 AND directories 0o555). The stage+rename write path needs - /// owner-write on the directory; `apply_file_patch` must grant it for + /// owner-write on the directory; `apply_file_patch_at` must grant it for /// the write and then restore the directory to its exact prior mode. /// Regression: before the `DirWriteGuard` fix the stage-file creation /// failed with EACCES and the patch could not be applied at all. @@ -1544,7 +1576,7 @@ mod tests { .await .unwrap(); - apply_file_patch(dir.path(), "index.js", patched, &patched_hash) + apply_file_patch_at(dir.path(), "index.js", patched, &patched_hash) .await .expect("apply must succeed even inside a read-only directory"); @@ -1600,7 +1632,7 @@ mod tests { .await .unwrap(); - apply_file_patch(dir.path(), "new.js", patched, &patched_hash) + apply_file_patch_at(dir.path(), "new.js", patched, &patched_hash) .await .expect("new-file apply must succeed inside a read-only directory"); @@ -1663,7 +1695,7 @@ mod tests { return; } - apply_file_patch(dir.path(), "suid-bin", patched, &patched_hash) + apply_file_patch_at(dir.path(), "suid-bin", patched, &patched_hash) .await .unwrap(); @@ -2404,7 +2436,7 @@ mod tests { .await .unwrap(); - apply_file_patch(dir.path(), "a/b/c/new.js", patched, &patched_hash) + apply_file_patch_at(dir.path(), "a/b/c/new.js", patched, &patched_hash) .await .expect("apply must succeed creating a subdir chain in a read-only pkg dir"); @@ -2465,7 +2497,7 @@ mod tests { .await .unwrap(); - apply_file_patch(dir.path(), "sub/new.js", patched, &patched_hash) + apply_file_patch_at(dir.path(), "sub/new.js", patched, &patched_hash) .await .expect("apply must succeed in an existing read-only subdir"); @@ -2815,7 +2847,7 @@ mod tests { assert_eq!(written, fresh); } - // ── create_dir_all failure inside apply_file_patch ─────────────── + // ── create_dir_all failure inside apply_file_patch_at ──────────── /// A patch adds a file under a path whose intermediate component /// exists as a regular FILE. `create_dir_all` must fail and the error @@ -2830,7 +2862,7 @@ mod tests { let patched = b"x"; let hash = compute_git_sha256_from_bytes(patched); - let res = apply_file_patch(dir.path(), "blocker/new.js", patched, &hash).await; + let res = apply_file_patch_at(dir.path(), "blocker/new.js", patched, &hash).await; assert!(res.is_err(), "mkdir through a regular file must fail"); // The blocking file is untouched. @@ -2868,7 +2900,7 @@ mod tests { let patched = b"x"; let hash = compute_git_sha256_from_bytes(patched); - let res = apply_file_patch(dir.path(), "blocker/new.js", patched, &hash).await; + let res = apply_file_patch_at(dir.path(), "blocker/new.js", patched, &hash).await; assert!(res.is_err(), "mkdir through a regular file must fail"); let mode = tokio::fs::metadata(&blocker) @@ -3009,14 +3041,14 @@ mod tests { assert!(!root.path().join("escape.js").exists()); } - // ── try_apply_from_diff bail-outs ──────────────────────────────── + // ── resolve_from_diff bail-outs ────────────────────────────────── // - // Direct-call tests for the private diff strategy's fail-soft - // contract: each bail returns `false` (fall through to blob) and - // writes NOTHING. + // Direct-call tests for the private diff resolver's fail-soft + // contract: each bail yields `None` (the pipeline falls through to + // the blob) and touches NOTHING on disk. #[tokio::test] - async fn test_try_apply_from_diff_bails_on_new_file_entry() { + async fn test_resolve_from_diff_bails_on_new_file_entry() { // A diff entry for a file with empty beforeHash (malformed or // adversarial patch data): there is no before content to diff // against, so the strategy must refuse. @@ -3028,15 +3060,23 @@ mod tests { after_hash: compute_git_sha256_from_bytes(b"x"), }; - let applied = - try_apply_from_diff(Some(&entries), "new.js", dir.path(), "new.js", &info, Some("anything")) - .await; - assert!(!applied, "new-file entries must never apply via diff"); + let applied = resolve_from_diff( + Some(&entries), + "new.js", + dir.path(), + &info, + Some("anything"), + ) + .await; + assert!( + applied.is_none(), + "new-file entries must never apply via diff" + ); assert!(!dir.path().join("new.js").exists(), "nothing written"); } #[tokio::test] - async fn test_try_apply_from_diff_bails_when_target_unreadable() { + async fn test_resolve_from_diff_bails_when_target_unreadable() { // The current_hash gate passes (verify/apply race or permission // loss) but the on-disk read fails: fail soft, fall through. let dir = tempfile::tempdir().unwrap(); @@ -3051,21 +3091,20 @@ mod tests { }; // NO file on disk, but current_hash claims the before state. - let applied = try_apply_from_diff( + let applied = resolve_from_diff( Some(&entries), "index.js", dir.path(), - "index.js", &info, Some(&before_hash), ) .await; - assert!(!applied, "unreadable target must bail"); + assert!(applied.is_none(), "unreadable target must bail"); assert!(!dir.path().join("index.js").exists(), "nothing written"); } #[tokio::test] - async fn test_try_apply_from_diff_bails_on_corrupt_delta() { + async fn test_resolve_from_diff_bails_on_corrupt_delta() { // `.socket/diffs` is on-disk and user-tamperable: garbage delta // bytes must fail apply_diff and leave the target untouched. let dir = tempfile::tempdir().unwrap(); @@ -3085,16 +3124,15 @@ mod tests { after_hash: compute_git_sha256_from_bytes(b"whatever"), }; - let applied = try_apply_from_diff( + let applied = resolve_from_diff( Some(&entries), "index.js", dir.path(), - "index.js", &info, Some(&before_hash), ) .await; - assert!(!applied, "corrupt delta must bail"); + assert!(applied.is_none(), "corrupt delta must bail"); assert_eq!( tokio::fs::read(dir.path().join("index.js")).await.unwrap(), original, @@ -3103,7 +3141,7 @@ mod tests { } #[tokio::test] - async fn test_try_apply_from_diff_bails_on_wrong_target_delta() { + async fn test_resolve_from_diff_bails_on_wrong_target_delta() { // A delta authored against the RIGHT base but toward the WRONG // target: apply_diff succeeds, but the product's hash differs // from afterHash — nothing may be written. @@ -3125,16 +3163,15 @@ mod tests { after_hash: compute_git_sha256_from_bytes(b"the real patched content"), }; - let applied = try_apply_from_diff( + let applied = resolve_from_diff( Some(&entries), "index.js", dir.path(), - "index.js", &info, Some(&before_hash), ) .await; - assert!(!applied, "wrong-target delta must bail"); + assert!(applied.is_none(), "wrong-target delta must bail"); assert_eq!( tokio::fs::read(dir.path().join("index.js")).await.unwrap(), original, @@ -3176,4 +3213,297 @@ mod tests { let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap(); assert_eq!(written, patched); } + + // ── hygiene / hardening pins ───────────────────────────────────── + + /// A manifest key whose parent component is a regular FILE: the open + /// fails ENOTDIR (Windows: path-not-found), which must read exactly like + /// the old `metadata` probe did — "File not found" for a pre-existing + /// entry, `Ready` for a new-file entry (the write then fails in mkdir). + #[tokio::test] + async fn test_verify_file_patch_parent_is_regular_file_reports_not_found() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("blocker"), b"not a dir") + .await + .unwrap(); + + let existing = PatchFileInfo { + before_hash: "aaaa".to_string(), + after_hash: "bbbb".to_string(), + }; + let result = verify_file_patch(dir.path(), "blocker/index.js", &existing).await; + assert_eq!(result.status, VerifyStatus::NotFound); + assert_eq!(result.message.as_deref(), Some("File not found")); + + let new_file = PatchFileInfo { + before_hash: String::new(), + after_hash: "bbbb".to_string(), + }; + let result = verify_file_patch(dir.path(), "blocker/index.js", &new_file).await; + assert_eq!(result.status, VerifyStatus::Ready); + } + + /// SECURITY: `afterHash` is joined onto the blobs directory. A committed + /// manifest carrying `afterHash: "../outside"` used to make apply read + /// the out-of-tree file and echo its content hash back in the mismatch + /// error (an oracle). The disk-blob read now refuses anything that is + /// not a 64-hex blob hash before any path is built. + #[tokio::test] + async fn test_apply_package_patch_refuses_invalid_blob_hash() { + let root = tempfile::tempdir().unwrap(); + let pkg_dir = root.path().join("pkg"); + let blobs_dir = root.path().join("blobs"); + tokio::fs::create_dir_all(&pkg_dir).await.unwrap(); + tokio::fs::create_dir_all(&blobs_dir).await.unwrap(); + + let original = b"original content"; + let outside = b"secret bytes outside the blobs dir"; + let outside_hash = compute_git_sha256_from_bytes(outside); + tokio::fs::write(pkg_dir.join("index.js"), original) + .await + .unwrap(); + // `blobs/../outside` resolves to this file. + tokio::fs::write(root.path().join("outside"), outside) + .await + .unwrap(); + + let mut files = HashMap::new(); + files.insert( + "index.js".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(original), + after_hash: "../outside".to_string(), + }, + ); + + let result = apply_package_patch( + "pkg:npm/test@1.0.0", + &pkg_dir, + &files, + &PatchSources::blobs_only(&blobs_dir), + None, + false, + MismatchPolicy::Warn, + ) + .await; + + assert!(!result.success); + let err = result.error.unwrap(); + assert!( + err.contains("Refusing to read blob with invalid hash"), + "must refuse before joining the path: {err}" + ); + assert!( + !err.contains(&outside_hash), + "the out-of-tree content hash must not leak: {err}" + ); + assert_eq!( + tokio::fs::read(pkg_dir.join("index.js")).await.unwrap(), + original + ); + } + + /// SECURITY: a symlink planted at `blobs/` (committable next + /// to the manifest) must not be read through — even when its target + /// hashes to `afterHash` and the write would otherwise have succeeded. + #[cfg(unix)] + #[tokio::test] + async fn test_apply_package_patch_symlinked_blob_entry_blocked() { + let root = tempfile::tempdir().unwrap(); + let pkg_dir = root.path().join("pkg"); + let blobs_dir = root.path().join("blobs"); + tokio::fs::create_dir_all(&pkg_dir).await.unwrap(); + tokio::fs::create_dir_all(&blobs_dir).await.unwrap(); + + let original = b"original content"; + let patched = b"patched content"; + let after_hash = compute_git_sha256_from_bytes(patched); + tokio::fs::write(pkg_dir.join("index.js"), original) + .await + .unwrap(); + tokio::fs::write(root.path().join("secret.txt"), patched) + .await + .unwrap(); + std::os::unix::fs::symlink("../secret.txt", blobs_dir.join(&after_hash)).unwrap(); + + let mut files = HashMap::new(); + files.insert( + "index.js".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(original), + after_hash, + }, + ); + + let result = apply_package_patch( + "pkg:npm/test@1.0.0", + &pkg_dir, + &files, + &PatchSources::blobs_only(&blobs_dir), + None, + false, + MismatchPolicy::Warn, + ) + .await; + + assert!(!result.success); + assert!( + result + .error + .as_deref() + .unwrap_or("") + .contains("Blob is not a regular file"), + "{:?}", + result.error + ); + assert_eq!( + tokio::fs::read(pkg_dir.join("index.js")).await.unwrap(), + original, + "nothing may be written through a symlinked blob entry" + ); + } + + /// SECURITY: the patch `uuid` is joined as `/.tar.gz`. A + /// traversal uuid that would resolve to a real archive elsewhere is + /// treated as "no archive" (both strategies skipped, blob applies) — + /// never joined. + #[tokio::test] + async fn test_apply_unsafe_uuid_skips_archives() { + let (_root, pkg_dir, blobs_dir, _packages_dir, diffs_dir, files, _orig, patched) = + make_fixture().await; + // `diffs/../packages/.tar.gz` IS the real package archive. + let escaping_uuid = format!("../packages/{TEST_UUID}"); + let sources = PatchSources { + blobs_path: &blobs_dir, + packages_path: Some(&diffs_dir), + diffs_path: None, + mem_blobs: None, + }; + let result = apply_package_patch( + "pkg:npm/x@1.0.0", + &pkg_dir, + &files, + &sources, + Some(&escaping_uuid), + false, + MismatchPolicy::Warn, + ) + .await; + + assert!(result.success, "expected success: {:?}", result.error); + assert_eq!( + result.applied_via.get("index.js"), + Some(&AppliedVia::Blob), + "an escaping uuid must not reach the package archive" + ); + let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap(); + assert_eq!(written, patched); + } + + /// A write failure under the archive strategy used to be swallowed as + /// "not applicable" and the pipeline fell through to the blob, so the + /// user saw `Failed to read blob …: No such file` while the real + /// failure was the write. The write error must surface as itself. + /// `chflags uchg` on the package dir is the unprivileged deterministic + /// route to a stage-creation failure (the guard defeats 0o555). + #[cfg(target_os = "macos")] + #[tokio::test] + async fn test_apply_write_failure_is_reported_not_masked_as_missing_blob() { + let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, original, _patched) = + make_fixture().await; + // Only the archives are staged — no blob to fall back on. + let after_hash = &files["index.js"].after_hash; + tokio::fs::remove_file(blobs_dir.join(after_hash)) + .await + .unwrap(); + + let status = std::process::Command::new("chflags") + .arg("uchg") + .arg(&pkg_dir) + .status() + .expect("chflags must be runnable"); + assert!(status.success(), "chflags uchg failed"); + + let sources = PatchSources { + blobs_path: &blobs_dir, + packages_path: Some(&packages_dir), + diffs_path: Some(&diffs_dir), + mem_blobs: None, + }; + let result = apply_package_patch( + "pkg:npm/x@1.0.0", + &pkg_dir, + &files, + &sources, + Some(TEST_UUID), + false, + MismatchPolicy::Warn, + ) + .await; + + // Clear the flag BEFORE any assert can panic, so the TempDir drops. + let status = std::process::Command::new("chflags") + .arg("nouchg") + .arg(&pkg_dir) + .status() + .expect("chflags must be runnable"); + assert!(status.success(), "chflags nouchg failed"); + + assert!(!result.success); + let err = result.error.unwrap(); + assert!( + !err.contains("Failed to read blob"), + "a write failure must not masquerade as a missing blob: {err}" + ); + assert!( + err.contains("Operation not permitted"), + "the real write error must surface: {err}" + ); + assert_eq!( + tokio::fs::read(pkg_dir.join("index.js")).await.unwrap(), + original + ); + } + + /// Ownership restore the caller is not privileged to make (`chown(2)` + /// EPERM: the pre-patch owner is another uid) is a WARNING, not a + /// failure — the bytes are already committed — and the mode is still + /// restored bit-for-bit. + #[cfg(unix)] + #[tokio::test] + async fn test_restore_file_permissions_ownership_failure_is_a_warning() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + // A root-owned file stands in for "pre-patch owner is another uid". + let foreign = std::fs::metadata("/etc/hosts").expect("/etc/hosts exists"); + let euid = unsafe { libc::geteuid() }; + if euid == 0 || foreign.uid() == euid { + eprintln!("skipping: needs an unprivileged caller and a foreign-owned reference"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("index.js"); + tokio::fs::write(&path, b"patched").await.unwrap(); + + let warning = restore_file_permissions(&path, Some(&foreign)) + .await + .expect("an unrestorable owner must not fail the write") + .expect("the failed chown must be reported"); + assert!( + warning.contains("ownership could not be restored"), + "unexpected warning text: {warning}" + ); + let mode = tokio::fs::metadata(&path) + .await + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!( + mode, + foreign.mode() & 0o7777, + "the mode is still restored after the failed chown" + ); + } } diff --git a/crates/socket-patch-core/src/patch/rollback.rs b/crates/socket-patch-core/src/patch/rollback.rs index cd8aaa48..360e1d17 100644 --- a/crates/socket-patch-core/src/patch/rollback.rs +++ b/crates/socket-patch-core/src/patch/rollback.rs @@ -39,6 +39,10 @@ pub struct RollbackResult { pub success: bool, pub files_verified: Vec, pub files_rolled_back: Vec, + /// Why the package failed (`success == false`). On a SUCCESSFUL result + /// it is an advisory instead: a restored file whose ownership the + /// caller was not privileged to put back (the bytes ARE restored — see + /// `apply::apply_file_patch_at`). pub error: Option, /// Ecosystem sidecar resync outcome — the rollback-side twin of /// [`ApplyResult::sidecar`](crate::patch::apply::ApplyResult::sidecar). @@ -161,21 +165,20 @@ pub async fn verify_file_rollback( }; } - // Check if file exists - if tokio::fs::metadata(&filepath).await.is_err() { - return VerifyRollbackResult { - file: file_name.to_string(), - status: VerifyRollbackStatus::NotFound, - message: Some("File not found".to_string()), - current_hash: None, - expected_hash: None, - target_hash: None, - }; - } - - // Compute current hash + // Hash the file straight away — the opener's own NotFound is the + // existence probe (see `verify_file_patch`). let current_hash = match compute_file_git_sha256(&filepath).await { Ok(h) => h, + Err(e) if crate::patch::apply::is_missing_path(&e) => { + return VerifyRollbackResult { + file: file_name.to_string(), + status: VerifyRollbackStatus::NotFound, + message: Some("File not found".to_string()), + current_hash: None, + expected_hash: None, + target_hash: None, + }; + } Err(e) => { return VerifyRollbackResult { file: file_name.to_string(), @@ -305,14 +308,65 @@ pub fn cannot_rollback_error(file: &str, why: &str) -> String { /// /// For each file in `files`, this function: /// 1. Verifies the file is ready to be rolled back (or already original). -/// 2. If not dry_run, reads the before-hash blob and writes it back. +/// 2. If not dry_run, reads the before-hash blob and writes it back (or +/// deletes the file, for a patch-added one). /// 3. Returns a summary of what happened. +/// +/// pnpm peer-variant copies are handled exactly as in +/// [`apply_package_patch`](crate::patch::apply::apply_package_patch): +/// after the primary, the same verify+rollback engine runs against every +/// other physical store copy of an npm package — including when the +/// primary is already original, which is the state an earlier single-copy +/// rollback left behind (original primary, still-patched twin), and on +/// dry-run (verify only, so a preview fails closed on a copy that cannot +/// be rolled back). Apply materializes patch-ADDED files in every copy +/// too, so the deletes must reach every copy as well. A failed copy fails +/// the whole result; the primary's per-file records are what the returned +/// `RollbackResult` carries. pub async fn rollback_package_patch( package_key: &str, pkg_path: &Path, files: &HashMap, blobs_path: &Path, dry_run: bool, +) -> RollbackResult { + let mut result = + rollback_package_patch_at(package_key, pkg_path, files, blobs_path, dry_run).await; + // Only npm purls can name pnpm store copies; everything else skips the + // (already cheap) discovery outright. + if result.success && package_key.starts_with("pkg:npm/") { + for copy in crate::crawlers::npm_crawler::find_pnpm_peer_variant_copies(pkg_path).await { + let copy_result = + rollback_package_patch_at(package_key, ©, files, blobs_path, dry_run).await; + if !copy_result.success { + result.success = false; + let copy_err = copy_result + .error + .unwrap_or_else(|| "unknown error".to_string()); + let note = format!( + "pnpm store copy {} failed to roll back: {}", + copy.display(), + copy_err + ); + result.error = Some(match result.error.take() { + Some(prev) => format!("{prev}; {note}"), + None => note, + }); + } + } + } + result +} + +/// The single-copy rollback engine behind [`rollback_package_patch`]: +/// verifies and rolls back the package at exactly the one `pkg_path` it is +/// given. +async fn rollback_package_patch_at( + package_key: &str, + pkg_path: &Path, + files: &HashMap, + blobs_path: &Path, + dry_run: bool, ) -> RollbackResult { let mut result = RollbackResult { package_key: package_key.to_string(), @@ -355,6 +409,11 @@ pub async fn rollback_package_patch( return result; } + // Advisory notes from restores that committed but could not put the + // ownership back (see `apply_file_patch_at`); reported on `error` + // alongside `success`. + let mut warnings: Vec = Vec::new(); + // Rollback files that need it for (file_name, file_info) in files { let already_original = result @@ -369,7 +428,7 @@ pub async fn rollback_package_patch( if file_info.before_hash.is_empty() { let normalized = normalize_file_path(file_name); // SECURITY: this delete path constructs the target itself and - // does NOT go through `apply_file_patch`, so it must enforce the + // does NOT go through `apply_file_patch_at`, so it must enforce the // same path-escape guard. Without it a poisoned manifest entry // (empty beforeHash + a `../../`/absolute key) would unlink an // arbitrary file outside the package directory. Verify already @@ -412,26 +471,21 @@ pub async fn rollback_package_patch( return result; } - // Read original content from blobs. - // SECURITY: defense-in-depth twin of the verify-time entry-type - // guard (exactly like the string guard above) — never read through - // a symlinked / non-regular blobs entry at the syscall either. The - // lstat rejects the entry itself; it must run here because the - // read must not depend on verify having blocked it. + // Read the original content from the blob. SECURITY: defense-in-depth + // twin of the verify-time entry-type guard (exactly like the string + // guard above) — `read_blob_entry` lstat's the ENTRY (a planted + // symlink is refused, never followed) and opens FIFO-safe, so this + // read does not depend on verify having blocked a bad entry. let blob_path = blobs_path.join(&file_info.before_hash); - let entry_is_regular = matches!( - tokio::fs::symlink_metadata(&blob_path).await, - Ok(meta) if meta.is_file() - ); - if !entry_is_regular { - result.error = Some(format!( - "Before blob is not a regular file: {}", - file_info.before_hash - )); - return result; - } - let original_content = match tokio::fs::read(&blob_path).await { + let original_content = match crate::patch::apply::read_blob_entry(&blob_path).await { Ok(content) => content, + Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => { + result.error = Some(format!( + "Before blob is not a regular file: {}", + file_info.before_hash + )); + return result; + } Err(e) => { result.error = Some(format!( "Failed to read blob {}: {}", @@ -441,14 +495,16 @@ pub async fn rollback_package_patch( } }; - // Restore via `apply_file_patch`, the hardened write path shared - // with apply — rolling a file back is the same operation as patching - // it forward ("safely overwrite this file with these hash-verified - // bytes") and must get the same guarantees: atomic stage+rename, - // hardlink/symlink broken into a private inode before writing (pnpm / - // Go-cache stores), blob hash-checked in memory before any disk - // write, and the file's original mode + uid/gid restored afterward. - if let Err(e) = crate::patch::apply::apply_file_patch( + // Restore via `apply_file_patch_at`, the hardened single-copy write + // path shared with apply — rolling a file back is the same operation + // as patching it forward ("safely overwrite this file with these + // hash-verified bytes") and gets the same guarantees: blob + // hash-checked in memory before any disk write, atomic stage+rename + // (which also isolates shared pnpm / Go-cache inodes), and the + // file's original mode + uid/gid restored afterward. pnpm store + // copies are rolled back by the package-level wrapper, one full + // verify each. + match crate::patch::apply::apply_file_patch_at( pkg_path, file_name, &original_content, @@ -456,8 +512,11 @@ pub async fn rollback_package_patch( ) .await { - result.error = Some(e.to_string()); - return result; + Ok(warning) => warnings.extend(warning), + Err(e) => { + result.error = Some(e.to_string()); + return result; + } } result.files_rolled_back.push(file_name.clone()); @@ -506,6 +565,9 @@ pub async fn rollback_package_patch( } } + if !warnings.is_empty() { + result.error = Some(warnings.join("; ")); + } result.success = true; result } @@ -514,10 +576,10 @@ pub async fn rollback_package_patch( mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; - // The rollback write path IS `apply_file_patch` (see the restore loop in - // `rollback_package_patch`); these tests pin the guarantees rollback - // relies on from it. - use crate::patch::apply::apply_file_patch; + // The rollback write path IS `apply_file_patch_at` (see the restore loop + // in `rollback_package_patch_at`); these tests pin the guarantees + // rollback relies on from it. + use crate::patch::apply::apply_file_patch_at; #[tokio::test] async fn test_verify_file_rollback_not_found() { @@ -659,7 +721,7 @@ mod tests { .await .unwrap(); - apply_file_patch(dir.path(), "index.js", original, &original_hash) + apply_file_patch_at(dir.path(), "index.js", original, &original_hash) .await .unwrap(); @@ -675,7 +737,7 @@ mod tests { .unwrap(); let result = - apply_file_patch(dir.path(), "index.js", b"original content", "wrong_hash").await; + apply_file_patch_at(dir.path(), "index.js", b"original content", "wrong_hash").await; assert!(result.is_err()); assert!(result .unwrap_err() @@ -698,7 +760,7 @@ mod tests { .unwrap(); let result = - apply_file_patch(dir.path(), "index.js", b"original content", "wrong_hash").await; + apply_file_patch_at(dir.path(), "index.js", b"original content", "wrong_hash").await; assert!(result.is_err()); // The file must NOT have been overwritten with the bad blob. @@ -739,7 +801,7 @@ mod tests { let original = b"original bytes"; let original_hash = compute_git_sha256_from_bytes(original); - apply_file_patch( + apply_file_patch_at( project.parent().unwrap(), "foo.js", original, @@ -775,7 +837,7 @@ mod tests { .await .unwrap(); - apply_file_patch(dir.path(), "index.js", original, &original_hash) + apply_file_patch_at(dir.path(), "index.js", original, &original_hash) .await .unwrap(); @@ -1161,7 +1223,7 @@ mod tests { /// SECURITY (new-file delete path-escape): the new-file deletion /// branch builds the path itself and calls `remove_file` directly, - /// bypassing `apply_file_patch`'s guard. A poisoned manifest with an + /// bypassing `apply_file_patch_at`'s guard. A poisoned manifest with an /// empty `beforeHash` and an escaping key must NOT unlink a file /// outside the package dir. Regression: the bare `remove_file` would /// delete an arbitrary host file. @@ -2068,7 +2130,7 @@ mod tests { /// Validate-before-write through the PACKAGE engine: verify never /// content-checks the blob (only lstat), so `blobs/` holding /// wrong bytes verifies `Ready` — the corruption must then be caught by - /// `apply_file_patch`'s in-memory hash check BEFORE any disk write. The + /// `apply_file_patch_at`'s in-memory hash check BEFORE any disk write. The /// user-facing contract: corrupt blob => rollback fails, the patched /// file is left byte-identical, and no stage/cow litter is dropped. /// Package-engine twin of @@ -2200,4 +2262,80 @@ mod tests { // The entry survives — it must not be reported as removed. assert!(tokio::fs::symlink_metadata(&path).await.is_ok()); } + + /// pnpm materializes one physical store copy per peer combination and + /// apply writes a patch-ADDED file into every copy. Rollback must + /// delete it from every copy too — including the heal case where the + /// primary is already original and only a twin still carries the file + /// (what an earlier single-copy rollback left behind). + #[cfg(unix)] + #[tokio::test] + async fn test_rollback_package_patch_new_file_deleted_in_every_pnpm_peer_variant_copy() { + let tmp = tempfile::tempdir().unwrap(); + let blobs_dir = tempfile::tempdir().unwrap(); + let nm = tmp.path().join("node_modules"); + let store = nm.join(".pnpm"); + + let added = b"file added by the patch\n"; + let after_hash = compute_git_sha256_from_bytes(added); + + let variants = [ + store.join("foo@1.0.0(react@17.0.2)").join("node_modules"), + store.join("foo@1.0.0(react@18.2.0)").join("node_modules"), + ]; + for entry_nm in &variants { + let pkg = entry_nm.join("foo"); + tokio::fs::create_dir_all(&pkg).await.unwrap(); + tokio::fs::write( + pkg.join("package.json"), + r#"{"name":"foo","version":"1.0.0"}"#, + ) + .await + .unwrap(); + tokio::fs::write(pkg.join("added.js"), added).await.unwrap(); + } + // Importer root: the direct dep symlinks to ONE of the variants — + // that is the primary the resolver hands rollback. + std::os::unix::fs::symlink(variants[0].join("foo"), nm.join("foo")).unwrap(); + let primary = nm.join("foo"); + + let mut files = HashMap::new(); + files.insert( + "package/added.js".to_string(), + PatchFileInfo { + before_hash: String::new(), + after_hash, + }, + ); + + let result = + rollback_package_patch("pkg:npm/foo@1.0.0", &primary, &files, blobs_dir.path(), false) + .await; + assert!(result.success, "expected success: {:?}", result.error); + assert_eq!(result.files_rolled_back, vec!["package/added.js".to_string()]); + for entry_nm in &variants { + assert!( + tokio::fs::symlink_metadata(entry_nm.join("foo").join("added.js")) + .await + .is_err(), + "the patch-added file must be deleted from EVERY store copy ({})", + entry_nm.display() + ); + } + + // Heal: primary already original, the twin still carries the file. + tokio::fs::write(variants[1].join("foo").join("added.js"), added) + .await + .unwrap(); + let result = + rollback_package_patch("pkg:npm/foo@1.0.0", &primary, &files, blobs_dir.path(), false) + .await; + assert!(result.success, "expected success: {:?}", result.error); + assert!( + tokio::fs::symlink_metadata(variants[1].join("foo").join("added.js")) + .await + .is_err(), + "an already-original primary must still heal a patched twin" + ); + } } From 2ea5ad3a32551bdd22b949341c8cbef391e57533 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 19:07:13 -0400 Subject: [PATCH 06/44] refactor(core/vendor): drift-keep parity, pre-write guards, husk pruning and shared helpers Vendor backends (crates/socket-patch-core/src/vendor/*, state.rs untouched): - gem revert: liveness-aware record restore (`RecordRevert::{Done, Drifted, FileMissing}`) + the family-wide drift-keep gate (`keep_artifact` on any `vendor_lock_entry_drifted`); converged files (regenerated pre-vendor Gemfile/lock, hand-deleted Added block, registry checksum line back) are silent; a missing Gemfile/Gemfile.lock reports `vendor_lockfile_missing` instead of drift so the artifact is still removed. - gem vendor: the pure Gemfile.lock edit is computed before any download / copy / write (a lock-shape failure no longer builds and unwinds); the copy hash + stub probe run only once the lock is known wired. - npm / yarn-classic / yarn-berry / pnpm-v9 reverts honor `keep_artifact` in the unwired-revert guard (bun + pnpm-legacy parity; doc/code mismatch). - pypi flavors: shared `refuse_symlinked` (poetry / pipenv / requirements gain `pypi__symlink_unsupported`; pdm / uv / pylock reuse `utils::fs::first_symlink`) and `ensure_unchanged` pre-write snapshot re-verification (`pypi_{poetry,pdm,pipenv,uv}_changed`) mirroring the pylock flavor; `wire_pipenv` takes the orchestrator's version instead of re-parsing the wheel filename. - golang service leg stages the module zip at `.socket-stage` and swaps it in only once verified (cargo / composer / gem shape): a failed re-download keeps a wired copy + directive; a missing copy keeps the dangling-directive teardown. go-patches takeover prune loop simplified. - D4 residue: one shared `common::prune_empty_vendor_levels` (uuid -> eco -> `.socket/vendor`, never `.socket`) replaces the cargo / gem / composer copies and runs after every wet non-keep revert (all 9 ecosystems) and on the golang / maven / nuget / pypi / gem failure legs. - cargo_config: the never-shipped `.socket/cargo-patches` legacy takeover is removed (writer ed435b4 and its removal 5356bb6 both first appear in v4.0.0; no tagged tree carries the writer); such entries now refuse as user-authored. - Guarded readers: 20 private `read_regular*` twins consolidated onto `utils::fs::{read_regular_to_string, read_regular_to_bytes, read_regular_to_string_sync}`; bun revert reads bun.lock through the guard (FIFO wedge); `verify::file_sha256_hex` opens once via `open_regular_file`; harvest's tarball decode runs on `spawn_blocking`. - Waste: `revert_lock_fragment_splice` writes only when a fragment changed; `zip_bytes_match_after_hashes` + `read_zip_artifact` (256 MiB cap) let the maven / nuget hot paths read the committed archive once; maven pom fetch streams through `utils::http::read_capped`; bun's prior-artifact hash is gated on a digest-less own tuple; `NpmStagedPack::uuid_dir_preexisted` replaces six per-backend stats; pypi dist lookup checks stem-matching dist-infos first; pipenv stale-install probe hoisted; `MetaSlot::Uv` no longer wraps an always-Some option. - Stage/swap helpers (`stage_dir_for`, `backup_dir_for`, `swap_stage_into_place`) hoisted from cargo / composer / gem into common. Tests: new preserve-state empty-wiring twins (npm, yarn-classic, yarn-berry, pnpm), bun revert FIFO, poetry/pipenv/requirements symlink refusals, changed-during-vendoring refusals (poetry/pdm/pipenv/uv), golang staged service rebuild (stale copy kept / missing copy torn down), converged-splice inode pin; gem drift tests re-pinned to the liveness contract; cargo legacy tests inverted to refusals. Co-Authored-By: Claude Fable 5.1 --- .../src/vendor/bun_binary.rs | 9 +- .../socket-patch-core/src/vendor/bun_lock.rs | 88 +++- crates/socket-patch-core/src/vendor/cargo.rs | 187 ++----- .../src/vendor/cargo_config.rs | 84 ++- .../src/vendor/cargo_lock.rs | 16 +- crates/socket-patch-core/src/vendor/common.rs | 253 +++++++-- .../src/vendor/composer_lock.rs | 115 +--- crates/socket-patch-core/src/vendor/gem.rs | 491 +++++++++--------- .../src/vendor/go_mod_edit.rs | 16 +- crates/socket-patch-core/src/vendor/golang.rs | 188 +++++-- .../src/vendor/lock_inventory.rs | 36 +- .../src/vendor/maven_repo.rs | 82 ++- crates/socket-patch-core/src/vendor/mod.rs | 51 +- .../src/vendor/npm_common.rs | 13 +- .../src/vendor/npm_flavor.rs | 18 +- .../socket-patch-core/src/vendor/npm_lock.rs | 81 ++- .../src/vendor/nuget_feed.rs | 83 ++- .../socket-patch-core/src/vendor/pnpm_lock.rs | 96 ++-- .../src/vendor/pnpm_lock_legacy.rs | 39 +- crates/socket-patch-core/src/vendor/pypi.rs | 106 ++-- .../socket-patch-core/src/vendor/pypi_lock.rs | 11 +- .../socket-patch-core/src/vendor/pypi_pdm.rs | 84 +-- .../src/vendor/pypi_pipenv.rs | 132 +++-- .../src/vendor/pypi_poetry.rs | 137 ++++- .../src/vendor/pypi_requirements.rs | 93 +++- .../socket-patch-core/src/vendor/pypi_uv.rs | 69 ++- .../src/vendor/pypi_wheel.rs | 43 +- crates/socket-patch-core/src/vendor/verify.rs | 10 +- .../src/vendor/yarn_berry_lock.rs | 84 ++- .../src/vendor/yarn_classic_lock.rs | 82 +-- 30 files changed, 1627 insertions(+), 1170 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/bun_binary.rs b/crates/socket-patch-core/src/vendor/bun_binary.rs index 1cb214d5..1eda96f5 100644 --- a/crates/socket-patch-core/src/vendor/bun_binary.rs +++ b/crates/socket-patch-core/src/vendor/bun_binary.rs @@ -1,7 +1,7 @@ //! Native binary Bun vendoring. Package records are edited without re-resolving //! dependencies or requiring a Bun executable. use super::bun_lockb::{BinaryPackage, BunLockb}; -use super::common::{already_patched_result, refused}; +use super::common::{already_patched_result, prune_empty_vendor_levels, refused}; use super::npm_common::{ done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, }; @@ -436,9 +436,14 @@ pub(crate) async fn revert(entry: &VendorEntry, root: &Path, opts: RevertOpts) - } prune_mirror_parents(&mirror).await; } - if let Err(e) = crate::patch::copy_tree::remove_tree(&root.join(&dir)).await { + let uuid_dir = root.join(&dir); + if let Err(e) = crate::patch::copy_tree::remove_tree(&uuid_dir).await { return RevertOutcome::failed(format!("cannot remove {dir}: {e}")); } + // The last npm-family entry leaves `.socket/vendor/npm/` (and + // `.socket/vendor/`) empty: prune them so a reverted project carries + // no vendor residue (`remove_dir` keeps non-empty levels). + prune_empty_vendor_levels(&uuid_dir).await; } outcome } diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index bbd9bfa2..d9568d17 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -46,7 +46,7 @@ use crate::vendor::bun_lock_text::{ parse_entry_line, parse_packages_section, split_name_spec, BunEntry, }; -use super::common::{already_patched_result, refused}; +use super::common::{already_patched_result, prune_empty_vendor_levels, refused}; use super::npm_common::{ done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, }; @@ -427,14 +427,25 @@ pub(crate) async fn vendor_bun( } } + // BN3 spelling: BARE project-relative path, no `file:`/`./` prefix (the + // shared pipeline's `prepare_tgz_dest` builds the identical string). + let rel_tgz = format!("{}/{}", coords.uuid_dir_rel, target_leaf); // The sha512 of the artifact already sitting at the target path, if // any — the one witness a digest-less in-sync tuple (see `classify`) // still has of the digest Bun dropped: the lock line was written from // these bytes. Read BEFORE staging, which overwrites the file; a // missing or non-regular path (a `repair` rebuild after deletion, a // FIFO) yields `None`, which the in-sync check below treats as "not - // provably the same bytes". - let prior_artifact_integrity: Option = { + // provably the same bytes". Only a digest-less 2-tuple of OURS at this + // path can consume it, so every other re-run skips the read + hash. + let has_digestless_own_tuple = entries.iter().any(|e| { + e.elems.len() == 2 + && matches!( + classify(e, &target_spec, name, &target_leaf), + Some(TupleShape::Ours { path }) if path == rel_tgz + ) + }); + let prior_artifact_integrity: Option = if has_digestless_own_tuple { let abs = project_root.join(&coords.uuid_dir_rel).join(&target_leaf); match tokio::fs::metadata(&abs).await { Ok(meta) if meta.is_file() => tokio::fs::read(&abs).await.ok().map(|bytes| { @@ -445,15 +456,11 @@ pub(crate) async fn vendor_bun( }), _ => None, } + } else { + None }; // ── 4. Stage → patch → pack (shared flavor-agnostic pipeline) ──────── - // A wiring failure past this point must unwind the uuid dir staging is - // about to create — but never one that already existed (a same-uuid - // re-vendor's dir may still be referenced by live wiring). - let uuid_dir_preexisted = tokio::fs::metadata(project_root.join(&coords.uuid_dir_rel)) - .await - .is_ok(); let (staged, result) = match stage_patch_pack( purl, installed_dir, @@ -478,8 +485,8 @@ pub(crate) async fn vendor_bun( warnings, }; }; - // BN3 spelling: BARE project-relative path, no `file:`/`./` prefix. - let rel_tgz = staged.rel_tgz; + let uuid_dir_preexisted = staged.uuid_dir_preexisted; + debug_assert_eq!(staged.rel_tgz, rel_tgz); let packed = staged.packed; if staged.staged_pkg_json.is_some() { // The tuple's deps object mirrors the package's own manifest; the @@ -756,7 +763,10 @@ pub(crate) async fn revert_bun_opts( let mut lines: Option> = None; if touches_lock { - match tokio::fs::read_to_string(project_root.join(BUN_LOCK)).await { + // Guarded read (`read_regular_to_string`, like every sibling revert): + // a FIFO planted as the committed bun.lock fails fast instead of + // wedging revert / remove / rollback forever in `open(2)`. + match read_regular_to_string(&project_root.join(BUN_LOCK)).await { Ok(text) => lines = Some(text.split('\n').map(str::to_string).collect()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { outcome.warnings.push(VendorWarning::new( @@ -799,9 +809,14 @@ pub(crate) async fn revert_bun_opts( // ran; the artifact dir stays behind (and the caller keeps the ledger // entry), so only the deletion is skipped. if !keep_artifact { - if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + let uuid_dir = project_root.join(&uuid_dir_rel); + if let Err(e) = remove_tree(&uuid_dir).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } + // The last npm-family entry leaves `.socket/vendor/npm/` (and + // `.socket/vendor/`) empty: prune them so a reverted project carries + // no vendor residue (`remove_dir` keeps non-empty levels). + prune_empty_vendor_levels(&uuid_dir).await; } outcome } @@ -3321,4 +3336,51 @@ mod tests { "the re-run converges and removes the uuid dir" ); } + + /// A FIFO planted as bun.lock must fail the revert fast instead of + /// wedging it forever in an `open(2)` waiting for a writer (the vendor + /// and preflight halves already read through the guarded reader). + #[cfg(unix)] + #[tokio::test] + async fn fifo_lock_fails_fast_instead_of_wedging_revert() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let lock_path = fx.root().join(BUN_LOCK); + tokio::fs::remove_file(&lock_path).await.unwrap(); + assert!(std::process::Command::new("mkfifo") + .arg(&lock_path) + .status() + .unwrap() + .success()); + + let deadline = std::time::Duration::from_secs(5); + let revert = revert_bun(&entry, fx.root(), false); + let Ok(outcome) = tokio::time::timeout(deadline, revert).await else { + // On timeout the open is wedged in a `spawn_blocking` thread the + // runtime waits for on shutdown; connect a non-blocking writer + // to release it so the test can FAIL instead of hanging the + // suite. + use std::os::unix::fs::OpenOptionsExt; + let _ = std::fs::OpenOptions::new() + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(&lock_path); + panic!("the revert lock read must fail fast on a FIFO"); + }; + assert!(!outcome.success, "a non-regular lock must fail the revert"); + assert!( + outcome + .error + .as_deref() + .unwrap_or("") + .contains("cannot read bun.lock"), + "{:?}", + outcome.error + ); + assert!( + fx.root().join(fx.rel_tgz()).exists(), + "artifact survives the failure" + ); + } } diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 6f692976..9ea9fd90 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -19,13 +19,14 @@ use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::{fresh_copy, remove_tree}; use crate::patch::path_safety::is_safe_single_segment; +use crate::utils::fs::read_regular_to_string; use crate::utils::purl::{parse_cargo_purl, strip_purl_qualifiers}; -use super::cargo_config::{self, LEGACY_CARGO_PATCHES_DIR}; +use super::cargo_config; use super::cargo_lock::{self, LockEditError}; use super::common::{ - already_patched_result, copy_matches_after_hashes, done, refused, service_offline_conflict, - synthesized_result, + already_patched_result, copy_matches_after_hashes, done, prune_empty_vendor_levels, refused, + service_offline_conflict, stage_dir_for, swap_stage_into_place, synthesized_result, }; use super::path::vendor_uuid_dir_rel; use super::registry_fetch::extract_tgz; @@ -55,15 +56,6 @@ async fn is_vendored(project_root: &Path, name: &str, version: &str) -> bool { false } -/// True iff a config-entry path points into the retired redirect backend's -/// `.socket/cargo-patches/` tree (vendor takes such entries over and reports -/// the takeover, rather than treating them as a silent refresh). -fn is_legacy_redirect_path(path: &str) -> bool { - let norm = path.replace('\\', "/"); - let norm = norm.strip_prefix("./").unwrap_or(&norm); - norm.starts_with(&format!("{LEGACY_CARGO_PATCHES_DIR}/")) -} - /// Is this vendored cargo entry still consumed by the project's `Cargo.lock` /// dependency graph? The lock is the truth source: /// @@ -97,18 +89,6 @@ pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> } } -/// Guarded read shared in shape with the setup/crawler twins: -/// `open_regular_file` opens with `O_NONBLOCK` and rejects non-regular files, -/// so a FIFO fails fast instead of wedging the caller forever. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// A LIVE hosted-redirect wiring for `name`+`version`: the lock resolves it /// from a Socket hosted patch registry, or Cargo.toml pins it to a /// `socket-patch-` registry (the shapes `scan --mode hosted` writes). @@ -163,96 +143,6 @@ async fn wiring_in_sync(project_root: &Path, name: &str, version: &str, copy_rel ) } -/// A swap sibling for a copy dir: `/-`. Same -/// directory as the copy → every swap step is a real rename, never a -/// cross-device copy. The suffixes can never collide with a copy dir: -/// `` is a validated single segment and cargo versions never end in -/// `.socket-stage` / `.socket-old`. -fn swap_sibling_for(copy_dir: &Path, suffix: &str) -> std::path::PathBuf { - let name = copy_dir - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| "copy".to_string()); - match copy_dir.parent() { - Some(parent) => parent.join(format!("{name}{suffix}")), - None => copy_dir.join(suffix), - } -} - -/// The staging sibling for a copy dir: `/-.socket-stage`. -/// Rebuilds are materialised here and swapped into place only on success, so -/// a failure can never destroy a pre-existing (possibly live-wired) copy. -fn stage_dir_for(copy_dir: &Path) -> std::path::PathBuf { - swap_sibling_for(copy_dir, ".socket-stage") -} - -/// The backup sibling the old copy is parked at mid-swap: -/// `/-.socket-old`. -fn backup_dir_for(copy_dir: &Path) -> std::path::PathBuf { - swap_sibling_for(copy_dir, ".socket-old") -} - -/// Swap a fully-built stage into place without a destructive window: park the -/// old copy (if any) at `.socket-old` with a same-dir rename, rename the -/// stage over the now-vacant copy path, and only then delete the backup. Every -/// step is a single atomic rename — unlike a remove-then-rename swap (where a -/// partial `remove_dir_all`, realistic under Windows file locks, strands a -/// half-deleted copy) no step can leave less recoverable state than it started -/// with. If the stage rename fails the backup is renamed straight back; should -/// even that restore fail (an external process racing the uuid dir), the old -/// copy still exists intact at `.socket-old` instead of being destroyed. -async fn swap_stage_into_place(stage: &Path, copy_dir: &Path) -> std::io::Result<()> { - let backup = backup_dir_for(copy_dir); - // A stale backup (crash mid-swap on an earlier run) would make the - // park rename fail; `remove_tree` is a no-op when it is absent. - remove_tree(&backup).await?; - let had_old = match tokio::fs::rename(copy_dir, &backup).await { - Ok(()) => true, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, - Err(e) => return Err(e), - }; - match tokio::fs::rename(stage, copy_dir).await { - Ok(()) => { - if had_old { - let _ = remove_tree(&backup).await; - } - Ok(()) - } - Err(e) => { - if had_old { - let _ = tokio::fs::rename(&backup, copy_dir).await; - } - Err(e) - } - } -} - -/// Best-effort removal of an EMPTY `/` dir plus the empty -/// `.socket/vendor/cargo/` and `.socket/vendor/` levels a failed run may have -/// created, so a hard failure leaves no husk for the user to commit. -/// `remove_dir` refuses non-empty dirs, so live copies, markers, and other -/// crates' vendor dirs always survive. -async fn prune_empty_vendor_dirs(uuid_dir: &Path) { - // The uuid level may already be gone (the unwind paths `remove_tree` it - // before pruning): NotFound must continue to the parent levels this run - // created, or they survive as committable husks. Any other error (i.e. - // non-empty: a live copy or marker) still stops the prune. - match tokio::fs::remove_dir(uuid_dir).await { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return, - } - let Some(eco_dir) = uuid_dir.parent() else { - return; - }; - if tokio::fs::remove_dir(eco_dir).await.is_err() { - return; - } - if let Some(vendor_dir) = eco_dir.parent() { - let _ = tokio::fs::remove_dir(vendor_dir).await; - } -} - /// Failure cleanup for a staged (re)build: always remove the stage, then /// either unwind the whole `/` dir (`unwind_uuid_dir` — a fresh vendor /// with no pre-existing state worth keeping) or leave existing state @@ -262,7 +152,7 @@ async fn cleanup_failed_stage(stage: &Path, uuid_dir: &Path, unwind_uuid_dir: bo if unwind_uuid_dir { let _ = remove_tree(uuid_dir).await; } - prune_empty_vendor_dirs(uuid_dir).await; + prune_empty_vendor_levels(uuid_dir).await; } /// Outcome of attempting to materialise the cargo copy from the patch service. @@ -775,19 +665,13 @@ pub async fn vendor_cargo_crate( if !prior_points_here { let _ = remove_tree(&uuid_dir).await; } - prune_empty_vendor_dirs(&uuid_dir).await; + prune_empty_vendor_levels(&uuid_dir).await; result.success = false; result.error = Some(format!("failed to update .cargo/config.toml: {e}")); return done(result, None, warnings); } let prior_path = prior_entry.as_ref().and_then(|i| i.path.clone()); - if prior_path.as_deref().is_some_and(is_legacy_redirect_path) { - warnings.push(VendorWarning::new( - "vendor_takeover", - format!("took over the legacy `.socket/cargo-patches/` [patch] entry for `{name}`"), - )); - } // ── detach the lock entry ───────────────────────────────────────────── let lock_original: Option = @@ -833,7 +717,7 @@ pub async fn vendor_cargo_crate( if !prior_points_here { let _ = remove_tree(&uuid_dir).await; } - prune_empty_vendor_dirs(&uuid_dir).await; + prune_empty_vendor_levels(&uuid_dir).await; result.success = false; result.error = Some(format!( "failed to detach the Cargo.lock entry for {name}@{version}: {e} \ @@ -993,12 +877,10 @@ pub async fn revert_cargo_vendor_opts( if !dry_run && !keep_artifact { let uuid_dir = project_root.join(&base_rel); let _ = remove_tree(&uuid_dir).await; // ignore NotFound - // Best-effort: prune the now-empty `.socket/vendor/cargo/` level so a - // fully-reverted project carries no vendor residue (`save_state` then - // prunes `.socket/vendor/` itself). `remove_dir` fails on non-empty. - if let Some(eco_dir) = uuid_dir.parent() { - let _ = tokio::fs::remove_dir(eco_dir).await; - } + // Best-effort: prune the now-empty `.socket/vendor/cargo/` and + // `.socket/vendor/` levels so a fully-reverted project carries no + // vendor residue. `remove_dir` fails on non-empty. + prune_empty_vendor_levels(&uuid_dir).await; } out @@ -1009,6 +891,7 @@ mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::{PatchFileInfo, VulnerabilityInfo}; + use crate::vendor::common::backup_dir_for; use crate::vendor::state::VENDOR_MARKER_FILE; use std::collections::HashMap; use std::path::PathBuf; @@ -1937,44 +1820,34 @@ mod tests { assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); } + /// `.socket/cargo-patches/` was the retired `[patch]`-redirect backend's + /// copy root; no tagged release ever wrote it (it lived only between two + /// main commits), so an entry pointing there is an unknown user path and + /// refuses like any other user-authored same-name entry. #[tokio::test] - async fn test_legacy_redirect_entry_is_taken_over() { + async fn test_retired_redirect_path_entry_is_user_authored() { let (dir, blobs, pristine, record) = fixture().await; let root = dir.path(); - // Residue from the retired redirect backend: a legacy-path entry. tokio::fs::create_dir_all(root.join(".cargo")) .await .unwrap(); - tokio::fs::write( - root.join(".cargo/config.toml"), - "[patch.crates-io]\ncfg-if = { path = \".socket/cargo-patches/cfg-if-1.0.4\" }\n", - ) - .await - .unwrap(); + let config = "[patch.crates-io]\ncfg-if = { path = \".socket/cargo-patches/cfg-if-1.0.4\" }\n"; + tokio::fs::write(root.join(".cargo/config.toml"), config) + .await + .unwrap(); - let (result, entry, warnings) = - expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); - assert!(result.success, "{:?}", result.error); - assert!( - warnings.iter().any(|w| w.code == "vendor_takeover"), - "legacy takeover surfaced: {warnings:?}" - ); - let entry = entry.unwrap(); - let cfg = &entry.wiring[0]; - assert_eq!(cfg.action, WiringAction::Rewritten); - assert_eq!( - cfg.original, - Some(serde_json::Value::from( - ".socket/cargo-patches/cfg-if-1.0.4" - )) + expect_refused( + run_vendor(PURL, root, &blobs, &pristine, &record, false).await, + "user_authored_patch_entry", ); - // The live entry now points at the vendor copy. assert_eq!( - cargo_config::read_patch_entries(root).await["cfg-if"] - .path - .as_deref(), - Some(copy_rel().as_str()) + tokio::fs::read_to_string(root.join(".cargo/config.toml")) + .await + .unwrap(), + config, + "the user's entry is never rewritten" ); + assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); } // ── filesystem-safety: coordinate traversal ────────────────────────── diff --git a/crates/socket-patch-core/src/vendor/cargo_config.rs b/crates/socket-patch-core/src/vendor/cargo_config.rs index 03a82e9a..880c5cc7 100644 --- a/crates/socket-patch-core/src/vendor/cargo_config.rs +++ b/crates/socket-patch-core/src/vendor/cargo_config.rs @@ -10,10 +10,7 @@ //! ## Ownership model (no sidecar manifest) //! A `[patch.crates-io]` entry is *socket-owned* iff its `path` value is a //! root-anchored relative path (not absolute, no `..`) under THIS project's -//! `.socket/vendor/cargo/` (this backend's committed copies) **or** the -//! legacy `.socket/cargo-patches/` (the retired `[patch]`-redirect backend) — -//! recognising the legacy prefix lets vendor take over / clean up entries left -//! by old releases instead of refusing them as user-authored. Anything else — +//! `.socket/vendor/cargo/` (this backend's committed copies). Anything else — //! a `git`/`registry` source, or a `path` pointing elsewhere (including one //! that merely traverses a *foreign* checkout's `.socket/vendor/cargo/`) — is //! user-authored and is never modified or removed. The path prefix is the @@ -32,26 +29,19 @@ use std::path::{Path, PathBuf}; use tokio::fs; use toml_edit::{DocumentMut, InlineTable, Item, Table, TableLike, Value}; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; /// Project-relative root of the vendor backend's committed crate copies. An /// entry whose `path` is under this prefix is socket-owned. const CARGO_VENDOR_DIR: &str = ".socket/vendor/cargo"; -/// Project-relative root of the retired `[patch]`-redirect backend's copies. -/// Entries under this prefix are still recognised as socket-owned so vendor -/// can rewrite (take over) or drop residue from old releases rather than -/// refusing it as user-authored. -pub const LEGACY_CARGO_PATCHES_DIR: &str = ".socket/cargo-patches"; - /// Info about one `[patch.crates-io]` entry, for vendor pre-flight / verify. #[derive(Debug, Clone)] pub struct PatchEntryInfo { /// The `path` value as written (verbatim), or `None` for a non-path /// source (e.g. `git`/`registry`). pub path: Option, - /// True iff `path` is under `CARGO_VENDOR_DIR` or - /// [`LEGACY_CARGO_PATCHES_DIR`]. + /// True iff `path` is under `CARGO_VENDOR_DIR`. pub socket_owned: bool, } @@ -60,10 +50,9 @@ pub struct PatchEntryInfo { /// Upsert `[patch.crates-io]. = { path = "" }`, where /// `rel_path` is the project-relative copy path /// (`.socket/vendor/cargo//-`). Idempotent. A -/// socket-owned same-name entry (either prefix) is refreshed in place — the -/// legacy-prefix rewrite is how vendor takes over an old redirect entry. -/// Returns whether the file changed. Errors (without writing) if a same-name -/// entry exists but is user-authored. +/// socket-owned same-name entry is refreshed in place. Returns whether the +/// file changed. Errors (without writing) if a same-name entry exists but is +/// user-authored. pub async fn ensure_patch_entry( project_root: &Path, name: &str, @@ -87,19 +76,6 @@ pub async fn drop_patch_entry( edit_config(project_root, dry_run, |c| remove_patch_entry(c, name)).await } -/// Guarded read shared in shape with the vendor/cargo.rs + setup twins: -/// `open_regular_file` opens with `O_NONBLOCK` and rejects non-regular files, -/// so a FIFO planted as `.cargo/config(.toml)` fails fast instead of wedging -/// scan / vendor apply forever in an `open(2)` that waits for a writer. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// Read all `[patch.crates-io]` entries. Read-only; a missing or malformed /// config yields an empty map (callers treat that as "no managed entries"). pub async fn read_patch_entries(project_root: &Path) -> HashMap { @@ -229,8 +205,9 @@ async fn edit_config( /// True if a `[patch]` `path` value denotes one of THIS project's /// socket-owned copies: a relative path that escapes nothing (not absolute, -/// no `..` segment) and sits under [`CARGO_VENDOR_DIR`] or the legacy -/// [`LEGACY_CARGO_PATCHES_DIR`]. Cargo resolves relative `[patch]` paths +/// no `..` segment) and sits under [`CARGO_VENDOR_DIR`] (the retired +/// `.socket/cargo-patches/` redirect root never shipped in a tagged release, +/// so an entry there is a user path). Cargo resolves relative `[patch]` paths /// against the project root, so only a root-anchored relative prefix can be a /// copy this backend wrote — a path that merely *traverses* some other /// checkout's `.socket/vendor/cargo/` (`../shared/.socket/vendor/cargo/…`, @@ -251,12 +228,8 @@ fn path_is_socket_owned(path: &str) -> bool { if segments.contains(&"..") { return false; } - [CARGO_VENDOR_DIR, LEGACY_CARGO_PATCHES_DIR] - .iter() - .any(|dir| { - let prefix: Vec<&str> = dir.split('/').collect(); - segments.len() > prefix.len() && segments[..prefix.len()] == prefix[..] - }) + let prefix: Vec<&str> = CARGO_VENDOR_DIR.split('/').collect(); + segments.len() > prefix.len() && segments[..prefix.len()] == prefix[..] } /// The `path` string of a `[patch]` entry (inline table or sub-table), if any. @@ -397,9 +370,10 @@ mod tests { assert!(path_is_socket_owned(&vendor_path("cfg-if", "1.0.4"))); assert!(path_is_socket_owned("./.socket/vendor/cargo/u/x-1.0.0")); // "." segment normalised assert!(path_is_socket_owned(r".socket\vendor\cargo\u\x-1.0.0")); // backslash normalised - // Legacy redirect copies are recognised as ours (takeover / cleanup). - assert!(path_is_socket_owned(".socket/cargo-patches/cfg-if-1.0.0")); - assert!(path_is_socket_owned("./.socket/cargo-patches/x-1.0.0")); + // The retired redirect backend's `.socket/cargo-patches/` never + // shipped in a tagged release: an entry there is a user path. + assert!(!path_is_socket_owned(".socket/cargo-patches/cfg-if-1.0.0")); + assert!(!path_is_socket_owned("./.socket/cargo-patches/x-1.0.0")); // User paths are not. assert!(!path_is_socket_owned("vendor/cfg-if")); assert!(!path_is_socket_owned("../cfg-if")); @@ -554,19 +528,14 @@ mod tests { } #[test] - fn test_upsert_takes_over_legacy_redirect_entry() { - // An entry left by the retired redirect backend is socket-owned → - // rewritten to the vendor copy, never refused. + fn test_upsert_refuses_retired_redirect_path_entry() { + // `.socket/cargo-patches/` (the retired redirect backend) never + // shipped: a same-name entry pointing there is user-authored and + // refused, never rewritten. let toml = "[patch.crates-io]\ncfg-if = { path = \".socket/cargo-patches/cfg-if-1.0.4\" }\n"; let want = vendor_path("cfg-if", "1.0.4"); - let out = upsert_patch_entry(toml, "cfg-if", &want).unwrap().unwrap(); - let doc = parse(&out); - assert_eq!( - entry_path(&doc["patch"]["crates-io"]["cfg-if"]), - Some(want.as_str()) - ); - assert!(!out.contains("cargo-patches"), "legacy path gone"); + assert!(upsert_patch_entry(toml, "cfg-if", &want).is_err()); } // ── remove ─────────────────────────────────────────────────────── @@ -583,11 +552,13 @@ mod tests { } #[test] - fn test_remove_legacy_entry_is_socket_owned() { + fn test_remove_leaves_retired_redirect_path_entry() { let toml = "[patch.crates-io]\ncfg-if = { path = \".socket/cargo-patches/cfg-if-1.0.4\" }\n"; - let out = remove_patch_entry(toml, "cfg-if").unwrap().unwrap(); - assert!(!out.contains("cfg-if"), "legacy entry removable: {out}"); + assert!( + remove_patch_entry(toml, "cfg-if").unwrap().is_none(), + "a user-authored entry is never removed" + ); } #[test] @@ -701,7 +672,10 @@ mod tests { ); let entries = parse_patch_entries(&toml); assert!(entries["mine"].socket_owned); - assert!(entries["legacy"].socket_owned, "legacy prefix is ours"); + assert!( + !entries["legacy"].socket_owned, + "the retired redirect prefix is a user path" + ); assert!(!entries["yours"].socket_owned); assert_eq!(entries["yours"].path, None); assert!(!entries["theirs"].socket_owned); diff --git a/crates/socket-patch-core/src/vendor/cargo_lock.rs b/crates/socket-patch-core/src/vendor/cargo_lock.rs index 5f98a205..f54e43c7 100644 --- a/crates/socket-patch-core/src/vendor/cargo_lock.rs +++ b/crates/socket-patch-core/src/vendor/cargo_lock.rs @@ -34,7 +34,7 @@ use std::path::Path; use toml_edit::{DocumentMut, Item, Table}; use super::state::CargoLockOriginal; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; /// Why a lock edit could not be performed. #[derive(Debug, Clone, PartialEq, Eq)] @@ -66,20 +66,6 @@ impl std::fmt::Display for LockEditError { } } -/// Guarded read shared in shape with the Cargo.toml / .cargo/config.toml -/// twins: `open_regular_file` opens with `O_NONBLOCK` and rejects non-regular -/// files, so a FIFO planted as `Cargo.lock` fails fast instead of wedging -/// every caller (scan's probe, vendor mode detection, wet detach/restore) -/// forever in an `open(2)` that waits for a writer. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// Read + parse `/Cargo.lock`, mapping errors to [`LockEditError`]. async fn read_lock( project_root: &Path, diff --git a/crates/socket-patch-core/src/vendor/common.rs b/crates/socket-patch-core/src/vendor/common.rs index 2e23077d..71be4fd8 100644 --- a/crates/socket-patch-core/src/vendor/common.rs +++ b/crates/socket-patch-core/src/vendor/common.rs @@ -14,8 +14,12 @@ use crate::manifest::schema::PatchFileInfo; use crate::patch::apply::{ is_safe_relative_subpath, normalize_file_path, ApplyResult, VerifyResult, VerifyStatus, }; +use crate::patch::copy_tree::remove_tree; use crate::patch::file_hash::compute_file_git_sha256; -use crate::utils::fs::{atomic_write_bytes_preserving_mode, open_regular_file}; +use crate::utils::fs::{ + atomic_write_bytes_preserving_mode, first_symlink, read_regular_to_bytes, + read_regular_to_string, +}; use super::state::{VendorEntry, WiringAction, WiringRecord}; use super::{RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; @@ -215,28 +219,36 @@ pub(crate) fn rebuild_zip(stage: &Path, skip_entry: Option<&str>) -> Result Option> { + let bytes = read_regular_to_bytes(archive_path).await.ok()?; + (bytes.len() as u64 <= MAX_ZIP_ARTIFACT_BYTES).then_some(bytes) +} + +/// True when the committed archive (a plain zip: `.jar` / `.nupkg`) — its +/// bytes read once through [`read_zip_artifact`] — has every patched file +/// already hashing to its `afterHash` (the zip twin of /// [`copy_matches_after_hashes`], reading the archive's entries). -pub(crate) async fn zip_matches_after_hashes( - archive_path: &Path, +pub(crate) fn zip_bytes_match_after_hashes( + bytes: &[u8], files: &HashMap, ) -> bool { use std::io::Read as _; - use tokio::io::AsyncReadExt as _; - use crate::hash::git_sha256::compute_git_sha256_from_bytes; - // Guarded read (`open_regular_file`: O_NONBLOCK + regular-file check): a - // FIFO planted at the archive path must read as out-of-sync, not wedge - // the probe forever in an `open(2)` waiting for a writer. - let Ok((mut file, metadata)) = open_regular_file(archive_path).await else { - return false; - }; - let mut bytes = Vec::with_capacity(metadata.len() as usize); - if file.read_to_end(&mut bytes).await.is_err() { - return false; - } let Ok(mut archive) = zip::ZipArchive::new(std::io::Cursor::new(bytes)) else { return false; }; @@ -261,6 +273,150 @@ pub(crate) async fn zip_matches_after_hashes( true } +// ── staged materialisation (cargo / composer / gem / golang) ──────────────── + +/// A swap sibling for a copy dir: `/`. Same directory +/// as the copy → every swap step is a real rename, never a cross-device copy. +/// The suffixes can never collide with a copy dir: every backend creates +/// exactly one validated `-` / `@` leaf per +/// uuid dir, and no version token ends in `.socket-stage` / `.socket-old`. +pub(crate) fn swap_sibling_for(copy_dir: &Path, suffix: &str) -> std::path::PathBuf { + let name = copy_dir + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "copy".to_string()); + match copy_dir.parent() { + Some(parent) => parent.join(format!("{name}{suffix}")), + None => copy_dir.join(suffix), + } +} + +/// The staging sibling for a copy dir: `.socket-stage`. (Re)builds are +/// materialised here and swapped into place only on success, so a failure +/// can never destroy a pre-existing (possibly live-wired) copy. +pub(crate) fn stage_dir_for(copy_dir: &Path) -> std::path::PathBuf { + swap_sibling_for(copy_dir, ".socket-stage") +} + +/// The backup sibling the old copy is parked at mid-swap: `.socket-old`. +pub(crate) fn backup_dir_for(copy_dir: &Path) -> std::path::PathBuf { + swap_sibling_for(copy_dir, ".socket-old") +} + +/// Swap a fully-built stage into place without a destructive window: park the +/// old copy (if any) at `.socket-old` with a same-dir rename, rename the +/// stage over the now-vacant copy path, and only then delete the backup. Every +/// step is a single atomic rename — unlike a remove-then-rename swap (where a +/// partial `remove_dir_all`, realistic under Windows file locks, strands a +/// half-deleted copy) no step can leave less recoverable state than it started +/// with. If the stage rename fails the backup is renamed straight back; should +/// even that restore fail (an external process racing the uuid dir), the old +/// copy still exists intact at `.socket-old` instead of being destroyed. +pub(crate) async fn swap_stage_into_place(stage: &Path, copy_dir: &Path) -> std::io::Result<()> { + let backup = backup_dir_for(copy_dir); + // A stale backup (crash mid-swap on an earlier run) would make the + // park rename fail; `remove_tree` is a no-op when it is absent. + remove_tree(&backup).await?; + let had_old = match tokio::fs::rename(copy_dir, &backup).await { + Ok(()) => true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => return Err(e), + }; + match tokio::fs::rename(stage, copy_dir).await { + Ok(()) => { + if had_old { + let _ = remove_tree(&backup).await; + } + Ok(()) + } + Err(e) => { + if had_old { + let _ = tokio::fs::rename(&backup, copy_dir).await; + } + Err(e) + } + } +} + +/// Best-effort removal of an EMPTY `/` dir plus the empty +/// `.socket/vendor//` and `.socket/vendor/` levels a vendor run may have +/// created (or a revert may have emptied), so neither a hard failure nor the +/// reversal of the last entry of an ecosystem leaves a husk for the user to +/// commit. `remove_dir` refuses non-empty dirs, so live copies, markers, the +/// ledger and other entries' vendor dirs always survive; `.socket/` itself is +/// never touched (the apply lock lives there while any operation runs). +pub(crate) async fn prune_empty_vendor_levels(uuid_dir: &Path) { + // The uuid level may already be gone (the unwind paths `remove_tree` it + // before pruning): NotFound must continue to the parent levels this run + // created, or they survive as committable husks. Any other error (i.e. + // non-empty: a live copy or marker) still stops the prune. + match tokio::fs::remove_dir(uuid_dir).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return, + } + let Some(eco_dir) = uuid_dir.parent() else { + return; + }; + if tokio::fs::remove_dir(eco_dir).await.is_err() { + return; + } + if let Some(vendor_dir) = eco_dir.parent() { + let _ = tokio::fs::remove_dir(vendor_dir).await; + } +} + +// ── pre-write guards shared by the pypi lock flavors ──────────────────────── + +/// Refuse (with the flavor's stable `code`) when any of `files` (root-relative) +/// is itself a symbolic link. Every lock writer stages a replacement next to +/// the path and renames over it, which REPLACES the link with a detached +/// regular file: the shared target the link points at stays unpatched (git +/// shows a 120000→100644 typechange), and `revert` restores bytes but never +/// the link. Both wire and revert check before any write — the package +/// managers themselves write THROUGH a linked lock. +pub(crate) async fn refuse_symlinked( + root: &Path, + files: &[&str], + code: &'static str, +) -> Result<(), (&'static str, String)> { + match first_symlink(root, files.iter().copied()).await { + Some(file) => Err(( + code, + format!( + "{file} is a symbolic link; the atomic rewrite would replace the link with \ + a regular file and leave its target stale — vendor the real file's directory \ + instead" + ), + )), + None => Ok(()), + } +} + +/// Refuse (with the flavor's stable `code`) when `file` (root-relative) no +/// longer holds the `snapshot` the wiring plan was computed from. The lock +/// flavors deliberately snapshot their files in the pre-flight (so refusals +/// leave the tree byte-untouched) and only write after the wheel build — a +/// `poetry lock` / `pdm lock` / editor save landing in between would +/// otherwise be silently overwritten with stale snapshot-derived text and +/// recorded as the entry's `original`. A file that cannot be re-read is NOT +/// refused here: the write that follows surfaces that failure with the +/// flavor's own write-failed code. +pub(crate) async fn ensure_unchanged( + root: &Path, + file: &str, + snapshot: &str, + code: &'static str, +) -> Result<(), (&'static str, String)> { + match read_regular_to_string(&root.join(file)).await { + Ok(live) if live != snapshot => Err(( + code, + format!("{file} changed during vendoring; re-run to vendor against the new contents"), + )), + _ => Ok(()), + } +} + /// Shared helper the vendor backends (and `go_redirect`) delegate to: true /// when the copy exists and every patched file in it already hashes to its /// `afterHash`. @@ -432,23 +588,21 @@ async fn revert_lock_fragment_splice_inner( flavor: &str, atomic: bool, ) -> RevertOutcome { - use tokio::io::AsyncReadExt as _; - let lock_path = root.join(lock_file); - // Guarded read (`open_regular_file`: O_NONBLOCK + regular-file check): a - // FIFO planted as the lock must fail this revert fast and loudly, not - // wedge remove/rollback forever in an `open(2)` waiting for a writer. - let mut lock_text = match open_regular_file(&lock_path).await { - Ok((mut file, metadata)) => { - let mut t = String::with_capacity(metadata.len() as usize); - if let Err(e) = file.read_to_string(&mut t).await { - return RevertOutcome::failed(format!("cannot read {lock_file}: {e}")); - } - t - } + // Guarded read (`read_regular_to_string`: O_NONBLOCK + regular-file + // check): a FIFO planted as the lock must fail this revert fast and + // loudly, not wedge remove/rollback forever in an `open(2)` waiting for + // a writer. + let mut lock_text = match read_regular_to_string(&lock_path).await { + Ok(t) => t, Err(e) => return RevertOutcome::failed(format!("cannot read {lock_file}: {e}")), }; let mut warnings: Vec = Vec::new(); + // Set once a fragment was actually spliced: a fully converged revert (a + // second `vendor --revert`, a rollback after a relock) must not rewrite a + // byte-identical lock (new inode + mtime, spurious "modified" in + // editors and watchers). + let mut changed = false; // Set when a recorded fragment is neither present nor already restored: // the only condition under which the atomic flavor must hold the write // (restoring the source table while its integrity entry stays patched, or @@ -486,7 +640,12 @@ async fn revert_lock_fragment_splice_inner( let new_text = rec.new.as_ref().and_then(Value::as_str); let original_text = rec.original.as_ref().and_then(Value::as_str); match super::toml_surgery::replace_fragment(&lock_text, new_text, original_text) { - Some(t) => lock_text = t, + Some(t) => { + if t != lock_text { + lock_text = t; + changed = true; + } + } None => { // ALREADY CONVERGED (the LIVENESS CONTRACT, vendor/mod.rs): // the lock already carries the recorded pre-vendor original @@ -509,7 +668,7 @@ async fn revert_lock_fragment_splice_inner( } } - if !dry_run && (!atomic || !drifted) { + if changed && !dry_run && (!atomic || !drifted) { // Mode-preserving: the lock is a user-owned file we merely edit, so // the swapped-in inode must keep its permission bits rather than // reset them to umask defaults. @@ -536,6 +695,19 @@ mod tests { use crate::hash::git_sha256::compute_git_sha256_from_bytes; + /// The path-taking shape the maven / nuget probes compose out of + /// [`read_zip_artifact`] + [`zip_bytes_match_after_hashes`]: one guarded, + /// capped read, then the member-hash check. + async fn zip_matches_after_hashes( + archive_path: &Path, + files: &HashMap, + ) -> bool { + match read_zip_artifact(archive_path).await { + Some(bytes) => zip_bytes_match_after_hashes(&bytes, files), + None => false, + } + } + /// A one-entry `pkg.jar` (`lib/a.js` = `b"patched\n"`) written into `dir`, /// plus the files map whose `afterHash` matches it — the in-sync baseline /// each `zip_matches_after_hashes` case perturbs. @@ -848,6 +1020,11 @@ mod tests { Some("OLD-FRAGMENT".into()), "NEW-FRAGMENT".into(), )]; + #[cfg(unix)] + let inode_before = { + use std::os::unix::fs::MetadataExt as _; + std::fs::metadata(&lock).unwrap().ino() + }; let outcome = revert_lock_fragment_splice( &entry, @@ -869,6 +1046,18 @@ mod tests { "alpha\nOLD-FRAGMENT\nomega\n", "nothing to restore" ); + // A converged revert must not churn the file either: the atomic + // writer would swap in a fresh inode (new mtime, spurious "modified" + // in editors and watchers) for byte-identical content. + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + assert_eq!( + std::fs::metadata(&lock).unwrap().ino(), + inode_before, + "a converged revert never rewrites the lock" + ); + } } /// The lock file is user-owned: reverting the splice must not reset its diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index d6efd34a..5b876a88 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -37,12 +37,13 @@ use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::{fresh_copy, remove_tree}; use crate::patch::path_safety::{is_safe_multi_segment, is_safe_single_segment}; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::purl::{build_composer_purl, parse_composer_purl}; use super::common::{ - already_patched_result, copy_matches_after_hashes, done, refused, serialize_json, - service_offline_conflict, synthesized_result, + already_patched_result, copy_matches_after_hashes, done, prune_empty_vendor_levels, refused, + serialize_json, service_offline_conflict, stage_dir_for, swap_stage_into_place, + synthesized_result, }; use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; use super::registry_fetch::extract_zip; @@ -55,20 +56,6 @@ use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, Vendo /// Project-relative lockfile this backend wires. const COMPOSER_LOCK: &str = "composer.lock"; -/// Guarded read shared in shape with the Cargo.lock / .cargo/config.toml -/// twins: `open_regular_file` opens with `O_NONBLOCK` and rejects non-regular -/// files, so a FIFO planted as `composer.lock` fails fast instead of wedging -/// every caller (vendor's presence read, revert's stranded scan and restore) -/// forever in an `open(2)` that waits for a writer. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// Wiring-record discriminator. The record's `key` is /// `"
:/"` where `
` is `packages` or /// `packages-dev` (the lock array holding the entry) and `/` is @@ -486,6 +473,10 @@ pub async fn revert_composer_opts( error: Some(format!("failed to remove {}: {e}", uuid_dir.display())), }; } + // The last composer entry leaves `.socket/vendor/composer/` (and + // `.socket/vendor/`) empty: prune them so a reverted project carries + // no vendor residue (`remove_dir` keeps non-empty levels). + prune_empty_vendor_levels(&uuid_dir).await; } warnings.push(VendorWarning::new( @@ -511,82 +502,23 @@ pub async fn revert_composer_opts( // ── helpers ────────────────────────────────────────────────────────────────── -fn swap_sibling_for(copy_dir: &Path, suffix: &str) -> std::path::PathBuf { - let name = copy_dir - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| "copy".to_string()); - match copy_dir.parent() { - Some(parent) => parent.join(format!("{name}{suffix}")), - None => copy_dir.join(suffix), - } -} - -/// The staging sibling for a copy dir: -/// `//@.socket-stage`. (Re)builds are -/// materialised here and swapped into place only on success, so a failure can -/// never destroy a pre-existing (possibly live-wired) copy. -fn stage_dir_for(copy_dir: &Path) -> std::path::PathBuf { - swap_sibling_for(copy_dir, ".socket-stage") -} - -/// The backup sibling the old copy is parked at mid-swap: -/// `//@.socket-old`. -fn backup_dir_for(copy_dir: &Path) -> std::path::PathBuf { - swap_sibling_for(copy_dir, ".socket-old") -} - -/// Swap a fully-built stage into place without a destructive window: park the -/// old copy (if any) at `.socket-old` with a same-dir rename, rename the -/// stage over the now-vacant copy path, and only then delete the backup. -/// Every step is a single atomic rename — no step can leave less recoverable -/// state than it started with (see the cargo twin for the full rationale). -async fn swap_stage_into_place(stage: &Path, copy_dir: &Path) -> std::io::Result<()> { - let backup = backup_dir_for(copy_dir); - // A stale backup (crash mid-swap on an earlier run) would make the - // park rename fail; `remove_tree` is a no-op when it is absent. - remove_tree(&backup).await?; - let had_old = match tokio::fs::rename(copy_dir, &backup).await { - Ok(()) => true, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, - Err(e) => return Err(e), - }; - match tokio::fs::rename(stage, copy_dir).await { - Ok(()) => { - if had_old { - let _ = remove_tree(&backup).await; - } - Ok(()) - } - Err(e) => { - if had_old { - let _ = tokio::fs::rename(&backup, copy_dir).await; - } - Err(e) - } - } -} - /// Best-effort removal of the EMPTY dir levels a failed run may have created -/// above the copy — `//`, `/`, `.socket/vendor/composer/` -/// and `.socket/vendor/` — so a hard failure leaves no husk for sweep to -/// enumerate as a vendored unit (or for the user to commit). `remove_dir` -/// refuses non-empty dirs, so live copies, markers, and other patches' vendor -/// dirs always survive. `copy_dir` may be the copy or its stage sibling -/// (same parent); pruning starts at its parent. +/// above the copy — `//`, then `/`, +/// `.socket/vendor/composer/` and `.socket/vendor/` via the shared prune — so +/// a hard failure leaves no husk for sweep to enumerate as a vendored unit (or +/// for the user to commit). `remove_dir` refuses non-empty dirs, so live +/// copies, markers, and other patches' vendor dirs always survive. `copy_dir` +/// may be the copy or its stage sibling (same parent); pruning starts at its +/// parent. async fn prune_empty_vendor_dirs(copy_dir: &Path) { - let mut level = copy_dir.parent(); - for _ in 0..4 { - let Some(dir) = level else { return }; - match tokio::fs::remove_dir(dir).await { - Ok(()) => {} - // Already unwound wholesale (`remove_tree(uuid_dir)`): keep - // pruning the parent levels this run created. - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - // Non-empty (a live copy or marker) or otherwise busy: stop. - Err(_) => return, - } - level = dir.parent(); + let Some(vendor_level) = copy_dir.parent() else { + return; + }; + // Already unwound wholesale (`remove_tree(uuid_dir)`) reads as NotFound + // and the shared prune below still walks the levels this run created. + let _ = tokio::fs::remove_dir(vendor_level).await; + if let Some(uuid_dir) = vendor_level.parent() { + prune_empty_vendor_levels(uuid_dir).await; } } @@ -1006,6 +938,7 @@ mod tests { use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; use crate::patch::apply::{ApplyResult, VerifyStatus}; + use crate::vendor::common::backup_dir_for; use crate::vendor::state::VENDOR_MARKER_FILE; use std::collections::HashMap; use std::path::PathBuf; diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 21792f77..92cd0283 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -60,12 +60,13 @@ use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::{fresh_copy, remove_tree}; use crate::patch::path_safety::is_safe_single_segment; use crate::patch::redirect::gem_line_trailing_options; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::purl::{build_gem_purl, parse_gem_purl, purl_qualifier}; use super::common::{ - already_patched_result, copy_matches_after_hashes, done, refused, service_offline_conflict, - synthesized_result, + already_patched_result, copy_matches_after_hashes, done, failed_result, + prune_empty_vendor_levels, refused, service_offline_conflict, stage_dir_for, + swap_stage_into_place, synthesized_result, }; use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; use super::registry_fetch::extract_gem_data; @@ -80,20 +81,6 @@ use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, Vendo const GEMFILE: &str = "Gemfile"; const GEMFILE_LOCK: &str = "Gemfile.lock"; -/// Guarded read shared in shape with the composer.lock / Cargo.lock twins: -/// `open_regular_file` opens with `O_NONBLOCK` and rejects non-regular files, -/// so a FIFO planted as the Gemfile, Gemfile.lock, or a stub gemspec fails -/// fast instead of wedging vendor's pair read, revert's restore readers, or -/// the ledger reconstruction forever in an `open(2)` that waits for a writer. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// Wiring-record discriminators (`key` is the gem name for all three). /// /// `gemfile_line`: `original`/`new` are verbatim line/block strings. @@ -329,19 +316,22 @@ pub async fn vendor_gem( let remote_line = format!(" remote: {copy_rel}"); let lock_wired = lock_text.split('\n').any(|l| l == remote_line) && gemfile_text.contains(©_rel); - // D4 heal: a project vendored before the invalid-stub hardening carries - // the defective SERVED stub on disk, so EXISTS is not enough — an on-disk - // stub that fails the required-attribute bar routes into the artifact - // rebuild below (which re-materialises a valid stub) instead of the - // silent `already_vendored` no-op. - let copy_stub_ok = match read_regular_to_string(©_dir.join(format!("{name}.gemspec"))).await - { - Ok(text) => gemspec_missing_required_attrs(&text).is_empty(), - Err(_) => false, - }; - let copy_ok = copy_matches_after_hashes(©_dir, &record.files).await && copy_stub_ok; if lock_wired { if lock_checksum_in_sync(&lock_text, name, version) { + // Probe the copy only once the lock is known to be wired (the + // common fresh vendor has no copy to hash). D4 heal: a project + // vendored before the invalid-stub hardening carries the + // defective SERVED stub on disk, so EXISTS is not enough — an + // on-disk stub that fails the required-attribute bar routes into + // the artifact rebuild below (which re-materialises a valid + // stub) instead of the silent `already_vendored` no-op. The stub + // read runs second so a hash mismatch short-circuits it. + let copy_ok = copy_matches_after_hashes(©_dir, &record.files).await + && match read_regular_to_string(©_dir.join(format!("{name}.gemspec"))).await + { + Ok(text) => gemspec_missing_required_attrs(&text).is_empty(), + Err(_) => false, + }; if copy_ok { return done( already_patched_result(purl, ©_dir, &record.files), @@ -438,6 +428,20 @@ pub async fn vendor_gem( Ok(p) => p, Err(detail) => return refused("gemfile_declaration_not_editable", detail), }; + // ── Gemfile.lock edit (pure text surgery, computed before any write) ── + // A lock-shape failure therefore costs no download / copy / patch and no + // Gemfile write — the same failed `Done` outcome the unwind path used to + // produce, minus the unwind. + let lock_edit = match edit_lock(&lock_text, name, version, ©_rel) { + Ok(edit) => edit, + Err(e) => { + return done( + failed_result(purl, ©_dir, format!("failed to edit Gemfile.lock: {e}")), + None, + Vec::new(), + ); + } + }; // ── materialise the patched copy ────────────────────────────────────── // Prefer the prebuilt `.gem` + stub gemspec from the patch service @@ -480,38 +484,30 @@ pub async fn vendor_gem( if let Err(e) = atomic_write_bytes_preserving_mode(&gemfile_path, new_gemfile.as_bytes()).await { let _ = remove_tree(&uuid_dir).await; + prune_empty_vendor_levels(&uuid_dir).await; result.success = false; result.error = Some(format!("failed to write Gemfile: {e}")); return done(result, None, warnings); } - // ── Gemfile.lock edit (a failure here unwinds the Gemfile) ─────────── - let lock_edit = match edit_lock(&lock_text, name, version, ©_rel) { - Ok(edit) => { - match atomic_write_bytes_preserving_mode(&lock_path, edit.text.as_bytes()).await { - Ok(()) => Ok(edit), - Err(e) => Err(format!("failed to write Gemfile.lock: {e}")), - } - } - Err(e) => Err(format!("failed to edit Gemfile.lock: {e}")), - }; - let lock_edit = match lock_edit { - Ok(edit) => edit, - Err(mut detail) => { - // Unwind: a Gemfile pointing at a path the lock doesn't agree - // with is exactly the half-wired state the pair edit exists to - // prevent — restore the recorded original bytes. - if let Err(e) = - atomic_write_bytes_preserving_mode(&gemfile_path, gemfile_text.as_bytes()).await - { - detail.push_str(&format!(" (Gemfile unwind also failed: {e})")); - } - let _ = remove_tree(&uuid_dir).await; - result.success = false; - result.error = Some(detail); - return done(result, None, warnings); + // ── Gemfile.lock write (a failure here unwinds the Gemfile) ────────── + if let Err(e) = atomic_write_bytes_preserving_mode(&lock_path, lock_edit.text.as_bytes()).await + { + let mut detail = format!("failed to write Gemfile.lock: {e}"); + // Unwind: a Gemfile pointing at a path the lock doesn't agree with + // is exactly the half-wired state the pair edit exists to prevent — + // restore the recorded original bytes. + if let Err(e) = + atomic_write_bytes_preserving_mode(&gemfile_path, gemfile_text.as_bytes()).await + { + detail.push_str(&format!(" (Gemfile unwind also failed: {e})")); } - }; + let _ = remove_tree(&uuid_dir).await; + prune_empty_vendor_levels(&uuid_dir).await; + result.success = false; + result.error = Some(detail); + return done(result, None, warnings); + } // ── marker + ledger entry ──────────────────────────────────────────── let base_purl = build_gem_purl(name, version); @@ -685,96 +681,6 @@ pub async fn vendor_gem( // ── materialisation (service download / local build) ────────────────────────── -/// A swap sibling for a copy dir: `/-`. Same -/// directory as the copy → every swap step is a real rename, never a -/// cross-device copy. The suffixes can never collide with a copy dir: this -/// backend creates exactly one `-` leaf per uuid dir, from -/// validated plain gem tokens (see the mirrored cargo.rs machinery). -fn swap_sibling_for(copy_dir: &Path, suffix: &str) -> std::path::PathBuf { - let name = copy_dir - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| "copy".to_string()); - match copy_dir.parent() { - Some(parent) => parent.join(format!("{name}{suffix}")), - None => copy_dir.join(suffix), - } -} - -/// The staging sibling for a copy dir: `/-.socket-stage`. -/// (Re)builds are materialised here and swapped into place only on success, so -/// a failure can never destroy a pre-existing (possibly live-wired) copy. -fn stage_dir_for(copy_dir: &Path) -> std::path::PathBuf { - swap_sibling_for(copy_dir, ".socket-stage") -} - -/// The backup sibling the old copy is parked at mid-swap: -/// `/-.socket-old`. -fn backup_dir_for(copy_dir: &Path) -> std::path::PathBuf { - swap_sibling_for(copy_dir, ".socket-old") -} - -/// Swap a fully-built stage into place without a destructive window: park the -/// old copy (if any) at `.socket-old` with a same-dir rename, rename the -/// stage over the now-vacant copy path, and only then delete the backup. Every -/// step is a single atomic rename — unlike a remove-then-rename swap (where a -/// partial `remove_dir_all`, realistic under Windows file locks, strands a -/// half-deleted copy) no step can leave less recoverable state than it started -/// with. If the stage rename fails the backup is renamed straight back; should -/// even that restore fail (an external process racing the uuid dir), the old -/// copy still exists intact at `.socket-old` instead of being destroyed. -async fn swap_stage_into_place(stage: &Path, copy_dir: &Path) -> std::io::Result<()> { - let backup = backup_dir_for(copy_dir); - // A stale backup (crash mid-swap on an earlier run) would make the - // park rename fail; `remove_tree` is a no-op when it is absent. - remove_tree(&backup).await?; - let had_old = match tokio::fs::rename(copy_dir, &backup).await { - Ok(()) => true, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, - Err(e) => return Err(e), - }; - match tokio::fs::rename(stage, copy_dir).await { - Ok(()) => { - if had_old { - let _ = remove_tree(&backup).await; - } - Ok(()) - } - Err(e) => { - if had_old { - let _ = tokio::fs::rename(&backup, copy_dir).await; - } - Err(e) - } - } -} - -/// Best-effort removal of an EMPTY `/` dir plus the empty -/// `.socket/vendor/gem/` and `.socket/vendor/` levels a failed run may have -/// created, so a hard failure leaves no husk for the user to commit. -/// `remove_dir` refuses non-empty dirs, so live copies, markers, and other -/// gems' vendor dirs always survive. -async fn prune_empty_vendor_dirs(uuid_dir: &Path) { - // The uuid level may already be gone (the unwind paths `remove_tree` it - // before pruning): NotFound must continue to the parent levels this run - // created, or they survive as committable husks. Any other error (i.e. - // non-empty: a live copy or marker) still stops the prune. - match tokio::fs::remove_dir(uuid_dir).await { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return, - } - let Some(eco_dir) = uuid_dir.parent() else { - return; - }; - if tokio::fs::remove_dir(eco_dir).await.is_err() { - return; - } - if let Some(vendor_dir) = eco_dir.parent() { - let _ = tokio::fs::remove_dir(vendor_dir).await; - } -} - /// Failure cleanup for a staged (re)build: always remove the stage, then /// either unwind the whole `/` dir (`unwind_uuid_dir` — a fresh vendor /// with no pre-existing state worth keeping) or leave existing state @@ -786,7 +692,7 @@ async fn cleanup_failed_stage(stage: &Path, uuid_dir: &Path, unwind_uuid_dir: bo if unwind_uuid_dir { let _ = remove_tree(uuid_dir).await; } - prune_empty_vendor_dirs(uuid_dir).await; + prune_empty_vendor_levels(uuid_dir).await; } /// The path-source stub gemspec served as the gem's SECOND artifact, alongside @@ -1233,6 +1139,15 @@ pub async fn revert_gem(entry: &VendorEntry, project_root: &Path, dry_run: bool) /// [`revert_gem`] with full [`RevertOpts`]: `keep_artifact` skips ONLY the /// artifact deletion; the wiring restore — and the empty-wiring refusal, /// which applies under `keep_artifact` too — runs unchanged. +/// +/// LOSSINESS GUARD (the [`RevertOutcome`] contract every backend honors): a +/// record left alone as genuine drift keeps the artifact dir (and the caller +/// keeps the ledger entry) — the Gemfile `path:` or the lock's PATH section +/// may still route through it, and the entry holds the only pre-vendor +/// originals. A record whose live state already equals its reverted state +/// is convergence, not drift, and stays silent (LIVENESS CONTRACT), so an +/// earlier partial revert or a `bundle update` regeneration never wedges +/// the entry forever. pub async fn revert_gem_opts( entry: &VendorEntry, project_root: &Path, @@ -1301,16 +1216,22 @@ pub async fn revert_gem_opts( continue; } }; + let key = w.key.as_deref().unwrap_or(""); match restored { - Ok(true) => {} - Ok(false) => warnings.push(VendorWarning::new( + Ok(RecordRevert::Done) => {} + Ok(RecordRevert::Drifted) => warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", format!( - "{} no longer carries what vendor wrote for {}; left alone", - w.file, - w.key.as_deref().unwrap_or("") + "{} no longer carries what vendor wrote for {key}; left alone", + w.file ), )), + // A missing wired file cannot still route through the copy dir: + // reported, but not drift — the artifact may still be removed. + Ok(RecordRevert::FileMissing) => warnings.push(VendorWarning::new( + "vendor_lockfile_missing", + format!("{} is missing; the {key} entry cannot be restored", w.file), + )), Err(e) => { return RevertOutcome { kept_artifact: false, @@ -1322,26 +1243,37 @@ pub async fn revert_gem_opts( } } - // `--preserve-state` (`keep_artifact`): the artifact dir stays behind - // (and the caller keeps the ledger entry), so only the deletion is - // skipped. - if !dry_run && !keep_artifact { - if let Err(e) = remove_tree(&uuid_dir).await { - return RevertOutcome { - kept_artifact: false, - success: false, - warnings, - error: Some(format!("failed to remove {}: {e}", uuid_dir.display())), - }; - } - } - - RevertOutcome { + let mut outcome = RevertOutcome { kept_artifact: false, success: true, warnings, error: None, + }; + if dry_run { + return outcome; + } + // Drift-keep (see the fn doc): never delete a copy dir a left-alone + // record may still reference. + if outcome.drift_skipped() { + outcome.keep_artifact(&uuid_dir_rel); + return outcome; } + // `--preserve-state` (`keep_artifact`): the artifact dir stays behind + // (and the caller keeps the ledger entry), so only the deletion is + // skipped. + if keep_artifact { + return outcome; + } + if let Err(e) = remove_tree(&uuid_dir).await { + outcome.success = false; + outcome.error = Some(format!("failed to remove {}: {e}", uuid_dir.display())); + return outcome; + } + // The last gem entry leaves `.socket/vendor/gem/` (and `.socket/vendor/`) + // empty: prune them so a reverted project carries no vendor residue + // (`remove_dir` keeps non-empty levels). + prune_empty_vendor_levels(&uuid_dir).await; + outcome } // ── ledger reconstruction ─────────────────────────────────────────────────── @@ -2308,36 +2240,61 @@ fn lock_checksum_in_sync(lock_text: &str, name: &str, version: &str) -> bool { // ── revert helpers ─────────────────────────────────────────────────────────── -/// Restore one `gemfile_line` record. `Ok(true)` = restored (or would be, on -/// dry run); `Ok(false)` = the written line/block is gone (drift), left alone. +/// What restoring one wiring record found on disk. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecordRevert { + /// Restored (or would be, on a dry run) — or already in its reverted + /// state (convergence: silent per the LIVENESS CONTRACT on + /// [`RevertOutcome::drift_skipped`]). + Done, + /// What vendor wrote is gone and the pre-vendor original is not back + /// either: genuine third-party drift, left alone in full. + Drifted, + /// The wired file itself no longer exists: nothing can still route + /// through the vendored copy via it. + FileMissing, +} + +/// Restore one `gemfile_line` record. async fn revert_gemfile_record( gemfile_path: &Path, w: &WiringRecord, dry_run: bool, -) -> Result { +) -> Result { let text = match read_regular_to_string(gemfile_path).await { Ok(t) => t, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(RecordRevert::FileMissing) + } Err(e) => return Err(format!("unreadable Gemfile: {e}")), }; let Some(written) = w.new.as_ref().and_then(Value::as_str) else { - return Ok(false); + return Ok(RecordRevert::Drifted); }; let restored = match w.action { WiringAction::Rewritten => { let Some(original) = w.original.as_ref().and_then(Value::as_str) else { - return Ok(false); + return Ok(RecordRevert::Drifted); }; let mut lines: Vec<&str> = text.split('\n').collect(); let Some(i) = lines.iter().position(|l| *l == written) else { - return Ok(false); + // ALREADY CONVERGED: the pre-vendor line is back (a hand + // restore, a `bundle update` regeneration, an earlier partial + // revert) — not drift, nothing to write. + return Ok(if lines.contains(&original) { + RecordRevert::Done + } else { + RecordRevert::Drifted + }); }; lines[i] = original; lines.join("\n") } WiringAction::Added => { let Some(at) = text.find(written) else { - return Ok(false); + // ALREADY CONVERGED: an Added block's reverted state is its + // absence (the LIVENESS CONTRACT's "key is absent" case). + return Ok(RecordRevert::Done); }; let mut out = String::with_capacity(text.len()); out.push_str(&text[..at]); @@ -2350,37 +2307,71 @@ async fn revert_gemfile_record( .await .map_err(|e| format!("failed to write Gemfile: {e}"))?; } - Ok(true) + Ok(RecordRevert::Done) } -/// Restore one `gemfile_lock_spec` record. `Ok(true)` = restored (or would -/// be, on dry run); `Ok(false)` = the lock no longer carries what vendor -/// wrote (drift), left alone in full — a partial splice would corrupt it. +/// Restore one `gemfile_lock_spec` record. Drift leaves the lock alone in +/// full — a partial splice would corrupt it. async fn revert_lock_record( lock_path: &Path, w: &WiringRecord, dry_run: bool, -) -> Result { +) -> Result { let Some(original_lines) = wiring_string_array(w.original.as_ref()) else { - return Ok(false); + return Ok(RecordRevert::Drifted); }; let Some(new_lines) = wiring_string_array(w.new.as_ref()) else { - return Ok(false); + return Ok(RecordRevert::Drifted); }; let text = match read_regular_to_string(lock_path).await { Ok(t) => t, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(RecordRevert::FileMissing) + } Err(e) => return Err(format!("unreadable Gemfile.lock: {e}")), }; let Some(restored) = revert_lock_text(&text, &original_lines, &new_lines) else { - return Ok(false); + // ALREADY CONVERGED: our PATH section is gone and every pre-vendor + // spec line is back in GEM/specs (a `bundle update` regeneration or + // an earlier partial revert) — not drift, nothing to write. + return Ok(if lock_record_converged(&text, &original_lines, &new_lines) { + RecordRevert::Done + } else { + RecordRevert::Drifted + }); }; if !dry_run { atomic_write_bytes_preserving_mode(lock_path, restored.as_bytes()) .await .map_err(|e| format!("failed to write Gemfile.lock: {e}"))?; } - Ok(true) + Ok(RecordRevert::Done) +} + +/// True when the lock already holds the record's reverted state: no PATH +/// section carries vendor's `remote:` line and every spec-block line of the +/// pre-vendor original is present. +fn lock_record_converged(text: &str, original_lines: &[String], new_lines: &[String]) -> bool { + // A record whose `new` lost its `remote:` line is malformed, never + // converged (the tampered-ledger matrix pins it as drift). + let Some(remote_line) = new_lines.get(1).filter(|l| l.starts_with(" remote: ")) else { + return false; + }; + let lines: Vec = text.split('\n').map(str::to_string).collect(); + if find_path_section(&lines, remote_line).is_some() { + return false; + } + let Some((gs, ge)) = section_span(&lines, "GEM") else { + return false; + }; + let gem = &lines[gs..ge]; + let mut spec_block = original_lines.iter().filter(|l| l.starts_with(" ")); + let mut any = false; + let all_present = spec_block.all(|l| { + any = true; + gem.contains(l) + }); + any && all_present } fn wiring_string_array(v: Option<&Value>) -> Option> { @@ -2398,36 +2389,46 @@ fn wiring_string_array(v: Option<&Value>) -> Option> { /// the token is not recomputable offline (spike `bare-checksum-registry-gem` /// pair). The search is confined to the CHECKSUMS section so a coincidental /// identical line elsewhere (e.g. a DEPENDENCIES entry) is never clobbered. -/// `Ok(true)` = restored (or would be, on dry run); `Ok(false)` = the line is -/// gone (drift), left alone. +/// The line vendor wrote being gone is drift — unless the registry line it +/// replaced is already back (convergence), left alone either way. async fn revert_lock_checksum_record( lock_path: &Path, w: &WiringRecord, dry_run: bool, -) -> Result { +) -> Result { let Some(written) = w.new.as_ref().and_then(Value::as_str) else { - return Ok(false); + return Ok(RecordRevert::Drifted); }; let text = match read_regular_to_string(lock_path).await { Ok(t) => t, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(RecordRevert::FileMissing) + } Err(e) => return Err(format!("unreadable Gemfile.lock: {e}")), }; let mut lines: Vec = text.split('\n').map(str::to_string).collect(); let Some((ck_start, ck_end)) = section_span(&lines, "CHECKSUMS") else { - return Ok(false); + return Ok(RecordRevert::Drifted); }; + let original = w.original.as_ref().and_then(Value::as_str); let Some(i) = (ck_start + 1..ck_end).find(|&i| lines[i] == written) else { - return Ok(false); + // ALREADY CONVERGED: the registry line vendor replaced is back. + let converged = + original.is_some_and(|orig| (ck_start + 1..ck_end).any(|i| lines[i] == orig)); + return Ok(if converged { + RecordRevert::Done + } else { + RecordRevert::Drifted + }); }; - let Some(original) = w.original.as_ref().and_then(Value::as_str) else { + let Some(original) = original else { // A re-vendor rides the checksum record forward with `original: None` // for the caller's carry-forward to fill. When the chain has no // registry line to fill FROM — the pre-vendor entry was ALREADY the // bare path form (vendor then recorded no checksum wiring at all) — // there is nothing to restore: the bare line still standing IS the // pre-vendor state, not drift. - return Ok(true); + return Ok(RecordRevert::Done); }; lines[i] = original.to_string(); if !dry_run { @@ -2435,7 +2436,7 @@ async fn revert_lock_checksum_record( .await .map_err(|e| format!("failed to write Gemfile.lock: {e}"))?; } - Ok(true) + Ok(RecordRevert::Done) } /// Pure splice reversing [`edit_lock`]: drop the PATH section vendor emitted, @@ -2693,6 +2694,7 @@ fn gemspec_missing_required_attrs(spec_text: &str) -> Vec<&'static str> { #[cfg(test)] mod tests { use super::*; + use crate::vendor::common::{backup_dir_for, swap_sibling_for}; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; use crate::patch::apply::VerifyStatus; @@ -3518,8 +3520,13 @@ mod tests { assert!(!root.join(format!(".socket/vendor/gem/{UUID}")).exists()); } + /// A `bundle update` regenerated both files back to their pre-vendor + /// registry form: that is CONVERGENCE (the reverted state is already on + /// disk), not drift — revert stays silent (LIVENESS CONTRACT), leaves the + /// files alone and still removes the artifact dir, so the entry can never + /// wedge in the ledger forever. #[tokio::test] - async fn test_revert_drift_warnings() { + async fn test_revert_converged_files_are_silent_and_still_remove() { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; let (result, entry, _w) = @@ -3527,9 +3534,6 @@ mod tests { assert!(result.success); let entry = entry.unwrap(); - // Third-party drift: a `bundle update` regenerated both files back to - // registry form. Revert must leave them alone, warn per file, and - // still remove the artifact dir. tokio::fs::write(root.join(GEMFILE), GEMFILE_DIRECT) .await .unwrap(); @@ -3539,16 +3543,12 @@ mod tests { let outcome = revert_gem(&entry, &root, false).await; assert!(outcome.success, "{:?}", outcome.error); - let drift_count = outcome - .warnings - .iter() - .filter(|w| w.code == "vendor_lock_entry_drifted") - .count(); - assert_eq!( - drift_count, 2, - "one drift warning per file: {:?}", + assert!( + outcome.warnings.is_empty(), + "regenerated pre-vendor files are convergence, not drift: {:?}", outcome.warnings ); + assert!(!outcome.kept_artifact); assert_eq!( tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), GEMFILE_DIRECT @@ -4038,6 +4038,15 @@ mod tests { "exactly the checksum record drifts: {:?}", outcome.warnings ); + assert!( + outcome.kept_artifact, + "genuine drift keeps the artifact (and the ledger entry): {:?}", + outcome.warnings + ); + assert!( + root.join(copy_rel_318()).exists(), + "the copy dir survives a drift-keep" + ); assert_eq!( tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) .await @@ -6846,6 +6855,9 @@ mod tests { /// An unrecognized wiring kind (a newer ledger) warns and continues — /// forward compatibility: the known records still restore byte-exactly. + /// The unknown record is a left-alone fragment, so the copy dir it may + /// still reference is kept (the family-wide drift-keep), never deleted + /// under a record this build cannot read. #[tokio::test] async fn revert_unrecognized_wiring_kind_warns_and_continues() { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; @@ -6881,7 +6893,10 @@ mod tests { .unwrap(), LOCK_DIRECT ); - assert!(!root.join(format!(".socket/vendor/gem/{UUID}")).exists()); + assert!( + outcome.kept_artifact && root.join(format!(".socket/vendor/gem/{UUID}")).exists(), + "an unreadable record keeps the copy dir it may reference" + ); } /// Artifact removal failing at revert's END (read-only parent dir): the @@ -6935,10 +6950,11 @@ mod tests { ); } - /// A deleted Gemfile drifts (NotFound → left alone) while the lock + /// A deleted Gemfile is reported as missing (NotFound → nothing can + /// route through the copy via it, so NOT a drift-keep) while the lock /// record still restores and the artifact is still removed. #[tokio::test] - async fn revert_missing_gemfile_drifts_and_restores_lock() { + async fn revert_missing_gemfile_warns_and_restores_lock() { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; let (r1, e1, _) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); assert!(r1.success, "{:?}", r1.error); @@ -6947,12 +6963,13 @@ mod tests { let outcome = revert_gem(&entry, &root, false).await; assert!(outcome.success, "{:?}", outcome.error); - let drift = outcome + assert!(!outcome.drift_skipped(), "{:?}", outcome.warnings); + let missing = outcome .warnings .iter() - .filter(|w| w.code == "vendor_lock_entry_drifted") + .filter(|w| w.code == "vendor_lockfile_missing") .count(); - assert_eq!(drift, 1, "{:?}", outcome.warnings); + assert_eq!(missing, 1, "{:?}", outcome.warnings); assert!(!root.join(GEMFILE).exists(), "the missing file stays gone"); assert_eq!( tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) @@ -6963,10 +6980,11 @@ mod tests { assert!(!root.join(format!(".socket/vendor/gem/{UUID}")).exists()); } - /// A deleted lock drifts BOTH lock-side records (spec + checksum, via - /// NotFound) while the Gemfile still restores. + /// A deleted lock reports BOTH lock-side records (spec + checksum, via + /// NotFound) as missing — not drift — while the Gemfile still restores + /// and the artifact is still removed. #[tokio::test] - async fn revert_missing_lock_drifts_and_restores_gemfile() { + async fn revert_missing_lock_warns_and_restores_gemfile() { let (_tmp, root, installed, blobs, record) = fixture_318(SPIKE_GEMFILE_CHECKSUMS, SPIKE_LOCK_CHECKSUMS_BEFORE).await; let (r1, e1, _) = @@ -6980,14 +6998,15 @@ mod tests { let outcome = revert_gem(&entry, &root, false).await; assert!(outcome.success, "{:?}", outcome.error); - let drift = outcome + assert!(!outcome.drift_skipped(), "{:?}", outcome.warnings); + let missing = outcome .warnings .iter() - .filter(|w| w.code == "vendor_lock_entry_drifted") + .filter(|w| w.code == "vendor_lockfile_missing") .count(); assert_eq!( - drift, 2, - "both lock-side records drift: {:?}", + missing, 2, + "both lock-side records report the missing lock: {:?}", outcome.warnings ); assert!(!root.join(GEMFILE_LOCK).exists()); @@ -7108,6 +7127,10 @@ mod tests { .filter(|w| w.code == "vendor_lock_entry_drifted") .count(); assert_eq!(drift, 1, "{:?}", outcome.warnings); + assert!( + outcome.kept_artifact && root.join(copy_rel_318()).exists(), + "the lock still holds the PATH section: the copy dir is kept" + ); assert_eq!( tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) .await @@ -7167,9 +7190,12 @@ mod tests { } /// A hand-deleted managed block (the `Added` Gemfile record's written - /// text is gone) drifts instead of guessing; the lock still restores. + /// text is gone) is already in its reverted state — convergence, not + /// drift (LIVENESS CONTRACT: an Added record with no original is + /// reverted once absent) — so the lock restores and the artifact is + /// removed without a drift-keep. #[tokio::test] - async fn revert_added_block_gone_drifts() { + async fn revert_added_block_gone_is_converged() { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_TRANSITIVE, LOCK_TRANSITIVE).await; let (r1, e1, _) = unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); @@ -7181,12 +7207,9 @@ mod tests { let outcome = revert_gem(&entry, &root, false).await; assert!(outcome.success, "{:?}", outcome.error); - let drift = outcome - .warnings - .iter() - .filter(|w| w.code == "vendor_lock_entry_drifted") - .count(); - assert_eq!(drift, 1, "{:?}", outcome.warnings); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(!outcome.kept_artifact); + assert!(!root.join(format!(".socket/vendor/gem/{UUID}")).exists()); assert_eq!( tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), GEMFILE_TRANSITIVE @@ -7743,16 +7766,16 @@ mod tests { assert!(!backup_dir_for(©).exists(), "no parked backup litter"); } - /// `prune_empty_vendor_dirs` removes exactly the three levels a failed + /// `prune_empty_vendor_levels` removes exactly the three levels a failed /// run may have created (`` → `gem` → `vendor`) and never climbs /// higher; a parentless uuid path has no levels above it and returns. #[tokio::test] - async fn prune_empty_vendor_dirs_removes_three_levels_and_stops() { + async fn prune_empty_vendor_levels_removes_three_levels_and_stops() { let dir = tempfile::tempdir().unwrap(); let keep = dir.path().join("keep"); let uuid = keep.join("vendor/gem").join(UUID); tokio::fs::create_dir_all(&uuid).await.unwrap(); - prune_empty_vendor_dirs(&uuid).await; + prune_empty_vendor_levels(&uuid).await; assert!( !keep.join("vendor").exists(), "all three empty levels pruned" @@ -7771,7 +7794,7 @@ mod tests { tokio::fs::write(busy.join("vendor/gem/other-gem-marker"), b"x") .await .unwrap(); - prune_empty_vendor_dirs(&uuid_b).await; + prune_empty_vendor_levels(&uuid_b).await; assert!(!uuid_b.exists(), "the empty uuid level is pruned"); assert!( busy.join("vendor/gem/other-gem-marker").exists(), @@ -7779,7 +7802,7 @@ mod tests { ); // Parentless uuid path: nothing above to prune, returns cleanly. - prune_empty_vendor_dirs(Path::new("")).await; + prune_empty_vendor_levels(Path::new("")).await; } /// DEPENDENCIES entries are exactly 2-space-indented and specs entries @@ -7835,7 +7858,11 @@ mod tests { new: Some(Value::String("not-an-array".to_string())), }; let restored = revert_lock_record(dir.path(), &w, true).await.unwrap(); - assert!(!restored, "malformed `new` wiring is drift, not an error"); + assert_eq!( + restored, + RecordRevert::Drifted, + "malformed `new` wiring is drift, not an error" + ); } /// The specs-splice scan steps over a line inside GEM/specs that is not diff --git a/crates/socket-patch-core/src/vendor/go_mod_edit.rs b/crates/socket-patch-core/src/vendor/go_mod_edit.rs index 1da99902..b13bcaae 100644 --- a/crates/socket-patch-core/src/vendor/go_mod_edit.rs +++ b/crates/socket-patch-core/src/vendor/go_mod_edit.rs @@ -36,6 +36,8 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use crate::utils::fs::read_regular_to_string; + /// Project-relative directory holding `apply`'s patched module copies. A /// `replace` whose target path is under this prefix is owned by /// [`ReplaceOwner::GoPatches`]. @@ -126,20 +128,6 @@ impl ReplaceEntry { // ── public async API ───────────────────────────────────────────────────────── -/// Guarded read shared in shape with the Cargo.lock / .cargo/config.toml -/// twins: `open_regular_file` opens with `O_NONBLOCK` and rejects non-regular -/// files, so a FIFO planted as `go.mod` fails fast instead of wedging every -/// caller (apply's redirect + reconcile, `--check`'s verify, vex's directive -/// scan) forever in an `open(2)` that waits for a writer that never comes. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// Read all `replace` directives. Read-only; a missing/unreadable `go.mod` /// yields an empty vec (callers treat that as "no managed entries"). pub async fn read_replace_entries(project_root: &Path) -> Vec { diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index 2fd97c9e..2940e8e4 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -30,8 +30,9 @@ use crate::vendor::go_mod_edit::{ }; use super::common::{ - already_patched_result, copy_matches_after_hashes, done, failed_result, refused, - service_offline_conflict, + already_patched_result, copy_matches_after_hashes, done, failed_result, + prune_empty_vendor_levels, refused, service_offline_conflict, stage_dir_for, + swap_stage_into_place, }; use super::path::vendor_uuid_dir_rel; use super::registry_fetch::extract_zip_with_prefix; @@ -247,8 +248,11 @@ pub async fn vendor_go_module( // The engine already rolled back a half-built copy, but its rollback // removes only the module leaf — clear the whole uuid dir so no empty // path husks (or a copy left by a failed `replace` upsert) linger - // under `.socket/vendor/golang/`. - let _ = remove_tree(&project_root.join(&base_rel)).await; + // under `.socket/vendor/golang/`, then prune the empty ecosystem / + // vendor levels a fresh run created. + let uuid_dir = project_root.join(&base_rel); + let _ = remove_tree(&uuid_dir).await; + prune_empty_vendor_levels(&uuid_dir).await; return done(result, None, warnings); } // A patch with no files is a no-op success: the engine wrote no copy and @@ -306,20 +310,23 @@ pub async fn vendor_go_module( let stale = copy_dir_for(project_root, GO_PATCHES_DIR, module, version); let _ = remove_tree(&stale).await; // Prune now-empty parent husks (`/example.com/`) up to - // and including the go-patches root. `remove_dir` is non-recursive: - // a parent still holding another module's copy fails harmlessly. + // and including the go-patches root (`starts_with` holds for the + // root itself and bounds the climb). `remove_dir` is non-recursive: + // a parent still holding another module's copy fails and stops the + // prune; a level the user already removed is skipped. let go_patches_root = project_root.join(GO_PATCHES_DIR); let mut parent = stale.parent().map(|p| p.to_path_buf()); while let Some(dir) = parent { - if !dir.starts_with(&go_patches_root) || dir < go_patches_root { + if !dir.starts_with(&go_patches_root) { break; } - if tokio::fs::remove_dir(&dir).await.is_err() { - break; // non-empty (or already gone) — stop pruning + match tokio::fs::remove_dir(&dir).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => break, // non-empty — stop pruning } parent = dir.parent().map(|p| p.to_path_buf()); } - let _ = tokio::fs::remove_dir(&go_patches_root).await; warnings.push(VendorWarning::new( "vendor_takeover", format!( @@ -467,19 +474,25 @@ async fn go_service_redirect( }; match fetch_verified_archive(cfg, &record.uuid).await { ServiceArtifact::Ready(archive) => { - // Clean copy dir; extract the module zip (strip its literal - // `{module}@{version}/` prefix) into it. - let _ = remove_tree(copy_dir).await; - if let Err(e) = tokio::fs::create_dir_all(copy_dir).await { - teardown_failed_service_copy(project_root, base_rel, module, wired).await; + // Extract the module zip (strip its literal `{module}@{version}/` + // prefix) into a STAGE sibling of the copy dir and swap it into + // place only once verified — the cargo / composer / gem shape: a + // failed re-download never destroys a pre-existing copy the + // vendor `replace` still points at. + let stage = stage_dir_for(copy_dir); + let _ = remove_tree(&stage).await; // a crashed earlier run's litter + if let Err(e) = tokio::fs::create_dir_all(&stage).await { + cleanup_failed_service_stage(&stage, project_root, base_rel, copy_dir, module, wired) + .await; return hard( "vendor_prebuilt_write_failed", - format!("cannot create {}: {e}", copy_dir.display()), + format!("cannot create {}: {e}", stage.display()), ); } let prefix = format!("{module}@{version}/"); - if let Err(e) = extract_zip_with_prefix(&archive.bytes, copy_dir, &prefix) { - teardown_failed_service_copy(project_root, base_rel, module, wired).await; + if let Err(e) = extract_zip_with_prefix(&archive.bytes, &stage, &prefix) { + cleanup_failed_service_stage(&stage, project_root, base_rel, copy_dir, module, wired) + .await; return hard( "vendor_prebuilt_extract_failed", format!("cannot extract the prebuilt module zip: {e}"), @@ -487,23 +500,25 @@ async fn go_service_redirect( } // A `replace` target needs a go.mod declaring the module path; // pre-modules zips may lack one — synthesize the minimal form. - if let Err(e) = ensure_module_go_mod(copy_dir, module).await { - teardown_failed_service_copy(project_root, base_rel, module, wired).await; + if let Err(e) = ensure_module_go_mod(&stage, module).await { + cleanup_failed_service_stage(&stage, project_root, base_rel, copy_dir, module, wired) + .await; return hard( "vendor_prebuilt_write_failed", format!("cannot synthesize go.mod for the copy: {e}"), ); } - // Verify the EXTRACTED TREE before wiring the consumer's go.mod: - // the SRI proves the zip bytes are intact, but an unexpected - // internal layout (the `{module}@{version}/` prefix strip - // mismatching) lands the patched files at the wrong paths, and - // the caller would synthesize success from `record.files` while - // the copy is wrong. Fail closed → `auto` falls back to the - // local build; do it BEFORE editing go.mod so nothing points at - // a bad copy. (Mirrors composer_lock.rs.) - if !copy_matches_after_hashes(copy_dir, &record.files).await { - teardown_failed_service_copy(project_root, base_rel, module, wired).await; + // Verify the EXTRACTED TREE before it replaces the copy or the + // consumer's go.mod is wired: the SRI proves the zip bytes are + // intact, but an unexpected internal layout (the + // `{module}@{version}/` prefix strip mismatching) lands the + // patched files at the wrong paths, and the caller would + // synthesize success from `record.files` while the copy is + // wrong. Fail closed → `auto` falls back to the local build; + // nothing points at the bad stage. (Mirrors composer_lock.rs.) + if !copy_matches_after_hashes(&stage, &record.files).await { + cleanup_failed_service_stage(&stage, project_root, base_rel, copy_dir, module, wired) + .await; return miss( warnings, "vendor_prebuilt_layout_mismatch", @@ -514,11 +529,26 @@ async fn go_service_redirect( ), ); } + if let Err(e) = swap_stage_into_place(&stage, copy_dir).await { + cleanup_failed_service_stage(&stage, project_root, base_rel, copy_dir, module, wired) + .await; + return hard( + "vendor_prebuilt_write_failed", + format!("cannot move the extracted module into place: {e}"), + ); + } if let Err(e) = go_mod_edit::ensure_replace_entry(project_root, module, version, base_rel, false) .await { - teardown_failed_service_copy(project_root, base_rel, module, wired).await; + // The verified copy is in place. A wired run's directive + // already targets this uuid's (now refreshed) copy, so both + // stay consistent as they are; a first run has nothing + // pointing at the copy — tear the uuid dir down so no orphan + // survives the wire failure. + if !wired { + teardown_failed_service_copy(project_root, base_rel, module, false).await; + } return hard( "vendor_prebuilt_wire_failed", format!("failed to update go.mod: {e}"), @@ -577,13 +607,36 @@ async fn teardown_failed_service_copy( module: &str, wired: bool, ) { - let _ = remove_tree(&project_root.join(base_rel)).await; + let uuid_dir = project_root.join(base_rel); + let _ = remove_tree(&uuid_dir).await; + prune_empty_vendor_levels(&uuid_dir).await; if wired { let _ = go_mod_edit::drop_replace_entry(project_root, module, ReplaceOwner::Vendor, false) .await; } } +/// Failure cleanup for the STAGED service leg: the stage is always removed. A +/// pre-existing copy the vendor `replace` still points at (`wired`, copy +/// present) is left exactly as it was — the failed re-download changed nothing +/// the build depends on, and the next run retries. Otherwise (a first run, a +/// directive pointing elsewhere, or a wired directive whose copy is MISSING and +/// would dangle) fall back to [`teardown_failed_service_copy`]. +async fn cleanup_failed_service_stage( + stage: &Path, + project_root: &Path, + base_rel: &str, + copy_dir: &Path, + module: &str, + wired: bool, +) { + let _ = remove_tree(stage).await; + if wired && tokio::fs::metadata(copy_dir).await.is_ok() { + return; + } + teardown_failed_service_copy(project_root, base_rel, module, wired).await; +} + /// Revert one vendored Go module: drop the vendor-owned `replace` directive /// and remove the uuid dir. A taken-over go-patches redirect is **not** /// restored (warned: re-run `socket-patch apply`). @@ -640,12 +693,10 @@ pub async fn revert_go_vendor_opts( if !dry_run && !keep_artifact { let uuid_dir = project_root.join(&base_rel); let _ = remove_tree(&uuid_dir).await; // ignore NotFound - // Best-effort: prune the now-empty `.socket/vendor/golang/` level so a - // fully-reverted project carries no vendor residue (`save_state` then - // prunes `.socket/vendor/` itself). `remove_dir` fails on non-empty. - if let Some(eco_dir) = uuid_dir.parent() { - let _ = tokio::fs::remove_dir(eco_dir).await; - } + // Best-effort: prune the now-empty `.socket/vendor/golang/` and + // `.socket/vendor/` levels so a fully-reverted project carries no + // vendor residue. `remove_dir` fails on non-empty. + prune_empty_vendor_levels(&uuid_dir).await; } if entry.took_over_go_patches { @@ -1808,18 +1859,65 @@ mod tests { ); } - /// Same invariant through the service legs: when the service rebuild of a - /// wired-but-stale copy fails mid-materialisation (corrupt zip), the - /// directive from the earlier healthy run is torn down with the uuid dir. + /// The service legs stage the download and swap it in only once verified + /// (the cargo / composer / gem shape): when the service rebuild of a + /// wired-but-STALE copy fails mid-materialisation (corrupt zip), the copy + /// the directive still points at survives byte-for-byte — buildable, + /// retried on the next run — with the directive intact and no stage + /// litter, instead of being torn down into an unpatched go.mod edit. #[tokio::test] - async fn failed_service_rebuild_of_stale_copy_drops_dangling_directive() { + async fn failed_service_rebuild_of_stale_copy_keeps_the_wired_copy() { let (dir, blobs, pristine, record) = fixture().await; let root = dir.path(); expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); // Stale copy → the service rebuild leg runs on the re-run. - tokio::fs::write(root.join(copy_rel()).join("bar.go"), b"drifted\n") + let copy = root.join(copy_rel()); + tokio::fs::write(copy.join("bar.go"), b"drifted\n") .await .unwrap(); + let gomod_before = tokio::fs::read(root.join("go.mod")).await.unwrap(); + + let junk: &[u8] = b"not a zip at all"; + let server = wiremock::MockServer::start().await; + mount_go_granted(&server, &sri_sha512(junk), None, junk).await; + let sources = PatchSources::blobs_only(&blobs); + let outcome = vendor_go_module( + PURL, + &pristine, + root, + &record, + &sources, + "2026-06-10T00:00:00Z", + false, + false, + Some(&go_service_cfg(&server.uri(), VendorSource::Auto, false)), + ) + .await; + expect_refused(outcome, "vendor_prebuilt_extract_failed"); + assert_eq!( + tokio::fs::read(copy.join("bar.go")).await.unwrap(), + b"drifted\n", + "the wired copy survives the failed re-download untouched" + ); + assert!(!stage_dir_for(©).exists(), "no stage litter"); + assert_eq!( + tokio::fs::read(root.join("go.mod")).await.unwrap(), + gomod_before, + "the directive still points at the surviving copy" + ); + } + + /// The wired copy is MISSING (deleted by hand) and the service rebuild + /// fails: nothing buildable survives, so the directive would dangle at a + /// deleted path (go: "replacement directory does not exist") — the + /// teardown clears the uuid dir and drops the directive, falling back to + /// the unpatched-module end state. + #[tokio::test] + async fn failed_service_rebuild_of_missing_copy_drops_dangling_directive() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + expect_done(run_vendor(PURL, root, &blobs, &pristine, &record, false).await); + remove_tree(&root.join(copy_rel())).await.unwrap(); let junk: &[u8] = b"not a zip at all"; let server = wiremock::MockServer::start().await; @@ -1839,8 +1937,8 @@ mod tests { .await; expect_refused(outcome, "vendor_prebuilt_extract_failed"); assert!( - !root.join(format!(".socket/vendor/golang/{UUID}")).exists(), - "uuid dir cleared" + !root.join(".socket/vendor").exists(), + "uuid dir cleared and the empty vendor levels pruned" ); assert!( read_replace_entries(root) diff --git a/crates/socket-patch-core/src/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs index a1fdff6c..111c75ed 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory.rs @@ -27,6 +27,7 @@ use toml_edit::{DocumentMut, Item, TableLike, Value as TomlValue}; use crate::crawlers::composer_crawler::normalize_version; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::patch::path_safety; +use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; use crate::utils::purl::{percent_decode_purl_component, strip_purl_qualifiers}; use crate::vendor::bun_lock_text; @@ -398,31 +399,6 @@ fn dedup_prefer_integrity(raw: Vec) -> Vec { out } -/// Guarded read shared in shape with the vendor siblings' twins -/// (cargo_lock.rs, gem.rs, go_mod_edit.rs): `open_regular_file` opens with -/// `O_NONBLOCK` and rejects non-regular files, so a FIFO planted as any -/// inventoried lockfile fails fast instead of wedging every consumer — -/// scan's lockfile supplement, vendor's auto-fetch, repair's no-ledger -/// reconstruction — forever in an `open(2)` that waits for a writer. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - -/// Bytes twin of [`read_regular_to_string`] for the JSON locks. -async fn read_regular(path: &Path) -> std::io::Result> { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut bytes).await?; - Ok(bytes) -} - // ──────────────────────────────── Cargo.lock ──────────────────────────────── /// Inventory `Cargo.lock` `[[package]]` blocks. Only crates.io-sourced @@ -553,7 +529,7 @@ async fn inventory_package_lock(root: &Path) -> Option> { // Shrinkwrap wins, mirroring `npm_lock::select_lockfile`. let mut bytes = None; for lock in ["npm-shrinkwrap.json", "package-lock.json"] { - if let Ok(b) = read_regular(&root.join(lock)).await { + if let Ok(b) = read_regular_to_bytes(&root.join(lock)).await { bytes = Some(b); break; } @@ -853,7 +829,7 @@ async fn inventory_bun_binary(root: &Path) -> Result, Unsuppo code: "bun_lockb_invalid", detail: format!("cannot inventory bun.lockb: {detail}"), }; - let bytes = read_regular(&root.join("bun.lockb")) + let bytes = read_regular_to_bytes(&root.join("bun.lockb")) .await .map_err(|error| invalid(error.to_string()))?; let lock = super::bun_lockb::BunLockb::parse(&bytes).map_err(invalid)?; @@ -937,7 +913,7 @@ async fn inventory_bun(root: &Path) -> Option> { /// versions drop the pretty leading `v`/`V` through the crawler's /// [`normalize_version`], so installed and lockfile rows agree. async fn inventory_composer_lock(project_root: &Path) -> Option> { - let bytes = read_regular(&project_root.join("composer.lock")) + let bytes = read_regular_to_bytes(&project_root.join("composer.lock")) .await .ok()?; let doc: Value = serde_json::from_slice(&bytes).ok()?; @@ -2007,7 +1983,7 @@ pub async fn wired_vendor_integrity( .await .is_err() { - if let Ok(bytes) = read_regular(&project_root.join("bun.lockb")).await { + if let Ok(bytes) = read_regular_to_bytes(&project_root.join("bun.lockb")).await { if let Ok(lock) = super::bun_lockb::BunLockb::parse(&bytes) { if let Ok(packages) = lock.packages() { let mut pinned: Option = None; @@ -2036,7 +2012,7 @@ pub async fn wired_vendor_integrity( // JSON locks: resolved == "file:" (npm writes exactly this form). for lock in ["npm-shrinkwrap.json", "package-lock.json"] { - let Ok(bytes) = read_regular(&project_root.join(lock)).await else { + let Ok(bytes) = read_regular_to_bytes(&project_root.join(lock)).await else { continue; }; let Ok(v) = serde_json::from_slice::(&bytes) else { diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index de98b7ec..f3f10505 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -67,12 +67,15 @@ use crate::manifest::schema::{PatchFileInfo, PatchRecord}; use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::remove_tree; use crate::patch::path_safety::is_safe_single_segment; -use crate::utils::fs::{atomic_write_bytes, atomic_write_bytes_preserving_mode}; +use crate::utils::fs::{ + atomic_write_bytes, atomic_write_bytes_preserving_mode, read_regular_to_bytes, + read_regular_to_string, +}; use crate::utils::purl::{build_maven_purl, parse_maven_purl}; use super::common::{ - already_patched_result, done, failed_result, rebuild_zip, refused, synthesized_result, - zip_matches_after_hashes, + already_patched_result, done, failed_result, prune_empty_vendor_levels, read_zip_artifact, + rebuild_zip, refused, synthesized_result, zip_bytes_match_after_hashes, }; use super::path::vendor_uuid_dir_rel; use super::registry_fetch::extract_zip; @@ -137,32 +140,6 @@ fn is_safe_group_id(group_id: &str) -> bool { group_id.split('.').all(is_safe_single_segment) } -/// Guarded read shared in shape with the vendor twins (cargo.rs, gem.rs, -/// composer_lock.rs …): `open_regular_file` opens with `O_NONBLOCK` and -/// rejects non-regular files, so a FIFO planted at any of this backend's read -/// paths — the committed vendored tree, the project `pom.xml`, the `~/.m2` -/// cache — fails fast instead of wedging the caller forever in an `open(2)` -/// waiting for a writer that never comes. -async fn read_regular(path: &Path) -> std::io::Result> { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut bytes).await?; - Ok(bytes) -} - -/// String twin of [`read_regular`] (invalid UTF-8 errors as `InvalidData`, -/// matching `tokio::fs::read_to_string`). -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// Vendor a Maven package: rebuild a patched `.jar` under a committed maven2 /// repository at `.socket/vendor/maven//`, copy the real upstream pom /// beside it, and wire the project `pom.xml` with a `` serving it @@ -385,6 +362,7 @@ pub async fn vendor_maven( Ok(text) => text, Err(detail) => { let _ = remove_tree(&uuid_dir).await; + prune_empty_vendor_levels(&uuid_dir).await; result.success = false; result.error = Some(detail); return done(result, None, warnings); @@ -393,6 +371,7 @@ pub async fn vendor_maven( if let Err(e) = atomic_write_bytes_preserving_mode(&pom_xml_path, new_pom_xml.as_bytes()).await { let _ = remove_tree(&uuid_dir).await; + prune_empty_vendor_levels(&uuid_dir).await; result.success = false; result.error = Some(format!("failed to write {}: {e}", pom_xml_path.display())); return done(result, None, warnings); @@ -537,6 +516,10 @@ pub async fn revert_maven_opts( error: Some(format!("failed to remove {}: {e}", uuid_dir.display())), }; } + // The last maven entry leaves `.socket/vendor/maven/` (and + // `.socket/vendor/`) empty: prune them so a reverted project carries + // no vendor residue (`remove_dir` keeps non-empty levels). + prune_empty_vendor_levels(&uuid_dir).await; } RevertOutcome { @@ -627,6 +610,7 @@ async fn materialise_and_write( if let Err(e) = write_maven_artifact(leaf_dir, jar_leaf, &jar_bytes, pom_leaf, &pom_bytes).await { let _ = remove_tree(uuid_dir).await; + prune_empty_vendor_levels(uuid_dir).await; return Ok((Vec::new(), failed_result(purl, jar_path, e))); } Ok((jar_bytes, result)) @@ -722,7 +706,7 @@ async fn acquire_upstream_pom( warnings: &mut Vec, ) -> Result, String> { let local = installed_dir.join(format!("{artifact_id}-{version}.pom")); - match read_regular(&local).await { + match read_regular_to_bytes(&local).await { Ok(bytes) => return Ok(bytes), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(format!("unreadable local pom {}: {e}", local.display())), @@ -773,17 +757,13 @@ async fn fetch_pom_bytes(url: &str) -> Result, String> { if !resp.status().is_success() { return Err(format!("GET {url}: HTTP {}", resp.status())); } - let bytes = resp - .bytes() + // Enforce the cap on the declared Content-Length AND on the streamed + // bytes (the shared reader every other registry download uses): a + // mirror serving a huge body is refused mid-stream instead of being + // buffered whole before the size check. + crate::utils::http::read_capped(resp, MAX_POM_BYTES as u64, "pom") .await - .map_err(|e| format!("read body of {url}: {e}"))?; - if bytes.len() > MAX_POM_BYTES { - return Err(format!( - "pom at {url} is {} bytes (cap {MAX_POM_BYTES})", - bytes.len() - )); - } - Ok(bytes.to_vec()) + .map_err(|e| format!("{url}: {e}")) } /// Dry-run verify-only: extract the local jar to a private stage and run the @@ -841,7 +821,7 @@ async fn dry_run_verify( /// entry fail-closed. Returns the live [`tempfile::TempDir`] (the caller holds /// it for the stage's lifetime). async fn extract_jar_to_stage(src_jar: &Path) -> Result { - let bytes = read_regular(src_jar) + let bytes = read_regular_to_bytes(src_jar) .await .map_err(|e| format!("cannot read {}: {e}", src_jar.display()))?; let stage = tempfile::tempdir().map_err(|e| format!("cannot create stage dir: {e}"))?; @@ -884,16 +864,28 @@ async fn artifact_in_sync( pom_leaf: &str, files: &HashMap, ) -> bool { - if !zip_matches_after_hashes(&leaf_dir.join(jar_leaf), files).await { + // One guarded read of the jar serves both the member-hash check and its + // `.sha1` sidecar compare (the hot path runs on every re-run). + let Some(jar) = read_zip_artifact(&leaf_dir.join(jar_leaf)).await else { + return false; + }; + if !zip_bytes_match_after_hashes(&jar, files) { + return false; + } + let Ok(recorded) = read_regular_to_string(&leaf_dir.join(format!("{jar_leaf}.sha1"))).await + else { + return false; + }; + if recorded.trim() != sha1_hex(&jar) { return false; } - // The pom + both sidecars must exist and match their bytes. - sidecar_matches(leaf_dir, jar_leaf).await && sidecar_matches(leaf_dir, pom_leaf).await + // The pom + its sidecar must exist and match their bytes too. + sidecar_matches(leaf_dir, pom_leaf).await } /// True when `.sha1` exists and equals the hex sha1 of ``'s bytes. async fn sidecar_matches(leaf_dir: &Path, leaf: &str) -> bool { - let Ok(bytes) = read_regular(&leaf_dir.join(leaf)).await else { + let Ok(bytes) = read_regular_to_bytes(&leaf_dir.join(leaf)).await else { return false; }; let Ok(recorded) = read_regular_to_string(&leaf_dir.join(format!("{leaf}.sha1"))).await else { diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index fbb107a3..60ce46c4 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -107,6 +107,7 @@ use crate::patch::apply::{ apply_package_patch, is_safe_relative_subpath, normalize_file_path, ApplyResult, PatchSources, VerifyStatus, }; +use crate::utils::fs::read_regular_to_string_sync; use crate::utils::purl::strip_purl_qualifiers; /// A non-fatal advisory surfaced as a warning event (`code` is a stable @@ -126,39 +127,6 @@ impl VendorWarning { } } -/// Read a UTF-8 file, requiring a regular file — the sync twin of -/// [`crate::utils::fs::open_regular_file`] for the advisory probe below. -/// The probe runs unconditionally at envelope-finalize time on every -/// vendor / scan --vendor run, and a plain `open(2)` of a FIFO planted at -/// `yarn.lock` or `package.json` waits for a writer that may never come — -/// wedging the whole run after all the real work already happened. -/// `O_NONBLOCK` makes the open return immediately; the handle-based -/// `is_file` check then rejects FIFOs/devices/directories so the probe -/// degrades to its unreadable-file behavior. -fn read_regular_file_to_string(path: &Path) -> std::io::Result { - use std::io::Read as _; - #[cfg(unix)] - let mut file = { - use std::os::unix::fs::OpenOptionsExt as _; - std::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NONBLOCK) - .open(path)? - }; - #[cfg(not(unix))] - let mut file = std::fs::File::open(path)?; - let metadata = file.metadata()?; - if !metadata.is_file() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("{} is not a regular file", path.display()), - )); - } - let mut s = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut s)?; - Ok(s) -} - /// Advisory probe: is this project one `yarn install` away from silently /// losing its vendored patches? /// @@ -177,11 +145,15 @@ fn read_regular_file_to_string(path: &Path) -> std::io::Result { /// callers can invoke it unconditionally at envelope-finalize time: it stays /// silent on unwired projects and after a full revert. pub fn yarn_classic_berry_migration_risk(project_root: &Path) -> Option { - let lock = read_regular_file_to_string(&project_root.join("yarn.lock")).ok()?; + // The guarded sync reader (`O_NONBLOCK` open + fstat regular-file check): + // this probe runs at envelope-finalize time on every vendor / scan + // --vendor run, and a plain `open(2)` of a FIFO planted at `yarn.lock` or + // `package.json` would wedge the whole run after the real work is done. + let lock = read_regular_to_string_sync(&project_root.join("yarn.lock")).ok()?; if !lock.contains("# yarn lockfile v1") || !lock.contains(".socket/vendor/") { return None; } - if let Some(pm) = read_regular_file_to_string(&project_root.join("package.json")) + if let Some(pm) = read_regular_to_string_sync(&project_root.join("package.json")) .ok() .and_then(|pkg| serde_json::from_str::(&pkg).ok()) .and_then(|v| { @@ -432,7 +404,14 @@ pub async fn harvest_artifact_blobs( // Tarball/wheel artifacts: read entries in memory. let lower = entry.artifact.path.to_ascii_lowercase(); if lower.ends_with(".tgz") || lower.ends_with(".tar.gz") { - if let Ok(map) = crate::patch::package::read_archive_to_map(&artifact) { + // The tarball reader is synchronous (gzip + tar decode): run it + // off the async thread like `verify` does, so a large committed + // artifact never stalls the runtime. + let tgz = artifact.clone(); + let read = + tokio::task::spawn_blocking(move || crate::patch::package::read_archive_to_map(&tgz)) + .await; + if let Ok(Ok(map)) = read { for bytes in map.into_values() { let h = compute_git_sha256_from_bytes(&bytes); if needed.contains(h.as_str()) { diff --git a/crates/socket-patch-core/src/vendor/npm_common.rs b/crates/socket-patch-core/src/vendor/npm_common.rs index bc03299e..c751cdf4 100644 --- a/crates/socket-patch-core/src/vendor/npm_common.rs +++ b/crates/socket-patch-core/src/vendor/npm_common.rs @@ -121,6 +121,12 @@ pub(super) struct NpmStagedPack { /// lockfile's dependency-mirror fields are then stale and the flavor /// wiring must recompute them from this parsed manifest). pub staged_pkg_json: Option, + /// True iff `/.socket/vendor/npm/` existed BEFORE this + /// run wrote into it. A wiring failure after the pack must unwind the + /// uuid dir the pipeline created — but never one that already existed + /// (a same-uuid re-vendor's dir may still be referenced by live wiring); + /// backends feed this to [`done_failure_unstage`]. + pub uuid_dir_preexisted: bool, } /// Stage → patch → pack one installed npm package. @@ -308,6 +314,7 @@ pub(super) async fn stage_patch_pack( rel_tgz, packed, staged_pkg_json, + uuid_dir_preexisted, }), result, )) @@ -500,6 +507,7 @@ async fn staged_pack_from_service_bytes( rel_tgz, packed, staged_pkg_json, + uuid_dir_preexisted, }) } @@ -633,7 +641,10 @@ pub(super) fn done_failure(purl: &str, error: String) -> VendorOutcome { /// ever persisted for a failed wiring, so `--revert` could never clean it up /// and the module contract ("a failure leaves the project byte-untouched") /// would be broken by an orphaned, possibly defective artifact dir. Empty -/// parent dirs are pruned non-recursively (a sibling artifact keeps them). +/// parent dirs are pruned non-recursively (a sibling artifact keeps them) up +/// to and including `.socket/vendor/`; `.socket/` itself is never pruned +/// here — the CLI holds `.socket/apply.lock` for the whole run, and its lock +/// guard removes the emptied directory when it releases. pub(super) async fn done_failure_unstage( purl: &str, error: String, diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index c23774ff..d74b3650 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -23,6 +23,7 @@ use std::path::Path; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; +use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; use super::pnpm_lock_legacy::PnpmLockGrammar; use super::state::VendorEntry; @@ -255,21 +256,6 @@ pub(crate) async fn detect_npm_lock_flavor( Ok((detected, warnings)) } -/// Guarded read shared in shape with the vendor siblings' twins -/// (lock_inventory.rs, cargo_lock.rs, gem.rs): `open_regular_file` opens -/// with `O_NONBLOCK` and rejects non-regular files, so a FIFO planted as a -/// sniffed lockfile fails fast instead of wedging the flavor probe (every -/// npm `vendor`), the in-use probe, and the unwired-revert guard forever in -/// an `open(2)` that waits for a writer. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// Read a lockfile for content-sniffing. An unreadable-but-present file maps /// to the same stable code as a missing one. async fn read_lock(project_root: &Path, name: &str) -> Result { @@ -415,7 +401,7 @@ pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> { return lock_text_mentions_uuid(project_root, &["bun.lock"], &entry.uuid).await; } - let bytes = crate::utils::fs::read_regular_to_bytes(&project_root.join("bun.lockb")) + let bytes = read_regular_to_bytes(&project_root.join("bun.lockb")) .await .ok()?; let lock = super::bun_lockb::BunLockb::parse(&bytes).ok()?; diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index 4617d740..f8019a90 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -21,9 +21,12 @@ use serde_json::Value; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; use crate::patch::copy_tree::remove_tree; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_bytes}; -use super::common::{already_patched_result, detect_indent, done, refused, serialize_json}; +use super::common::{ + already_patched_result, detect_indent, done, prune_empty_vendor_levels, refused, + serialize_json, +}; use super::npm_common::{ done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, }; @@ -198,12 +201,6 @@ pub async fn vendor_npm( // ── 4–7. Stage → patch → pack (shared flavor-agnostic pipeline: // tempdir stage outside the project, nested node_modules prune, // bundled-deps refusal, hardened apply, deterministic pack) ──── - // A wiring failure past this point must unwind the uuid dir staging is - // about to create — but never one that already existed (a same-uuid - // re-vendor's dir may still be referenced by live wiring). - let uuid_dir_preexisted = tokio::fs::metadata(project_root.join(&uuid_dir_rel)) - .await - .is_ok(); let (staged, result) = match stage_patch_pack( purl, installed_dir, @@ -225,6 +222,7 @@ pub async fn vendor_npm( // byte-untouched) or a dry run (stops after the verify). return done(result, None, warnings); }; + let uuid_dir_preexisted = staged.uuid_dir_preexisted; // `staged.name`/`staged.version` echo the validated coords (the wiring // below keeps using the borrowed `name`/`version`). debug_assert_eq!( @@ -501,7 +499,7 @@ pub async fn revert_npm_opts( // the wet run refuses (same precedent as the uuid guard above). Skipped // under `keep_artifact`: the refusal exists only to protect the // deletion, which a preserve-state revert never performs. - if entry.wiring.is_empty() { + if !keep_artifact && entry.wiring.is_empty() { if let Some(blocked) = guard_unwired_textual_revert( project_root, &entry.uuid, @@ -539,7 +537,7 @@ pub async fn revert_npm_opts( for lock_name in lock_files { let lock_path = project_root.join(lock_name); - let lock_bytes = match read_regular(&lock_path).await { + let lock_bytes = match read_regular_to_bytes(&lock_path).await { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { // The lock is gone (user regenerated the project?); the @@ -644,9 +642,14 @@ pub async fn revert_npm_opts( // Remove the whole validated uuid dir (tgz + marker + any @scope level) // in one tree delete — pruning by leaf would leave empty dirs behind. - if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + let uuid_dir = project_root.join(&uuid_dir_rel); + if let Err(e) = remove_tree(&uuid_dir).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } + // The last npm-family entry leaves `.socket/vendor/npm/` (and + // `.socket/vendor/`) empty: prune them so a reverted project carries no + // vendor residue (`remove_dir` keeps non-empty levels). + prune_empty_vendor_levels(&uuid_dir).await; outcome } @@ -964,24 +967,9 @@ fn revert_one_record( // ───────────────────────────── small helpers ───────────────────────────── // (the flavor-agnostic coordinate/staging helpers live in `npm_common`) -/// Guarded read shared in shape with the vendor siblings' twins -/// (npm_flavor.rs, lock_inventory.rs, cargo_lock.rs): `open_regular_file` -/// opens with `O_NONBLOCK` and rejects non-regular files, so a FIFO planted -/// as a lockfile fails fast instead of wedging vendor (flavor detection is -/// existence-only for the npm locks, so [`select_lockfile`]'s read is the -/// FIRST open) or revert forever in an `open(2)` waiting for a writer. -async fn read_regular(path: &Path) -> std::io::Result> { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut bytes).await?; - Ok(bytes) -} - async fn select_lockfile(project_root: &Path) -> std::io::Result)>> { for lock_name in [SHRINKWRAP, PACKAGE_LOCK] { - match read_regular(&project_root.join(lock_name)).await { + match read_regular_to_bytes(&project_root.join(lock_name)).await { Ok(bytes) => return Ok(Some((lock_name.to_string(), bytes))), Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, Err(e) => return Err(e), @@ -3533,4 +3521,43 @@ mod tests { assert_eq!(tokio::fs::read(fx.lock_path()).await.unwrap(), before); assert!(!fx.root().join(fx.expected_rel_tgz()).exists()); } + + /// `--preserve-state` with a repair-reconstructed (empty-wiring) entry: + /// the deletion-protecting refusal must be SKIPPED (the fn doc, bun and + /// pnpm-legacy all promise it — a preserve-state revert deletes + /// nothing), so the revert completes as a successful no-op with lock and + /// artifact intact. Dry-run preview included: it must never advertise a + /// refusal the wet preserve-state run does not hit. + #[tokio::test] + async fn empty_wiring_preserve_state_revert_skips_the_deletion_refusal() { + let (fx, entry) = reconstructed_fixture().await; + let tgz_path = fx.root().join(fx.expected_rel_tgz()); + let lock_vendored = tokio::fs::read(fx.lock_path()).await.unwrap(); + + for dry_run in [true, false] { + let outcome = revert_npm_opts( + &entry, + fx.root(), + RevertOpts { + dry_run, + keep_artifact: true, + }, + ) + .await; + assert!( + outcome.success, + "dry_run={dry_run}: preserve-state deletes nothing, so the \ + deletion guard must not fire: {:?}", + outcome.error + ); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(!outcome.kept_artifact, "preserve-state is not a drift-keep"); + assert!(tgz_path.exists(), "artifact kept"); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + lock_vendored, + "empty wiring replays nothing" + ); + } + } } diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index a2006cd3..2e1cbf66 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -54,12 +54,15 @@ use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::remove_tree; use crate::patch::path_safety::is_safe_single_segment; -use crate::utils::fs::{atomic_write_bytes, atomic_write_bytes_preserving_mode, list_dir_entries}; +use crate::utils::fs::{ + atomic_write_bytes, atomic_write_bytes_preserving_mode, list_dir_entries, read_regular_to_bytes, + read_regular_to_string, +}; use crate::utils::purl::{build_nuget_purl, parse_nuget_purl}; use super::common::{ - already_patched_result, done, failed_result, rebuild_zip, refused, synthesized_result, - zip_matches_after_hashes, + already_patched_result, done, failed_result, prune_empty_vendor_levels, read_zip_artifact, + rebuild_zip, refused, synthesized_result, zip_bytes_match_after_hashes, }; use super::path::vendor_uuid_dir_rel; use super::registry_fetch::extract_zip; @@ -147,32 +150,6 @@ fn is_plain_nuget_token(s: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+')) } -/// Guarded read shared in shape with the vendor twins (cargo.rs, gem.rs, -/// maven_repo.rs …): `open_regular_file` opens with `O_NONBLOCK` and rejects -/// non-regular files, so a FIFO planted at any of this backend's read paths — -/// the committed vendored tree, the project `nuget.config` / -/// `packages.lock.json`, the `~/.nuget` cache — fails fast instead of wedging -/// the caller forever in an `open(2)` waiting for a writer that never comes. -async fn read_regular(path: &Path) -> std::io::Result> { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut bytes).await?; - Ok(bytes) -} - -/// String twin of [`read_regular`] (invalid UTF-8 errors as `InvalidData`, -/// matching `tokio::fs::read_to_string`). -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// Vendor a NuGet package: rebuild a patched `.nupkg` under /// `.socket/vendor/nuget//`, wire `nuget.config` to serve it, and pin its /// `contentHash` in `packages.lock.json` (see the module doc). @@ -272,22 +249,27 @@ pub async fn vendor_nuget( .as_deref() .is_some_and(|t| t.contains(&source_key)); if config_wired { - let nupkg_ok = zip_matches_after_hashes(&nupkg_path, &record.files).await; - let lock_ok = match &lock_text { - None => true, - Some(text) => match read_regular(&nupkg_path).await { - Ok(bytes) => { - let expected = content_hash(&bytes); - // Pinned at our bytes, or no matching resolved entry at - // all — the same absence `edit_lock` tolerates with a - // warning on the first run. Treating absence as stale - // would misreport "missing or stale; rebuilt" on every - // rerun with nothing to actually pin. - lock_pinned(text, name, &version_norm, &expected) - || matches!(edit_lock(text, name, &version_norm, &expected), Ok(None)) - } - Err(_) => false, - }, + // One guarded read of the committed nupkg serves both the member-hash + // check and the lock's content-hash pin. + let nupkg_bytes = read_zip_artifact(&nupkg_path).await; + let nupkg_ok = nupkg_bytes + .as_deref() + .is_some_and(|bytes| zip_bytes_match_after_hashes(bytes, &record.files)); + // Only worth computing when the artifact itself is in sync (a stale + // nupkg rebuilds regardless of what the lock pins). + let lock_ok = match (&lock_text, &nupkg_bytes) { + (None, _) => true, + (Some(text), Some(bytes)) if nupkg_ok => { + let expected = content_hash(bytes); + // Pinned at our bytes, or no matching resolved entry at + // all — the same absence `edit_lock` tolerates with a + // warning on the first run. Treating absence as stale + // would misreport "missing or stale; rebuilt" on every + // rerun with nothing to actually pin. + lock_pinned(text, name, &version_norm, &expected) + || matches!(edit_lock(text, name, &version_norm, &expected), Ok(None)) + } + _ => false, }; if nupkg_ok && lock_ok { return done( @@ -417,6 +399,7 @@ pub async fn vendor_nuget( Ok(edit) => edit, Err(detail) => { let _ = remove_tree(&uuid_dir).await; + prune_empty_vendor_levels(&uuid_dir).await; result.success = false; result.error = Some(detail); return done(result, None, warnings); @@ -429,6 +412,7 @@ pub async fn vendor_nuget( atomic_write_bytes_preserving_mode(&config_target, config_edit.new_text.as_bytes()).await { let _ = remove_tree(&uuid_dir).await; + prune_empty_vendor_levels(&uuid_dir).await; result.success = false; result.error = Some(format!("failed to write {}: {e}", config_target.display())); return done(result, None, warnings); @@ -655,6 +639,10 @@ pub async fn revert_nuget_opts( error: Some(format!("failed to remove {}: {e}", uuid_dir.display())), }; } + // The last nuget entry leaves `.socket/vendor/nuget/` (and + // `.socket/vendor/`) empty: prune them so a reverted project carries + // no vendor residue (`remove_dir` keeps non-empty levels). + prune_empty_vendor_levels(&uuid_dir).await; } RevertOutcome { @@ -698,6 +686,7 @@ async fn materialise_patched_nupkg( if let Err(e) = write_nupkg(uuid_dir, nupkg_path, &bytes).await { if !config_wired { let _ = remove_tree(uuid_dir).await; + prune_empty_vendor_levels(uuid_dir).await; } return Err(Box::new(refused("vendor_prebuilt_write_failed", e))); } @@ -756,7 +745,7 @@ async fn local_rebuild( ), ))); }; - let bytes = match read_regular(&src_nupkg).await { + let bytes = match read_regular_to_bytes(&src_nupkg).await { Ok(b) => b, Err(e) => { return Ok(( @@ -835,6 +824,7 @@ async fn local_rebuild( // the marker) must stay — the config still routes restores here. if !config_wired { let _ = remove_tree(uuid_dir).await; + prune_empty_vendor_levels(uuid_dir).await; } return Ok((Vec::new(), failed_result(purl, nupkg_path, e))); } @@ -1405,6 +1395,7 @@ async fn unwind_config(config_target: &Path, original: Option<&str>, uuid_dir: & } } let _ = remove_tree(uuid_dir).await; + prune_empty_vendor_levels(uuid_dir).await; } #[cfg(test)] diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index 3043161d..afef5c1d 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -51,9 +51,14 @@ use serde_json::Value; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; use crate::patch::copy_tree::remove_tree; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::fs::{ + atomic_write_bytes_preserving_mode, read_regular_to_bytes, read_regular_to_string, +}; -use super::common::{already_patched_result, detect_indent, done, refused, serialize_json}; +use super::common::{ + already_patched_result, detect_indent, done, prune_empty_vendor_levels, refused, + serialize_json, +}; use super::npm_common::{ done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, }; @@ -124,7 +129,7 @@ pub async fn vendor_pnpm( let override_key = format!("{name}@{version}"); // ── 2. Read the pair (refuse before any write) ─────────────────────── - let pkg_bytes = match read_regular(&project_root.join(PACKAGE_JSON)).await { + let pkg_bytes = match read_regular_to_bytes(&project_root.join(PACKAGE_JSON)).await { Ok(bytes) => bytes, Err(e) => { return refused( @@ -146,7 +151,7 @@ pub async fn vendor_pnpm( ); } }; - let lock_text = match read_regular_string(&project_root.join(PNPM_LOCK)).await { + let lock_text = match read_regular_to_string(&project_root.join(PNPM_LOCK)).await { Ok(text) => text, Err(e) => { return refused( @@ -183,7 +188,7 @@ pub async fn vendor_pnpm( // would route into the create path, which OVERWRITES the user's // workspace definition with the root-only scaffold. let ws_text: Option = - match read_regular_string(&project_root.join(PNPM_WORKSPACE)).await { + match read_regular_to_string(&project_root.join(PNPM_WORKSPACE)).await { Ok(text) => Some(text), Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, Err(e) => { @@ -241,12 +246,6 @@ pub async fn vendor_pnpm( } // ── 4. Stage → patch → pack (shared flavor-agnostic pipeline) ──────── - // A wiring failure past this point must unwind the uuid dir staging is - // about to create — but never one that already existed (a same-uuid - // re-vendor's dir may still be referenced by live wiring). - let uuid_dir_preexisted = tokio::fs::metadata(project_root.join(&coords.uuid_dir_rel)) - .await - .is_ok(); let (staged, result) = match stage_patch_pack( purl, installed_dir, @@ -267,6 +266,7 @@ pub async fn vendor_pnpm( // Failed patch or dry run: wiring never ran, project byte-untouched. return done(result, None, warnings); }; + let uuid_dir_preexisted = staged.uuid_dir_preexisted; debug_assert_eq!(staged.rel_tgz, rel_tgz); let packed = staged.packed; if staged.staged_pkg_json.is_some() { @@ -451,7 +451,7 @@ pub async fn vendor_pnpm( /// `None`: cannot determine (missing/unreadable/unsupported lock) — /// callers must keep the entry, fail-safe. pub async fn pnpm_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Option { - let text = read_regular_string(&project_root.join(PNPM_LOCK)) + let text = read_regular_to_string(&project_root.join(PNPM_LOCK)) .await .ok()?; if check_lock_version(&text).is_err() { @@ -573,7 +573,7 @@ pub async fn revert_pnpm_opts( // the wet run refuses (same precedent as the uuid guard above). Skipped // under `keep_artifact`: the refusal exists only to protect the // deletion, which a preserve-state revert never performs. - if entry.wiring.is_empty() { + if !keep_artifact && entry.wiring.is_empty() { let in_use = pnpm_entry_in_use(entry, project_root).await; if let Some(blocked) = guard_unwired_revert(project_root, in_use, &uuid_dir_rel).await { return blocked; @@ -610,7 +610,7 @@ pub async fn revert_pnpm_opts( // file degrades to a warning and the artifact removal still proceeds). let mut lock_lines: Option> = None; if touches_lock { - match read_regular_string(&project_root.join(PNPM_LOCK)).await { + match read_regular_to_string(&project_root.join(PNPM_LOCK)).await { Ok(text) => lock_lines = Some(split_lines(&text)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { outcome.warnings.push(VendorWarning::new( @@ -623,7 +623,7 @@ pub async fn revert_pnpm_opts( } let mut pkg_state: Option<(Value, String)> = None; // (doc, indent) if touches_pkg { - match read_regular(&project_root.join(PACKAGE_JSON)).await { + match read_regular_to_bytes(&project_root.join(PACKAGE_JSON)).await { Ok(bytes) => match serde_json::from_slice::(&bytes) { Ok(doc) if doc.is_object() => { let indent = detect_indent(&String::from_utf8_lossy(&bytes)); @@ -773,9 +773,14 @@ pub async fn revert_pnpm_opts( // ran; the artifact dir stays behind (and the caller keeps the ledger // entry), so only the deletion is skipped. if !keep_artifact { - if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + let uuid_dir = project_root.join(&uuid_dir_rel); + if let Err(e) = remove_tree(&uuid_dir).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } + // The last npm-family entry leaves `.socket/vendor/npm/` (and + // `.socket/vendor/`) empty: prune them so a reverted project carries + // no vendor residue (`remove_dir` keeps non-empty levels). + prune_empty_vendor_levels(&uuid_dir).await; } outcome } @@ -794,7 +799,7 @@ async fn revert_workspace( warnings: &mut Vec, ) -> Result<(), String> { let path = project_root.join(PNPM_WORKSPACE); - let text = match read_regular_string(&path).await { + let text = match read_regular_to_string(&path).await { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { // ALREADY CONVERGED: for an Added override (no recorded @@ -2517,28 +2522,6 @@ async fn unwind_override_surfaces( // ───────────────────────────── guarded reads ────────────────────────────── -/// Guarded read shared in shape with the vendor siblings' twins -/// (npm_lock.rs, npm_flavor.rs, lock_inventory.rs): `open_regular_file` -/// opens with `O_NONBLOCK` and rejects non-regular files, so a FIFO planted -/// as one of the pair files fails fast instead of wedging vendor / revert / -/// the in-use probe forever in an `open(2)` waiting for a writer. -pub(super) async fn read_regular(path: &Path) -> std::io::Result> { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut bytes).await?; - Ok(bytes) -} - -/// [`read_regular`], decoded as UTF-8 (`InvalidData` on failure, matching -/// `read_to_string`'s error kind). -pub(super) async fn read_regular_string(path: &Path) -> std::io::Result { - let bytes = read_regular(path).await?; - String::from_utf8(bytes) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) -} - // ─────────────────────── yaml-ish line-block helpers ────────────────────── // pnpm-lock.yaml is machine-emitted with a fixed 2/4/6/8-space shape; these // helpers splice line blocks and never interpret YAML generically. @@ -7258,4 +7241,39 @@ snapshots: "no original bytes recorded: the unwind must not delete or guess" ); } + + /// `--preserve-state` with a repair-reconstructed (empty-wiring) entry: + /// the deletion-protecting refusal — and the lock probe that feeds it — + /// must be SKIPPED (the fn doc, bun and the legacy backend all promise + /// it — a preserve-state revert deletes nothing), so the revert + /// completes as a successful no-op with lock and artifact intact. + /// Dry-run preview included. + #[tokio::test] + async fn empty_wiring_preserve_state_revert_skips_the_deletion_refusal() { + let (fx, entry) = reconstructed_fixture().await; + let tgz_path = fx.root().join(fx.rel_tgz()); + let lock_before = fx.read(PNPM_LOCK).await; + + for dry_run in [true, false] { + let outcome = revert_pnpm_opts( + &entry, + fx.root(), + RevertOpts { + dry_run, + keep_artifact: true, + }, + ) + .await; + assert!( + outcome.success, + "dry_run={dry_run}: preserve-state deletes nothing, so the \ + deletion guard must not fire: {:?}", + outcome.error + ); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(!outcome.kept_artifact, "preserve-state is not a drift-keep"); + assert!(tgz_path.exists(), "artifact kept"); + assert_eq!(fx.read(PNPM_LOCK).await, lock_before, "lock untouched"); + } + } } diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs index d1d30f0b..0b4e1656 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs @@ -61,18 +61,23 @@ use serde_json::Value; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; use crate::patch::copy_tree::remove_tree; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::fs::{ + atomic_write_bytes_preserving_mode, read_regular_to_bytes, read_regular_to_string, +}; -use super::common::{already_patched_result, detect_indent, done, refused, serialize_json}; +use super::common::{ + already_patched_result, detect_indent, done, prune_empty_vendor_levels, refused, + serialize_json, +}; use super::npm_common::{ done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, }; use super::path::parse_vendor_path; use super::pnpm_lock::{ apply_pkg_override, check_lock_override, classify_pkg_override, commit_surfaces, drifted, - guard_unwired_revert, lines_value, next_block, overrides_record, parse_key_line, read_regular, - read_regular_string, revert_overrides_line, revert_pkg_record, section_bounds, split_lines, - value_lines, vendor_value_is_for, yaml_key, yaml_key_like, KIND_LOCK_OVERRIDES, + guard_unwired_revert, lines_value, next_block, overrides_record, parse_key_line, + revert_overrides_line, revert_pkg_record, section_bounds, split_lines, value_lines, + vendor_value_is_for, yaml_key, yaml_key_like, KIND_LOCK_OVERRIDES, }; use super::state::{ write_marker, PnpmMeta, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, @@ -303,7 +308,7 @@ pub async fn vendor_pnpm_legacy( let override_key = format!("{name}@{version}"); // ── 2. Read the pair (refuse before any write) ─────────────────────── - let pkg_bytes = match read_regular(&project_root.join(PACKAGE_JSON)).await { + let pkg_bytes = match read_regular_to_bytes(&project_root.join(PACKAGE_JSON)).await { Ok(bytes) => bytes, Err(e) => { return refused( @@ -325,7 +330,7 @@ pub async fn vendor_pnpm_legacy( ); } }; - let lock_text = match read_regular_string(&project_root.join(PNPM_LOCK)).await { + let lock_text = match read_regular_to_string(&project_root.join(PNPM_LOCK)).await { Ok(text) => text, Err(e) => { return refused( @@ -432,12 +437,6 @@ pub async fn vendor_pnpm_legacy( } // ── 4. Stage → patch → pack ─────────────────────────────────────────── - // A wiring failure past this point must unwind the uuid dir staging is - // about to create — but never one that already existed (a same-uuid - // re-vendor's dir may still be referenced by live wiring). - let uuid_dir_preexisted = tokio::fs::metadata(project_root.join(&coords.uuid_dir_rel)) - .await - .is_ok(); let (staged, result) = match stage_patch_pack( purl, installed_dir, @@ -457,6 +456,7 @@ pub async fn vendor_pnpm_legacy( let Some(staged) = staged else { return done(result, None, warnings); }; + let uuid_dir_preexisted = staged.uuid_dir_preexisted; debug_assert_eq!(staged.rel_tgz, rel_tgz); let packed = staged.packed; if staged.staged_pkg_json.is_some() { @@ -670,7 +670,7 @@ pub async fn vendor_pnpm_legacy( /// (the `overrides:` declaration alone never counts); `None` when /// undeterminable — callers keep the entry, fail-safe. pub async fn pnpm_legacy_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Option { - let text = read_regular_string(&project_root.join(PNPM_LOCK)) + let text = read_regular_to_string(&project_root.join(PNPM_LOCK)) .await .ok()?; match sniff_lock_grammar(&text) { @@ -1319,7 +1319,7 @@ pub async fn revert_pnpm_legacy_opts( let mut lock_lines: Option> = None; if touches_lock { - match read_regular_string(&project_root.join(PNPM_LOCK)).await { + match read_regular_to_string(&project_root.join(PNPM_LOCK)).await { Ok(text) => lock_lines = Some(split_lines(&text)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { outcome.warnings.push(VendorWarning::new( @@ -1332,7 +1332,7 @@ pub async fn revert_pnpm_legacy_opts( } let mut pkg_state: Option<(Value, String)> = None; if touches_pkg { - match read_regular(&project_root.join(PACKAGE_JSON)).await { + match read_regular_to_bytes(&project_root.join(PACKAGE_JSON)).await { Ok(bytes) => match serde_json::from_slice::(&bytes) { Ok(doc) if doc.is_object() => { let indent = detect_indent(&String::from_utf8_lossy(&bytes)); @@ -1451,9 +1451,14 @@ pub async fn revert_pnpm_legacy_opts( // ran; the artifact dir stays behind (and the caller keeps the ledger // entry), so only the deletion is skipped. if !keep_artifact { - if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + let uuid_dir = project_root.join(&uuid_dir_rel); + if let Err(e) = remove_tree(&uuid_dir).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } + // The last npm-family entry leaves `.socket/vendor/npm/` (and + // `.socket/vendor/`) empty: prune them so a reverted project carries + // no vendor residue (`remove_dir` keeps non-empty levels). + prune_empty_vendor_levels(&uuid_dir).await; } outcome } diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 3cc460ef..b612ca3c 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -14,11 +14,13 @@ use crate::api::client::ApiClient; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; -use crate::utils::fs::atomic_write_bytes; +use crate::utils::fs::{atomic_write_bytes, read_regular_to_string}; use crate::utils::purl::{parse_pypi_purl, strip_purl_qualifiers}; use crate::utils::toml_edit_ext::has_table; -use super::common::{already_patched_result, done, refused, service_offline_conflict}; +use super::common::{ + already_patched_result, done, prune_empty_vendor_levels, refused, service_offline_conflict, +}; use super::path::vendor_uuid_dir_rel; use super::pypi_pdm::{PdmProject, PdmTarget}; use super::pypi_pipenv::{PipenvProject, PipenvTarget}; @@ -133,20 +135,6 @@ const SETUP_ALTERNATIVE: &str = "use the `socket-patch setup` .pth install hook instead, which patches installed \ site-packages without lockfile edits"; -/// `open_regular_file` opens with `O_NONBLOCK` and rejects non-regular files, -/// so a FIFO planted as `pyproject.toml` fails fast — read as "no pyproject", -/// falling through to the requirements routing — instead of wedging flavor -/// detection (and every lockless-project vendor run) forever in an `open(2)` -/// that waits for a writer. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// Route the project to a wiring flavor, first match wins. Lockfiles are the /// authoritative "this tool manages installs" signal, so locks are compared /// with locks (precedence follows migration direction / ecosystem currency: @@ -352,7 +340,7 @@ enum WiringPlan { /// Which `VendorEntry` meta slot a flavor's wiring produced. enum MetaSlot { - Uv(Option), + Uv(UvMeta), Poetry(PoetryMeta), Pdm(PdmMeta), Pipenv(PipenvMeta), @@ -714,33 +702,30 @@ pub async fn vendor_pypi_with_pipenv_version( ), )); } - match super::pypi_pipenv::check_target_guards( + let target = match super::pypi_pipenv::check_target_guards( &project, &canon_name, &record.uuid, version, ) { - Ok(PipenvTarget::InSync) => { - // A re-run over an already-wired lock keeps warning while - // the venv still holds the upstream release. - if let Some(stale) = - pipenv_stale_install_warning(project_root, purl, record).await - { - warnings.push(stale); - } + Ok(target) => target, + // A refusal carries no warnings: probe nothing for it. + Err((code, detail)) => return refused(code, detail), + }; + if target == PipenvTarget::Fresh { + warnings.extend(project.warnings.iter().cloned()); + } + // Both a fresh vendor and a re-run over an already-wired lock + // keep warning while the venv still holds the upstream release. + if let Some(stale) = pipenv_stale_install_warning(project_root, purl, record).await { + warnings.push(stale); + } + match target { + PipenvTarget::InSync => { wired_pin = pipenv_wired_pin(&project.lock, &uuid_dir_rel); WiringPlan::InSync } - Ok(PipenvTarget::Fresh) => { - warnings.extend(project.warnings.iter().cloned()); - if let Some(stale) = - pipenv_stale_install_warning(project_root, purl, record).await - { - warnings.push(stale); - } - WiringPlan::Pipenv(Box::new(project)) - } - Err((code, detail)) => return refused(code, detail), + PipenvTarget::Fresh => WiringPlan::Pipenv(Box::new(project)), } } }; @@ -824,9 +809,22 @@ pub async fn vendor_pypi_with_pipenv_version( .await { Ok(a) => a, - Err(outcome) => return outcome, + Err(outcome) => { + // A refused/hard-failed acquisition may have scaffolded the + // empty uuid dir (and the ecosystem / vendor levels on a fresh + // project) before failing: prune them so the failure leaves no + // committable husk. Dry runs create nothing. + if !dry_run { + prune_empty_vendor_levels(&project_root.join(&uuid_dir_rel)).await; + } + return outcome; + } }; - if dry_run || !result.success { + if !result.success { + prune_empty_vendor_levels(&project_root.join(&uuid_dir_rel)).await; + return done(result, None, warnings); + } + if dry_run { return done(result, None, warnings); } let Some(artifact) = artifact else { @@ -875,6 +873,7 @@ pub async fn vendor_pypi_with_pipenv_version( if let Some((pin_path, pin_sha)) = &expected_pin { if *pin_path != rel_wheel || *pin_sha != artifact.sha256_hex { let _ = tokio::fs::remove_dir_all(project_root.join(&uuid_dir_rel)).await; + prune_empty_vendor_levels(&project_root.join(&uuid_dir_rel)).await; let mut result = result; result.success = false; result.error = Some(format!( @@ -912,6 +911,7 @@ pub async fn vendor_pypi_with_pipenv_version( let marker = VendorMarker::new("pypi", base, record, vendored_at); if let Err(e) = write_marker(&project_root.join(&uuid_dir_rel), &marker).await { let _ = tokio::fs::remove_dir_all(project_root.join(&uuid_dir_rel)).await; + prune_empty_vendor_levels(&project_root.join(&uuid_dir_rel)).await; let mut result = result; result.success = false; result.error = Some(format!("cannot write vendor marker: {e}")); @@ -934,7 +934,7 @@ pub async fn vendor_pypi_with_pipenv_version( .await .map(|(wiring, meta, advisories)| { warnings.extend(advisories); - (wiring, MetaSlot::Uv(Some(meta))) + (wiring, MetaSlot::Uv(meta)) }), WiringPlan::PythonLocks(project) => super::pypi_lock::wire_python_locks( &project, @@ -993,6 +993,7 @@ pub async fn vendor_pypi_with_pipenv_version( &project, project_root, &canon_name, + version, &rel_wheel, &artifact.sha256_hex, &record.uuid, @@ -1006,6 +1007,7 @@ pub async fn vendor_pypi_with_pipenv_version( Ok(pair) => pair, Err((code, detail)) => { let _ = tokio::fs::remove_dir_all(project_root.join(&uuid_dir_rel)).await; + prune_empty_vendor_levels(&project_root.join(&uuid_dir_rel)).await; let mut result = result; result.success = false; result.error = Some(format!("{code}: {detail}")); @@ -1037,7 +1039,7 @@ pub async fn vendor_pypi_with_pipenv_version( pipenv: None, }; match meta { - MetaSlot::Uv(m) => entry.uv = m, + MetaSlot::Uv(m) => entry.uv = Some(m), MetaSlot::Poetry(m) => entry.poetry = Some(m), MetaSlot::Pdm(m) => entry.pdm = Some(m), MetaSlot::Pipenv(m) => entry.pipenv = Some(m), @@ -1176,14 +1178,13 @@ async fn unwired_pypi_reference_clause(project_root: &Path, uuid: &str) -> Optio } for name in &names { let path = project_root.join(name); - if matches!(tokio::fs::try_exists(&path).await, Ok(false)) { - continue; - } match read_regular_to_string(&path).await { Ok(text) if text.contains(&needle) => { return Some(format!("{name} still resolves through it")); } Ok(_) => {} + // A file that no longer exists cannot reference it. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} // Fail-closed: a file we cannot read may still reference it. Err(_) => { return Some(format!( @@ -1331,6 +1332,11 @@ pub async fn revert_pypi_opts( format!("could not remove {uuid_dir_rel}: {e}"), )), } + // The last pypi entry leaves `.socket/vendor/pypi/` (and `.socket/vendor/`) + // empty: prune them so a reverted project carries no vendor residue + // (`remove_dir` keeps non-empty levels, and a dir the removal above could + // not delete stops the climb). + prune_empty_vendor_levels(&project_root.join(&uuid_dir_rel)).await; outcome } @@ -3718,7 +3724,7 @@ wheels = [ .unwrap(); let rel_wheel = format!(".socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl"); let p = load_pipenv_project(root).await.unwrap(); - let (wiring, _meta) = wire_pipenv(&p, root, "six", &rel_wheel, &"0".repeat(64), UUID) + let (wiring, _meta) = wire_pipenv(&p, root, "six", "1.16.0", &rel_wheel, &"0".repeat(64), UUID) .await .unwrap(); let uuid_dir = root.join(format!(".socket/vendor/pypi/{UUID}")); @@ -3776,6 +3782,7 @@ wheels = [ &load_pipenv_project(root).await.unwrap_or_else(|e| panic!("{e:?}")), root, "six", + "1.16.0", &rel_wheel, &"0".repeat(64), UUID, @@ -3840,7 +3847,7 @@ wheels = [ .unwrap(); let rel_wheel = format!(".socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl"); let p = load_pipenv_project(root).await.unwrap(); - let (wiring, _meta) = wire_pipenv(&p, root, "six", &rel_wheel, &"0".repeat(64), UUID) + let (wiring, _meta) = wire_pipenv(&p, root, "six", "1.16.0", &rel_wheel, &"0".repeat(64), UUID) .await .unwrap(); let uuid_dir = root.join(format!(".socket/vendor/pypi/{UUID}")); @@ -3974,7 +3981,7 @@ wheels = [ .unwrap(); let rel_wheel = format!(".socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl"); let p = load_pipenv_project(root).await.unwrap(); - let (wiring, _meta) = wire_pipenv(&p, root, "six", &rel_wheel, &"0".repeat(64), UUID) + let (wiring, _meta) = wire_pipenv(&p, root, "six", "1.16.0", &rel_wheel, &"0".repeat(64), UUID) .await .unwrap(); let uuid_dir = root.join(format!(".socket/vendor/pypi/{UUID}")); @@ -5384,10 +5391,9 @@ wheels = [ "six==1.16.0\n", "the wiring is only ever written after a successful wheel" ); - // Pin of CURRENT residue behavior: the refusal does not sweep the - // (pre-existing) uuid dir — nothing references it, since the wiring - // was never touched. FIXME(no-residue): candidate cleanup gap if the - // dir was created by this very run. + // The refusal prunes only EMPTY levels this run may have created: + // the pre-existing, non-empty uuid dir is never collateral (nothing + // references it, and `remove_dir` refuses a non-empty dir). assert!(blocker.is_dir()); } diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 054a20d1..642856d8 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -4,7 +4,7 @@ use std::path::Path; use toml_edit::{DocumentMut, Item, Table, TableLike, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::utils::fs::{atomic_write_bytes_preserving_mode, is_symlink, read_regular_to_string}; +use crate::utils::fs::{atomic_write_bytes_preserving_mode, first_symlink, read_regular_to_string}; use crate::utils::python_lock::{ is_python_lock_name, python_lock_paths, rewrite_python_lock, ArtifactSource, }; @@ -68,12 +68,9 @@ fn symlink_refusal(file: &str) -> String { } async fn refuse_symlinked(root: &Path, files: impl Iterator) -> Option { - for file in files { - if is_symlink(&root.join(file)).await { - return Some(symlink_refusal(file)); - } - } - None + first_symlink(root, files.map(String::as_str)) + .await + .map(symlink_refusal) } fn package<'a>(document: &'a DocumentMut, name: &str, version: &str) -> Option<&'a Table> { diff --git a/crates/socket-patch-core/src/vendor/pypi_pdm.rs b/crates/socket-patch-core/src/vendor/pypi_pdm.rs index b3d89b28..efa63055 100644 --- a/crates/socket-patch-core/src/vendor/pypi_pdm.rs +++ b/crates/socket-patch-core/src/vendor/pypi_pdm.rs @@ -5,11 +5,11 @@ use std::path::Path; use toml_edit::{DocumentMut, Item, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use super::common::{ - item_get, lock_units_named, pep508_name, pep621_declared_names, record, - revert_lock_fragment_splice, + ensure_unchanged, item_get, lock_units_named, pep508_name, pep621_declared_names, record, + refuse_symlinked, revert_lock_fragment_splice, }; use super::path::parse_vendor_path; use super::state::{PdmMeta, VendorEntry, WiringAction, WiringRecord}; @@ -21,42 +21,6 @@ const LOCK_FILE: &str = "pdm.lock"; /// The `WiringRecord.kind` discriminator this backend owns. const KIND_LOCK_PACKAGE: &str = "pdm_lock_package"; -/// Refuse when `pdm.lock` is itself a symlink. Every writer here stages a -/// replacement next to the path and renames over it, which REPLACES the link -/// with a detached regular file: the shared target the link points at stays -/// unpatched (git shows a 120000→100644 typechange), and `revert` restores -/// bytes but never the link. Both wire and revert check before any write — -/// mirroring the uv/pypi_lock vendored siblings and the hosted `first_symlink` -/// guard (pdm itself relocks THROUGH a linked pdm.lock). -async fn refuse_symlinked_lock(root: &Path) -> Result<(), (&'static str, String)> { - if crate::utils::fs::is_symlink(&root.join(LOCK_FILE)).await { - return Err(( - "pypi_pdm_symlink_unsupported", - format!( - "{LOCK_FILE} is a symbolic link; the atomic rewrite would replace the link with \ - a regular file and leave its target stale — vendor the real file's directory \ - instead" - ), - )); - } - Ok(()) -} - -/// Guarded read shared in shape with the sibling backend twins: -/// `open_regular_file` opens with `O_NONBLOCK` and rejects non-regular -/// files, so a FIFO planted as `pdm.lock` (or the diagnostics-only -/// `pyproject.toml`) fails fast instead of wedging every pdm-project vendor -/// run forever in an `open(2)` that waits for a writer — the flavor-routing -/// probes ahead of the load are metadata-only, so these are the first opens. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// A loaded-and-guard-checked pdm project. #[derive(Debug)] pub struct PdmProject { @@ -409,7 +373,7 @@ pub async fn wire_pdm( record_uuid: &str, ) -> Result<(Vec, PdmMeta), (&'static str, String)> { // Before ANY write: a symlinked lock would be replaced by the rename-over. - refuse_symlinked_lock(root).await?; + refuse_symlinked(root, &[LOCK_FILE], "pypi_pdm_symlink_unsupported").await?; match check_target_guards(p, canon_name, version, record_uuid)? { // Defensive: the orchestrator short-circuits in-sync pre-flight and // never calls wire on it (we must never re-record our own edit as an @@ -457,6 +421,9 @@ pub async fn wire_pdm( .map_err(|detail| ("pypi_pdm_lock_parse_failed", detail))?; let fragments = crate::utils::pdm_lock::pdm_lock_edits(&p.lock_text, &new_lock, canon_name) .map_err(|detail| ("pypi_pdm_lock_parse_failed", detail))?; + // The edit was computed from the pre-flight snapshot; a `pdm lock` / + // editor save that landed during the wheel build must not be clobbered. + ensure_unchanged(root, LOCK_FILE, &p.lock_text, "pypi_pdm_changed").await?; // Mode-preserving: the lock is a user-owned file we merely edit, so the // swapped-in inode must keep its permission bits rather than reset them // to umask defaults (same class as the revert leg in common.rs). @@ -498,7 +465,9 @@ pub async fn revert_pdm(entry: &VendorEntry, root: &Path, dry_run: bool) -> Reve // its target stale and never restoring the link. Keep the artifact (the // wiring still routes through the linked file) and fail — the guard lives // here, not in the poetry-shared splice helper, so poetry is untouched. - if let Err((code, detail)) = refuse_symlinked_lock(root).await { + if let Err((code, detail)) = + refuse_symlinked(root, &[LOCK_FILE], "pypi_pdm_symlink_unsupported").await + { return RevertOutcome { kept_artifact: true, success: false, @@ -1517,4 +1486,37 @@ distribution = false let err = check_target_guards(&p, "six", "1.16.0", UUID).unwrap_err(); assert_eq!(err.0, "pypi_pdm_lock_no_hashes"); } + + /// The lock changed between the pre-flight snapshot and the write (a + /// `pdm lock` landed during the wheel build): refuse instead of + /// clobbering it with the stale snapshot-derived text. + #[tokio::test] + async fn lock_changed_during_vendoring_is_refused_before_the_write() { + let tmp = write_project(LOCK_DIRECT_REGISTRY, PYPROJECT_DIRECT).await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + let relocked = format!("{LOCK_DIRECT_REGISTRY}# relocked\n"); + tokio::fs::write(tmp.path().join(LOCK_FILE), &relocked) + .await + .unwrap(); + + let err = wire_pdm( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_pdm_changed"); + assert!(err.1.contains("changed during vendoring"), "{}", err.1); + assert_eq!( + read_lock(tmp.path()).await, + relocked, + "the live lock is left alone" + ); + } } diff --git a/crates/socket-patch-core/src/vendor/pypi_pipenv.rs b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs index 0a11f015..8abd14b1 100644 --- a/crates/socket-patch-core/src/vendor/pypi_pipenv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs @@ -7,9 +7,9 @@ use std::path::Path; use serde_json::{Map, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; -use super::common::serialize_json; +use super::common::{ensure_unchanged, refuse_symlinked, serialize_json}; use super::path::parse_vendor_path; use super::state::{PipenvMeta, VendorEntry, WiringAction, WiringRecord}; use super::{RevertOutcome, VendorWarning}; @@ -30,21 +30,6 @@ fn category_names(lock: &Value) -> Vec { .collect() } -/// Guarded read shared in shape with the sibling backend twins: -/// `open_regular_file` opens with `O_NONBLOCK` and rejects non-regular -/// files, so a FIFO planted as `Pipfile.lock` fails fast instead of wedging -/// every pipenv-project vendor run (and revert) forever in an `open(2)` that -/// waits for a writer — the flavor-routing probes ahead of the load are -/// metadata-only, so these are the first opens. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// Pipfile.lock entry keys that mark a user-declared non-registry source. const NON_REGISTRY_KEYS: [&str; 6] = ["path", "git", "hg", "svn", "bzr", "editable"]; @@ -53,6 +38,10 @@ const NON_REGISTRY_KEYS: [&str; 6] = ["path", "git", "hg", "svn", "bzr", "editab pub(super) struct PipenvProject { /// Parsed lock (the edit substrate — re-serialized canonically). pub lock: Value, + /// Verbatim lock text the parse came from: the wire step re-reads the + /// file and refuses when it no longer matches (a `pipenv lock` landed + /// during the wheel build). + pub lock_text: String, /// The lock's line ending (`\r\n` when the checkout carries CRLF — git /// autocrlf; Pipenv itself preserves it), reapplied on every write so the /// wired lock and the reverted lock stay byte-comparable to the original. @@ -144,6 +133,7 @@ pub(super) async fn load_pipenv_project( Ok(PipenvProject { lock, crlf: lock_text.contains("\r\n"), + lock_text, warnings, }) } @@ -272,18 +262,13 @@ pub(super) async fn wire_pipenv( p: &PipenvProject, root: &Path, canon_name: &str, + version: &str, rel_wheel: &str, wheel_sha256_hex: &str, record_uuid: &str, ) -> Result<(Vec, PipenvMeta), (&'static str, String)> { - let version = rel_wheel - .rsplit('/') - .next() - .and_then(|filename| filename.split('-').nth(1)) - .ok_or(( - "pypi_pipenv_invalid_wheel", - "missing wheel version".to_owned(), - ))?; + // Before ANY write: a symlinked lock would be replaced by the rename-over. + refuse_symlinked(root, &[LOCK_FILE], "pypi_pipenv_symlink_unsupported").await?; match check_target_guards(p, canon_name, record_uuid, version)? { // Defensive: the orchestrator short-circuits in-sync pre-flight and // never calls wire on it (we must never re-record our own edit as an @@ -373,6 +358,9 @@ pub(super) async fn wire_pipenv( } let new_text = with_line_ending(to_canonical_json(&lock), p.crlf); + // The edit was computed from the pre-flight snapshot; a `pipenv lock` / + // editor save that landed during the wheel build must not be clobbered. + ensure_unchanged(root, LOCK_FILE, &p.lock_text, "pypi_pipenv_changed").await?; atomic_write_bytes_preserving_mode(&root.join(LOCK_FILE), new_text.as_bytes()) .await .map_err(|e| { @@ -393,6 +381,19 @@ pub(super) async fn revert_pipenv( root: &Path, dry_run: bool, ) -> RevertOutcome { + // A symlinked lock would be replaced by the atomic rewrite-over, leaving + // its target stale and never restoring the link. Keep the artifact (the + // wiring still routes through the linked file) and fail. + if let Err((code, detail)) = + refuse_symlinked(root, &[LOCK_FILE], "pypi_pipenv_symlink_unsupported").await + { + return RevertOutcome { + kept_artifact: true, + success: false, + warnings: Vec::new(), + error: Some(format!("{code}: {detail}")), + }; + } let lock_path = root.join(LOCK_FILE); let lock_text = match read_regular_to_string(&lock_path).await { Ok(t) => t, @@ -836,7 +837,7 @@ mod tests { } async fn wire_default(p: &PipenvProject, root: &Path) -> (Vec, PipenvMeta) { - wire_pipenv(p, root, "six", REL_WHEEL, WHEEL_SHA, UUID) + wire_pipenv(p, root, "six", "1.16.0", REL_WHEEL, WHEEL_SHA, UUID) .await .unwrap() } @@ -1020,7 +1021,7 @@ mod tests { // wire re-runs the guards itself (refusal before any write) let before = read_lock(tmp.path()).await; - let err = wire_pipenv(&p, tmp.path(), "six", REL_WHEEL, WHEEL_SHA, UUID) + let err = wire_pipenv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_SHA, UUID) .await .unwrap_err(); assert_eq!(err.0, "pypi_pipenv_source_already_exists"); @@ -1364,7 +1365,7 @@ mod tests { let tmp = write_lock(LOCK_DIRECT_VENDORED).await; let p = load_pipenv_project(tmp.path()).await.unwrap(); - let err = wire_pipenv(&p, tmp.path(), "six", REL_WHEEL, WHEEL_SHA, UUID) + let err = wire_pipenv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_SHA, UUID) .await .unwrap_err(); assert_eq!(err.0, "pypi_pipenv_source_already_exists"); @@ -1466,7 +1467,7 @@ mod tests { .await .unwrap(); - let err = wire_pipenv(&p, tmp.path(), "six", REL_WHEEL, WHEEL_SHA, UUID) + let err = wire_pipenv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_SHA, UUID) .await .unwrap_err(); tokio::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o755)) @@ -1725,4 +1726,77 @@ mod tests { assert!(reverted.success); assert_eq!(read_lock(tmp.path()).await, before); } + + /// A symlinked Pipfile.lock is refused before any write by both wire and + /// revert: the rename-over would replace the link with a regular file + /// and leave its target stale, and revert would never restore the link. + #[cfg(unix)] + #[tokio::test] + async fn symlinked_lock_refuses_wire_and_revert_without_writing() { + let outer = tempfile::tempdir().unwrap(); + let real = outer.path().join("real.lock"); + tokio::fs::write(&real, LOCK_DIRECT_REGISTRY).await.unwrap(); + let root = outer.path().join("proj"); + tokio::fs::create_dir_all(&root).await.unwrap(); + std::os::unix::fs::symlink(&real, root.join(LOCK_FILE)).unwrap(); + + let p = load_pipenv_project(&root).await.unwrap(); + let err = wire_pipenv(&p, &root, "six", "1.16.0", REL_WHEEL, WHEEL_SHA, UUID) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_pipenv_symlink_unsupported"); + assert!(std::fs::symlink_metadata(root.join(LOCK_FILE)) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(read_lock(&root).await, LOCK_DIRECT_REGISTRY); + + // revert refuses the same way and keeps the artifact. + let wiring = vec![WiringRecord { + file: LOCK_FILE.to_string(), + kind: KIND_LOCK_ENTRY.to_string(), + action: WiringAction::Rewritten, + key: Some("default:six".to_string()), + original: Some(serde_json::json!({})), + new: Some(serde_json::json!({})), + }]; + let meta = PipenvMeta { + sections: vec!["default".into()], + }; + let outcome = revert_pipenv(&entry_for(wiring, meta), &root, false).await; + assert!(!outcome.success); + assert!(outcome.kept_artifact); + assert!( + outcome + .error + .as_deref() + .is_some_and(|e| e.contains("pypi_pipenv_symlink_unsupported")), + "{:?}", + outcome.error + ); + } + + /// The lock changed between the pre-flight snapshot and the write (a + /// `pipenv lock` landed during the wheel build): refuse instead of + /// clobbering it with the stale snapshot-derived text. + #[tokio::test] + async fn lock_changed_during_vendoring_is_refused_before_the_write() { + let tmp = write_lock(LOCK_DIRECT_REGISTRY).await; + let p = load_pipenv_project(tmp.path()).await.unwrap(); + let relocked = format!("{LOCK_DIRECT_REGISTRY}\n"); + tokio::fs::write(tmp.path().join(LOCK_FILE), &relocked) + .await + .unwrap(); + + let err = wire_pipenv(&p, tmp.path(), "six", "1.16.0", REL_WHEEL, WHEEL_SHA, UUID) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_pipenv_changed"); + assert!(err.1.contains("changed during vendoring"), "{}", err.1); + assert_eq!( + read_lock(tmp.path()).await, + relocked, + "the live lock is left alone" + ); + } } diff --git a/crates/socket-patch-core/src/vendor/pypi_poetry.rs b/crates/socket-patch-core/src/vendor/pypi_poetry.rs index c2c7e736..16ad672c 100644 --- a/crates/socket-patch-core/src/vendor/pypi_poetry.rs +++ b/crates/socket-patch-core/src/vendor/pypi_poetry.rs @@ -5,11 +5,11 @@ use std::path::Path; use toml_edit::{DocumentMut, Item}; use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use super::common::{ - item_get, lock_units_named, pep621_declared_names, record, revert_lock_fragment_splice_atomic, - unit_has_canon_name, + ensure_unchanged, item_get, lock_units_named, pep621_declared_names, record, + refuse_symlinked, revert_lock_fragment_splice_atomic, unit_has_canon_name, }; use super::path::parse_vendor_path; use super::state::{PoetryMeta, VendorEntry, WiringAction, WiringRecord}; @@ -22,22 +22,6 @@ const LOCK_FILE: &str = "poetry.lock"; /// The `WiringRecord.kind` discriminator this backend owns. const KIND_LOCK_PACKAGE: &str = "poetry_lock_package"; -/// Guarded read shared in shape with the sibling backend twins: -/// `open_regular_file` opens with `O_NONBLOCK` and rejects non-regular -/// files, so a FIFO planted as `poetry.lock` (or the diagnostics-only -/// `pyproject.toml`) fails fast instead of wedging every poetry-project -/// vendor run forever in an `open(2)` that waits for a writer — the -/// flavor-routing probes ahead of the load are metadata-only, so these are -/// the first opens. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// A loaded-and-guard-checked poetry project. #[derive(Debug)] pub(super) struct PoetryProject { @@ -300,6 +284,8 @@ pub(super) async fn wire_poetry( wheel_sha256_hex: &str, record_uuid: &str, ) -> Result<(Vec, PoetryMeta), (&'static str, String)> { + // Before ANY write: a symlinked lock would be replaced by the rename-over. + refuse_symlinked(root, &[LOCK_FILE], "pypi_poetry_symlink_unsupported").await?; match check_target_guards(p, canon_name, version, record_uuid)? { // Defensive: the orchestrator short-circuits in-sync pre-flight and // never calls wire on it (we must never re-record our own edit as an @@ -349,6 +335,9 @@ pub(super) async fn wire_poetry( for (old_unit, new_unit) in &edits { new_lock = new_lock.replacen(old_unit, new_unit, 1); } + // The edit was computed from the pre-flight snapshot; a `poetry lock` / + // editor save that landed during the wheel build must not be clobbered. + ensure_unchanged(root, LOCK_FILE, &p.lock_text, "pypi_poetry_changed").await?; // Mode-preserving: the lock is a user-owned file we merely edit, so the // swapped-in inode must keep its permission bits rather than reset them // to umask defaults (same class as the revert leg in common.rs). @@ -389,6 +378,19 @@ pub(super) async fn revert_poetry( root: &Path, dry_run: bool, ) -> RevertOutcome { + // A symlinked lock would be replaced by the atomic rewrite-over, leaving + // its target stale and never restoring the link. Keep the artifact (the + // wiring still routes through the linked file) and fail. + if let Err((code, detail)) = + refuse_symlinked(root, &[LOCK_FILE], "pypi_poetry_symlink_unsupported").await + { + return RevertOutcome { + kept_artifact: true, + success: false, + warnings: Vec::new(), + error: Some(format!("{code}: {detail}")), + }; + } revert_lock_fragment_splice_atomic(entry, root, dry_run, LOCK_FILE, KIND_LOCK_PACKAGE, "poetry") .await } @@ -1781,4 +1783,101 @@ content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01 assert!(!is_newer_2x("3.0")); assert!(!is_newer_2x("garbage")); } + + /// A symlinked poetry.lock is refused before any write by both wire and + /// revert: the rename-over would replace the link with a regular file + /// and leave its target stale, and revert would never restore the link. + #[cfg(unix)] + #[tokio::test] + async fn symlinked_lock_refuses_wire_and_revert_without_writing() { + let outer = tempfile::tempdir().unwrap(); + let real = outer.path().join("real.lock"); + tokio::fs::write(&real, LOCK21_DIRECT_REGISTRY) + .await + .unwrap(); + let root = outer.path().join("proj"); + tokio::fs::create_dir_all(&root).await.unwrap(); + tokio::fs::write(root.join("pyproject.toml"), PYPROJECT_DIRECT) + .await + .unwrap(); + std::os::unix::fs::symlink(&real, root.join(LOCK_FILE)).unwrap(); + + let p = load_poetry_project(&root).await.unwrap(); + let err = wire_poetry( + &p, + &root, + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_poetry_symlink_unsupported"); + assert!(std::fs::symlink_metadata(root.join(LOCK_FILE)) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(read_lock(&root).await, LOCK21_DIRECT_REGISTRY); + + // revert refuses the same way and keeps the artifact. + let meta = PoetryMeta { + dep_class: "direct".into(), + lock_version: "2.1".into(), + }; + let wiring = vec![record( + LOCK_FILE, + KIND_LOCK_PACKAGE, + WiringAction::Rewritten, + "six", + Some("a".into()), + "b".into(), + )]; + let outcome = revert_poetry(&entry_for(wiring, meta), &root, false).await; + assert!(!outcome.success); + assert!(outcome.kept_artifact); + assert!( + outcome + .error + .as_deref() + .is_some_and(|e| e.contains("pypi_poetry_symlink_unsupported")), + "{:?}", + outcome.error + ); + } + + /// The lock changed between the pre-flight snapshot and the write (a + /// `poetry lock` landed during the wheel build): refuse instead of + /// clobbering it with the stale snapshot-derived text. + #[tokio::test] + async fn lock_changed_during_vendoring_is_refused_before_the_write() { + let tmp = write_project(LOCK21_DIRECT_REGISTRY, PYPROJECT_DIRECT).await; + let p = load_poetry_project(tmp.path()).await.unwrap(); + let relocked = format!("{LOCK21_DIRECT_REGISTRY}# relocked\n"); + tokio::fs::write(tmp.path().join(LOCK_FILE), &relocked) + .await + .unwrap(); + + let err = wire_poetry( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_poetry_changed"); + assert!(err.1.contains("changed during vendoring"), "{}", err.1); + assert_eq!( + read_lock(tmp.path()).await, + relocked, + "the live lock is left alone" + ); + } } diff --git a/crates/socket-patch-core/src/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/vendor/pypi_requirements.rs index 6b7c7dde..192a3aae 100644 --- a/crates/socket-patch-core/src/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/vendor/pypi_requirements.rs @@ -17,28 +17,12 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; -use super::common::detect_eol; +use super::common::{detect_eol, refuse_symlinked}; use super::state::{VendorEntry, WiringAction, WiringRecord}; use super::{RevertOutcome, VendorWarning}; -/// Guarded read shared in shape with the sibling backend twins: -/// `open_regular_file` opens with `O_NONBLOCK` and rejects non-regular -/// files, so a FIFO planted as `requirements.txt` (or an include) fails -/// fast instead of wedging every requirements-project vendor run (and -/// revert) forever in an `open(2)` that waits for a writer — the -/// flavor-routing probe ahead of the walk is metadata-only, so these are -/// the first opens. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// Classification of the target package within the requirements tree. #[derive(Debug, PartialEq, Eq)] enum PinSearch { @@ -243,6 +227,10 @@ pub(super) async fn wire_requirements( wheel_sha256_hex: &str, ) -> Result, (&'static str, String)> { let plan = plan_requirements(root, canon_name, version, rel_wheel, wheel_sha256_hex).await?; + // Before ANY write: a symlinked requirements file (root or `-r` include) + // would be replaced by the rename-over. + let planned: Vec<&str> = plan.iter().map(|f| f.rel.as_str()).collect(); + refuse_symlinked(root, &planned, "pypi_requirements_symlink_unsupported").await?; let mut wiring = Vec::new(); let mut written: Vec<&PlannedFile> = Vec::new(); for file in &plan { @@ -317,6 +305,21 @@ pub(super) async fn revert_requirements( } } + // A symlinked file would be replaced by the atomic rewrite-over, leaving + // its target stale and never restoring the link. Keep the artifact (the + // wiring still routes through the linked file) and fail. + let file_refs: Vec<&str> = files.iter().map(String::as_str).collect(); + if let Err((code, detail)) = + refuse_symlinked(root, &file_refs, "pypi_requirements_symlink_unsupported").await + { + return RevertOutcome { + kept_artifact: true, + success: false, + warnings, + error: Some(format!("{code}: {detail}")), + }; + } + let mut reverted: Vec<(String, String)> = Vec::new(); for file in &files { let path = root.join(file); @@ -2121,4 +2124,58 @@ mod tests { } ); } + + /// A symlinked requirements file is refused before any write by both + /// wire and revert: the rename-over would replace the link with a + /// regular file and leave its target stale, and revert would never + /// restore the link. + #[cfg(unix)] + #[tokio::test] + async fn symlinked_requirements_refuses_wire_and_revert_without_writing() { + // wire: the planned root file is a link. + let outer = tempfile::tempdir().unwrap(); + let real = outer.path().join("real.txt"); + tokio::fs::write(&real, "six==1.16.0\n").await.unwrap(); + let root = outer.path().join("proj"); + tokio::fs::create_dir_all(&root).await.unwrap(); + std::os::unix::fs::symlink(&real, root.join("requirements.txt")).unwrap(); + let err = wire_requirements(&root, "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_requirements_symlink_unsupported"); + assert!(std::fs::symlink_metadata(root.join("requirements.txt")) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(read_root(&root).await, "six==1.16.0\n"); + + // revert: a wired regular file swapped for a link afterwards. + let tmp = write_root("six==1.16.0\n").await; + let wiring = wire_requirements(tmp.path(), "six", "1.16.0", REL_WHEEL, SHA) + .await + .unwrap(); + let wired = read_root(tmp.path()).await; + let target = outer.path().join("wired.txt"); + tokio::fs::write(&target, &wired).await.unwrap(); + tokio::fs::remove_file(tmp.path().join("requirements.txt")) + .await + .unwrap(); + std::os::unix::fs::symlink(&target, tmp.path().join("requirements.txt")).unwrap(); + let outcome = revert_requirements(&entry_for(wiring), tmp.path(), false).await; + assert!(!outcome.success); + assert!(outcome.kept_artifact); + assert!( + outcome + .error + .as_deref() + .is_some_and(|e| e.contains("pypi_requirements_symlink_unsupported")), + "{:?}", + outcome.error + ); + assert!(std::fs::symlink_metadata(tmp.path().join("requirements.txt")) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(read_root(tmp.path()).await, wired, "the link target is untouched"); + } } diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 2afd4657..294ca69f 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -31,7 +31,9 @@ use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::python_lock::preserve_line_endings; -use super::common::{item_get, pep508_name, pep621_declared_names, record}; +use super::common::{ + ensure_unchanged, item_get, pep508_name, pep621_declared_names, record, refuse_symlinked, +}; use super::state::{UvMeta, VendorEntry, WiringAction, WiringRecord}; use super::toml_surgery::{ balanced_span, find_unit_span, line_index, remove_exact_line, remove_substring, @@ -458,7 +460,7 @@ pub(super) async fn wire_uv( record_uuid: &str, ) -> Result<(Vec, UvMeta, Vec), (&'static str, String)> { // Before ANY write: a symlinked half would be replaced by the rename. - refuse_symlinked_pair(root).await?; + refuse_symlinked(root, &UV_PAIR, "pypi_uv_symlink_unsupported").await?; match check_target_guards(p, canon_name, record_uuid)? { // Defensive: the orchestrator short-circuits in-sync pre-flight and // never calls wire on it (we must never re-record our own edit as an @@ -696,6 +698,10 @@ pub(super) async fn wire_uv( } // ── commit: pyproject first, then the lock; unwind on lock failure ──── + // Both edits were computed from the pre-flight snapshot; a `uv lock` / + // editor save that landed during the wheel build must not be clobbered. + ensure_unchanged(root, UV_PAIR[0], &p.pyproject_text, "pypi_uv_changed").await?; + ensure_unchanged(root, UV_PAIR[1], &p.lock_text, "pypi_uv_changed").await?; // Mode-preserving: both are user-owned files we merely edit, so the // swapped-in inode must keep its permission bits rather than reset them // to umask defaults (same class as the poetry/pdm/pipenv writers). @@ -746,7 +752,8 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - let lock_path = root.join("uv.lock"); // A symlinked half would be replaced by the rename-over write: keep the // artifact (the wiring still routes through it) and fail the revert. - if let Err((code, detail)) = refuse_symlinked_pair(root).await { + if let Err((code, detail)) = refuse_symlinked(root, &UV_PAIR, "pypi_uv_symlink_unsupported").await + { return RevertOutcome { kept_artifact: true, success: false, @@ -956,26 +963,10 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - // ── helpers ────────────────────────────────────────────────────────────── -/// Refuse when `pyproject.toml` or `uv.lock` is itself a symlink. The -/// writers stage a replacement next to the path and rename over it, which -/// would REPLACE the link with a regular file — the target left stale, git -/// showing a typechange — so both wire and revert check before any write -/// (uv itself writes through the link). `Err` names the offending file. -async fn refuse_symlinked_pair(root: &Path) -> Result<(), (&'static str, String)> { - for name in ["pyproject.toml", "uv.lock"] { - if crate::utils::fs::is_symlink(&root.join(name)).await { - return Err(( - "pypi_uv_symlink_unsupported", - format!( - "{name} is a symbolic link; the atomic rewrite would replace the link with \ - a regular file and leave its target stale — vendor the real file's \ - directory instead" - ), - )); - } - } - Ok(()) -} +/// The two files this backend edits — both are checked for symlinks before +/// any write (uv itself writes through a link; the atomic rename would +/// replace it) and re-verified against the pre-flight snapshot. +const UV_PAIR: [&str; 2] = ["pyproject.toml", "uv.lock"]; /// The lock's line terminator. uv writes LF, but git autocrlf on Windows /// hands us a CRLF file; every fragment we splice, append or remove must be @@ -5532,4 +5523,36 @@ six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.1 assert_eq!(tokio::fs::read(&target).await.unwrap(), target_before); } } + + /// A pair file changed between the pre-flight snapshot and the write (a + /// `uv lock` landed during the wheel build): refuse before the FIRST + /// write instead of clobbering it with the stale snapshot-derived text — + /// neither half is touched. + #[tokio::test] + async fn pair_changed_during_vendoring_is_refused_before_the_write() { + let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let relocked = format!("{DIRECT_REGISTRY_LOCK}# relocked\n"); + tokio::fs::write(tmp.path().join("uv.lock"), &relocked) + .await + .unwrap(); + + let err = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); + assert_eq!(err.0, "pypi_uv_changed"); + assert!(err.1.contains("uv.lock changed during vendoring"), "{}", err.1); + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, DIRECT_REGISTRY_PYPROJECT, "pyproject never written"); + assert_eq!(lock, relocked, "the live lock is left alone"); + } } diff --git a/crates/socket-patch-core/src/vendor/pypi_wheel.rs b/crates/socket-patch-core/src/vendor/pypi_wheel.rs index 9632c3c5..f3aba366 100644 --- a/crates/socket-patch-core/src/vendor/pypi_wheel.rs +++ b/crates/socket-patch-core/src/vendor/pypi_wheel.rs @@ -21,7 +21,9 @@ use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ is_safe_relative_subpath, normalize_file_path, ApplyResult, PatchSources, }; -use crate::utils::fs::{atomic_write_bytes, list_dir_entries}; +use crate::utils::fs::{ + atomic_write_bytes, list_dir_entries, read_regular_to_bytes, read_regular_to_string, +}; use super::common::{failed_result, is_executable, write_zip_entries}; @@ -51,23 +53,10 @@ pub struct WheelArtifact { pub size: u64, } -/// `open_regular_file` opens with `O_NONBLOCK` and rejects non-regular -/// files, so a FIFO planted in the dist-info (or squatting a RECORD member) -/// fails fast — surfacing as the same unreadable-file refusal/failure as a -/// missing file — instead of wedging the vendor run forever in an `open(2)` -/// that waits for a writer. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - -/// Byte-reading twin of [`read_regular_to_string`], also handing back the +/// Byte-reading twin of [`read_regular_to_string`] that also hands back the /// metadata from the already-open handle (the member staging loop needs the -/// exec bit without a second stat). +/// exec bit without a second stat — the one reason this is not the shared +/// `read_regular_to_bytes`). async fn read_regular(path: &Path) -> std::io::Result<(Vec, std::fs::Metadata)> { use tokio::io::AsyncReadExt as _; @@ -88,7 +77,23 @@ pub async fn locate_installed_dist( version: &str, ) -> Result { let want = canonicalize_pypi_name(purl_name); - for entry in list_dir_entries(site_packages).await { + // Two passes over the one in-memory listing: dist-info stems that already + // spell `-` first (one METADATA read in the common case + // instead of one per installed dist), then every other dist-info as the + // fallback (a stem without a version part, or a stale install whose stem + // disagrees with its METADATA). Both passes run the same authoritative + // METADATA compare below. + let stem_matches = |dir_name: &str| { + dir_name + .strip_suffix(".dist-info") + .and_then(|stem| stem.rfind('-').map(|i| (&stem[..i], &stem[i + 1..]))) + .is_some_and(|(n, v)| canonicalize_pypi_name(n) == want && v == version) + }; + let (likely, rest): (Vec<_>, Vec<_>) = list_dir_entries(site_packages) + .await + .into_iter() + .partition(|e| stem_matches(&e.file_name().to_string_lossy())); + for entry in likely.into_iter().chain(rest) { let dir_name = entry.file_name().to_string_lossy().into_owned(); let Some(stem) = dir_name.strip_suffix(".dist-info") else { continue; @@ -532,7 +537,7 @@ fn is_installer_bookkeeping(path: &str, dist_info_name: &str) -> bool { /// True when `dist-info/direct_url.json` marks the install editable. async fn is_editable_install(dist_info_dir: &Path) -> bool { - let Ok((bytes, _)) = read_regular(&dist_info_dir.join("direct_url.json")).await else { + let Ok(bytes) = read_regular_to_bytes(&dist_info_dir.join("direct_url.json")).await else { return false; }; let Ok(value) = serde_json::from_slice::(&bytes) else { diff --git a/crates/socket-patch-core/src/vendor/verify.rs b/crates/socket-patch-core/src/vendor/verify.rs index ccee3d15..2eb2d619 100644 --- a/crates/socket-patch-core/src/vendor/verify.rs +++ b/crates/socket-patch-core/src/vendor/verify.rs @@ -403,16 +403,18 @@ pub async fn check_vendored_artifact( /// Plain sha256 hex of a regular file, size-capped; `None` on any read /// failure or cap breach. Public for repair's ledger re-synthesis (the -/// rebuilt artifact's recorded sha). +/// rebuilt artifact's recorded sha). Opens once through the shared guarded +/// opener (`O_NONBLOCK` + fstat on the handle), so the size gate and the +/// bytes hashed come from the same inode and a FIFO swapped in at the path +/// can never wedge the health check in `open(2)`. pub async fn file_sha256_hex(path: &Path) -> Option { use sha2::{Digest, Sha256}; use tokio::io::AsyncReadExt; - let meta = tokio::fs::metadata(path).await.ok()?; - if !meta.is_file() || meta.len() > MAX_HEALTH_HASH_BYTES { + let (mut file, meta) = crate::utils::fs::open_regular_file(path).await.ok()?; + if meta.len() > MAX_HEALTH_HASH_BYTES { return None; } - let mut file = tokio::fs::File::open(path).await.ok()?; let mut hasher = Sha256::new(); let mut buf = vec![0u8; 64 * 1024]; loop { diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index 64ba8d90..823e3989 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -36,11 +36,16 @@ use sha2::{Digest, Sha512}; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{normalize_file_path, PatchSources}; use crate::patch::copy_tree::remove_tree; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::fs::{ + atomic_write_bytes_preserving_mode, read_regular_to_bytes, read_regular_to_string, +}; use crate::utils::uri::encode_uri_component; use super::berry_zip::berry_cache_checksum_10c0; -use super::common::{already_patched_result, detect_eol, detect_indent, refused, serialize_json}; +use super::common::{ + already_patched_result, detect_eol, detect_indent, prune_empty_vendor_levels, refused, + serialize_json, +}; use super::npm_common::{ done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, }; @@ -49,9 +54,8 @@ use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::yarn_classic_lock::{ - body_field_line, lines_to_json, pattern_real_name, read_regular, read_regular_to_string, - read_yarn_lock, replace_block, revert_recorded_block, scan_blocks, split_key_patterns, - split_pattern, LockBlock, + body_field_line, lines_to_json, pattern_real_name, read_yarn_lock, replace_block, + revert_recorded_block, scan_blocks, split_key_patterns, split_pattern, LockBlock, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; @@ -161,7 +165,7 @@ pub async fn vendor_yarn_berry( // ── 5. package.json + user-override conflict gate ───────────────────── let pkg_path = project_root.join(PACKAGE_JSON); - let pkg_bytes = match read_regular(&pkg_path).await { + let pkg_bytes = match read_regular_to_bytes(&pkg_path).await { Ok(b) => b, Err(e) => { return refused( @@ -278,12 +282,6 @@ pub async fn vendor_yarn_berry( .any(|k| normalize_file_path(k) == "package.json"); // ── 7. Stage → patch → pack (shared flavor-agnostic pipeline) ───────── - // A wiring failure past this point must unwind the uuid dir staging is - // about to create — but never one that already existed (a same-uuid - // re-vendor's dir may still be referenced by live wiring). - let uuid_dir_preexisted = tokio::fs::metadata(project_root.join(&uuid_dir_rel)) - .await - .is_ok(); let (staged, result) = match stage_patch_pack( purl, installed_dir, @@ -308,6 +306,7 @@ pub async fn vendor_yarn_berry( warnings, }; }; + let uuid_dir_preexisted = staged.uuid_dir_preexisted; debug_assert_eq!(staged.rel_tgz, rel_tgz); let packed = staged.packed; let dest = project_root.join(&rel_tgz); @@ -544,7 +543,7 @@ pub async fn revert_yarn_berry_opts( // wet run refuses. Skipped under `keep_artifact`: the refusal exists // only to protect the deletion, which a preserve-state revert never // performs. - if entry.wiring.is_empty() { + if !keep_artifact && entry.wiring.is_empty() { for wired in [YARN_LOCK, PACKAGE_JSON] { if let Some(blocked) = super::npm_lock::guard_unwired_textual_revert( project_root, @@ -621,7 +620,7 @@ pub async fn revert_yarn_berry_opts( // package.json resolutions entries. if !pkg_recs.is_empty() { let pkg_path = project_root.join(PACKAGE_JSON); - match read_regular(&pkg_path).await { + match read_regular_to_bytes(&pkg_path).await { Ok(bytes) => { let mut pkg: Value = match serde_json::from_slice(&bytes) { Ok(v) => v, @@ -720,9 +719,14 @@ pub async fn revert_yarn_berry_opts( } } - if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + let uuid_dir = project_root.join(&uuid_dir_rel); + if let Err(e) = remove_tree(&uuid_dir).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } + // The last npm-family entry leaves `.socket/vendor/npm/` (and + // `.socket/vendor/`) empty: prune them so a reverted project carries no + // vendor residue (`remove_dir` keeps non-empty levels). + prune_empty_vendor_levels(&uuid_dir).await; outcome } @@ -3025,4 +3029,54 @@ __metadata: assert!(detail.contains("@workspace:."), "{detail}"); fx.assert_untouched().await; } + + /// `--preserve-state` with a repair-reconstructed (empty-wiring) entry: + /// both deletion-protecting probes (yarn.lock AND package.json) must be + /// SKIPPED — a preserve-state revert deletes nothing — so the revert + /// completes as a successful no-op with lock, package.json and artifact + /// intact. Dry-run preview included. + #[tokio::test] + async fn empty_wiring_preserve_state_revert_skips_the_deletion_refusal() { + let fx = fixture().await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + let lock_vendored = tokio::fs::read(fx.lock_path()).await.unwrap(); + let pkg_vendored = tokio::fs::read(fx.root().join(PACKAGE_JSON)) + .await + .unwrap(); + + for dry_run in [true, false] { + let outcome = revert_yarn_berry_opts( + &entry, + fx.root(), + RevertOpts { + dry_run, + keep_artifact: true, + }, + ) + .await; + assert!( + outcome.success, + "dry_run={dry_run}: preserve-state deletes nothing, so the \ + deletion guards must not fire: {:?}", + outcome.error + ); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(!outcome.kept_artifact, "preserve-state is not a drift-keep"); + assert!(fx.tgz_path().exists(), "artifact kept"); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + lock_vendored, + "empty wiring replays nothing" + ); + assert_eq!( + tokio::fs::read(fx.root().join(PACKAGE_JSON)) + .await + .unwrap(), + pkg_vendored, + "package.json untouched" + ); + } + } } diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index 5ffb0b97..dd29e9af 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -28,9 +28,9 @@ use serde_json::Value; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; use crate::patch::copy_tree::remove_tree; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; -use super::common::{already_patched_result, detect_eol, refused}; +use super::common::{already_patched_result, detect_eol, prune_empty_vendor_levels, refused}; use super::npm_common::{ done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, }; @@ -134,12 +134,6 @@ pub async fn vendor_yarn_classic( drop(blocks); // ── 4–7. Stage → patch → pack (shared flavor-agnostic pipeline) ─────── - // A wiring failure past this point must unwind the uuid dir staging is - // about to create — but never one that already existed (a same-uuid - // re-vendor's dir may still be referenced by live wiring). - let uuid_dir_preexisted = tokio::fs::metadata(project_root.join(&uuid_dir_rel)) - .await - .is_ok(); let (staged, result) = match stage_patch_pack( purl, installed_dir, @@ -164,6 +158,7 @@ pub async fn vendor_yarn_classic( warnings, }; }; + let uuid_dir_preexisted = staged.uuid_dir_preexisted; let rel_tgz = staged.rel_tgz; let packed = staged.packed; let staged_pkg_json = staged.staged_pkg_json; @@ -335,7 +330,7 @@ pub async fn revert_yarn_classic_opts( // advertises a revert the wet run refuses. Skipped under // `keep_artifact`: the refusal exists only to protect the deletion, // which a preserve-state revert never performs. - if entry.wiring.is_empty() { + if !keep_artifact && entry.wiring.is_empty() { if let Some(blocked) = super::npm_lock::guard_unwired_textual_revert( project_root, &entry.uuid, @@ -454,9 +449,14 @@ pub async fn revert_yarn_classic_opts( return outcome; } - if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + let uuid_dir = project_root.join(&uuid_dir_rel); + if let Err(e) = remove_tree(&uuid_dir).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } + // The last npm-family entry leaves `.socket/vendor/npm/` (and + // `.socket/vendor/`) empty: prune them so a reverted project carries no + // vendor residue (`remove_dir` keeps non-empty levels). + prune_empty_vendor_levels(&uuid_dir).await; outcome } @@ -715,28 +715,6 @@ pub(super) async fn read_yarn_lock(project_root: &Path) -> Result std::io::Result> { - use tokio::io::AsyncReadExt as _; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut bytes).await?; - Ok(bytes) -} - -/// [`read_regular`], decoded as UTF-8 (`InvalidData` on failure, matching -/// `read_to_string`'s error kind). -pub(super) async fn read_regular_to_string(path: &Path) -> std::io::Result { - let bytes = read_regular(path).await?; - String::from_utf8(bytes) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) -} - /// One key-line block of a yarn lockfile (classic or berry). pub(super) struct LockBlock { /// Byte offset of the key line's first byte. @@ -2563,4 +2541,44 @@ left-pad@^1.3.0: fx.lock_bytes ); } + + /// `--preserve-state` with a repair-reconstructed (empty-wiring) entry: + /// the deletion-protecting refusal must be SKIPPED (the fn doc, bun and + /// pnpm-legacy all promise it — a preserve-state revert deletes + /// nothing), so the revert completes as a successful no-op with lock and + /// artifact intact. Dry-run preview included. + #[tokio::test] + async fn empty_wiring_preserve_state_revert_skips_the_deletion_refusal() { + let fx = fixture_with_lock(Y2_BEFORE).await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let mut entry = entry.unwrap(); + entry.wiring.clear(); + let lock_vendored = tokio::fs::read(fx.lock_path()).await.unwrap(); + + for dry_run in [true, false] { + let outcome = revert_yarn_classic_opts( + &entry, + fx.root(), + RevertOpts { + dry_run, + keep_artifact: true, + }, + ) + .await; + assert!( + outcome.success, + "dry_run={dry_run}: preserve-state deletes nothing, so the \ + deletion guard must not fire: {:?}", + outcome.error + ); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(!outcome.kept_artifact, "preserve-state is not a drift-keep"); + assert!(fx.tgz_path().exists(), "artifact kept"); + assert_eq!( + tokio::fs::read(fx.lock_path()).await.unwrap(), + lock_vendored, + "empty wiring replays nothing" + ); + } + } } From 350f6518a092d1199ac26a3d9bcba635569bd616 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 18:49:17 -0400 Subject: [PATCH 07/44] refactor(core): cache plain client, one org route builder, setup/update hygiene - api/client: build the header-free proxy client once per ApiClient instead of per blob/diff/tarball download; one `patches_path` builder for the four JSON routes, with `fetch_registry_references_for_org` honoring a per-call org override (one-arg wrapper kept); `get_api_client_with_overrides` builds the client once and fills the auto-resolved slug in place. - telemetry: drop the VITEST kill-switch relic (cleanup ruling: no in-repo harness sets it); add a 2 s connect timeout so a blackholed endpoint no longer stalls every command for the full 5 s request budget. - setup: replace four private FIFO-guarded readers with utils::fs::read_regular_to_string; gem `--remove` prunes an emptied `.socket/`; pyproject.toml edits preserve CRLF (toml_edit re-emits LF); `add_plugin_directive_with` lets the CLI probe bundler once per run. - update/swap: only a contention errno maps to `update_in_progress`; other flock failures surface their real cause. Co-Authored-By: Claude Fable 5.1 --- crates/socket-patch-core/src/api/client.rs | 169 ++++++++++++------ .../src/setup/composer/mod.rs | 20 +-- crates/socket-patch-core/src/setup/gem/mod.rs | 42 +++-- .../socket-patch-core/src/setup/gem/update.rs | 17 +- .../src/setup/gem/version.rs | 2 +- .../src/setup/pypi/detect.rs | 21 +-- .../socket-patch-core/src/setup/pypi/edit.rs | 60 +++++-- crates/socket-patch-core/src/telemetry.rs | 17 +- crates/socket-patch-core/src/update/swap.rs | 13 +- .../tests/telemetry_helpers_e2e.rs | 37 +--- 10 files changed, 221 insertions(+), 177 deletions(-) diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index 2ebc34ca..a7f0b2ff 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -42,6 +42,12 @@ pub struct ApiClientOptions { #[derive(Debug, Clone)] pub struct ApiClient { client: reqwest::Client, + /// Header-free twin of `client` (User-Agent only, never Authorization) + /// for the public-proxy and grant-tokenized serve requests, where + /// sending the Socket bearer would leak it to a third party. Built once + /// here so every blob/diff/tarball download shares one connection pool + /// instead of paying a fresh TLS-config build + handshake per request. + plain: reqwest::Client, api_url: String, api_token: Option, use_public_proxy: bool, @@ -98,6 +104,7 @@ impl ApiClient { Self { client, + plain: plain_client(), api_url, api_token: options.api_token, use_public_proxy: options.use_public_proxy, @@ -184,6 +191,27 @@ impl ApiClient { ))) } + /// The org slug an authenticated `/v0/orgs/{slug}/...` route uses: the + /// per-call override, else the client's configured slug, else `default`. + fn org_slug_or_default<'a>(&'a self, org_slug: Option<&'a str>) -> &'a str { + org_slug.or(self.org_slug.as_deref()).unwrap_or("default") + } + + /// Path of a patches JSON endpoint: `/patch/{suffix}` on the public + /// proxy, `/v0/orgs/{slug}/patches/{suffix}` on the authenticated API. + /// The one place the proxy-vs-org switch and the slug fallback live for + /// the JSON family (`get_json`/`post_json` prefix `api_url`). + fn patches_path(&self, org_slug: Option<&str>, suffix: &str) -> String { + if self.use_public_proxy { + format!("/patch/{suffix}") + } else { + format!( + "/v0/orgs/{}/patches/{suffix}", + self.org_slug_or_default(org_slug) + ) + } + } + // ── Public API methods ──────────────────────────────────────────── /// Fetch a patch by UUID (full details with blob content). @@ -194,12 +222,7 @@ impl ApiClient { org_slug: Option<&str>, uuid: &str, ) -> Result, ApiError> { - let path = if self.use_public_proxy { - format!("/patch/view/{}", uuid) - } else { - let slug = org_slug.or(self.org_slug.as_deref()).unwrap_or("default"); - format!("/v0/orgs/{}/patches/view/{}", slug, uuid) - }; + let path = self.patches_path(org_slug, &format!("view/{uuid}")); self.get_json(&path).await } @@ -213,12 +236,7 @@ impl ApiClient { identifier: &str, ) -> Result { let encoded = urlencoding_encode(identifier); - let path = if self.use_public_proxy { - format!("/patch/{route}/{encoded}") - } else { - let slug = org_slug.or(self.org_slug.as_deref()).unwrap_or("default"); - format!("/v0/orgs/{slug}/patches/{route}/{encoded}") - }; + let path = self.patches_path(org_slug, &format!("{route}/{encoded}")); let mut result = self .get_json::(&path) .await? @@ -281,8 +299,8 @@ impl ApiClient { purls: &[String], ) -> Result { if !self.use_public_proxy { - let slug = org_slug.or(self.org_slug.as_deref()).unwrap_or("default"); - let path = format!("/v0/orgs/{}/patches/batch", slug); + let slug = self.org_slug_or_default(org_slug); + let path = self.patches_path(org_slug, "batch"); let body = BatchSearchBody::new(purls); let result = self .post_json::(&path, &body) @@ -323,19 +341,28 @@ impl ApiClient { /// `POST /v0/orgs/{org}/patches/package` when a token+org are set, else the /// public proxy `POST /patch/package` (free patches only). Returns a /// UUID → reference map (missing/404 → empty). + /// + /// Uses the client's configured org slug; see + /// [`Self::fetch_registry_references_for_org`] for a per-call override. pub async fn fetch_registry_references( &self, uuids: &[String], + ) -> Result, ApiError> { + self.fetch_registry_references_for_org(None, uuids).await + } + + /// [`Self::fetch_registry_references`] with the same per-call `org_slug` + /// override the other JSON routes (`fetch_patch`, `search_patches_*`) + /// accept: `Some(slug)` wins over the client's configured slug. + pub async fn fetch_registry_references_for_org( + &self, + org_slug: Option<&str>, + uuids: &[String], ) -> Result, ApiError> { if uuids.is_empty() { return Ok(std::collections::HashMap::new()); } - let path = if self.use_public_proxy { - "/patch/package".to_string() - } else { - let slug = self.org_slug.as_deref().unwrap_or("default"); - format!("/v0/orgs/{}/patches/package", slug) - }; + let path = self.patches_path(org_slug, "package"); let body = PackageVendorRequest { uuids: uuids.to_vec(), free_only: None, @@ -576,13 +603,9 @@ impl ApiClient { debug_log(&format!("GET {} {}", kind, url)); // When fetching from the public proxy (different base URL than - // self.api_url), use a plain client without auth headers to avoid + // self.api_url), use the plain client without auth headers to avoid // leaking credentials to the proxy. - let client = if use_auth { - self.client.clone() - } else { - plain_client() - }; + let client = if use_auth { &self.client } else { &self.plain }; let resp = client .get(&url) .header(header::ACCEPT, "application/octet-stream") @@ -810,7 +833,7 @@ impl ApiClient { .await } else { // Plain (no-auth) client: never leak the bearer to the proxy. - plain_client() + self.plain .post(&url) .header(header::CONTENT_TYPE, "application/json") .header(header::ACCEPT, "application/json") @@ -850,7 +873,8 @@ impl ApiClient { ))); } debug_log(&format!("GET vendor package {url}")); - let resp = match plain_client() + let resp = match self + .plain .get(url) .header(header::ACCEPT, "application/octet-stream") .send() @@ -972,8 +996,9 @@ enum ServeDownload { } /// Build a plain `reqwest::Client` carrying only the User-Agent — no -/// Authorization. Used for the public-proxy POST and the grant-tokenized serve -/// GET, where sending the Socket bearer would leak it to a third party. +/// Authorization. Built once per [`ApiClient`] (its `plain` field) for the +/// public-proxy POST and the grant-tokenized serve GETs, where sending the +/// Socket bearer would leak it to a third party. fn plain_client() -> reqwest::Client { let mut headers = HeaderMap::new(); headers.insert( @@ -1151,29 +1176,27 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> // telemetry endpoint resolver so the two can't disagree. .unwrap_or_else(socket_cli_config::resolve_api_base_url); - // Auto-resolve org slug if not provided - let final_org_slug = if resolved_org_slug.is_some() { - resolved_org_slug - } else if is_offline_env() { - // Strict airgap: `--offline` (mirrored into `SOCKET_OFFLINE` by the - // CLI before any client is built — same vocabulary the telemetry - // kill-switch matches) means zero network contact, so the org-slug - // auto-resolution round-trip must not fire. The slug only labels - // org-scoped fetches and telemetry, both already gated off offline. - None - } else { - let temp_client = ApiClient::new(ApiClientOptions { - api_url: api_url.clone(), - api_token: api_token.clone(), - use_public_proxy: false, - org_slug: None, - }); - match temp_client.resolve_org_slug().await { - Ok(slug) => Some(slug), + // Build the client once; the org-slug round-trip below runs on it and + // fills in `org_slug` in place (it only needs the token + base URL). + let mut client = ApiClient::new(ApiClientOptions { + api_url, + api_token, + use_public_proxy: false, + org_slug: resolved_org_slug, + }); + + // Auto-resolve the org slug if not provided. Strict airgap: `--offline` + // (mirrored into `SOCKET_OFFLINE` by the CLI before any client is built + // — same vocabulary the telemetry kill-switch matches) means zero + // network contact, so the round-trip must not fire. The slug only labels + // org-scoped fetches and telemetry, both already gated off offline. + if client.org_slug.is_none() && !is_offline_env() { + match client.resolve_org_slug().await { + Ok(slug) => client.org_slug = Some(slug), Err(e) => { eprintln!("Warning: Could not auto-detect organization: {e}"); if matches!(e, ApiError::Unauthorized(_)) { - if let Some(ref t) = api_token { + if let Some(t) = client.api_token.as_deref() { if looks_like_token_hash(t) { eprintln!( " Hint: SOCKET_API_TOKEN starts with `{}-` \ @@ -1184,17 +1207,9 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> } } } - None } } - }; - - let client = ApiClient::new(ApiClientOptions { - api_url, - api_token, - use_public_proxy: false, - org_slug: final_org_slug, - }); + } (client, false) } @@ -3170,6 +3185,42 @@ mod vendor_package_tests { assert_eq!(map[UUID].status, "granted"); } + /// The package-reference route honors the same per-call org override as + /// `fetch_patch`/`search_patches_*`: `Some(slug)` beats the client's + /// configured `acme`, and the one-arg wrapper keeps using `acme`. + #[tokio::test] + async fn fetch_registry_references_for_org_overrides_client_slug() { + let server = MockServer::start().await; + let body = json!({ + "results": { UUID: { "status": "granted", "url": null, "artifacts": [] } } + }); + Mock::given(method("POST")) + .and(path("/v0/orgs/other-org/patches/package")) + .respond_with(ResponseTemplate::new(200).set_body_json(body.clone())) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v0/orgs/acme/patches/package")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .expect(1) + .mount(&server) + .await; + + let client = auth_client(server.uri()); + let uuids = [UUID.to_string()]; + let overridden = client + .fetch_registry_references_for_org(Some("other-org"), &uuids) + .await + .expect("override route must succeed"); + assert_eq!(overridden[UUID].status, "granted"); + let configured = client + .fetch_registry_references(&uuids) + .await + .expect("configured-slug route must succeed"); + assert_eq!(configured[UUID].status, "granted"); + } + // ── fetch_vendor_package grant / artifact edge arms ─────────────── /// Forward-compat contract: an unrecognized vendor status must degrade diff --git a/crates/socket-patch-core/src/setup/composer/mod.rs b/crates/socket-patch-core/src/setup/composer/mod.rs index e83eaa4d..e92e79e1 100644 --- a/crates/socket-patch-core/src/setup/composer/mod.rs +++ b/crates/socket-patch-core/src/setup/composer/mod.rs @@ -274,27 +274,17 @@ pub async fn remove_hook(composer_json: &Path, dry_run: bool) -> ComposerEditRes edit(composer_json, dry_run, composer_remove).await } -/// Guarded read: a FIFO planted as `composer.json` would make a plain -/// `read_to_string` open block forever waiting for a writer — discovery -/// accepts any path whose metadata stats, so it reaches here unopened. -/// `open_regular_file` opens with `O_NONBLOCK` and rejects non-regular -/// files, same as the package_json/update.rs and find.rs guards. -async fn read_composer_json_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - async fn edit( composer_json: &Path, dry_run: bool, transform: impl FnOnce(&str) -> Result, String>, ) -> ComposerEditResult { let result = async { - let content = match read_composer_json_to_string(composer_json).await { + // Guarded read: a FIFO planted as `composer.json` would make a plain + // `read_to_string` open block forever waiting for a writer — + // discovery accepts any path whose metadata stats, so it reaches + // here unopened. The shared reader rejects non-regular files. + let content = match crate::utils::fs::read_regular_to_string(composer_json).await { Ok(c) => c, // A missing composer.json on remove is a no-op, not an error. Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), diff --git a/crates/socket-patch-core/src/setup/gem/mod.rs b/crates/socket-patch-core/src/setup/gem/mod.rs index c292ff08..04fbe5ca 100644 --- a/crates/socket-patch-core/src/setup/gem/mod.rs +++ b/crates/socket-patch-core/src/setup/gem/mod.rs @@ -39,9 +39,16 @@ use std::path::{Path, PathBuf}; use tokio::fs; +// Guarded read for every raw read in this module tree: a FIFO planted at any +// path setup reads (`plugins.rb`, the gemspec, `.socket/.gitignore`, +// bundler's plugin index, `Gemfile.lock`) fails fast with `InvalidInput` +// instead of wedging `setup`/`--check`/`--remove` forever in an `open(2)` +// that waits for a writer. +use crate::utils::fs::read_regular_to_string; + pub use update::{ - add_plugin_directive, is_plugin_directive_present, remove_plugin_directive, GemEditResult, - GemSetupStatus, + add_plugin_directive, add_plugin_directive_with, is_plugin_directive_present, + remove_plugin_directive, GemEditResult, GemSetupStatus, }; pub use version::{probe_bundler, unsupported_bundler_message, BundlerProbe, MIN_BUNDLER}; @@ -163,22 +170,6 @@ fn stamp_gitignore_path(root: &Path) -> PathBuf { root.join(".socket").join(".gitignore") } -/// Guarded read shared by every raw read in this module: `open_regular_file` -/// opens with `O_NONBLOCK` and rejects non-regular files with `InvalidInput`, -/// so a FIFO planted at any path setup reads (`plugins.rb`, the gemspec, -/// `.socket/.gitignore`, bundler's plugin index) fails fast instead of -/// wedging `setup`/`--check`/`--remove` forever in an `open(2)` that waits -/// for a writer — the same guard as the composer/npm setup twins and the -/// crawlers. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// Whether `.socket/.gitignore` is missing the stamp entry (so `setup` still /// has a write to make). Shared by [`add_plugin_files`] and /// [`plugin_files_present`] to keep `setup` and `--check` in agreement. @@ -695,8 +686,13 @@ async fn remove_plugin_files(root: &Path, dry_run: bool) -> GemEditResult { remove_generated(&gemspec_path(root)).await?; } remove_stamp_artifacts(root).await; - // Prune the now-empty plugin dir (leave .socket/ — apply uses it). + // Prune the now-empty plugin dir, then `.socket/` itself when + // nothing else — manifest, blobs, vendor tree, a user + // `.gitignore` — is left in it: `remove_dir` refuses a non-empty + // dir, and every writer recreates `.socket/` on demand. Never + // `remove_dir_all`. let _ = fs::remove_dir(&dir).await; + let _ = fs::remove_dir(root.join(".socket")).await; } Ok(true) } @@ -1022,6 +1018,10 @@ mod tests { !stamp_gitignore_path(root).exists(), "the .gitignore we created (nothing but our line) is removed too" ); + assert!( + !root.join(".socket").exists(), + "an emptied .socket/ is pruned: --remove restores the pre-setup tree" + ); // Remove again → already gone. assert_eq!( remove_plugin_files(root, false).await.status, @@ -2114,6 +2114,10 @@ mod tests { user_bytes, "a stamp-free user .gitignore must survive byte-identical (CRLF kept)" ); + assert!( + root.join(".socket").is_dir(), + "a .socket/ that still holds user content is kept (prune is remove_dir, not _all)" + ); } #[tokio::test] diff --git a/crates/socket-patch-core/src/setup/gem/update.rs b/crates/socket-patch-core/src/setup/gem/update.rs index 650215d3..735de498 100644 --- a/crates/socket-patch-core/src/setup/gem/update.rs +++ b/crates/socket-patch-core/src/setup/gem/update.rs @@ -249,8 +249,21 @@ async fn edit_gemfile_remove(gemfile: &Path, dry_run: bool) -> GemEditResult { /// an undetectable version; `remove_plugin_directive` is never gated (it is /// the recovery path for an already-wired 1.x project). pub async fn add_plugin_directive(project: &BundlerProject, dry_run: bool) -> Vec { - if let BundlerProbe::Unsupported { version, source } = probe_bundler(project).await { - let mut message = unsupported_bundler_message(&version, &source); + let probe = probe_bundler(project).await; + add_plugin_directive_with(project, &probe, dry_run).await +} + +/// [`add_plugin_directive`] with the bundler probe supplied by the caller. +/// `probe_bundler` may spawn `bundle --version` (10 s cap) when the lock has +/// no `BUNDLED WITH`, so a caller that runs a dry-run preview and then the +/// real edit (the CLI's `setup`) probes once and passes the result to both. +pub async fn add_plugin_directive_with( + project: &BundlerProject, + probe: &BundlerProbe, + dry_run: bool, +) -> Vec { + if let BundlerProbe::Unsupported { version, source } = probe { + let mut message = unsupported_bundler_message(version, source); // An ALREADY-wired project (wired before the floor existed, or on // another machine) gets the recovery path by name — "Not wiring this // project" alone would be misleading when the wiring is the problem. diff --git a/crates/socket-patch-core/src/setup/gem/version.rs b/crates/socket-patch-core/src/setup/gem/version.rs index 05180d2a..61b196cd 100644 --- a/crates/socket-patch-core/src/setup/gem/version.rs +++ b/crates/socket-patch-core/src/setup/gem/version.rs @@ -140,7 +140,7 @@ async fn probe_bundler_with( // to the `bundle --version` fallback, not wedge `setup`/`--check` // forever in `open(2)` — same guard as every other raw read in this // module tree. - if let Ok(lock) = super::read_regular_to_string(&lock_path).await { + if let Ok(lock) = crate::utils::fs::read_regular_to_string(&lock_path).await { if let Some(version) = parse_bundled_with(&lock) { let lock_name = lock_path .file_name() diff --git a/crates/socket-patch-core/src/setup/pypi/detect.rs b/crates/socket-patch-core/src/setup/pypi/detect.rs index 788fcde2..65b3d357 100644 --- a/crates/socket-patch-core/src/setup/pypi/detect.rs +++ b/crates/socket-patch-core/src/setup/pypi/detect.rs @@ -2,6 +2,7 @@ use std::path::Path; +use crate::utils::fs::read_regular_to_string; use crate::utils::toml_edit_ext::has_table; /// The dependency `setup` adds (PEP 508 form, used for `requirements.txt` and @@ -64,21 +65,6 @@ impl PythonPackageManager { } } -/// Guarded read shared with the gem/composer/npm setup twins: -/// `open_regular_file` opens with `O_NONBLOCK` and rejects non-regular files, -/// so a FIFO planted at `pyproject.toml` fails fast to the `Pip` fallback -/// instead of wedging `setup`/`--check` forever in an `open(2)` that waits -/// for a writer — the `is_python_project` gate ahead of detection is -/// metadata-only and does not filter these. -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// Detect the dependency manager from lockfiles and `pyproject.toml` tables. /// /// Lockfiles are the strongest signal; `[tool.*]` tables come next; a project @@ -94,6 +80,11 @@ pub async fn detect_python_pm(cwd: &Path) -> PythonPackageManager { if tokio::fs::metadata(cwd.join("poetry.lock")).await.is_ok() { return PythonPackageManager::Poetry; } + // Guarded read (shared with the gem/composer/npm setup twins): a FIFO + // planted at `pyproject.toml` fails fast to the `Pip` fallback instead of + // wedging `setup`/`--check` forever in an `open(2)` that waits for a + // writer — the `is_python_project` gate ahead of detection is + // metadata-only and does not filter these. if let Ok(content) = read_regular_to_string(&cwd.join("pyproject.toml")).await { // Header-anchored checks so a stray substring in a value/comment does // not misclassify. diff --git a/crates/socket-patch-core/src/setup/pypi/edit.rs b/crates/socket-patch-core/src/setup/pypi/edit.rs index a938c43a..926dbc54 100644 --- a/crates/socket-patch-core/src/setup/pypi/edit.rs +++ b/crates/socket-patch-core/src/setup/pypi/edit.rs @@ -16,7 +16,14 @@ use std::path::Path; use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value}; use super::detect::{deps_contain_hook, HOOK_DEP}; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +// Guarded read shared with the detect.rs/gem/composer/npm setup twins: a +// FIFO planted at `requirements.txt` / `pyproject.toml` fails fast to `Error` +// instead of wedging `setup` / `setup --remove` forever in an `open(2)` that +// waits for a writer — detection never opens the manifest it hands the edit +// path (a lockfile routes here without a read, and the Pip fallback targets +// `requirements.txt` sight-unseen). +use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; +use crate::utils::python_lock::preserve_line_endings; use crate::utils::toml_edit_ext::ensure_table; use crate::vendor::common::detect_eol; @@ -59,22 +66,6 @@ impl PthEditResult { } } -/// Guarded read shared with the detect.rs/gem/composer/npm setup twins: -/// `open_regular_file` opens with `O_NONBLOCK` and rejects non-regular files, -/// so a FIFO planted at `requirements.txt` / `pyproject.toml` fails fast to -/// `Error` instead of wedging `setup` / `setup --remove` forever in an -/// `open(2)` that waits for a writer — detection never opens the manifest it -/// hands the edit path (a lockfile routes here without a read, and the Pip -/// fallback targets `requirements.txt` sight-unseen). -async fn read_regular_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// Shared tail of add/remove: `None` means already in the desired state, /// `Some(new_content)` is written atomically (unless `dry_run`). async fn finish( @@ -241,7 +232,7 @@ fn pyproject_add(content: &str) -> Result, String> { .to_string(), ); }; - Ok(if changed { Some(doc.to_string()) } else { None }) + Ok(changed.then(|| render_pyproject(content, &doc))) } fn pyproject_remove(content: &str) -> Result, String> { @@ -253,7 +244,15 @@ fn pyproject_remove(content: &str) -> Result, String> { changed |= pep621_remove(&mut doc); changed |= poetry_remove(&mut doc); - Ok(if changed { Some(doc.to_string()) } else { None }) + Ok(changed.then(|| render_pyproject(content, &doc))) +} + +/// Render an edited pyproject document in the original's newline convention. +/// toml_edit (0.25.x) re-emits every line terminator as `\n`, untouched +/// lines included, so without this a CRLF checkout is rewritten wholesale +/// and `--remove` could never hand back the pre-setup bytes. +fn render_pyproject(original: &str, doc: &DocumentMut) -> String { + preserve_line_endings(original, doc.to_string()) } fn pep621_add(doc: &mut DocumentMut) -> Result { @@ -753,6 +752,29 @@ mod tests { assert_eq!(removed, "requests\r\n"); } + /// A Windows (`core.autocrlf`) checkout's pyproject.toml is CRLF. toml_edit + /// renders every newline as LF, so `setup` must re-apply CRLF (or every + /// line of the manifest diffs) and `--remove` must hand back the exact + /// pre-setup bytes (CLI_CONTRACT property 8). + #[test] + fn test_pyproject_preserves_crlf() { + let original = + "[project]\r\nname = \"demo\"\r\ndependencies = [\r\n \"requests\",\r\n]\r\n"; + let added = pyproject_add(original).unwrap().expect("hook dep added"); + assert!(added.contains(HOOK_DEP), "hook added: {added:?}"); + assert!( + !added.replace("\r\n", "").contains('\n'), + "every newline must stay CRLF: {added:?}" + ); + // Idempotent on the CRLF output. + assert_eq!(pyproject_add(&added).unwrap(), None); + let removed = pyproject_remove(&added).unwrap().expect("hook dep removed"); + assert_eq!( + removed, original, + "--remove restores the CRLF bytes exactly" + ); + } + // ── file-level NotFound handling (the create / no-op paths) ────── #[tokio::test] diff --git a/crates/socket-patch-core/src/telemetry.rs b/crates/socket-patch-core/src/telemetry.rs index 4a12ea4b..a65b06b4 100644 --- a/crates/socket-patch-core/src/telemetry.rs +++ b/crates/socket-patch-core/src/telemetry.rs @@ -121,7 +121,6 @@ struct PatchTelemetryEvent { /// Telemetry is disabled when: /// - `SOCKET_TELEMETRY_DISABLED` is `"1"` or `"true"` /// (legacy `SOCKET_PATCH_TELEMETRY_DISABLED` still honored with warning) -/// - `VITEST` is `"true"` (test environment) /// - `SOCKET_OFFLINE` is `"1"` or `"true"` (airgap mode — the telemetry /// endpoint is a network call, so honoring `--offline`/`SOCKET_OFFLINE` /// here keeps every command compliant with the strict-airgap contract) @@ -136,8 +135,7 @@ pub fn is_telemetry_disabled() -> bool { ) .unwrap_or_default(); let disabled_via_env = matches!(env_value.as_str(), "1" | "true"); - let vitest = std::env::var("VITEST").unwrap_or_default() == "true"; - disabled_via_env || vitest || is_offline_env() + disabled_via_env || is_offline_env() } /// Log debug messages when debug mode is enabled. @@ -257,7 +255,11 @@ fn resolve_telemetry_endpoint(api_token: Option<&str>, org_slug: Option<&str>) - /// Send a telemetry event to the API. /// /// This is fire-and-forget: errors are logged in debug mode but never -/// propagated. Uses `reqwest` with a 5-second timeout. +/// propagated. Uses `reqwest` with a 5-second request timeout and a +/// 2-second connect timeout: the send is awaited inline by every command +/// before it prints, so a network that blackholes the endpoint (dropped +/// SYNs, no RST) must give up on the handshake quickly rather than stall +/// even a read-only `scan --json` for the full request budget. async fn send_telemetry_event( event: &PatchTelemetryEvent, api_token: Option<&str>, @@ -268,6 +270,7 @@ async fn send_telemetry_event( debug_log(&format!("Sending telemetry to {url}")); let client = match reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(2)) .timeout(std::time::Duration::from_secs(5)) .build() { @@ -726,13 +729,11 @@ mod tests { // Save originals let orig_new = std::env::var("SOCKET_TELEMETRY_DISABLED").ok(); let orig_legacy = std::env::var("SOCKET_PATCH_TELEMETRY_DISABLED").ok(); - let orig_vitest = std::env::var("VITEST").ok(); let orig_offline = std::env::var("SOCKET_OFFLINE").ok(); // Default: not disabled std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); - std::env::remove_var("VITEST"); std::env::remove_var("SOCKET_OFFLINE"); assert!(!is_telemetry_disabled()); @@ -776,10 +777,6 @@ mod tests { Some(v) => std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", v), None => std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"), } - match orig_vitest { - Some(v) => std::env::set_var("VITEST", v), - None => std::env::remove_var("VITEST"), - } match orig_offline { Some(v) => std::env::set_var("SOCKET_OFFLINE", v), None => std::env::remove_var("SOCKET_OFFLINE"), diff --git a/crates/socket-patch-core/src/update/swap.rs b/crates/socket-patch-core/src/update/swap.rs index 9551975c..159aa031 100644 --- a/crates/socket-patch-core/src/update/swap.rs +++ b/crates/socket-patch-core/src/update/swap.rs @@ -71,7 +71,18 @@ pub fn acquire_update_lock() -> Result, UpdateError> { } match file.try_lock_exclusive() { Ok(()) => Ok(Some(UpdateLock { _file: file })), - Err(_) => Err(UpdateError::InProgress), + // Only a genuine contention errno is "another update is running"; + // every other flock(2) failure (ENOLCK on an NFS-homed state dir, + // ENOTSUP on a lockless filesystem, ...) must surface with its real + // cause instead of masquerading as `update_in_progress` — the same + // split `patch/apply_lock.rs` makes for apply.lock. + Err(e) if e.raw_os_error() == fs2::lock_contended_error().raw_os_error() => { + Err(UpdateError::InProgress) + } + Err(e) => Err(UpdateError::SwapFailed(format!( + "cannot lock {}: {e}", + path.display() + ))), } } diff --git a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs index 827e4673..acf89c0c 100644 --- a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs +++ b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs @@ -5,7 +5,7 @@ //! and the home-dir redaction were uncovered. //! //! Hardening notes: every disable-gate test runs inside `with_clean_env`, -//! which scrubs ALL four disabling vars first. Each test then proves +//! which scrubs ALL three disabling vars first. Each test then proves //! *causation*, not mere correlation: //! 1. clean env => NOT disabled (kills an always-`true` impl + ambient //! `SOCKET_OFFLINE=1` masking the result), @@ -21,7 +21,6 @@ use socket_patch_core::telemetry::{is_telemetry_disabled, sanitize_error_message const DISABLE_VARS: &[&str] = &[ "SOCKET_TELEMETRY_DISABLED", "SOCKET_PATCH_TELEMETRY_DISABLED", - "VITEST", "SOCKET_OFFLINE", ]; @@ -110,40 +109,6 @@ fn telemetry_not_disabled_when_socket_telemetry_disabled_falsy() { }); } -#[test] -#[serial] -fn telemetry_disabled_when_vitest_env_is_true() { - with_clean_env(|| { - assert!(!is_telemetry_disabled(), "baseline must be enabled"); - std::env::set_var("VITEST", "true"); - assert!( - is_telemetry_disabled(), - "VITEST=true must disable telemetry" - ); - std::env::remove_var("VITEST"); - assert!( - !is_telemetry_disabled(), - "removing VITEST must re-enable telemetry" - ); - }); -} - -/// VITEST is matched strictly against `"true"` (not "1"/truthy). Pin it so a -/// regression that loosens the comparison is caught. -#[test] -#[serial] -fn telemetry_not_disabled_when_vitest_is_not_literal_true() { - with_clean_env(|| { - for v in ["1", "", "false", "True", "TRUE", "yes"] { - std::env::set_var("VITEST", v); - assert!( - !is_telemetry_disabled(), - "VITEST={v:?} must NOT disable telemetry (only literal 'true' does)" - ); - } - }); -} - #[test] #[serial] fn telemetry_disabled_legacy_socket_patch_var_honored() { From fb68c53455181bdc3a369c519c86b6631e53ae68 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 19:46:09 -0400 Subject: [PATCH 08/44] fix(lock): classify a vanished-parent EINVAL open unconditionally, widen the retry bound The full-suite run tripped the new acquire/release hammer test with "failed to open lock file: Invalid argument (os error 22)": macOS reports an O_CREAT open inside a directory that a releaser rmdir'd a moment ago as EINVAL, and open_failure only treated that as the benign "vanished" race when the parent was STILL missing at classification time. Under load the competitor recreates the directory in between, so the race was misreported as a hard I/O fault. - open_failure: a Unix EINVAL from the open is always Vanished (we never pass invalid flags, so it has no other cause on this path). - VANISHED_LIMIT 16 -> 256 with a yield_now between attempts: each vanished outcome means a competitor completed a whole cycle, so the bound only guards against a pathological non-cooperating actor. Verified: apply_lock module 20/20; hammer test 25/25 in a loop; clippy. Co-Authored-By: Claude Fable 5.1 --- .../socket-patch-core/src/patch/apply_lock.rs | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-core/src/patch/apply_lock.rs b/crates/socket-patch-core/src/patch/apply_lock.rs index 3216b3dd..2399a70a 100644 --- a/crates/socket-patch-core/src/patch/apply_lock.rs +++ b/crates/socket-patch-core/src/patch/apply_lock.rs @@ -69,12 +69,21 @@ const SOCKET_DIR_NAME: &str = ".socket"; /// Longest single backoff sleep while waiting on a live holder. const BACKOFF_CAP: Duration = Duration::from_millis(100); -/// Consecutive "the file vanished under us" retries (open `NotFound`, or -/// a post-lock identity mismatch) before giving up with `Io`. Each one -/// is a releaser pruning `.socket/` between two of our steps; they cost -/// no sleep and are not contention, so they are bounded by count rather -/// than by `timeout`. -const VANISHED_LIMIT: u32 = 16; +/// Consecutive "the file vanished under us" retries (open `NotFound` / +/// `EINVAL`, or a post-lock identity mismatch) before giving up with +/// `Io`. Each one means a releaser pruned `.socket/` between two of our +/// steps — i.e. a competitor completed a whole acquire→release cycle — +/// so they are not contention and are bounded by count rather than by +/// `timeout`. The bound is generous: two processes hammering the lock +/// back-to-back (the unit tests do exactly that) can string dozens of +/// these together, each costing only a `yield_now`. +const VANISHED_LIMIT: u32 = 256; + +/// `EINVAL`: macOS reports an `O_CREAT` open inside a directory that was +/// rmdir'd a moment ago with this errno instead of `ENOENT`. Same value +/// on every Unix we build for; unused on Windows. +#[cfg(unix)] +const EINVAL: i32 = 22; /// Windows delete-pending grace: `attempts × sleep`, independent of /// `timeout` (a zero-timeout try-once still waits it out, because the @@ -224,6 +233,10 @@ pub fn acquire(socket_dir: &Path, timeout: Duration) -> Result { delete_pending += 1; @@ -355,6 +368,15 @@ fn open_failure(e: std::io::Error, path: &Path, socket_dir: &Path) -> Attempt { if e.kind() == ErrorKind::NotFound { return Attempt::Vanished; } + // Unconditional, not gated on a "is the parent gone right now" stat: + // a competitor can recreate the directory between our failed open + // and that probe, which would misreport this benign race as a fault + // (seen under full-suite load). We never pass invalid flags, so + // `EINVAL` on this open has no other cause. + #[cfg(unix)] + if e.raw_os_error() == Some(EINVAL) { + return Attempt::Vanished; + } if is_delete_pending(&e) { return Attempt::DeletePending(e); } From 4efdc8c2cec37ae321c62a633b925ddc23cfa816 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 19:43:57 -0400 Subject: [PATCH 09/44] scan(cli): report-only mode-less scan, hosted human parity, single ledger/inventory loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D3: a mode-less human `scan` (no --mode/--apply/--sync/--vendor/--redirect, no --prune) with a non-TTY stdin and no --yes now stops before the download with exit 0 and the get-hint — never downloads, never creates .socket/. The gate is a scan-side pre-check before confirm(); confirm() keeps its non-TTY auto-accept so every explicit-intent flag (and every other command) still proceeds unattended. --prune counts as intent. Hosted human arm: no longer returns straight into the redirect engine — it shares the results table and update detection with the other modes and confirms ("Redirect N package(s) to the hosted patch server?", default yes, skipped by --yes / --dry-run) before handing the selection to run_redirect_selected, the same entry `get --mode hosted` uses. D2 follow-through in scan: the four vendored-mode fall-throughs that let an empty discovery/selection reach the vendor step (re-vendor-from-manifest) are plain early returns; `--detached` is hidden (still requires vendored mode, still a no-op); update detection folds the vendor ledger's embedded records too (manifest > redirect ledger > vendor ledger), so manifest-free vendored projects keep their updates[] signal; the corrupt-ledger supplement fallback also recovers purls from the committed artifact leaves. Fewer redundant reads/copies: .socket/vendor/state.json is loaded once per run (supplement + prune/skip key set + updates); the lockfile inventory is parsed once (LockfileSupplement::entries feeds the hosted-wiring probe); the whole-manifest clones, the per-batch purl Vec and the third purl set are gone. Shared helpers replace the duplicated JSON-vs-human blocks: the detail-fetch loop (fetch_patch_details), the agent skip partition (partition_agent_selection + lockfile_only_contains), the \r-vs-plain progress pairs, the get-hint. The feature-gate relic install hint is one literal. Tests: discovery.rs merge/supplement/key-set units re-pinned + new vendored fold and leaf-recovery cases; mod.rs probe tests take the inventory explicitly; covgap_commands_scan_mod.rs gains the non-TTY report-only / explicit-intent / --prune / empty-discovery / hosted table+confirm tests and a PTY hosted-decline twin; cli_parse_scan.rs pins --detached hidden. Co-Authored-By: Claude Fable 5.1 --- .../src/commands/scan/discovery.rs | 329 ++++++-- .../socket-patch-cli/src/commands/scan/mod.rs | 710 ++++++++++-------- .../socket-patch-cli/tests/cli_parse_scan.rs | 25 + .../tests/covgap_commands_scan_mod.rs | 353 ++++++++- 4 files changed, 1044 insertions(+), 373 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index dd084a6f..66e220ce 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -4,9 +4,12 @@ use socket_patch_core::api::ranking::cmp_batch_infos; use socket_patch_core::api::types::{BatchPackagePatches, BatchPatchInfo, PatchSearchResult}; -use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; -use std::collections::HashSet; +use socket_patch_core::vendor::lock_inventory::LockfileEntry; +use socket_patch_core::vendor::VendorState; +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; use crate::args::GlobalArgs; @@ -27,6 +30,10 @@ pub(super) struct LockfileSupplement { pub(super) packages: Vec, /// Literal crawler-form purls, for fast membership tests. pub(super) purls: HashSet, + /// The FULL lockfile inventory the supplement was derived from (installed + /// packages included), kept so the hosted-wiring probes reuse it instead + /// of re-parsing every project lockfile. Empty for global scans. + pub(super) entries: Vec, /// npm layouts the lockfile inventory REFUSED (Plug'n'Play loaders) — /// packages structurally unreachable, as opposed to nothing-to-inventory. /// Scan surfaces these as explicit refusal warnings: under yarn PnP the @@ -93,7 +100,7 @@ pub(super) async fn lockfile_supplement( return out; } let crawled_purls: HashSet<&str> = crawled.iter().map(|p| p.purl.as_str()).collect(); - for entry in entries { + for entry in &entries { if crawled_purls.contains(entry.purl.as_str()) { continue; } @@ -103,9 +110,19 @@ pub(super) async fn lockfile_supplement( out.purls.insert(entry.purl.clone()); out.packages.push(pkg); } + out.entries = entries; out } +/// Whether an API-spelled purl (percent-encoded, possibly qualified) names +/// a lockfile-only package: `purls` holds the crawler's literal spelling, so +/// the comparison bridges the two via `normalize_purl`. The ONE predicate +/// behind the `notInstalled` flag, the `[NOT INSTALLED]` marker, the +/// `package_not_installed` skip partition and the vendor baseline pre-check. +pub(super) fn lockfile_only_contains(purls: &HashSet, api_purl: &str) -> bool { + purls.contains(normalize_purl(strip_purl_qualifiers(api_purl)).as_ref()) +} + /// A displayable crawl entry fabricated from a purl (decoded form). The /// path is a placeholder consumers degrade safely on. fn crawled_from_purl( @@ -130,19 +147,45 @@ fn crawled_from_purl( }) } +/// The vendor ledger's purl keys in every spelling the CLI matches on — the +/// ledger map key, its qualifier-stripped form, and the entry's base purl +/// (the same three `socket_patch_core::vendor::vendored_purl_keys` derives, +/// minus the load: `run` loads the ledger ONCE and shares it). Feeds the +/// prune exemption and the agent-path vendored skip. A corrupt ledger +/// degrades to the EMPTY set — fail-open by that helper's documented +/// contract (the supplement below is the fail-closed half). +pub(super) fn vendored_purl_keys(state: &std::io::Result) -> HashSet { + let Ok(state) = state else { + return HashSet::new(); + }; + state + .entries + .iter() + .flat_map(|(key, entry)| { + [ + key.clone(), + entry.base_purl.clone(), + strip_purl_qualifiers(key).to_string(), + ] + }) + .collect() +} + /// Vendored-ledger packages with no crawled counterpart: on a fresh clone /// the committed artifact IS the dependency, so these stay discoverable /// (updates[] detection, the table, and `scan --vendor` re-vendor/in-sync /// runs all keep working before any install). They are NOT "lockfile-only" -/// — nothing needs installing; the artifact satisfies the lock. +/// — nothing needs installing; the artifact satisfies the lock. `state` is +/// the ledger `run` already loaded (`vendor::load_state`). pub(super) async fn vendored_ledger_supplement( common: &GlobalArgs, crawled: &[socket_patch_core::crawlers::types::CrawledPackage], + state: &std::io::Result, ) -> Vec { if common.global || common.global_prefix.is_some() { return Vec::new(); } - let base_purls: Vec = match socket_patch_core::vendor::load_state(&common.cwd).await { + let base_purls: Vec = match state { Ok(state) => state .entries .values() @@ -180,33 +223,45 @@ pub(super) async fn vendored_ledger_supplement( } /// Fallback source for [`vendored_ledger_supplement`] when the vendor ledger -/// is unreadable: base purls of manifest entries whose patch uuid owns a -/// live `.socket/vendor//` artifact dir. `vendor_uuid_dir_rel` -/// validates the (committed, tamper-able) uuid grammar fail-closed before -/// any disk probe. Entries without a live artifact dir are NOT recovered — -/// nothing committed consumes them, so they stay prunable. +/// is unreadable — the committed ground truth, read two ways: +/// +/// 1. base purls of manifest entries whose patch uuid owns a live +/// `.socket/vendor//` artifact dir (legacy manifest-mode +/// vendored projects; `vendor_uuid_dir_rel` validates the committed, +/// tamper-able uuid grammar fail-closed before any disk probe — entries +/// without a live artifact dir are NOT recovered: nothing committed +/// consumes them, so they stay prunable); +/// 2. base purls reconstructed from the artifact leaves under every +/// canonical `.socket/vendor///` dir (`sweep_vendor_dirs`, +/// the documented external-tool recovery rule) — the only source for a +/// manifest-free vendored project, whose records live in the ledger +/// alone. +/// +/// Duplicates between the two are collapsed by the caller. async fn vendored_purls_from_artifacts(common: &GlobalArgs) -> Vec { use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::vendor::ecosystem_dir_for_purl; - use socket_patch_core::vendor::path::vendor_uuid_dir_rel; + use socket_patch_core::vendor::path::{sweep_vendor_dirs, vendor_uuid_dir_rel}; - let Ok(Some(manifest)) = read_manifest(common.resolved_manifest_path()).await else { - return Vec::new(); - }; let mut out = Vec::new(); - for (purl, record) in &manifest.patches { - let base = strip_purl_qualifiers(purl); - let Some(eco) = ecosystem_dir_for_purl(base) else { - continue; - }; - let Some(rel) = vendor_uuid_dir_rel(eco, &record.uuid) else { - continue; - }; - match tokio::fs::metadata(common.cwd.join(&rel)).await { - Ok(md) if md.is_dir() => out.push(base.to_string()), - _ => {} + if let Ok(Some(manifest)) = read_manifest(common.resolved_manifest_path()).await { + for (purl, record) in &manifest.patches { + let base = strip_purl_qualifiers(purl); + let Some(eco) = ecosystem_dir_for_purl(base) else { + continue; + }; + let Some(rel) = vendor_uuid_dir_rel(eco, &record.uuid) else { + continue; + }; + match tokio::fs::metadata(common.cwd.join(&rel)).await { + Ok(md) if md.is_dir() => out.push(base.to_string()), + _ => {} + } } } + for unit in sweep_vendor_dirs(&common.cwd).await { + out.extend(unit.purls); + } out } @@ -238,7 +293,7 @@ pub(super) async fn preverify_vendor_baselines( let base = strip_purl_qualifiers(&patch.purl); // Lockfile-only packages have no installed bytes to compare — the // vendor engine fetches them pristine (nothing to annotate). - if lockfile_only.contains(normalize_purl(base).as_ref()) { + if lockfile_only_contains(lockfile_only, base) { continue; } let Some(pkg) = crawled.iter().find(|c| purl_eq(&c.purl, base)) else { @@ -265,30 +320,52 @@ pub(super) async fn preverify_vendor_baselines( mismatched } -/// Fold the hosted redirect ledger's patch records into the manifest view -/// update detection consults. Hosted mode persists its purl→uuid records ONLY -/// in `.socket/vendor/redirect-state.json` — it never writes -/// `.socket/manifest.json` — so without this fold a pure hosted project's -/// `updates[]` (the documented CI signal, see CLI_CONTRACT.md) is structurally -/// empty and a superseding patch is never reported. An existing manifest entry -/// wins a collision (that PURL is manifest-owned), matching VEX's -/// `augment_with_redirect`. Pure / no I/O so it's unit-testable. -pub(super) fn merge_redirect_records_for_updates( - manifest: Option, +/// Fold both ledgers' patch records into the manifest view update detection +/// consults. Hosted mode persists its purl→uuid records ONLY in +/// `.socket/vendor/redirect-state.json`, and vendored mode ONLY in +/// `.socket/vendor/state.json` (each entry embeds its patch `record`) — +/// neither writes `.socket/manifest.json` — so without this fold a pure +/// hosted or vendored project's `updates[]` (the documented CI signal, see +/// CLI_CONTRACT.md) is structurally empty and a superseding patch is never +/// reported. Precedence on a collision: manifest > redirect ledger > vendor +/// ledger (a manifest PURL is manifest-owned, matching VEX's +/// `augment_with_redirect`). Vendor entries are keyed by their ledger map key +/// (the manifest-form purl, qualifiers included — `detect_updates` bridges +/// the spellings); a legacy entry without an embedded record contributes its +/// uuid alone, which is all update detection reads. Borrows the manifest +/// untouched when neither ledger contributes. Pure / no I/O so it's +/// unit-testable. +pub(super) fn merge_ledger_records_for_updates<'a>( + manifest: Option<&'a PatchManifest>, redirect: Option<&socket_patch_core::patch::redirect::RedirectState>, -) -> Option { - let records = redirect.map(|s| &s.records).filter(|r| !r.is_empty()); - let Some(records) = records else { - return manifest; - }; - let mut merged = manifest.unwrap_or_default(); - for (purl, record) in records { + vendor: Option<&VendorState>, +) -> Option> { + let redirect_records = redirect.map(|s| &s.records).filter(|r| !r.is_empty()); + let vendor_entries = vendor.map(|s| &s.entries).filter(|e| !e.is_empty()); + if redirect_records.is_none() && vendor_entries.is_none() { + return manifest.map(Cow::Borrowed); + } + let mut merged = manifest.cloned().unwrap_or_default(); + for (purl, record) in redirect_records.into_iter().flatten() { merged .patches .entry(purl.clone()) .or_insert_with(|| record.clone()); } - Some(merged) + for (purl, entry) in vendor_entries.into_iter().flatten() { + merged.patches.entry(purl.clone()).or_insert_with(|| { + entry.record.clone().unwrap_or_else(|| PatchRecord { + uuid: entry.uuid.clone(), + exported_at: String::new(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + }) + }); + } + Some(Cow::Owned(merged)) } /// Cross-reference an existing manifest against discovery results to find @@ -796,10 +873,11 @@ mod tests { assert_eq!(updates[0].new_uuid, "uuid-new"); } - // ---- merge_redirect_records_for_updates --------------------------------- - // Hosted mode records patches ONLY in the redirect ledger — these pin that - // ledger-only projects still surface `updates[]` (the documented CI - // signal) through the merged manifest view. + // ---- merge_ledger_records_for_updates ----------------------------------- + // Hosted mode records patches ONLY in the redirect ledger and vendored + // mode ONLY in the vendor ledger — these pin that ledger-only projects + // still surface `updates[]` (the documented CI signal) through the + // merged manifest view. fn ledger_with(entries: &[(&str, &str)]) -> socket_patch_core::patch::redirect::RedirectState { let mut state = socket_patch_core::patch::redirect::RedirectState::new(); @@ -808,6 +886,35 @@ mod tests { state } + /// A vendor ledger with one entry per `(key, uuid, detached)`: detached + /// entries embed their record (the D2 posture), legacy ones carry only + /// the uuid. + fn vendor_ledger_with(entries: &[(&str, &str, bool)]) -> VendorState { + let entries: serde_json::Map = entries + .iter() + .map(|(key, uuid, detached)| { + let record = crate::commands::scan::tests::manifest_with(&[(key, uuid)]) + .patches + .remove(*key) + .expect("manifest_with inserted the key"); + let mut entry = serde_json::json!({ + "ecosystem": "npm", + "basePurl": strip_purl_qualifiers(key), + "uuid": uuid, + "artifact": { "path": format!(".socket/vendor/npm/{uuid}/pkg.tgz") }, + "wiring": [], + "detached": detached, + }); + if *detached { + entry["record"] = serde_json::to_value(record).unwrap(); + } + ((*key).to_string(), entry) + }) + .collect(); + serde_json::from_value(serde_json::json!({ "version": 1, "entries": entries })) + .expect("the camelCase wire shape deserializes") + } + #[test] fn ledger_only_project_reports_superseding_patch_in_updates() { // Pure hosted project: NO .socket/manifest.json, one redirected patch @@ -815,67 +922,103 @@ mod tests { // uuid. The merged view must make detect_updates flag it — this was // structurally impossible before the fold (manifest-only detection). let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-old")]); - let merged = merge_redirect_records_for_updates(None, Some(&ledger)); + let merged = merge_ledger_records_for_updates(None, Some(&ledger), None); let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-new"])]; - let updates = detect_updates(merged.as_ref(), &pkgs); + let updates = detect_updates(merged.as_deref(), &pkgs); assert_eq!(updates.len(), 1); assert_eq!(updates[0].purl, "pkg:npm/foo@1.0"); assert_eq!(updates[0].old_uuid, "uuid-old"); assert_eq!(updates[0].new_uuid, "uuid-new"); } + #[test] + fn vendored_only_project_reports_superseding_patch_in_updates() { + // Pure vendored project (manifest-free, D2): the ledger entry's + // embedded record is the "old" side. A legacy entry with no embedded + // record still contributes its uuid — all detection reads. + for detached in [true, false] { + let vendor = vendor_ledger_with(&[("pkg:npm/foo@1.0", "uuid-old", detached)]); + let merged = merge_ledger_records_for_updates(None, None, Some(&vendor)); + let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-new"])]; + let updates = detect_updates(merged.as_deref(), &pkgs); + assert_eq!(updates.len(), 1, "detached={detached}"); + assert_eq!(updates[0].old_uuid, "uuid-old"); + assert_eq!(updates[0].new_uuid, "uuid-new"); + } + // Still the top offer — no nag. + let vendor = vendor_ledger_with(&[("pkg:npm/foo@1.0", "uuid-a", true)]); + let merged = merge_ledger_records_for_updates(None, None, Some(&vendor)); + let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-a"])]; + assert!(detect_updates(merged.as_deref(), &pkgs).is_empty()); + } + #[test] fn ledger_record_matching_the_candidate_is_not_an_update() { // The redirected patch is still the top offer — no nag. let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-a")]); - let merged = merge_redirect_records_for_updates(None, Some(&ledger)); + let merged = merge_ledger_records_for_updates(None, Some(&ledger), None); let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-a"])]; - assert!(detect_updates(merged.as_ref(), &pkgs).is_empty()); + assert!(detect_updates(merged.as_deref(), &pkgs).is_empty()); } #[test] fn manifest_entry_wins_a_collision_with_a_ledger_record() { - // A PURL present in both stores is manifest-owned (same precedence as - // VEX's augment_with_redirect): the manifest's uuid is the "old" side. + // A PURL present in every store is manifest-owned (same precedence as + // VEX's augment_with_redirect): the manifest's uuid is the "old" + // side; between the ledgers, the redirect record wins. let manifest = crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-manifest")]); let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-ledger")]); - let merged = merge_redirect_records_for_updates(Some(manifest), Some(&ledger)); + let vendor = vendor_ledger_with(&[("pkg:npm/foo@1.0", "uuid-vendor", true)]); + let merged = merge_ledger_records_for_updates(Some(&manifest), Some(&ledger), Some(&vendor)); let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-new"])]; - let updates = detect_updates(merged.as_ref(), &pkgs); + let updates = detect_updates(merged.as_deref(), &pkgs); assert_eq!(updates.len(), 1); assert_eq!(updates[0].old_uuid, "uuid-manifest"); + let merged = merge_ledger_records_for_updates(None, Some(&ledger), Some(&vendor)); + let updates = detect_updates(merged.as_deref(), &pkgs); + assert_eq!(updates[0].old_uuid, "uuid-ledger"); } #[test] fn ledger_and_manifest_cover_disjoint_purls() { // A mixed project (some deps applied via manifest, some hosted via - // ledger) gets update detection across BOTH stores. + // the redirect ledger, some vendored) gets update detection across + // every store. let manifest = crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-f1")]); let ledger = ledger_with(&[("pkg:npm/bar@2.0", "uuid-b1")]); - let merged = merge_redirect_records_for_updates(Some(manifest), Some(&ledger)); + let vendor = vendor_ledger_with(&[("pkg:npm/baz@3.0", "uuid-z1", true)]); + let merged = merge_ledger_records_for_updates(Some(&manifest), Some(&ledger), Some(&vendor)); let pkgs = vec![ batch_with("pkg:npm/foo@1.0", &["uuid-f2"]), batch_with("pkg:npm/bar@2.0", &["uuid-b2"]), + batch_with("pkg:npm/baz@3.0", &["uuid-z2"]), ]; - let mut updates = detect_updates(merged.as_ref(), &pkgs); + let mut updates = detect_updates(merged.as_deref(), &pkgs); updates.sort_by(|a, b| a.purl.cmp(&b.purl)); - assert_eq!(updates.len(), 2); + assert_eq!(updates.len(), 3); assert_eq!(updates[0].old_uuid, "uuid-b1"); - assert_eq!(updates[1].old_uuid, "uuid-f1"); + assert_eq!(updates[1].old_uuid, "uuid-z1"); + assert_eq!(updates[2].old_uuid, "uuid-f1"); } #[test] - fn absent_or_empty_ledger_leaves_the_manifest_view_untouched() { - assert!(merge_redirect_records_for_updates(None, None).is_none()); + fn absent_or_empty_ledgers_leave_the_manifest_view_untouched() { + assert!(merge_ledger_records_for_updates(None, None, None).is_none()); let empty = socket_patch_core::patch::redirect::RedirectState::new(); - assert!(merge_redirect_records_for_updates(None, Some(&empty)).is_none()); + let empty_vendor = VendorState::new(); + assert!(merge_ledger_records_for_updates(None, Some(&empty), Some(&empty_vendor)).is_none()); let manifest = crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); - let merged = merge_redirect_records_for_updates(Some(manifest.clone()), Some(&empty)); + let merged = merge_ledger_records_for_updates(Some(&manifest), Some(&empty), None) + .expect("manifest present"); + assert!( + matches!(merged, Cow::Borrowed(_)), + "empty ledgers must not clone the manifest" + ); assert_eq!( - merged.unwrap().patches.len(), + merged.patches.len(), manifest.patches.len(), "an empty ledger adds nothing" ); @@ -934,7 +1077,29 @@ mod tests { cwd: root.to_path_buf(), ..GlobalArgs::default() }; - vendored_ledger_supplement(&args, crawled).await + let state = socket_patch_core::vendor::load_state(root).await; + vendored_ledger_supplement(&args, crawled, &state).await + } + + /// The shared-load key set: every spelling the prune exemption and the + /// agent-path vendored skip match on, and EMPTY (fail-open) on a corrupt + /// ledger — the supplement's artifact fallback is the fail-closed half. + #[tokio::test] + async fn vendored_purl_keys_carry_every_spelling_and_degrade_to_empty() { + let state = vendor_ledger_with(&[("pkg:npm/%40scope/pkg@1.0.0?artifact_id=x", "u", true)]); + let keys = vendored_purl_keys(&Ok(state)); + for spelling in [ + "pkg:npm/%40scope/pkg@1.0.0?artifact_id=x", + "pkg:npm/%40scope/pkg@1.0.0", + ] { + assert!(keys.contains(spelling), "missing {spelling}: {keys:?}"); + } + assert!(vendored_purl_keys(&Ok(VendorState::new())).is_empty()); + let tmp = tempfile::tempdir().unwrap(); + seed_corrupt_ledger(tmp.path()); + let corrupt = socket_patch_core::vendor::load_state(tmp.path()).await; + assert!(corrupt.is_err(), "the fixture must be unreadable"); + assert!(vendored_purl_keys(&corrupt).is_empty()); } #[tokio::test] @@ -1052,6 +1217,36 @@ mod tests { assert!(supplement_in(tmp.path(), &crawled).await.is_empty()); } + #[tokio::test] + async fn corrupt_ledger_fallback_recovers_manifest_free_vendored_purls_from_leaves() { + // Manifest-free vendored project (the `scan --mode vendored` posture: + // records live in the ledger alone) with a corrupt ledger: the + // committed artifact leaves are the only ground truth left, and the + // documented leaf grammar recovers the purl. Non-uuid dirs and + // unparsable leaves stay out. + let tmp = tempfile::tempdir().unwrap(); + seed_corrupt_ledger(tmp.path()); + let uuid_dir = tmp + .path() + .join(format!(".socket/vendor/npm/{VENDORED_UUID}")); + std::fs::create_dir_all(&uuid_dir).unwrap(); + std::fs::write(uuid_dir.join("left-pad-1.3.0.tgz"), b"tgz").unwrap(); + std::fs::create_dir_all(tmp.path().join(".socket/vendor/npm/not-a-uuid")).unwrap(); + std::fs::write( + tmp.path().join(".socket/vendor/npm/not-a-uuid/ghost-9.9.9.tgz"), + b"tgz", + ) + .unwrap(); + + let out = supplement_in(tmp.path(), &[]).await; + assert_eq!( + out.iter().map(|p| p.purl.as_str()).collect::>(), + vec!["pkg:npm/left-pad@1.3.0"], + "a manifest-free vendored project must recover its purls from the \ + committed leaves when the ledger is unreadable" + ); + } + // ---- collect_vuln_ids -------------------------------------------------- /// Build a single-patch package whose patch carries the given CVE and diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 1eb74e99..6f75f42d 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -36,8 +36,9 @@ mod hosted; mod vendor_flow; use self::discovery::{ - collect_vuln_ids, detect_updates, lockfile_supplement, merge_redirect_records_for_updates, - preverify_vendor_baselines, severity_order, vendored_ledger_supplement, + collect_vuln_ids, detect_updates, lockfile_only_contains, lockfile_supplement, + merge_ledger_records_for_updates, preverify_vendor_baselines, severity_order, + vendored_ledger_supplement, vendored_purl_keys, LockfileSupplement, }; // Shared with `get --mode hosted|vendored` (commands::get): the advisory- // pinned entry into the hosted engine, the vendor step + its dry-run @@ -199,8 +200,11 @@ pub struct ScanArgs { /// lists available patches plus an `updates` array but does not mutate /// the manifest. Designed for unattended workflows (cron jobs, bots /// that open PRs); pair with `--yes` for clarity though `--json` - /// already implies non-interactive confirmation. No effect outside - /// `--json` mode (the non-JSON path always prompts the user). + /// already implies non-interactive confirmation. On the non-JSON path + /// it is an explicit intent flag: a TTY prompts before downloading and + /// a non-TTY run auto-proceeds, whereas a mode-less human `scan` + /// without `--yes` on a non-TTY stdin is report-only (exit 0, nothing + /// downloaded, no `.socket/` created). #[arg(long, default_value_t = false)] pub apply: bool, @@ -222,30 +226,27 @@ pub struct ScanArgs { pub sync: bool, /// Deprecated spelling of `--mode vendored` (kept for compatibility; - /// prefer `--mode`). Vendor every patched dependency into the - /// committable `.socket/vendor/` tree instead of applying patches in - /// place: download the selected patches, record them in the manifest, - /// then build + wire the vendored artifacts (the whole manifest is - /// vendored, so a package vendored at an older patch uuid is - /// re-vendored automatically). Conflicts with `--apply`/`--sync` - /// (vendoring replaces the in-place apply); combine with `--prune` - /// to drop uninstalled entries before they fail vendoring. JSON mode - /// is non-interactive like `--apply`; the interactive path prompts - /// before downloading. + /// prefer `--mode`). Vendor every patched dependency the scan selects + /// into the committable `.socket/vendor/` tree instead of applying + /// patches in place: the selected patch records are fetched in memory + /// (never written to `.socket/manifest.json` — the vendor ledger, + /// `.socket/vendor/state.json`, embeds each record), then the vendored + /// artifacts are built + wired; a package vendored at an older patch + /// uuid is re-vendored automatically. Conflicts with `--apply`/`--sync` + /// (vendoring replaces the in-place apply); combine with `--prune` to + /// garbage-collect stale state. JSON mode is non-interactive like + /// `--apply`; the interactive path prompts before downloading. #[arg(long, default_value_t = false, conflicts_with_all = ["apply", "sync"])] pub vendor: bool, - /// With vendored mode (`--mode vendored` / `--vendor`): do not write - /// `.socket/manifest.json` entries — the vendor ledger - /// (`.socket/vendor/state.json`) carries an embedded copy of each - /// patch record instead. Detached patches are invisible to - /// apply/rollback/repair (nothing is in the manifest); they are - /// undone per-purl via `remove ` or wholesale via - /// `vendor --revert`, and are exempt from `vendor`'s manifest - /// reconcile. The vendored-mode requirement is enforced in - /// `resolve_mode_flags` (not clap `requires`) so `--mode vendored` + /// Accepted for compatibility (hidden): vendored mode is always + /// manifest-free — the vendor ledger (`.socket/vendor/state.json`) + /// embeds each patch record and `.socket/manifest.json` is never + /// written — so the flag is a no-op. It still requires vendored mode in + /// either spelling (`--mode vendored` / `--vendor`), enforced in + /// `resolve_mode_flags` rather than clap `requires` so `--mode vendored` /// satisfies it too. - #[arg(long, default_value_t = false)] + #[arg(long, default_value_t = false, hide = true)] pub detached: bool, /// Redirect every patched dependency to Socket's HOSTED vendored patches @@ -425,32 +426,68 @@ async fn discover_selected( packages: &[BatchPackagePatches], can_access_paid_patches: bool, ) -> Result, (i32, String)> { - let mut all_search_results: Vec = Vec::new(); + let (all_search_results, error_count, last_error) = + fetch_patch_details(api_client, org_slug, packages, false, false).await; + if error_count > 0 && error_count == packages.len() { + let err = last_error.unwrap_or_else(|| "all patch-detail queries failed".to_string()); + let message = format!("all {error_count} patch-detail queries failed: {err}"); + eprintln!("Error: {message}"); + return Err((1, message)); + } + if all_search_results.is_empty() { + return Ok(Vec::new()); + } + select_patches(&all_search_results, can_access_paid_patches, false) + .map_err(|code| (code, "patch selection failed".to_string())) +} + +/// One `search_patches_by_package` query per package with patches, merged +/// into one result list — the detail-fetch loop the apply, vendor, redirect +/// and human-preview flows share. Returns the merged results plus the +/// number of failed queries and the last error text; the CALLERS own the +/// failure rule ([`discover_selected`] bails only when every query errored, +/// the human arm treats an empty merged set as a fetch failure). The two +/// output knobs are human-only: `show_progress` renders the +/// `\r`-overwriting counter on stderr, `warn` the per-package failure line. +async fn fetch_patch_details( + api_client: &socket_patch_core::api::client::ApiClient, + org_slug: Option<&str>, + packages: &[BatchPackagePatches], + show_progress: bool, + warn: bool, +) -> (Vec, usize, Option) { + let mut results: Vec = Vec::new(); let mut error_count = 0usize; let mut last_error: Option = None; - for pkg in packages { + if show_progress && !packages.is_empty() { + eprint!("\nFetching patch details..."); + } + for (i, pkg) in packages.iter().enumerate() { + if show_progress { + eprint!( + "\rFetching patch details... ({}/{})", + i + 1, + packages.len() + ); + } match api_client .search_patches_by_package(org_slug, &pkg.purl) .await { - Ok(response) => all_search_results.extend(response.patches), + Ok(response) => results.extend(response.patches), Err(e) => { + if warn { + eprintln!("\n Warning: could not fetch details for {}: {e}", pkg.purl); + } error_count += 1; last_error = Some(e.to_string()); } } } - if error_count > 0 && error_count == packages.len() { - let err = last_error.unwrap_or_else(|| "all patch-detail queries failed".to_string()); - let message = format!("all {error_count} patch-detail queries failed: {err}"); - eprintln!("Error: {message}"); - return Err((1, message)); - } - if all_search_results.is_empty() { - return Ok(Vec::new()); + if show_progress && !packages.is_empty() { + eprintln!(); } - select_patches(&all_search_results, can_access_paid_patches, false) - .map_err(|code| (code, "patch selection failed".to_string())) + (results, error_count, last_error) } /// Fold a [`discover_selected`] failure into a JSON caller's `result` and @@ -468,9 +505,79 @@ fn emit_discovery_error_json(result: &mut serde_json::Value, message: &str) { ); } +/// The report-only / declined-prompt hint: how to consume one patch +/// explicitly. Hosted runs name their mode (`get` defaults to agent mode). +fn print_get_hint(hosted: bool) { + let (action, mode) = if hosted { + ("redirect a package", " --mode hosted") + } else { + ("apply a patch", "") + }; + println!("\nTo {action}, run:"); + println!(" socket-patch get {mode}"); + println!(" socket-patch get {mode}"); +} + +/// The agent-flow selection split both arms (JSON + human) share. Vendor- +/// owned purls leave first (any uuid: the committed artifact IS the patch, +/// and a manifest moved past the vendored uuid would break VEX verification +/// until a vendor run refreshes the artifact — a newer patch still surfaces +/// in `updates[]`, the operator's signal to run `scan --vendor`), then +/// lockfile-only purls (nothing installed to patch in place; `scan --vendor` +/// fetches them pristine). Both classes become calm `skipped` records — +/// never an error. +struct AgentSelection { + /// What is left to download + apply. + kept: Vec, + /// Every skip record (`vendored` + `package_not_installed`), purl-sorted, + /// in the `{purl, uuid, action: "skipped", errorCode}` shape the apply + /// report folds in. + skip_records: Vec, + /// The vendored partition's purls alone — feeds the run-level + /// `vendored_ownership_retained` warning and the human `[skip]` lines. + vendored_purls: Vec, + /// The lockfile-only partition's purls alone (human `[skip]` lines). + not_installed_purls: Vec, +} + +fn partition_agent_selection( + selected: Vec, + vendored: &HashSet, + lockfile_only: &LockfileSupplement, +) -> AgentSelection { + let (kept, vendored_records) = partition_skipped_selected( + selected, + |p| vendored.contains(p) || vendored.contains(strip_purl_qualifiers(p)), + "vendored", + ); + let (kept, not_installed_records) = partition_skipped_selected( + kept, + |p| lockfile_only_contains(&lockfile_only.purls, p), + "package_not_installed", + ); + let purls_of = |records: &[serde_json::Value]| -> Vec { + records + .iter() + .filter_map(|r| r["purl"].as_str().map(str::to_string)) + .collect() + }; + let vendored_purls = purls_of(&vendored_records); + let not_installed_purls = purls_of(¬_installed_records); + let mut skip_records = vendored_records; + skip_records.extend(not_installed_records); + skip_records.sort_by(|a, b| a["purl"].as_str().cmp(&b["purl"].as_str())); + AgentSelection { + kept, + skip_records, + vendored_purls, + not_installed_purls, + } +} + /// The `DownloadParams` every scan-driven download shares. Only the output -/// shape (`json`/`silent`) and `save_only` differ per flow; vendor mode -/// never persists blobs (the vendor step consumes the staged sources). +/// shape (`json`/`silent`) and `save_only` differ per flow; vendored mode +/// never persists blobs (its records stay in memory and the vendor step +/// consumes the staged sources). fn download_params(args: &ScanArgs, save_only: bool, json: bool, silent: bool) -> DownloadParams { DownloadParams { cwd: args.common.cwd.clone(), @@ -1183,10 +1290,16 @@ pub(super) const VENDORED_OWNERSHIP_RETAINED: &str = "vendored_ownership_retaine /// * the live lock does not prove hosted wiring (registry-clean lock, an /// ecosystem whose lock we cannot read) — never guess from ledger /// presence alone. +/// +/// `inventory` is the run's lockfile inventory (`LockfileSupplement:: +/// entries`, parsed once per run) — the same set the inventory proof in +/// [`hosted_wiring_live`] reads; the text proof reads the recorded lockfiles +/// directly. pub(super) async fn hosted_wiring_retained_purls( cwd: &Path, redirect_state: Option<&socket_patch_core::patch::redirect::RedirectState>, - scanned_purls: &HashSet, + scanned_purls: impl IntoIterator>, + inventory: &[socket_patch_core::vendor::lock_inventory::LockfileEntry], ) -> Vec { let Some(redirect) = redirect_state else { return Vec::new(); @@ -1195,12 +1308,13 @@ pub(super) async fn hosted_wiring_retained_purls( return Vec::new(); } let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); - let scanned: std::collections::BTreeSet = - scanned_purls.iter().map(|p| canon(p)).collect(); + let scanned: std::collections::BTreeSet = scanned_purls + .into_iter() + .map(|p| canon(p.as_ref())) + .collect(); // Cheap no-I/O gate: only ledger records naming a scanned purl can ever // prove live wiring, so when none do (a zero/filtered discovery, or a - // ledger about other packages) skip the lockfile inventory below — a - // full multi-file lock parse — entirely. + // ledger about other packages) skip the lockfile proofs below entirely. let candidates: Vec<(String, &str)> = redirect .records .iter() @@ -1213,10 +1327,9 @@ pub(super) async fn hosted_wiring_retained_purls( let mut redirect_files: Vec<&str> = redirect.edits.iter().map(|e| e.path.as_str()).collect(); redirect_files.sort(); redirect_files.dedup(); - let inventory = socket_patch_core::vendor::lock_inventory::inventory_project(cwd).await; let mut out = Vec::new(); for (purl, uuid) in candidates { - if hosted_wiring_live(cwd, &purl, Some(uuid), &redirect_files, &inventory).await { + if hosted_wiring_live(cwd, &purl, Some(uuid), &redirect_files, inventory).await { out.push(purl); } } @@ -1533,7 +1646,15 @@ pub async fn run(mut args: ScanArgs) -> i32 { } all_crawled.extend(lockfile_only.packages.iter().cloned()); } - let ledger_supplement = vendored_ledger_supplement(&args.common, &all_crawled).await; + // The vendor ledger, loaded ONCE and shared by the supplement here, the + // prune-exemption / vendored-skip key set below, and update detection — + // three read-only consumers of the same bytes. Their failure policies + // stay distinct on purpose: the supplement falls back to the committed + // artifacts (fail-closed for the prune), the key set degrades to empty + // (fail-open, its documented contract). + let vendor_state = socket_patch_core::vendor::load_state(&args.common.cwd).await; + let ledger_supplement = + vendored_ledger_supplement(&args.common, &all_crawled, &vendor_state).await; for pkg in &ledger_supplement { if let Some(eco) = Ecosystem::from_purl(&pkg.purl) { *eco_counts.entry(eco).or_insert(0) += 1; @@ -1555,11 +1676,11 @@ pub async fn run(mut args: ScanArgs) -> i32 { // is wiped or partially installed. let scanned_purls: HashSet = all_crawled.iter().map(|p| p.purl.clone()).collect(); - // Vendor-ledger purl keys, loaded once and shared by the prune - // exemption (a vendored package is consumed from the committed + // Vendor-ledger purl keys (from the single load above), shared by the + // prune exemption (a vendored package is consumed from the committed // artifact, so "absent from the crawl" is its normal state, not // grounds for pruning) and the vendored-skip in the apply path. - let vendored_purls = socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await; + let vendored_purls = vendored_purl_keys(&vendor_state); // Filter by --ecosystems if provided let filtered_crawled: Vec<_> = if let Some(ref allowed) = args.common.ecosystems { @@ -1722,13 +1843,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { } else if args.common.global || args.common.global_prefix.is_some() { println!("No global packages found."); } else { - #[allow(unused_mut)] - let mut install_cmds = String::from("npm/yarn/pnpm/pip"); - install_cmds.push_str("/cargo"); - install_cmds.push_str("/go"); - install_cmds.push_str("/mvn"); - install_cmds.push_str("/composer"); - println!("No packages found. Run {install_cmds} install first."); + println!("No packages found. Run your package manager's install first."); } return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } @@ -1758,12 +1873,12 @@ pub async fn run(mut args: ScanArgs) -> i32 { format!(" ({})", eco_parts.join(", ")) }; + // With progress on, a done-line overwrites the in-progress `eprint!` + // line before it (`\r`); otherwise it prints plain. + let cr = if show_progress { "\r" } else { "" }; + if !args.common.json && !args.common.silent { - if show_progress { - eprintln!("\rFound {package_count} packages{eco_summary}"); - } else { - eprintln!("Found {package_count} packages{eco_summary}"); - } + eprintln!("{cr}Found {package_count} packages{eco_summary}"); if !lockfile_only.purls.is_empty() { eprintln!( "Note: {} package(s) from project lockfiles are not yet installed (lockfile-only).", @@ -1798,9 +1913,8 @@ pub async fn run(mut args: ScanArgs) -> i32 { ); } - let purls: Vec = chunk.to_vec(); let mut result = api_client - .search_patches_batch(effective_org_slug, &purls) + .search_patches_batch(effective_org_slug, chunk) .await; // Fallback: a 401/403 against the authenticated endpoint can @@ -1820,7 +1934,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { use_public_proxy = true; fallback_to_proxy = true; result = api_client - .search_patches_batch(effective_org_slug, &purls) + .search_patches_batch(effective_org_slug, chunk) .await; } } @@ -1904,21 +2018,12 @@ pub async fn run(mut args: ScanArgs) -> i32 { if !args.common.json && !args.common.silent { if total_patches_found > 0 { - if show_progress { - eprintln!( - "\rFound {total_patches_found} patches for {} packages", - all_packages_with_patches.len() - ); - } else { - eprintln!( - "Found {total_patches_found} patches for {} packages", - all_packages_with_patches.len() - ); - } - } else if show_progress { - eprintln!("\rAPI query complete"); + eprintln!( + "{cr}Found {total_patches_found} patches for {} packages", + all_packages_with_patches.len() + ); } else { - eprintln!("API query complete"); + eprintln!("{cr}API query complete"); } } @@ -1956,53 +2061,37 @@ pub async fn run(mut args: ScanArgs) -> i32 { ) .await; - // Registry-redirect (hosted) mode is a distinct, self-contained flow - // (rewrite lockfiles → hosted vendored patches). It reuses discovery - // above, then returns — it must NOT fall through to the apply/vendor - // branches. The HUMAN path returns here; the `--json` path returns from - // inside the JSON block below (after building the classic scan object) - // so the redirect result can be NESTED under a `redirect` key — keeping - // the hosted `--json` envelope schema-consistent with the zero-discovery - // and non-hosted paths (mirroring vendored mode's nested `vendor` block) - // rather than replacing the whole envelope with a bare `{status, redirect}`. - if hosted && !args.common.json { - return run_redirect( - &args, - &api_client, - effective_org_slug, - &all_packages_with_patches, - can_access_paid_patches, - None, - ) - .await; - } - // Read existing manifest once for update detection. Used by both the // JSON-mode emission (always includes an `updates` array) and the // non-JSON table-print path (counts `updates_available`). // (`manifest_path`/`socket_dir` are resolved at the top of `run`.) let existing_manifest = read_manifest(&manifest_path).await.ok().flatten(); - // Hosted mode records its patches ONLY in the redirect ledger (it never - // writes the manifest), so fold the ledger's purl→uuid records into the - // view update detection sees — otherwise a pure hosted project's - // `updates[]` (the documented CI signal) stays structurally empty and a - // superseding patch is never reported. The envelope schema is unchanged. - // A malformed ledger is only warned about here (and muted by --silent — - // the warning is advisory) — this is a read-only consult, and the hosted - // write path hard-errors on it. + // Hosted and vendored modes record their patches ONLY in their ledgers + // (neither writes the manifest), so fold both ledgers' purl→uuid records + // into the view update detection sees — otherwise a pure hosted or + // vendored project's `updates[]` (the documented CI signal) stays + // structurally empty and a superseding patch is never reported. The + // envelope schema is unchanged. A malformed redirect ledger is only + // warned about here (and muted by --silent — the warning is advisory) + // — this is a read-only consult, and the hosted write path hard-errors + // on it; a malformed vendor ledger contributes nothing (the supplement + // above already recovered its purls from the committed artifacts). let redirect_state = crate::commands::load_redirect_state_lenient(&args.common.cwd, args.common.silent).await; - let update_manifest = - merge_redirect_records_for_updates(existing_manifest.clone(), redirect_state.as_ref()); - let updates = detect_updates(update_manifest.as_ref(), &all_packages_with_patches); - - // Post-filter scanned set for the hosted-wiring probes: `wiringLive` and - // the agent-flow `hosted_wiring_retained` warning only ever name - // packages this run actually counted/queried (an `--ecosystems` filter - // narrows both — a filtered-out purl reads as "not covered this run", - // never as "wiring unwound"). Distinct from `scanned_purls` above, which - // deliberately stays PRE-filter for the GC prune (see its comment). - let wiring_scanned: HashSet = all_purls.iter().cloned().collect(); + let update_manifest = merge_ledger_records_for_updates( + existing_manifest.as_ref(), + redirect_state.as_ref(), + vendor_state.as_ref().ok(), + ); + let updates = detect_updates(update_manifest.as_deref(), &all_packages_with_patches); + + // The hosted-wiring probes below (`wiringLive`, the agent-flow + // `hosted_wiring_retained` warning) take `all_purls` — the POST-filter + // scanned set: they only ever name packages this run actually + // counted/queried (an `--ecosystems` filter narrows both — a + // filtered-out purl reads as "not covered this run", never as "wiring + // unwound"). Distinct from `scanned_purls` above, which deliberately + // stays PRE-filter for the GC prune (see its comment). if args.common.json { let mut result = serde_json::json!({ @@ -2075,14 +2164,19 @@ pub async fn run(mut args: ScanArgs) -> i32 { // reason (its takeover reconciliation may retire ledger records // mid-run — the `vendor_supersedes_redirect` warning covers it). // - // The live-wiring probe (a full lockfile-inventory parse behind its - // cheap no-I/O gate) runs ONCE here and is shared with the agent-flow - // warning in the apply branch below. + // The live-wiring probe (over the run's lockfile inventory, behind + // its cheap no-I/O gate) runs ONCE here and is shared with the + // agent-flow warning in the apply branch below. let hosted_retained = if vendor { Vec::new() } else { - hosted_wiring_retained_purls(&args.common.cwd, redirect_state.as_ref(), &wiring_scanned) - .await + hosted_wiring_retained_purls( + &args.common.cwd, + redirect_state.as_ref(), + &all_purls, + &lockfile_only.entries, + ) + .await }; if !vendor { if let Some(state) = redirect_state_json(redirect_state.as_ref(), &hosted_retained) { @@ -2114,52 +2208,28 @@ pub async fn run(mut args: ScanArgs) -> i32 { } }; - // Vendor-owned purls are skipped BEFORE download (any uuid); - // a newer patch still surfaces in `updates[]` — the - // operator's signal to run `scan --vendor` (or `vendor`). - let (selected, vendored_records) = partition_skipped_selected( - selected, - |p| vendored_purls.contains(p) || vendored_purls.contains(strip_purl_qualifiers(p)), - "vendored", - ); - // Captured from the vendored partition ONLY (before the - // not-installed skips merge in below — those are a different, - // already-calm class): feeds the run-level + // Vendor-owned and lockfile-only purls leave the selection as + // calm skip records BEFORE download (see `partition_agent_ + // selection`); the vendored purls alone feed the run-level // `vendored_ownership_retained` warning emitted after apply. - let vendored_skip_purls: Vec = vendored_records - .iter() - .filter_map(|r| r["purl"].as_str().map(str::to_string)) - .collect(); - // Lockfile-only purls leave the apply selection here (calm - // skip records, never an error); the union rides the same - // bookkeeping as the vendored skips. - let (selected, vendored_records) = { - let (kept, not_installed) = partition_skipped_selected( - selected, - |p| { - lockfile_only - .purls - .contains(normalize_purl(strip_purl_qualifiers(p)).as_ref()) - }, - "package_not_installed", - ); - let mut all = vendored_records; - all.extend(not_installed); - all.sort_by(|a, b| a["purl"].as_str().cmp(&b["purl"].as_str())); - (kept, all) - }; + let AgentSelection { + kept: selected, + skip_records: vendored_records, + vendored_purls: vendored_skip_purls, + .. + } = partition_agent_selection(selected, &vendored_purls, &lockfile_only); if dry { // Synthesize the per-patch outcome without touching disk. // `decide_patch_action` consults the existing manifest, // so it accurately reports what `--apply` *would* do. - let manifest_for_preview = - existing_manifest.clone().unwrap_or_else(PatchManifest::new); + let empty_manifest = PatchManifest::new(); + let manifest_for_preview = existing_manifest.as_ref().unwrap_or(&empty_manifest); let mut patches: Vec = selected .iter() .map(|p| { match super::get::decide_patch_action( - &manifest_for_preview, + manifest_for_preview, &p.purl, &p.uuid, ) { @@ -2300,28 +2370,22 @@ pub async fn run(mut args: ScanArgs) -> i32 { let use_color = std::io::stdout().is_terminal(); + // Every mode stops on an empty discovery — vendored mode included: scan + // vendors what THIS discovery selects (a fresh clone or wiped + // `.socket/vendor/` is `repair`'s job, from the committed ledger), so + // there is nothing for its vendor step to do and reaching it would only + // take the apply lock for a no-op. if all_packages_with_patches.is_empty() { if !args.common.silent { println!("\nNo patches available for installed packages."); } - // Vendored mode still has work to do on an empty discovery: the - // committed manifest is re-vendored wholesale, which is how a - // fresh clone (or a wiped `.socket/vendor/`) gets its artifacts - // back. The JSON arm states this outright — "the vendor step - // still runs when zero patches were downloaded (re-vendor after a - // wipe)" — and `selected.is_empty() && !vendor` below encodes the - // same rule; without this the interactive arm never reaches it. - if !vendor { - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; - } + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } // The whole table + summary section is presentational only (nothing // computed inside is consumed downstream), so `--silent` skips it - // wholesale — as does an empty discovery, which vendored mode now - // falls through with (an all-header, no-row table plus a "0 package(s)" - // summary is noise, not information). - if !args.common.silent && !all_packages_with_patches.is_empty() { + // wholesale. + if !args.common.silent { let mut updates_available = 0usize; // Canonical set of PURLs with a newer patch available, computed once via @@ -2403,10 +2467,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { // `normalize_purl` bridges the API's percent-encoded spelling // to the supplement's literal form, like the JSON flag and the // apply-path skip partitions. - let not_installed_marker = if lockfile_only - .purls - .contains(normalize_purl(strip_purl_qualifiers(&pkg.purl)).as_ref()) - { + let not_installed_marker = if lockfile_only_contains(&lockfile_only.purls, &pkg.purl) { color(" [NOT INSTALLED]", "33", use_color) } else { String::new() @@ -2468,6 +2529,59 @@ pub async fn run(mut args: ScanArgs) -> i32 { } } + // Registry-redirect (hosted) mode is a distinct, self-contained flow + // (rewrite lockfiles → hosted vendored patches). It reuses the + // discovery, table and update detection above, confirms, then hands + // the selection to the redirect engine — it must NOT fall through to + // the apply/vendor branches. Same discovery/selection as `run_redirect` + // (the `--json` arm, which returned above with the redirect result + // NESTED in its envelope) and the same engine entry as `get --mode + // hosted`. + if hosted { + let selected = match discover_selected( + &api_client, + effective_org_slug, + &all_packages_with_patches, + can_access_paid_patches, + ) + .await + { + Ok(s) => s, + // `discover_selected` already printed the failure to stderr. + Err((code, _)) => return code, + }; + // The engine honors `--dry-run` itself (a preview mutates nothing), + // so only a wet run with work confirms. `--mode hosted` is explicit + // intent, so a non-TTY run auto-proceeds like every other mode — + // only the mode-less scan below is report-only. + if !selected.is_empty() && !args.common.dry_run { + let prompt = format!( + "Redirect {} package(s) to the hosted patch server?", + selected.len() + ); + if !confirm(&prompt, true, args.common.yes, false) { + if !args.common.silent { + print_get_hint(true); + } + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + } + } + let pairs: Vec<(String, String)> = selected + .iter() + .map(|s| (s.purl.clone(), s.uuid.clone())) + .collect(); + return boxed_run_redirect_selected( + &args.common, + &args.vex, + prune, + &api_client, + effective_org_slug, + &pairs, + None, + ) + .await; + } + // Count downloadable patches let downloadable_count = if can_access_paid_patches { all_packages_with_patches.len() @@ -2479,57 +2593,25 @@ pub async fn run(mut args: ScanArgs) -> i32 { }; if downloadable_count == 0 { - // The paid-plan nudge only makes sense when the API DID return - // patches; with an empty discovery (vendored mode falls through - // the guard above) there is no gated catalog to point at. - if !args.common.silent && !all_packages_with_patches.is_empty() { + if !args.common.silent { println!("\nNo downloadable patches (paid subscription required)."); } - // Same reason as above: vendored mode re-vendors the committed - // manifest regardless of what discovery turned up. - if !vendor { - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; - } - } - - // Fetch full PatchSearchResult for each package that has patches - if show_progress && !all_packages_with_patches.is_empty() { - eprint!("\nFetching patch details..."); - } - - let mut all_search_results: Vec = Vec::new(); - for (i, pkg) in all_packages_with_patches.iter().enumerate() { - if show_progress { - eprint!( - "\rFetching patch details... ({}/{})", - i + 1, - all_packages_with_patches.len() - ); - } - match api_client - .search_patches_by_package(effective_org_slug, &pkg.purl) - .await - { - Ok(response) => { - all_search_results.extend(response.patches); - } - Err(e) => { - if !args.common.silent { - eprintln!("\n Warning: could not fetch details for {}: {e}", pkg.purl); - } - } - } - } - - if show_progress && !all_packages_with_patches.is_empty() { - eprintln!(); + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } - // Empty details are a failure only when there WERE packages to fetch - // details for. Vendored mode now reaches here with nothing discovered - // (see the two guards above) and must fall through to the vendor step - // rather than report a fetch failure that never happened. - if all_search_results.is_empty() && !all_packages_with_patches.is_empty() { + // Fetch the full per-package patch lists — the same loop the JSON arms + // run through `discover_selected`, here with progress + per-package + // warnings. Discovery said these packages HAVE patches, so an empty + // merged set is a fetch failure. + let (all_search_results, _, _) = fetch_patch_details( + &api_client, + effective_org_slug, + &all_packages_with_patches, + show_progress, + !args.common.silent, + ) + .await; + if all_search_results.is_empty() { eprintln!("Could not fetch patch details."); return 1; } @@ -2541,59 +2623,34 @@ pub async fn run(mut args: ScanArgs) -> i32 { Err(code) => return code, }; - // Vendor-owned purls never download/apply here (mirrors the JSON - // path): the committed artifact is the patch, and a manifest moved - // past the vendored uuid would break VEX verification until a vendor - // run refreshes the artifact. In `--vendor` mode the partition is a - // no-op — re-vendoring a stale uuid is exactly what the flag is for. - let is_vendored = - |p: &str| vendored_purls.contains(p) || vendored_purls.contains(strip_purl_qualifiers(p)); - let (vendored_selected, selected): (Vec<_>, Vec<_>) = if vendor { - (Vec::new(), selected) + // Agent flow (mirrors the JSON arm): vendor-owned and lockfile-only + // purls leave the selection as calm skips. In vendored mode nothing is + // partitioned — re-vendoring a stale uuid is exactly what the flag is + // for, and the vendor engine fetches lockfile-resolved packages + // pristine. + let selected = if vendor { + selected } else { - selected.into_iter().partition(|p| is_vendored(&p.purl)) - }; - if !args.common.silent { - for p in &vendored_selected { - println!( - " [skip] {} (vendored — run scan --vendor to update)", - normalize_purl(&p.purl) - ); + let split = partition_agent_selection(selected, &vendored_purls, &lockfile_only); + if !args.common.silent { + for purl in &split.vendored_purls { + println!( + " [skip] {} (vendored — run scan --vendor to update)", + normalize_purl(purl) + ); + } + for purl in &split.not_installed_purls { + println!( + " [skip] {} (not installed — run your package manager's install first, \ + or `scan --vendor` to vendor it from the lockfile)", + normalize_purl(purl) + ); + } } - } - - // Lockfile-only purls leave the in-place apply selection (calm skip, - // mirrors the JSON path). In `--vendor` mode they stay: the vendor - // engine fetches lockfile-resolved packages pristine. - let (selected, not_installed_selected): (Vec<_>, Vec) = if vendor { - (selected, Vec::new()) - } else { - let (kept, skipped) = partition_skipped_selected( - selected, - |p| { - lockfile_only - .purls - .contains(normalize_purl(strip_purl_qualifiers(p)).as_ref()) - }, - "package_not_installed", - ); - let printed: Vec = skipped - .iter() - .filter_map(|r| r["purl"].as_str().map(str::to_string)) - .collect(); - (kept, printed) + split.kept }; - if !args.common.silent { - for purl in ¬_installed_selected { - println!( - " [skip] {} (not installed — run your package manager's install first, \ - or `scan --vendor` to vendor it from the lockfile)", - normalize_purl(purl) - ); - } - } - if selected.is_empty() && !vendor { + if selected.is_empty() { if !args.common.silent { println!("No patches selected."); } @@ -2716,14 +2773,22 @@ pub async fn run(mut args: ScanArgs) -> i32 { return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } - // Prompt to download + // Prompt to download. A MODE-LESS human scan (no `--mode`/`--apply`/ + // `--sync`/`--vendor`/`--redirect` and no `--prune`) with a non-TTY + // stdin and no `--yes` is report-only: it stops here with exit 0 and + // the hint below, never downloads, never creates `.socket/`. This is a + // scan-side pre-check — `confirm()` itself keeps its non-TTY + // auto-accept, so every explicit-intent flag (and every other command's + // prompt) still proceeds unattended, and a TTY always prompts. let verb = if vendor { "vendor" } else { "apply" }; let prompt = format!("Download and {verb} {} patch(es)?", selected.len()); - if !confirm(&prompt, true, args.common.yes, args.common.json) { + let report_only = args.mode.is_none() + && !args.prune + && !args.common.yes + && !crate::output::stdin_is_tty(); + if report_only || !confirm(&prompt, true, args.common.yes, false) { if !args.common.silent { - println!("\nTo apply a patch, run:"); - println!(" socket-patch get "); - println!(" socket-patch get "); + print_get_hint(false); } return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } @@ -2768,7 +2833,8 @@ pub async fn run(mut args: ScanArgs) -> i32 { let hosted_retained = hosted_wiring_retained_purls( &args.common.cwd, redirect_state.as_ref(), - &wiring_scanned, + &all_purls, + &lockfile_only.entries, ) .await; if !hosted_retained.is_empty() { @@ -3074,6 +3140,15 @@ mod tests { .unwrap() } + /// The run's lockfile inventory (`LockfileSupplement::entries` in + /// production): re-taken after every lockfile write, like `run` parses + /// it once per run. + async fn inventory_of( + root: &Path, + ) -> Vec { + socket_patch_core::vendor::lock_inventory::inventory_project(root).await + } + #[tokio::test] async fn hosted_only_wiring_fires_agent_probe_not_the_overlap_classifier() { let tmp = tempfile::tempdir().unwrap(); @@ -3093,7 +3168,9 @@ mod tests { // …but the agent flow's direct probe sees it for scanned purls. let scanned: HashSet = [purl.to_string()].into_iter().collect(); let ledger = load_ledger(root).await; - let retained = hosted_wiring_retained_purls(root, ledger.as_ref(), &scanned).await; + let retained = + hosted_wiring_retained_purls(root, ledger.as_ref(), &scanned, &inventory_of(root).await) + .await; assert_eq!(retained, vec![purl.to_string()]); } @@ -3112,9 +3189,14 @@ mod tests { write_hosted_yarn_lock(tmp.path(), TAKEOVER_UUID).await; let ledger = load_ledger(tmp.path()).await; assert!( - hosted_wiring_retained_purls(tmp.path(), ledger.as_ref(), &scanned) - .await - .is_empty(), + hosted_wiring_retained_purls( + tmp.path(), + ledger.as_ref(), + &scanned, + &inventory_of(tmp.path()).await + ) + .await + .is_empty(), "records gone ⇒ silent (pre-reverted wiring must not re-warn)" ); @@ -3132,9 +3214,14 @@ mod tests { .unwrap(); let ledger = load_ledger(tmp.path()).await; assert!( - hosted_wiring_retained_purls(tmp.path(), ledger.as_ref(), &scanned) - .await - .is_empty(), + hosted_wiring_retained_purls( + tmp.path(), + ledger.as_ref(), + &scanned, + &inventory_of(tmp.path()).await + ) + .await + .is_empty(), "registry-clean lock ⇒ silent" ); @@ -3145,9 +3232,14 @@ mod tests { let other: HashSet = ["pkg:npm/lodash@4.17.21".to_string()].into_iter().collect(); let ledger = load_ledger(tmp.path()).await; assert!( - hosted_wiring_retained_purls(tmp.path(), ledger.as_ref(), &other) - .await - .is_empty(), + hosted_wiring_retained_purls( + tmp.path(), + ledger.as_ref(), + &other, + &inventory_of(tmp.path()).await + ) + .await + .is_empty(), "unscanned purl ⇒ silent" ); @@ -3155,9 +3247,14 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); write_hosted_yarn_lock(tmp.path(), TAKEOVER_UUID).await; assert!( - hosted_wiring_retained_purls(tmp.path(), None, &scanned) - .await - .is_empty(), + hosted_wiring_retained_purls( + tmp.path(), + None, + &scanned, + &inventory_of(tmp.path()).await + ) + .await + .is_empty(), "no ledger ⇒ silent" ); } @@ -3217,7 +3314,13 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); write_redirect_ledger_with_edit(tmp.path(), &[purl]).await; let ledger = load_ledger(tmp.path()).await; - let wiring = hosted_wiring_retained_purls(tmp.path(), ledger.as_ref(), &scanned).await; + let wiring = hosted_wiring_retained_purls( + tmp.path(), + ledger.as_ref(), + &scanned, + &inventory_of(tmp.path()).await, + ) + .await; assert_eq!(wiring, Vec::::new()); let block = redirect_state_json(ledger.as_ref(), &wiring).expect("records present ⇒ block present"); @@ -3229,9 +3332,16 @@ mod tests { ); assert_eq!(block["wiringLive"], serde_json::json!([])); - // Live lock present too: the same purl graduates into wiringLive. + // Live lock present too: the same purl graduates into wiringLive + // (a fresh run re-parses the inventory, so re-take it here). write_hosted_yarn_lock(tmp.path(), TAKEOVER_UUID).await; - let wiring = hosted_wiring_retained_purls(tmp.path(), ledger.as_ref(), &scanned).await; + let wiring = hosted_wiring_retained_purls( + tmp.path(), + ledger.as_ref(), + &scanned, + &inventory_of(tmp.path()).await, + ) + .await; let block = redirect_state_json(ledger.as_ref(), &wiring).expect("records present ⇒ block present"); assert_eq!(block["wiringLive"], serde_json::json!([purl])); @@ -3295,7 +3405,13 @@ mod tests { let scanned: HashSet = [scoped_canon.to_string()].into_iter().collect(); let ledger = load_ledger(tmp.path()).await; - let wiring = hosted_wiring_retained_purls(tmp.path(), ledger.as_ref(), &scanned).await; + let wiring = hosted_wiring_retained_purls( + tmp.path(), + ledger.as_ref(), + &scanned, + &inventory_of(tmp.path()).await, + ) + .await; assert_eq!( wiring, vec![scoped_canon.to_string()], diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 1fe14f53..5468ddc1 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -909,3 +909,28 @@ fn mode_aliases_hidden_from_help() { ); } } + +/// `--detached` is a compatibility no-op (vendored mode is always +/// manifest-free): still parsed, still requiring vendored mode, but hidden +/// from help like the legacy `--redirect` spelling. +#[test] +#[serial_test::serial] +fn detached_flag_is_hidden_from_help() { + use clap::CommandFactory; + let long = with_clean_env(|| { + let mut cmd = Cli::command(); + let scan = cmd.find_subcommand_mut("scan").expect("scan subcommand"); + scan.render_long_help().to_string() + }); + assert!( + !long.contains("--detached"), + "--detached must be hidden from scan --help; help was:\n{long}" + ); + assert!( + long.contains("--prune"), + "control: a documented flag renders; help was:\n{long}" + ); + // Still parsed (compatibility), still folded under vendored mode. + let folded = parse_and_resolve(&["--mode", "vendored", "--detached"]).expect("fold ok"); + assert!(folded.detached); +} diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index 75515378..741716bd 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -19,7 +19,12 @@ //! * per-patch vulnerability rendering in the "Patches to apply" preview; //! * the human post-apply GC line (both pluralization arms) and the //! `hosted_wiring_retained` stderr warning after an in-place apply; -//! * the declined download confirm via a PTY (exit 0, hint, no mutation). +//! * non-TTY human runs: a mode-less scan is report-only (no download, no +//! `.socket/`), while `--mode agent` / `--apply` / `--prune` auto-proceed; +//! * the hosted human arm's results table, `[UPDATE]` detection and +//! confirm prompt (parity with the agent/vendored arms); +//! * the declined download / redirect confirm via a PTY (exit 0, hint, no +//! mutation). //! //! Subprocess runs scrub the `SOCKET_*` flag environment (the //! `cli_scan_silent.rs` pattern) so ambient developer/CI configuration @@ -774,12 +779,13 @@ async fn scan_human_preview_renders_vulnerability_details() { // hosted_wiring_retained warning after an in-place apply // --------------------------------------------------------------------------- -/// Run the full human apply pipeline via `--sync --yes` with `orphans` -/// pre-seeded manifest entries (plus one orphan blob file each), and -/// return `(code, stdout, stderr, root)`. -async fn run_sync_with_orphans( +/// Run the full human apply pipeline with `flags` (e.g. `--sync --yes`) +/// and `orphans` pre-seeded manifest entries (plus one orphan blob file +/// each), and return `(code, stdout, stderr, root)`. +async fn run_apply_with_orphans( mock: &MockServer, orphans: &[(&str, &str, char)], + flags: &[&str], ) -> (i32, String, String, tempfile::TempDir) { let purl = "pkg:npm/silent-target@1.0.0"; let before = b"before\n"; @@ -796,16 +802,17 @@ async fn run_sync_with_orphans( std::fs::write(blobs.join(fill.to_string().repeat(64)), b"orphan").unwrap(); } - let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--sync", "--yes"]); + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), flags); (code, stdout, stderr, tmp) } #[tokio::test] async fn scan_sync_human_gc_line_singular_arms() { let mock = MockServer::start().await; - let (code, stdout, stderr, tmp) = run_sync_with_orphans( + let (code, stdout, stderr, tmp) = run_apply_with_orphans( &mock, &[("pkg:npm/gone@1.0.0", OLD_UUID, 'c')], + &["--sync", "--yes"], ) .await; assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); @@ -834,12 +841,13 @@ async fn scan_sync_human_gc_line_singular_arms() { #[tokio::test] async fn scan_sync_human_gc_line_plural_arms() { let mock = MockServer::start().await; - let (code, stdout, stderr, _tmp) = run_sync_with_orphans( + let (code, stdout, stderr, _tmp) = run_apply_with_orphans( &mock, &[ ("pkg:npm/gone@1.0.0", OLD_UUID, 'c'), ("pkg:npm/also-gone@2.0.0", "88888888-8888-4888-8888-888888888888", 'd'), ], + &["--sync", "--yes"], ) .await; assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); @@ -1064,10 +1072,273 @@ async fn scan_human_pnp_refusal_prints_alongside_other_ecosystems() { ); } +// --------------------------------------------------------------------------- +// Non-TTY human scans: the mode-less scan is report-only, explicit intent +// auto-proceeds +// --------------------------------------------------------------------------- +// Every `Command::output()` child here has a non-TTY stdin. A bare `scan` +// (no `--mode`/`--apply`/`--sync`/`--vendor`/`--redirect`, no `--prune`, +// no `--yes`) must stop BEFORE the download with exit 0 and the get-hint, +// creating nothing under `.socket/`; any intent flag keeps `confirm()`'s +// non-TTY auto-accept and applies. + +/// Bare `scan` piped: the discovery, table and per-patch preview print +/// (the report IS the value), then the run stops — no view fetch, no +/// `.socket/`, the installed file untouched — and `confirm()` was never +/// consulted (no "Non-interactive mode" line). +#[tokio::test] +async fn scan_bare_human_non_tty_is_report_only() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + mount_one_patch_api(&mock, purl, b"x\n").await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); + + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "report-only is a success; stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains("Patches to apply:") && stdout.contains(purl), + "the per-patch preview still prints; got {stdout:?}" + ); + assert!( + stdout.contains("To apply a patch, run:") && stdout.contains("socket-patch get "), + "the get-hint must print; got {stdout:?}" + ); + assert!( + !stderr.contains("Non-interactive mode detected"), + "confirm() must not be consulted on the report-only path; got {stderr:?}" + ); + assert!( + !tmp.path().join(".socket").exists(), + "a report-only scan must not create .socket/" + ); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/minimist/index.js")).unwrap(), + b"x\n", + "a report-only scan must not patch the installed file" + ); + let reqs = recorded(&mock).await; + assert_eq!(view_gets(&reqs), 0, "a report-only scan must not download the patch"); +} + +/// The same piped run with an explicit intent flag (each spelling that +/// folds to `--mode agent`) auto-proceeds through `confirm()`'s non-TTY +/// default and applies. +#[tokio::test] +async fn scan_human_non_tty_explicit_intent_auto_proceeds() { + for flags in [&["--mode", "agent"][..], &["--apply"][..]] { + let mock = MockServer::start().await; + let purl = "pkg:npm/silent-target@1.0.0"; + let before = b"before\n"; + mount_one_patch_api(&mock, purl, before).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "silent-target", "1.0.0", before); + + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), flags); + assert_eq!(code, 0, "flags={flags:?}: stdout={stdout}; stderr={stderr}"); + assert!( + stderr.contains("Non-interactive mode detected, proceeding with default."), + "flags={flags:?}: explicit intent keeps confirm()'s non-TTY auto-accept; got {stderr:?}" + ); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/silent-target/index.js")).unwrap(), + b"after\n", + "flags={flags:?}: the apply must proceed" + ); + let manifest = + std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).expect("manifest"); + let v: serde_json::Value = serde_json::from_str(&manifest).unwrap(); + assert_eq!(v["patches"][purl]["uuid"], UUID, "flags={flags:?}: {v}"); + } +} + +/// `--prune` alone is explicit intent too (it asks for a `.socket/` +/// mutation): the piped run applies AND garbage-collects. +#[tokio::test] +async fn scan_human_non_tty_prune_counts_as_intent() { + let mock = MockServer::start().await; + let (code, stdout, stderr, tmp) = run_apply_with_orphans( + &mock, + &[("pkg:npm/gone@1.0.0", OLD_UUID, 'c')], + &["--prune"], + ) + .await; + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stderr.contains("Non-interactive mode detected, proceeding with default."), + "--prune auto-proceeds through confirm(); got {stderr:?}" + ); + assert!( + stdout.contains("GC: pruned 1 manifest entry and removed 1 orphan file ("), + "the GC still runs; got {stdout:?}" + ); + let manifest = + std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).expect("manifest"); + let v: serde_json::Value = serde_json::from_str(&manifest).unwrap(); + assert!(v["patches"]["pkg:npm/gone@1.0.0"].is_null(), "{v}"); + assert_eq!(v["patches"]["pkg:npm/silent-target@1.0.0"]["uuid"], UUID, "{v}"); +} + +/// An empty discovery stops every human mode at "No patches available" +/// with exit 0 and no `.socket/`: vendored mode no longer falls through to +/// its vendor step to re-vendor a committed manifest (scan vendors what +/// discovery selects), and hosted mode no longer enters its engine for a +/// zero-package redirect. +#[tokio::test] +async fn scan_human_empty_discovery_stops_in_every_mode() { + let mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(&mock) + .await; + for flags in [ + &[][..], + &["--mode", "vendored"][..], + &["--mode", "hosted"][..], + &["--mode", "agent"][..], + ] { + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), flags); + assert_eq!(code, 0, "flags={flags:?}: stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains("No patches available for installed packages."), + "flags={flags:?}: got {stdout:?}" + ); + assert!( + !tmp.path().join(".socket").exists(), + "flags={flags:?}: an empty discovery must create nothing" + ); + } +} + +// --------------------------------------------------------------------------- +// Human `--mode hosted`: table + update detection parity and the confirm +// --------------------------------------------------------------------------- +// The hosted human arm used to return straight into the redirect engine — +// no results table, no `[UPDATE]` marker, and no prompt (while `get --mode +// hosted` and scan's agent/vendored arms all confirm). It now shares the +// table/update block and confirms before the engine runs. + +/// Reference endpoint denying the grant: the engine runs (proving the +/// prompt was accepted) but rewrites nothing, so no lockfile fixture is +/// needed. +async fn mount_forbidden_reference(mock: &MockServer, purl: &str) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { UUID: { + "status": "forbidden", "url": null, "purl": purl, + "artifacts": [], "registryOverride": null + } } + }))) + .mount(mock) + .await; +} + +fn reference_posts(reqs: &[wiremock::Request]) -> usize { + reqs.iter() + .filter(|r| { + format!("{}", r.method) == "POST" && r.url.path().ends_with("/patches/package") + }) + .count() +} + +/// Seed a redirect ledger recording `uuid` for `purl` (hosted mode's only +/// patch store), so update detection has an "old" side to compare. Written +/// through the real ledger type so the hosted engine's strict loader +/// accepts it. +fn seed_redirect_ledger(root: &Path, purl: &str, uuid: &str) { + use socket_patch_core::manifest::schema::PatchRecord; + use socket_patch_core::patch::redirect::RedirectState; + let mut state = RedirectState::new(); + state.records.insert( + purl.to_string(), + PatchRecord { + uuid: uuid.to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files: std::collections::HashMap::new(), + vulnerabilities: std::collections::HashMap::new(), + description: "seed".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }, + ); + let vendor_dir = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + std::fs::write( + vendor_dir.join("redirect-state.json"), + serde_json::to_string_pretty(&state).unwrap(), + ) + .unwrap(); +} + +#[tokio::test] +async fn scan_hosted_human_prints_table_updates_and_confirms() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + mount_batch_one(&mock, purl, UUID, "free", &["CVE-2024-0001"], false).await; + mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; + mount_forbidden_reference(&mock, purl).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); + // The ledger records an OLDER patch: the shared update detection must + // flag the newer offer in hosted mode too. + seed_redirect_ledger(tmp.path(), purl, OLD_UUID); + + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--mode", "hosted"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains("VULNERABILITIES") && stdout.contains("CVE-2024-0001"), + "the results table must print in hosted mode; got {stdout:?}" + ); + assert!( + stdout.contains("Summary: 1 package(s) with 1 free patch(es)"), + "the summary must print in hosted mode; got {stdout:?}" + ); + assert!( + stdout.contains("[UPDATE]") && stdout.contains("1 package(s) have newer patches available."), + "update detection must run in hosted mode; got {stdout:?}" + ); + // `--mode hosted` is explicit intent: the new prompt auto-accepts on a + // non-TTY stdin and the engine runs. + assert!( + stderr.contains("Non-interactive mode detected, proceeding with default."), + "the hosted confirm must run (and auto-accept) on a non-TTY; got {stderr:?}" + ); + assert!( + stdout.contains("Redirected 0 package(s)"), + "the engine must run after the prompt; got {stdout:?}" + ); + let reqs = recorded(&mock).await; + assert_eq!(reference_posts(&reqs), 1, "the engine resolved the reference"); + + // `--dry-run` previews without confirming (the engine honors it itself). + let (code, _stdout, stderr) = + run_scan_human(tmp.path(), &mock.uri(), &["--mode", "hosted", "--dry-run"]); + assert_eq!(code, 0, "stderr={stderr}"); + assert!( + !stderr.contains("Non-interactive mode detected"), + "a hosted dry run never prompts; got {stderr:?}" + ); +} + // --------------------------------------------------------------------------- // Declined download confirm via PTY (unix only) // --------------------------------------------------------------------------- -// `confirm()` returns the default in non-TTY runs, so only a PTY reaches +// A non-TTY mode-less scan never reaches `confirm()` (report-only above), +// and every explicit-intent run auto-accepts there, so only a PTY reaches // the decline arm: exit 0, the get-hint, and no mutation. #[cfg(unix)] @@ -1202,6 +1473,70 @@ mod pty { let reqs = recorded(&mock).await; assert_eq!(view_gets(&reqs), 0, "declining must not download the patch"); } + + /// The hosted twin: declining "Redirect N package(s) …?" exits 0 with + /// the hosted get-hint and never enters the engine (no reference + /// resolve, no `.socket/`). + #[tokio::test(flavor = "multi_thread")] + async fn scan_hosted_decline_at_redirect_prompt_exits_zero_without_mutation() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + mount_batch_one(&mock, purl, UUID, "free", &[], false).await; + mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; + mount_forbidden_reference(&mock, purl).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); + + let uri = mock.uri(); + let cwd = tmp.path().to_path_buf(); + let (code, output) = tokio::task::spawn_blocking(move || { + run_in_pty( + &[ + "scan", + "--mode", + "hosted", + "--api-url", + &uri, + "--api-token", + "fake-token-for-test", + "--org", + ORG_SLUG, + ], + &cwd, + "n\n", + Duration::from_secs(60), + ) + }) + .await + .expect("spawn_blocking join"); + + assert_eq!(code, 0, "declining is not an error; output:\n{output}"); + assert!( + output.contains("Redirect 1 package(s) to the hosted patch server?"), + "the hosted confirm prompt must have shown; got:\n{output}" + ); + assert!( + output.contains("To redirect a package, run:") + && output.contains("socket-patch get --mode hosted"), + "the hosted decline hint must print; got:\n{output}" + ); + assert!( + !output.contains("Redirected"), + "declining must not enter the redirect engine; got:\n{output}" + ); + assert!( + !tmp.path().join(".socket").exists(), + "declining must create nothing under .socket/" + ); + let reqs = recorded(&mock).await; + assert_eq!( + reference_posts(&reqs), + 0, + "declining must not resolve the hosted reference" + ); + } } // --------------------------------------------------------------------------- From 60f05b0cd4c188d9548931d978a06f5e18f9b450 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 19:48:25 -0400 Subject: [PATCH 10/44] cli(vendor,repair_vendor): D2 no-op wording, detached GC leg, lock ordering, residue-free reverts, qualified-key repair resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vendor.rs - Standalone `vendor` without a manifest stays a lock-free exit-0 no-op, but names the MANIFEST: "No manifest found, nothing to vendor." / "No manifest to vendor from; N vendored entr(y|ies) tracked in the ledger — `socket-patch repair` verifies them." (the old "No .socket folder found" was false on every hosted-only / vendored-mode project). - API client + VendorServiceConfig are built only for the vendoring arm, after the no-manifest no-op and before the lock; `--revert` never touches the API. - The lock is released right after note_vendor_supersedes_redirect (which may persist the redirect ledger) and before JSON print + telemetry; the `--revert`-without-.socket skip stays (a no-op revert must never create .socket/), comment rewritten for D1. - vendor_records: one load_state for the run (fails vendor_state_unreadable before the crawler walk and any registry traffic); lockfile inventory built lazily and shared by the fetch rung and the --offline detail. - reconcile_dropped: per-purl save mirroring --revert (no write when nothing was reverted; a failed save is a counted vendor_state_write_failed event). - run_vendor_gc: pass (b) applies to detached entries too (D2 — the probe asks the lockfile, not the manifest; (a) keeps exempting them); acquire distinguishes Held (unchanged skip marker) from Io (distinct lock_io marker + human warning) and honors --lock-timeout; ledger written only when a pass removed something; failed ledger/manifest rewrites are surfaced as pass-level markers instead of `let _ =`. - Orphan sweep and stale-uuid removal use core's remove_tree_and_prune so no empty .socket/vendor// or .socket/vendor/ husk survives a revert. - Docs rewritten to the D2 posture (vendored modes are always detached; this command is the one manifest-driven writer). repair_vendor.rs - Installed copies resolved with find_packages_for_rollback: qualified ledger keys (gem ?platform=, pypi ?artifact_id=, maven ?classifier=) never matched the base-keyed map, so installed packages read as absent. - repair_vendored_artifacts_with_references(...) added; the old signature is a wrapper that scans, so repair.rs can pass its hoisted scan (handoff). - A crashed set-aside's `.pre-rebuild` leftover is put back when the live dir is absent (the only copy the wiring points at), wet runs only. ecosystem_dispatch.rs (one-line ownership exception): #[allow(dead_code)] on find_packages_for_purls — no production caller remains; CI's lib clippy would otherwise fail. Integration pass: delete or cfg(test). Tests: gc detached test flipped to assert (b) reclaims; new lock_io marker, no_manifest_message, orphan-sweep prune, reconcile save-failure, ledger-aware wording, qualified-key repair and pre-rebuild leftover tests; gem scan --vendor tests made manifest-agnostic (no manifest reads, revert instead of manifest-edit reconcile) ahead of the D2 scan flip. Co-Authored-By: Claude Fable 5.1 --- .../src/commands/repair_vendor.rs | 79 ++- .../socket-patch-cli/src/commands/vendor.rs | 451 ++++++++++++++---- .../src/ecosystem_dispatch.rs | 7 + .../tests/covgap_commands_repair_vendor.rs | 133 ++++++ .../tests/covgap_commands_vendor.rs | 92 +++- .../tests/in_process_vendor.rs | 77 ++- 6 files changed, 686 insertions(+), 153 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index f8938a68..0d9b8f7b 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -73,7 +73,7 @@ use crate::commands::vendor::{ dispatch_vendor_one, ecosystem_in_scope, fetch_pristine_package, persist_vendor_entry, record_warning, PristineFetch, }; -use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls}; +use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls}; use crate::json_envelope::{Envelope, PatchAction, PatchEvent, RunWarning}; /// One broken vendored unit queued for rebuild. @@ -466,21 +466,85 @@ async fn restore_aside_vendor_dir(live: &Path, kept: &Path) { let _ = tokio::fs::rename(kept, live).await; } +/// Crash recovery for [`set_aside_vendor_dir`]'s transient: a run killed +/// between the move-aside and the backend's replacement leaves +/// `.socket/vendor//.pre-rebuild` as the ONLY copy of bytes the +/// rewired lockfiles still point at, with the live path a bare ENOENT. Put +/// every such leftover back where the wiring expects it before pass 1 +/// classifies the unit (it then re-derives corrupt/soft/healthy from the +/// restored bytes exactly as the crashed run did). A leftover whose live +/// sibling EXISTS is left alone: the live dir may be the completed +/// replacement or a partial husk, and only the health pass can tell — a +/// unit it condemns is set aside again, which clears the leftover. Wet +/// runs only; scope-gated like every other unit; best-effort throughout. +async fn restore_orphaned_pre_rebuild_dirs(common: &GlobalArgs) { + const SUFFIX: &str = ".pre-rebuild"; + let vendor_root = common.cwd.join(".socket/vendor"); + let Ok(mut ecos) = tokio::fs::read_dir(&vendor_root).await else { + return; + }; + while let Ok(Some(eco_dir)) = ecos.next_entry().await { + let eco = eco_dir.file_name().to_string_lossy().into_owned(); + if !ecosystem_in_scope(common, &eco) || !eco_dir.path().is_dir() { + continue; + } + let Ok(mut units) = tokio::fs::read_dir(eco_dir.path()).await else { + continue; + }; + while let Ok(Some(unit)) = units.next_entry().await { + let name = unit.file_name().to_string_lossy().into_owned(); + let Some(uuid) = name.strip_suffix(SUFFIX) else { + continue; + }; + let live = eco_dir.path().join(uuid); + if unit.path().is_dir() && tokio::fs::symlink_metadata(&live).await.is_err() { + let _ = tokio::fs::rename(unit.path(), &live).await; + } + } + } +} + /// The vendored-artifact phase of `repair`. Runs between the download and /// cleanup phases (and under `--download-only` — restoring artifacts IS /// repair's job). `manifest` is `None` when the project has no /// `.socket/manifest.json` (detached/reconstruction-only repairs). /// Returns the number of artifacts rebuilt (for the human summary line); /// failures are carried by `env` (`Failed` events + partial-failure status). +/// +/// Scans the wiring files for vendored references itself; a caller that +/// already ran [`scan_vendor_references`] under the same lock (repair.rs +/// does, for its `referenced_uuids`) should pass that result to +/// [`repair_vendored_artifacts_with_references`] instead of paying for the +/// ~20-file scan a second time. pub(crate) async fn repair_vendored_artifacts( common: &GlobalArgs, manifest: Option<&PatchManifest>, socket_dir: &Path, env: &mut Envelope, +) -> usize { + let references = scan_vendor_references(&common.cwd).await; + repair_vendored_artifacts_with_references(common, manifest, socket_dir, env, &references).await +} + +/// [`repair_vendored_artifacts`] with the wiring-file reference scan +/// supplied by the caller: `references` is [`scan_vendor_references`]'s +/// `(ecosystem, uuid, artifact relpath)` output for `common.cwd`, taken +/// under the apply lock this phase runs under (the lockfiles it describes +/// are the ones the reconstruction below rewires). +pub(crate) async fn repair_vendored_artifacts_with_references( + common: &GlobalArgs, + manifest: Option<&PatchManifest>, + socket_dir: &Path, + env: &mut Envelope, + references: &[(String, String, String)], ) -> usize { let quiet = common.json || common.silent; let mut rebuilt = 0usize; + if !common.dry_run { + restore_orphaned_pre_rebuild_dirs(common).await; + } + let mut state = match load_state(&common.cwd).await { Ok(s) => s, Err(e) => { @@ -729,7 +793,7 @@ pub(crate) async fn repair_vendored_artifacts( .values() .map(|e| (e.ecosystem.clone(), e.uuid.clone())) .collect(); - for (eco, uuid, relpath) in scan_vendor_references(&common.cwd).await { + for (eco, uuid, relpath) in references.iter().cloned() { if covered.contains(&(eco.clone(), uuid.clone())) || !ecosystem_in_scope(common, &eco) { continue; } @@ -1047,7 +1111,16 @@ pub(crate) async fn repair_vendored_artifacts( global: common.global, global_prefix: common.global_prefix.clone(), }; - let mut all_packages = find_packages_for_purls(&partitioned, &crawler_options, quiet).await; + // Ledger keys are the manifest spelling — QUALIFIED for release-variant + // ecosystems (gem `?platform=`, pypi `?artifact_id=`, maven + // `?classifier=&ext=`) — while the crawler knows only base purls. + // `find_packages_for_purls` keys its result by the base purl, so the + // `contains_key(&c.purl)` checks below would miss every installed + // qualified-key package and fall through to a needless registry fetch + // (or, offline, a spurious unrepairable / fingerprint-less restore). + // The rollback variant fans each base path back out to every qualified + // caller purl — the same fix `vendor_records` carries. + let mut all_packages = find_packages_for_rollback(&partitioned, &crawler_options, quiet).await; let inventory = lock_inventory::inventory_project(&common.cwd).await; let client = registry_fetch::build_registry_client(); let mut holders: Vec = Vec::new(); diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 14d573bf..3674241f 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -10,21 +10,24 @@ //! //! The rest of the CLI is vendor-aware: `apply`/`rollback` yield ownership of //! ledger-recorded purls, `remove` reverts vendoring as part of removing a -//! patch, `scan --prune` exempts vendored entries, and `scan --vendor` -//! drives this module's [`vendor_records`] engine directly (optionally -//! `--detached`, writing ledger entries with embedded patch records instead -//! of manifest entries). See CLI_CONTRACT.md "Ownership, state, and -//! reversal". +//! patch, `scan --prune` exempts vendored entries, and `scan`/`get --mode +//! vendored` drive this module's [`vendor_records`] engine directly in +//! DETACHED mode: every entry they write carries its patch record embedded +//! in the ledger and no `.socket/manifest.json` is ever written — this +//! command is the one manifest-driven writer (`detached: false`). See +//! CLI_CONTRACT.md "Ownership, state, and reversal". use clap::Args; use socket_patch_core::api::client::get_api_client_with_overrides; +use socket_patch_core::constants::SOCKET_DIR; use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{verify_file_patch, PatchSources}; -use socket_patch_core::patch::copy_tree::remove_tree; +use socket_patch_core::patch::apply_lock::{self, LockError}; use socket_patch_core::telemetry::{track_patch_vendor_failed, track_patch_vendored}; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; +use socket_patch_core::utils::socket_dir::remove_tree_and_prune; use socket_patch_core::vendor::{ self, ecosystem_dir_for_purl, load_state, lock_inventory, lookup_entry, registry_fetch, save_state, RevertOpts, RevertOutcome, VendorEntry, VendorOutcome, VendorServiceConfig, @@ -39,7 +42,7 @@ use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::apply::{representative_file, result_to_event, variant_matches_installed}; use crate::commands::bun_preflight::bun_vendor_preflight_pairs; use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; -use crate::commands::lock_cli::acquire_or_emit; +use crate::commands::lock_cli::{acquire_or_emit, lock_failure}; use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls}; use crate::json_envelope::{ @@ -102,9 +105,9 @@ pub(crate) async fn dispatch_vendor_one( dry_run: bool, force: bool, // The patch.socket.dev vendoring-service config. `None` = build-only (the - // pre-service behavior); used by the `vendor` command, `None` from `scan - // --vendor` / repair. Per-ecosystem backends consume it as they gain a - // service path. + // pre-service behavior). `vendor` and `scan`/`get --mode vendored` pass + // `Some(_)` (honoring `--vendor-source`); repair passes `None` — it + // rebuilds locally from the recorded patch. service: Option<&VendorServiceConfig>, pipenv_version: &tokio::sync::OnceCell>, ) -> Option { @@ -274,13 +277,19 @@ async fn sweep_orphan_vendor_dirs(cwd: &Path, state: &VendorState, dry_run: bool .into_iter() .map(|(eco, uuid, _path)| (eco, uuid)) .collect(); + // Each removal also prunes the `/` and `vendor/` levels it + // emptied (never `.socket/` — the lock guard owns that level): the + // sweep runs AFTER the per-entry reverts and their ledger saves, so it + // is the last thing that can leave a fully reverted project with empty + // `.socket/vendor//` husks. + let stop_dir = cwd.join(SOCKET_DIR); for unit in candidates { if wired.contains(&(unit.eco.clone(), unit.uuid.clone())) { out.still_wired.push(unit); continue; } if !dry_run { - let _ = remove_tree(&unit.dir).await; + let _ = remove_tree_and_prune(&unit.dir, &stop_dir).await; } out.removed.push(unit); } @@ -364,23 +373,6 @@ pub(crate) fn note_classic_migration_risk( pub async fn run(args: VendorArgs) -> i32 { apply_env_toggles(&args.common); - let (telemetry_client, use_public_proxy) = - get_api_client_with_overrides(args.common.api_client_overrides()).await; - let api_token = telemetry_client.api_token().cloned(); - let org_slug = telemetry_client.org_slug().cloned(); - - // Vendoring-service config, built once from the run-level client + flags. - // `vendor_source` was validated by clap, so the parse cannot fail; fall - // back to the `auto` default defensively. The same client is reused for - // the package-reference request (no second auth round-trip). - let vendor_service = VendorServiceConfig { - source: VendorSource::parse(&args.common.vendor_source).unwrap_or_default(), - client: Some(telemetry_client.clone()), - use_public_proxy, - vendor_url: args.common.vendor_url.clone(), - patch_server_url: args.common.patch_server_url.clone(), - offline: args.common.offline, - }; let manifest_path = args.common.resolved_manifest_path(); let socket_dir = manifest_path @@ -390,7 +382,12 @@ pub async fn run(args: VendorArgs) -> i32 { // `--revert` derives everything from state.json + the vendor tree; it // must work after the manifest was deleted. Plain vendor needs the - // manifest and exits clean without one (same contract as apply). + // manifest and exits clean without one (same contract as apply). This + // is a MANIFEST check, not a `.socket/` check: it also fires on + // hosted-only projects and on every `scan`/`get --mode vendored` + // project (those never write a manifest — the vendor ledger owns their + // entries and `repair` is what verifies them), where `.socket/` exists. + // Nothing is locked or written on this path. if !args.revert && tokio::fs::metadata(&manifest_path).await.is_err() { if args.common.json { let mut env = Envelope::new(Command::Vendor); @@ -398,22 +395,56 @@ pub async fn run(args: VendorArgs) -> i32 { env.dry_run = args.common.dry_run; println!("{}", env.to_pretty_json()); } else if !args.common.silent { - println!("No .socket folder found, nothing to vendor."); + let tracked = load_state(&args.common.cwd) + .await + .map(|s| s.entries.len()) + .unwrap_or(0); + println!("{}", no_manifest_message(tracked)); } return 0; } + // The API client and the vendoring-service config exist for the + // vendoring arm alone — `--revert` never talks to the API (no service + // downloads, no telemetry) — so they are built after the no-manifest + // no-op and only when this run vendors. Built BEFORE the lock, like + // apply/rollback: the client's org-resolve round-trip must not run + // while other commands wait on `apply.lock`. `vendor_source` was + // validated by clap, so the parse cannot fail; fall back to the `auto` + // default defensively. The client moves into the config and is reused + // for the package-reference request (no second auth round-trip); the + // telemetry ids ride alongside for the post-run report. + let vendor_service = if args.revert { + None + } else { + let (client, use_public_proxy) = + get_api_client_with_overrides(args.common.api_client_overrides()).await; + let telemetry_ids = (client.api_token().cloned(), client.org_slug().cloned()); + Some(( + VendorServiceConfig { + source: VendorSource::parse(&args.common.vendor_source).unwrap_or_default(), + client: Some(client), + use_public_proxy, + vendor_url: args.common.vendor_url.clone(), + patch_server_url: args.common.patch_server_url.clone(), + offline: args.common.offline, + }, + telemetry_ids, + )) + }; + // Same lock as apply/rollback: vendor mutates the same lockfiles and // `.socket/` tree, so a separate lock would allow an apply↔vendor race. // - // The lock file lives INSIDE `.socket/`, and `acquire` creates the file - // but never its parent. `--revert` skipped the manifest check above, so - // it is the one path that can reach here with no `.socket/` dir at all — - // the documented clean no-op ("a missing ledger is an empty ledger"). - // Locking first would turn that into a `lock_io` failure, so skip it: - // with no `.socket/` there is no ledger to read and nothing to write, - // hence nothing to serialize against. - let _lock = if args.revert && tokio::fs::metadata(&socket_dir).await.is_err() { + // `--revert` skipped the manifest check above, so it is the one path + // that can reach here with no `.socket/` dir at all — the documented + // clean no-op ("a missing ledger is an empty ledger"). `acquire` would + // create `.socket/` for its lock file and the guard's drop would prune + // it again, but a no-op revert must never be the thing that creates a + // `.socket/` dir, even transiently: with no `.socket/` there is no + // ledger to read and nothing to write, hence nothing to serialize + // against. Skip the lock. + let lock = if args.revert && tokio::fs::metadata(&socket_dir).await.is_err() { None } else { match acquire_or_emit( @@ -431,10 +462,9 @@ pub async fn run(args: VendorArgs) -> i32 { let mut env = Envelope::new(Command::Vendor); env.dry_run = args.common.dry_run; - let mut exit = if args.revert { - run_revert(&args, &mut env).await - } else { - run_vendor(&args, &manifest_path, &mut env, &vendor_service).await + let mut exit = match &vendor_service { + None => run_revert(&args, &mut env).await, + Some((service, _)) => run_vendor(&args, &manifest_path, &mut env, service).await, }; // Embedded VEX: same contract as `apply --vex` — only on success, and a @@ -487,11 +517,19 @@ pub async fn run(args: VendorArgs) -> i32 { // ledger feeding VEX indefinitely. super::scan::note_vendor_supersedes_redirect(&mut env, &args.common.cwd, &args.common).await; + // That advisory may persist the redirect ledger, so it ran under the + // lock; everything below is output and telemetry — nothing touches + // `.socket/` or the lockfiles — so release the lock first (the drop also + // unlinks `apply.lock` and prunes an emptied `.socket/`) rather than + // holding it across a network round-trip while competitors see + // `lock_held`. + drop(lock); + if args.common.json { println!("{}", env.to_pretty_json()); } - if !args.revert { + if let Some((_, (api_token, org_slug))) = &vendor_service { track_outcomes_for_vendor( exit != 0, &env, @@ -505,6 +543,24 @@ pub async fn run(args: VendorArgs) -> i32 { exit } +/// The human no-op line for a plain `vendor` with no manifest. Names the +/// MANIFEST (the thing actually missing), and — when the vendor ledger +/// tracks entries, i.e. a `scan`/`get --mode vendored` project — says so +/// instead of implying nothing is vendored: their refresh path is `scan +/// --mode vendored`, and `repair` is what re-verifies the ledger. +fn no_manifest_message(tracked_entries: usize) -> String { + match tracked_entries { + 0 => "No manifest found, nothing to vendor.".to_string(), + 1 => "No manifest to vendor from; 1 vendored entry is tracked in the ledger — \ + `socket-patch repair` verifies it." + .to_string(), + n => format!( + "No manifest to vendor from; {n} vendored entries are tracked in the ledger — \ + `socket-patch repair` verifies them." + ), + } +} + /// Telemetry for a vendor run's success/failure split, shared by /// [`run`] and the scan-driven vendor step (`scan --vendor`). pub(crate) async fn track_outcomes_for_vendor( @@ -660,7 +716,11 @@ pub(crate) async fn persist_vendor_entry( return has_errors; } if !common.dry_run { - let _ = remove_tree(&common.cwd.join(rel)).await; + // Prunes the emptied `/` level too (a uuid change + // within one ecosystem never empties it, but a re-vendor + // that moved ecosystems would otherwise leave a husk). + let _ = remove_tree_and_prune(&common.cwd.join(rel), &common.cwd.join(SOCKET_DIR)) + .await; } env.record( PatchEvent::new(PatchAction::Removed, candidate.clone()).with_reason( @@ -729,10 +789,12 @@ pub(crate) async fn fetch_pristine_package( /// The vendoring engine, decoupled from the manifest file. `records` is the /// purl → [`PatchRecord`] view to vendor: `manifest.patches` for the -/// manifest-driven `vendor` command (and `scan --vendor`), or the -/// freshly-fetched record map for `scan --vendor --detached`. Entries written -/// in `detached` mode carry [`VendorEntry::detached`] plus an embedded copy -/// of their record, so revert/verify/VEX work without a manifest entry. +/// manifest-driven `vendor` command, or the in-memory record map +/// `scan`/`get --mode vendored` fetched (`detached`). Entries written in +/// `detached` mode carry [`VendorEntry::detached`] plus an embedded copy of +/// their record, so revert/verify/VEX work without a manifest entry — the +/// vendored modes never write one; only this command's `detached: false` +/// entries are manifest-tracked. /// /// Does NOT lock, read the manifest, or print the envelope — callers own all /// three. Returns whether any non-benign failure occurred. @@ -791,6 +853,21 @@ pub(crate) async fn vendor_records( }) .collect(); + // The vendor ledger, loaded ONCE for the whole run: the artifact-staging + // path below, the Bun preflight and every per-package persist read or + // mutate this copy. An unreadable ledger is the hard error it is — + // failing here, before the crawler walk and any registry traffic, is + // what keeps a corrupt state.json from running the whole fetch ladder + // first (and pushing its warnings into the envelope) only to fail the + // run afterwards. + let mut state = match load_state(&common.cwd).await { + Ok(s) => s, + Err(e) => { + env.mark_error(EnvelopeError::new("vendor_state_unreadable", e.to_string())); + return true; + } + }; + let crawler_options = CrawlerOptions { cwd: common.cwd.clone(), global: common.global, @@ -850,6 +927,13 @@ pub(crate) async fn vendor_records( // Fetch failures must keep their distinct Failed event; this set // suppresses the later duplicate `package_not_installed` skip. let mut fetch_failed: HashSet = HashSet::new(); + // The lockfile inventory (every recognized lockfile parsed) — a local + // read, fine offline — built lazily at the first site that consumes it + // and shared by the registry-fetch rung below and the `--offline` + // "the lockfile resolves it" detail at the end, so a run parses the + // lockfiles at most once (and not at all when every missing purl + // stages from its committed artifact or nothing is missing). + let mut inventory: Option> = None; { let missing: Vec = vendorable .iter() @@ -857,17 +941,13 @@ pub(crate) async fn vendor_records( .cloned() .collect(); if !missing.is_empty() { - // The inventory is a local file read — fine offline; only the - // fetch itself needs the network. - let inventory = lock_inventory::inventory_project(&common.cwd).await; let client = registry_fetch::build_registry_client(); - // Pre-loaded vendor ledger for the artifact-staging path: an - // already-vendored purl with no installed copy (fresh clone) - // stages from its own committed artifact, sha256-verified - // against the ledger — offline-safe, no registry traffic. - let ledger = load_state(&common.cwd).await.unwrap_or_default(); + // Artifact-staging path: an already-vendored purl with no + // installed copy (fresh clone) stages from its own committed + // artifact, sha256-verified against the ledger — offline-safe, + // no registry traffic. for purl in &missing { - let ledger_entry = lookup_entry(&ledger.entries, purl); + let ledger_entry = lookup_entry(&state.entries, purl); if let Some(entry) = ledger_entry .filter(|e| e.ecosystem == "npm" && e.artifact.path.ends_with(".tgz")) { @@ -929,9 +1009,11 @@ pub(crate) async fn vendor_records( // pass (the purl stays unmatched). continue; } - match fetch_pristine_package(&common.cwd, &inventory, &client, purl, ledger_entry) - .await - { + if inventory.is_none() { + inventory = Some(lock_inventory::inventory_project(&common.cwd).await); + } + let inv = inventory.as_deref().expect("filled just above"); + match fetch_pristine_package(&common.cwd, inv, &client, purl, ledger_entry).await { PristineFetch::Fetched(fetched) => { record_warning( env, @@ -983,13 +1065,6 @@ pub(crate) async fn vendor_records( } let vendored_at = now_rfc3339(); - let mut state = match load_state(&common.cwd).await { - Ok(s) => s, - Err(e) => { - env.mark_error(EnvelopeError::new("vendor_state_unreadable", e.to_string())); - return true; - } - }; // Bun vendored preflight (see `crate::commands::bun_preflight`), run // ONCE per run over the in-scope npm records and consulted per @@ -1446,12 +1521,16 @@ pub(crate) async fn vendor_records( if !unmatched.is_empty() { has_errors = true; // Offline runs name the packages the lockfile COULD have fetched — - // the inventory is a local file read, allowed offline. + // the inventory is a local file read, allowed offline (and reused + // when the fetch rung above already built it). let lock_resolvable: HashSet = if common.offline { - let entries = lock_inventory::inventory_project(&common.cwd).await; + if inventory.is_none() { + inventory = Some(lock_inventory::inventory_project(&common.cwd).await); + } + let entries = inventory.as_deref().expect("filled just above"); unmatched .iter() - .filter(|p| lock_inventory::lookup(&entries, p).is_some()) + .filter(|p| lock_inventory::lookup(entries, p).is_some()) .cloned() .collect() } else { @@ -1551,9 +1630,10 @@ fn flavor_install_command(flavor: &str) -> Option<&'static str> { /// run's --ecosystems scope: a `vendor --ecosystems npm` invocation must /// not silently revert a cargo/go entry (restoring its lockfile and /// deleting its artifact) as a cross-ecosystem side effect. Detached -/// entries (`scan --vendor --detached`) are never manifest-tracked, so -/// "absent from the manifest" is their normal state, not a drop — only -/// `vendor --revert` or `remove` may undo them. +/// entries — every `scan`/`get --mode vendored` entry — are never +/// manifest-tracked, so "absent from the manifest" is their normal state, +/// not a drop — only `vendor --revert`, `remove`, or the lockfile-driven +/// half of [`run_vendor_gc`] may undo them. fn manifest_dropped_purls( state: &VendorState, manifest: &PatchManifest, @@ -1612,6 +1692,18 @@ pub(crate) async fn reconcile_dropped( ); if !common.dry_run { state.entries.remove(&purl); + // Per-purl save, exactly like `--revert`: crash-consistent + // with the wiring just restored, no write at all on the + // (normal) run that reverted nothing, and a failed write is + // the failure it is — the reverted purl would otherwise + // linger in the ledger with exit 0 and no event. + if let Err(e) = save_state(&common.cwd, &state).await { + had_error = true; + env.record( + PatchEvent::new(PatchAction::Failed, purl.clone()) + .with_error("vendor_state_write_failed", e.to_string()), + ); + } } } else { had_error = true; @@ -1623,9 +1715,6 @@ pub(crate) async fn reconcile_dropped( ); } } - if !common.dry_run { - let _ = save_state(&common.cwd, &state).await; - } had_error } @@ -1780,7 +1869,8 @@ pub(crate) struct VendorGcSummary { /// (c) orphan uuid dirs (no owning ledger entry) swept. pub orphan_dirs: usize, /// Entries that could not be reverted (kept in the ledger), plus any - /// pass-level skip marker (e.g. lock contention). + /// pass-level marker: the lock skip (`lock_held` contention or a + /// `lock_io` fault) and a failed post-revert ledger/manifest rewrite. pub failed: Vec, } @@ -1803,15 +1893,23 @@ pub(crate) struct VendorGcSummary { /// before the wiring replay that detects drift, so the dry lists still /// carry such an entry as revertable. /// -/// Detached entries are exempt from BOTH (a) (never manifest-tracked) and -/// (b) (lockfile-invisible by design — the probe would always call them -/// unused). A missing/unreadable manifest skips (a) only (a prune must -/// not mass-revert on a deleted manifest — that is `vendor --revert`'s -/// explicit contract). +/// Detached entries — every `scan`/`get --mode vendored` entry — are exempt +/// from (a) alone: they are never manifest-tracked, so "absent from the +/// manifest" is their normal state, not a drop. (b) applies to every entry: +/// it asks the lockfile, not the manifest, and a detached entry is wired +/// into the lock exactly like a manifest-tracked one, so a dependency that +/// left the lock graph is reclaimed either way. A missing/unreadable +/// manifest skips (a) only (a prune must not mass-revert on a deleted +/// manifest — that is `vendor --revert`'s explicit contract). /// -/// Wet runs take the apply lock (lockfiles + the manifest are rewritten); -/// contention records a skip marker and returns — it never fails the -/// scan. Dry runs are read-only, lock-free, and list-only. +/// Wet runs take the apply lock (lockfiles + the manifest are rewritten), +/// honoring `--lock-timeout`; a live holder records the contention skip +/// marker and returns — it never fails the scan — while a lock that cannot +/// even be opened (`lock_io`) records a distinct marker and a human-mode +/// warning. The ledger and manifest are rewritten only when a pass removed +/// something; a failed rewrite is recorded as a pass-level marker (the +/// reverts themselves already happened on disk). Dry runs are read-only, +/// lock-free, and list-only. pub(crate) async fn run_vendor_gc( common: &GlobalArgs, manifest_path: &Path, @@ -1832,14 +1930,25 @@ pub(crate) async fn run_vendor_gc( let _guard = if dry_run { None } else { - match socket_patch_core::patch::apply_lock::acquire(&socket_dir, Duration::from_secs(0)) { + let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); + match apply_lock::acquire(&socket_dir, timeout) { Ok(g) => Some(g), - Err(_) => { + Err(LockError::Held) => { out.failed.push( "vendor GC skipped: another socket-patch run holds the apply lock".to_string(), ); return out; } + Err(e) => { + // Not contention: a file squatting on `.socket/`, a directory + // on `apply.lock`, a permissions problem. Mislabelling it as + // a live holder would hide a real fault behind a benign skip. + let (code, message) = lock_failure(&e, timeout); + gc_note(common, code, &format!("vendor GC skipped: {message}")); + out.failed + .push(format!("vendor GC skipped ({code}): {message}")); + return out; + } } }; @@ -1849,6 +1958,10 @@ pub(crate) async fn run_vendor_gc( // second time, which the wet success path (entry removed before (b)'s // candidate scan) never does. let mut handled_by_a: HashSet = HashSet::new(); + // Set at the two `state.entries.remove` sites: the ledger is rewritten + // only when a pass reclaimed something (the common `scan --prune` with + // nothing reclaimable must not churn a committed file). + let mut ledger_dirty = false; let mut manifest = read_manifest(manifest_path).await.ok().flatten(); if let Some(m) = &manifest { for purl in manifest_dropped_purls(&state, m, common) { @@ -1871,20 +1984,20 @@ pub(crate) async fn run_vendor_gc( out.kept.push(purl); } else { state.entries.remove(&purl); + ledger_dirty = true; out.dropped_reverted.push(purl); } } } - // (b) lockfile-unused entries. + // (b) lockfile-unused entries — detached ones included: the probe asks + // the live lockfile wiring, which a detached entry has like any other. let mut manifest_dirty = false; let candidates: Vec = state .entries .iter() .filter(|(purl, entry)| { - !entry.detached - && ecosystem_in_scope(common, &entry.ecosystem) - && !handled_by_a.contains(*purl) + ecosystem_in_scope(common, &entry.ecosystem) && !handled_by_a.contains(*purl) }) .map(|(purl, _)| purl.clone()) .collect(); @@ -1911,6 +2024,7 @@ pub(crate) async fn run_vendor_gc( continue; } state.entries.remove(&purl); + ledger_dirty = true; if let Some(m) = manifest.as_mut() { let base = strip_purl_qualifiers(&entry.base_purl).to_string(); let dropped: Vec = m @@ -1928,10 +2042,30 @@ pub(crate) async fn run_vendor_gc( } if !dry_run { - let _ = save_state(&common.cwd, &state).await; + // The reverts above already restored the wiring and removed the + // artifacts; a failed ledger/manifest rewrite leaves records for + // state that is gone, which must not pass silently (the sibling + // callers all report `vendor_state_write_failed`). + if ledger_dirty { + if let Err(e) = save_state(&common.cwd, &state).await { + let detail = format!( + "reverted vendored entries but could not update \ + .socket/vendor/state.json: {e}" + ); + gc_note(common, "vendor_state_write_failed", &detail); + out.failed.push(format!("vendor GC: {detail}")); + } + } if manifest_dirty { if let Some(m) = &manifest { - let _ = write_manifest(manifest_path, m).await; + if let Err(e) = write_manifest(manifest_path, m).await { + let detail = format!( + "reverted vendored entries but could not update {}: {e}", + manifest_path.display() + ); + gc_note(common, "manifest_write_failed", &detail); + out.failed.push(format!("vendor GC: {detail}")); + } } } } @@ -1945,6 +2079,15 @@ pub(crate) async fn run_vendor_gc( out } +/// Human-mode stderr line for a pass-level GC problem (the GC has no +/// envelope of its own; JSON consumers see the marker in +/// [`VendorGcSummary::failed`]). Muted under `--json` and `--silent`. +fn gc_note(common: &GlobalArgs, code: &str, detail: &str) { + if !common.json && !common.silent { + eprintln!("Warning ({code}): {detail}"); + } +} + #[cfg(test)] mod dispatch_tests { use super::*; @@ -2541,10 +2684,16 @@ mod gc_tests { ); } - /// A missing/undeterminable lockfile keeps the entry (fail-safe), and a - /// DETACHED entry is exempt from both (a) and (b). + /// A missing/undeterminable lockfile keeps the entry (fail-safe). A + /// DETACHED entry — the shape every `scan`/`get --mode vendored` run + /// writes — is exempt from (a) alone (never manifest-tracked, so its + /// absence from the manifest is not a drop) but NOT from (b): it is + /// wired into the lock like any other entry, so once the dependency + /// leaves the lock graph the GC reclaims it. Pre-fix (b) skipped + /// detached entries as "lockfile-invisible", which made the unused GC + /// dead for every vendored-mode project. #[tokio::test] - async fn vendor_gc_keeps_undeterminable_and_detached_entries() { + async fn vendor_gc_keeps_undeterminable_entries_and_reclaims_unused_detached() { // Lock removed entirely: probe says None → keep. let (tmp, common, manifest_path) = gc_fixture(false).await; tokio::fs::remove_file(tmp.path().join("package-lock.json")) @@ -2558,15 +2707,12 @@ mod gc_tests { .entries .contains_key(PURL)); - // Detached entry: absent from the manifest AND lockfile-invisible — - // exactly its normal state. Never reverted by the GC. + // Detached entry, still wired: (a) exempts it and (b) sees it in + // use — kept. let (tmp, common, manifest_path) = gc_fixture(true).await; write_manifest(&manifest_path, &PatchManifest::new()) .await .unwrap(); - tokio::fs::write(tmp.path().join("package-lock.json"), "{\"packages\":{}}") - .await - .unwrap(); let out = run_vendor_gc(&common, &manifest_path, false).await; assert!(out.dropped_reverted.is_empty(), "{out:?}"); assert!(out.unused_reverted.is_empty(), "{out:?}"); @@ -2575,6 +2721,68 @@ mod gc_tests { .unwrap() .entries .contains_key(PURL)); + + // Detached entry whose dependency left the lock graph: (a) still + // exempts it (no manifest drop to detect), (b) reclaims it. + tokio::fs::write(tmp.path().join("package-lock.json"), "{\"packages\":{}}") + .await + .unwrap(); + let out = run_vendor_gc(&common, &manifest_path, false).await; + assert!( + out.dropped_reverted.is_empty(), + "(a) never touches a detached entry: {out:?}" + ); + assert_eq!( + out.unused_reverted, + vec![PURL.to_string()], + "(b) reclaims a lockfile-unused detached entry: {out:?}" + ); + assert!(out.failed.is_empty(), "{out:?}"); + assert!( + load_state(tmp.path()).await.unwrap().entries.is_empty(), + "the reclaimed detached entry leaves the ledger" + ); + assert!( + !tmp.path() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "the reclaimed detached entry's artifacts are removed" + ); + } + + /// A lock the GC cannot even OPEN (a directory squatting on + /// `apply.lock`) is a fault, not contention: it records a distinct + /// `lock_io` marker — never the "another run holds the apply lock" + /// text a live holder produces — and reclaims nothing. + #[tokio::test] + async fn vendor_gc_lock_io_is_reported_distinctly_from_contention() { + let (tmp, common, manifest_path) = gc_fixture(false).await; + write_manifest(&manifest_path, &PatchManifest::new()) + .await + .unwrap(); + tokio::fs::create_dir(tmp.path().join(".socket/apply.lock")) + .await + .unwrap(); + + let out = run_vendor_gc(&common, &manifest_path, false).await; + assert_eq!(out.failed.len(), 1, "{out:?}"); + assert!( + out.failed[0].contains("lock_io") && out.failed[0].contains("apply.lock"), + "an I/O fault is reported as lock_io naming the path: {out:?}" + ); + assert!( + !out.failed[0].contains("holds the apply lock"), + "an I/O fault must not be mislabelled as contention: {out:?}" + ); + assert!(out.dropped_reverted.is_empty(), "{out:?}"); + assert!( + load_state(tmp.path()) + .await + .unwrap() + .entries + .contains_key(PURL), + "a GC that could not lock must not touch the ledger" + ); } /// An entry that is BOTH manifest-dropped and lockfile-unused must be @@ -2960,6 +3168,17 @@ mod gc_tests { assert_eq!(sweep.removed.len(), 1, "{:?}", sweep.removed); assert!(sweep.still_wired.is_empty()); assert!(!wheel.exists(), "the unreferenced orphan is reclaimed"); + // The sweep was the last unit under `.socket/vendor/`: the emptied + // `/` and `vendor/` husks go with it, `.socket/` itself stays + // (the lock guard's level). + assert!( + !root.join(".socket/vendor").exists(), + "the orphan sweep prunes the emptied vendor tree" + ); + assert!( + root.join(".socket").is_dir(), + ".socket/ is never the sweep's to remove" + ); } /// (c) uuid dirs with no owning ledger entry are swept (wet) / counted @@ -3177,6 +3396,36 @@ mod scope_and_hint_tests { assert_eq!(flavor_install_command(""), None); } + /// The no-manifest no-op names the MANIFEST (the thing missing) and, + /// on a ledger-tracked project (`scan`/`get --mode vendored` never + /// write a manifest), says what IS vendored instead of "nothing" — + /// the old "No .socket folder found" text was false on every such + /// project (`.socket/vendor/` exists). + #[test] + fn no_manifest_message_names_the_manifest_and_tracked_entries() { + assert_eq!( + no_manifest_message(0), + "No manifest found, nothing to vendor." + ); + let one = no_manifest_message(1); + assert!( + one.starts_with("No manifest to vendor from; 1 vendored entry is tracked"), + "{one}" + ); + assert!(one.contains("`socket-patch repair`"), "{one}"); + let many = no_manifest_message(3); + assert!( + many.contains("3 vendored entries are tracked in the ledger"), + "{many}" + ); + for msg in [&one, &many] { + assert!( + !msg.contains(".socket folder"), + "never claims .socket/ is missing: {msg}" + ); + } + } + fn with_scope(list: Option<&[&str]>) -> GlobalArgs { GlobalArgs { ecosystems: list.map(|l| l.iter().map(|s| s.to_string()).collect()), diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 2e2423eb..6db96305 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -429,6 +429,13 @@ pub async fn find_all_packages_for_rollback( /// For each ecosystem in the partitioned map, create the crawler, discover /// source paths, and look up the given PURLs. Returns a unified /// `purl -> path` map (one representative copy per PURL). +/// +/// No production caller since `repair` moved onto the qualified-aware +/// [`find_packages_for_rollback`] (ledger keys are qualified for +/// release-variant ecosystems, and this base-keyed map never matched them); +/// kept for the in-file tests that pin that contrast. Integration pass: +/// delete it or make it `#[cfg(test)]`. +#[allow(dead_code)] pub async fn find_packages_for_purls( partitioned: &HashMap>, options: &CrawlerOptions, diff --git a/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs b/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs index 49849ec7..3af3242a 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs @@ -33,6 +33,9 @@ const GEM_UUID: &str = "22222222-2222-4222-8222-222222222222"; const GEM_NAME: &str = "padlock"; const GEM_VERSION: &str = "1.2.0"; const GEM_PURL: &str = "pkg:gem/padlock@1.2.0"; +/// The qualified spelling production publishes for gems (`platform=ruby`, +/// the portable default): the ledger key when the served record carries it. +const GEM_PURL_QUALIFIED: &str = "pkg:gem/padlock@1.2.0?platform=ruby"; const GEM_ENCODED: &str = "pkg%3Agem%2Fpadlock%401.2.0"; const GEMSPEC_STUB: &[u8] = b"Gem::Specification.new do |s|\n s.name = \"padlock\"\n s.version = \"1.2.0\"\n s.summary = \"repair fixture\"\n s.authors = [\"socket-patch e2e\"]\n s.require_paths = [\"lib\"]\nend\n"; @@ -1350,6 +1353,136 @@ async fn repair_offline_soft_restore_without_installed_copy() { ); } +// ─────────────── qualified ledger keys resolve the installed copy ─────────────── + +/// Ledger keys are the manifest spelling — for release-variant ecosystems +/// the QUALIFIED purl production publishes (`pkg:gem/…?platform=ruby`) — +/// while the crawler knows only base purls. Repair must resolve the +/// installed copy through the qualified-aware resolver (the one +/// `vendor_records` uses): pre-fix the base-keyed lookup never matched a +/// qualified ledger key, so an INSTALLED package read as absent and an +/// offline rebuild of a missing artifact failed `vendor_artifact_missing` +/// instead of rebuilding from the copy on disk (online, it fell through to +/// the registry-fetch rung — a needless network round-trip that, with no +/// rubygems route mounted here, fails the repair outright). +#[tokio::test] +async fn repair_rebuilds_qualified_ledger_key_from_installed_copy() { + let mock = MockServer::start().await; + mount_gem_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_gem_fixture(tmp.path(), false); + let copy = vendor_gem_project(tmp.path(), &mock.uri(), AFTER); + + // Re-key the ledger entry to the qualified spelling (`basePurl` stays + // bare — exactly what `scan --vendor` records for a served qualified + // purl). + let mut state = read_state(tmp.path()); + let entry = state["entries"] + .as_object_mut() + .unwrap() + .remove(GEM_PURL) + .expect("the vendored ledger entry"); + state["entries"][GEM_PURL_QUALIFIED] = entry; + write_state(tmp.path(), &state); + // The artifact is gone: the installed copy is the pristine source (the + // patch content itself comes from the mocked patch view, as in every + // online rebuild here). + std::fs::remove_dir_all(©).unwrap(); + + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["action"] == "rebuilt" && e["purl"] == GEM_PURL_QUALIFIED), + "the installed copy must drive the rebuild of the qualified-keyed entry: {v}" + ); + assert!( + !events_of(&v).iter().any(|e| e["action"] == "failed" + || e["errorCode"] + .as_str() + .is_some_and(|c| c.starts_with("vendor_fetch"))), + "an installed package is never 'not installed' — no registry rung, no failure: {v}" + ); + assert_eq!( + std::fs::read(copy.join("lib/padlock.rb")).unwrap(), + AFTER, + "rebuilt from the installed copy plus the recorded patch" + ); + let state = read_state(tmp.path()); + assert!( + state["entries"][GEM_PURL_QUALIFIED].is_object(), + "the qualified key survives the rebuild: {state}" + ); + assert!( + state["entries"][GEM_PURL].is_null(), + "no duplicate base-keyed entry is invented: {state}" + ); +} + +// ─────────────── crashed set-aside leftovers ─────────────── + +/// A repair killed between the move-aside and the backend's replacement +/// leaves `.pre-rebuild` as the ONLY copy of the bytes the rewired +/// Gemfile/lock still point at, and a bare ENOENT at the live path. The +/// next wet repair puts the leftover back first — the healthy bytes need no +/// rebuild, and no `.pre-rebuild` survives — while `--dry-run` (which +/// mutates nothing) leaves the leftover exactly where it was. Pre-fix the +/// leftover lingered forever: the orphan sweeps skip non-uuid names, and +/// the entry classified Missing so set-aside (the only other clearer) never +/// ran for it. +#[tokio::test] +async fn repair_restores_crashed_set_aside_leftover_before_classifying() { + let mock = MockServer::start().await; + mount_gem_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_gem_fixture(tmp.path(), false); + let copy = vendor_gem_project(tmp.path(), &mock.uri(), AFTER); + // `set_aside_vendor_dir` moves the whole UUID dir (marker + copy), so + // the crash leaves `/.pre-rebuild` beside a missing + // `/`. + let uuid_dir = tmp.path().join(format!(".socket/vendor/gem/{GEM_UUID}")); + let kept = tmp + .path() + .join(format!(".socket/vendor/gem/{GEM_UUID}.pre-rebuild")); + std::fs::rename(&uuid_dir, &kept).unwrap(); + // The installed copy is gone too: nothing but the leftover can serve + // the wiring, so a run that ignores it has no source at all. + std::fs::remove_dir_all(tmp.path().join("vendor/bundle")).unwrap(); + + let (_, stdout, stderr) = run_cli( + tmp.path(), + &mock.uri(), + &["repair", "--offline", "--dry-run"], + ); + assert!( + kept.is_dir(), + "--dry-run must not move the leftover: stdout={stdout} stderr={stderr}" + ); + assert!( + !uuid_dir.exists(), + "--dry-run must not restore the live dir" + ); + + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair", "--offline"]); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + !events_of(&v).iter().any(|e| e["action"] == "failed"), + "the restored bytes are healthy — nothing to rebuild, nothing failed: {v}" + ); + assert_eq!( + std::fs::read(copy.join("lib/padlock.rb")).unwrap(), + AFTER, + "the leftover is back at the live path the wiring points at" + ); + assert!( + !kept.exists(), + "no .pre-rebuild leftover survives the wet run" + ); +} + // ─────────────── precise unrepairable detail selection ─────────────── /// A non-soft pass-1 gem candidate with a precise Unverifiable cause diff --git a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs index 94732451..3a13e725 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs @@ -730,7 +730,10 @@ fn human_revert_empty_ledger_prints_nothing_to_revert() { } /// Human plain vendor with no manifest at all: the clean no-op message, -/// exit 0 (same contract as apply). +/// exit 0 (same contract as apply). The line names the MANIFEST — the +/// fixture's `.socket/` (blobs) very much exists, so the old "No .socket +/// folder found" text was false here and on every hosted-only or +/// vendored-mode project. #[test] fn human_missing_manifest_prints_nothing_to_vendor() { let fx = npm_fixture(); @@ -739,10 +742,59 @@ fn human_missing_manifest_prints_nothing_to_vendor() { let (code, stdout, stderr) = human_vendor(&fx, &[]); assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!( - stdout.contains("No .socket folder found, nothing to vendor."), + stdout.contains("No manifest found, nothing to vendor."), "the no-manifest no-op line: {stdout}" ); + assert!( + !stdout.contains(".socket folder"), + "never claims .socket/ is missing: {stdout}" + ); assert!(!fx.vendor_dir().exists(), "nothing written"); + assert!( + !fx.root().join(".socket/apply.lock").exists(), + "the no-op path takes no lock" + ); +} + +/// Human plain vendor on a LEDGER-tracked project with no manifest — the +/// shape every `scan`/`get --mode vendored` project has (`.socket/vendor/` +/// exists, `.socket/manifest.json` does not): still the clean exit-0 +/// no-op (nothing locked, reverted or written), but the line says what IS +/// vendored and points at `repair` instead of implying nothing is set up. +#[tokio::test] +async fn human_missing_manifest_with_ledger_names_the_tracked_entries() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "stage vendor"); + std::fs::remove_file(fx.manifest_path()).unwrap(); + let wired_lock = fx.lock_bytes(); + let state_before = std::fs::read(fx.state_path()).unwrap(); + + let (code, stdout, stderr) = human_vendor(&fx, &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("No manifest to vendor from; 1 vendored entry is tracked in the ledger"), + "the ledger-aware no-op line: {stdout}" + ); + assert!( + stdout.contains("`socket-patch repair`"), + "points at repair as the verification path: {stdout}" + ); + assert!(!stdout.contains(".socket folder"), "{stdout}"); + assert!(fx.tgz_path().is_file(), "no-op: the artifact survives"); + assert_eq!( + fx.lock_bytes(), + wired_lock, + "no-op: the wiring is untouched" + ); + assert_eq!( + std::fs::read(fx.state_path()).unwrap(), + state_before, + "no-op: the ledger is untouched" + ); + assert!( + !fx.root().join(".socket/apply.lock").exists(), + "the no-op path takes no lock" + ); } // ───────────────────────────────────────────────────────────────────── @@ -860,6 +912,42 @@ async fn revert_state_write_failure_reports_failed_after_removal() { ); } +/// The reconcile twin of the pin above: a patch dropped from the manifest +/// whose ledger save fails AFTER the entry's revert succeeded +/// (`.socket/vendor` read-only, the artifact dir under `npm/` still +/// deletable). The purl carries BOTH its `vendor_reconciled` removal and a +/// `vendor_state_write_failed` failure, and the run exits 1 — pre-fix +/// `reconcile_dropped` swallowed the error (`let _ = save_state`) and +/// exited 0 with a ledger still listing the reverted purl. +#[cfg(unix)] +#[tokio::test] +async fn reconcile_state_write_failure_reports_failed_after_removal() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "stage vendor"); + std::fs::write(fx.manifest_path(), b"{\"patches\": {}}\n").unwrap(); + chmod(&fx.vendor_dir(), 0o555); + let _restore = RestorePerms(fx.vendor_dir()); + + let (code, env) = vendor_cli(fx.root(), &[]); + assert_eq!(code, 1, "{env:#}"); + let removed = find_event(&env, "removed", Some("vendor_reconciled")); + assert_eq!( + removed["purl"], PURL, + "the revert itself succeeded: {env:#}" + ); + let failed = find_event(&env, "failed", Some("vendor_state_write_failed")); + assert_eq!(failed["purl"], PURL, "{env:#}"); + assert_eq!( + fx.lock_bytes(), + fx.original_lock, + "the lock restore itself succeeded" + ); + assert!( + fx.state_path().is_file(), + "the stale ledger is left in place — the write is what failed" + ); +} + /// A vendor run whose per-package `save_state` fails after the backend /// already wrote the artifact and rewired the lock: the package's /// `Applied` event stands, a `vendor_state_write_failed` failure rides diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 4ded3379..e8762ffd 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1336,11 +1336,12 @@ fn lock_contention_exits_lock_held() { /// `vendor --revert` is documented to work without a manifest, and "a /// missing ledger is an empty ledger (clean no-op plus the orphan-dir /// sweep)" (CLI_CONTRACT, "Ownership, state, and reversal"). A project -/// with no `.socket/` directory at all is exactly that case — but the -/// apply lock lives INSIDE `.socket/`, and `apply_lock::acquire` only -/// creates the lock *file*, never its parent. Taking the lock before -/// noticing there is nothing to revert turns the documented no-op into a -/// `lock_io` failure. +/// with no `.socket/` directory at all is exactly that case. The apply +/// lock lives INSIDE `.socket/`: `apply_lock::acquire` would create the +/// directory for its lock file (and the guard's drop prune it again), but +/// a no-op revert must never be the thing that creates `.socket/`, even +/// transiently — so `vendor` skips the lock when there is no `.socket/` +/// to serialize against. #[test] fn revert_without_a_socket_dir_is_a_clean_no_op() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -1933,13 +1934,16 @@ fn run_scan_vendor(root: &Path, mock_uri: &str, extra: &[&str]) -> (i32, Value) (code, env) } -/// `scan --vendor` end to end on a gem project: discover → download -/// (manifest written) → vendor lands the gem pair edit (Gemfile pin + -/// `path:`, lock PATH section + `(= …)!` DEPENDENCIES pin) and the patched -/// artifact dir — then reconcile auto-reverts once the manifest drops the -/// patch, byte-restoring both halves. +/// `scan --vendor` end to end on a gem project: discover → download → +/// vendor lands the gem pair edit (Gemfile pin + `path:`, lock PATH section +/// + `(= …)!` DEPENDENCIES pin) and the patched artifact dir, keyed in the +/// ledger by the gem purl; a re-run is an `already_vendored` no-op; and +/// `vendor --revert` (the vendored entry's exit path) byte-restores both +/// halves and prunes the vendor tree. Manifest-agnostic on purpose: the +/// vendored mode never writes `.socket/manifest.json` (its ledger owns the +/// entries), so nothing here reads one. #[tokio::test] -async fn scan_vendor_gem_end_to_end_and_reconcile() { +async fn scan_vendor_gem_end_to_end_and_reverts() { let mock = wiremock::MockServer::start().await; mount_gem_patch_api(&mock, GEM_PURL).await; let fx = gem_fixture(); @@ -1951,12 +1955,6 @@ async fn scan_vendor_gem_end_to_end_and_reconcile() { assert_eq!(env["vendor"]["summary"]["applied"], 1, "envelope: {env:#}"); assert_eq!(env["vendor"]["summary"]["failed"], 0, "envelope: {env:#}"); - // Manifest written by the download phase, keyed by the gem purl. - let manifest: Value = - serde_json::from_slice(&std::fs::read(fx.root().join(".socket/manifest.json")).unwrap()) - .unwrap(); - assert_eq!(manifest["patches"][GEM_PURL]["uuid"], GEM_UUID); - // Artifact: patched bytes + the stub gemspec a path source needs. assert_eq!( std::fs::read(fx.vendored_lib()).unwrap(), @@ -1997,15 +1995,11 @@ async fn scan_vendor_gem_end_to_end_and_reconcile() { ); // The installed tree stays pristine (vendoring is not an in-place apply) - // and the ledger entry is manifest-tracked (not detached). + // and the ledger entry is keyed by the served gem purl. assert_eq!(std::fs::read(fx.installed_lib()).unwrap(), GEM_ORIG); let state: Value = serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap(); assert_eq!(state["entries"][GEM_PURL]["ecosystem"], "gem"); assert_eq!(state["entries"][GEM_PURL]["uuid"], GEM_UUID); - assert!( - state["entries"][GEM_PURL]["detached"].is_null(), - "manifest-mode entries are not detached: {state:#}" - ); // Idempotent re-run through the same JSON arm. let gemfile_wired = std::fs::read(fx.gemfile_path()).unwrap(); @@ -2024,34 +2018,29 @@ async fn scan_vendor_gem_end_to_end_and_reconcile() { assert_eq!(std::fs::read(fx.gemfile_path()).unwrap(), gemfile_wired); assert_eq!(std::fs::read(fx.lock_path()).unwrap(), lock_wired); - // Reconcile: the patch dropped from the manifest is auto-reverted by the - // next plain vendor run — BOTH pair-edit halves byte-restored. - std::fs::write( - fx.root().join(".socket/manifest.json"), - b"{\"patches\": {}}\n", - ) - .unwrap(); - let (code, renv) = vendor_cli(fx.root(), &[]); - assert_eq!(code, 0, "reconcile-only run must exit 0: {renv:#}"); - let removed = find_event(&renv, "removed", Some("vendor_reconciled")); + // `vendor --revert` is the vendored entry's exit path — BOTH pair-edit + // halves byte-restored, the vendor tree fully pruned. + let (code, renv) = vendor_cli(fx.root(), &["--revert"]); + assert_eq!(code, 0, "revert must undo the vendored entry: {renv:#}"); + let removed = find_event(&renv, "removed", None); assert_eq!(removed["purl"], GEM_PURL); assert_eq!( std::fs::read(fx.gemfile_path()).unwrap(), GEM_GEMFILE.as_bytes(), - "reconcile must byte-restore the Gemfile" + "revert must byte-restore the Gemfile" ); assert_eq!( std::fs::read(fx.lock_path()).unwrap(), GEM_LOCK.as_bytes(), - "reconcile must byte-restore Gemfile.lock" + "revert must byte-restore Gemfile.lock" ); assert!( !fx.root().join(".socket/vendor").exists(), - "the reconciled vendor tree must be fully pruned" + "the reverted vendor tree must be fully pruned" ); } -/// Same in-process flow as [`scan_vendor_gem_end_to_end_and_reconcile`], but +/// Same in-process flow as [`scan_vendor_gem_end_to_end_and_reverts`], but /// the served patch records carry the QUALIFIED gem purl (`?platform=ruby`) /// — the spelling production has published since the 2026-08-18 gem catalog /// republish. `platform=ruby` is the portable default: the vendor gate @@ -2088,20 +2077,14 @@ async fn scan_vendor_gem_qualified_platform_ruby_purl_vendors() { an `applied` event for {GEM_PURL_QUALIFIED}: {env:#}" ); - // Manifest and ledger entry are keyed by the SERVED (qualified) purl; - // the ledger entry additionally records the qualifier-stripped - // `basePurl` (built by `build_gem_purl`) — both halves of the mapping. - let manifest: Value = - serde_json::from_slice(&std::fs::read(fx.root().join(".socket/manifest.json")).unwrap()) - .unwrap(); - assert_eq!( - manifest["patches"][GEM_PURL_QUALIFIED]["uuid"], GEM_UUID, - "manifest keys by the served purl: {manifest:#}" - ); + // The ledger entry is keyed by the SERVED (qualified) purl and + // additionally records the qualifier-stripped `basePurl` (built by + // `build_gem_purl`) — both halves of the mapping. (The ledger is the + // vendored mode's owner of record; no manifest is consulted.) let state: Value = serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap(); assert_eq!( state["entries"][GEM_PURL_QUALIFIED]["uuid"], GEM_UUID, - "ledger keys by the manifest (qualified) purl: {state:#}" + "ledger keys by the served (qualified) purl: {state:#}" ); assert_eq!( state["entries"][GEM_PURL_QUALIFIED]["basePurl"], GEM_PURL, From 0e74dc700e402b430b9c06fac8d8a13abcc8d021 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 19:41:07 -0400 Subject: [PATCH 11/44] docs(contract/readme/changelog): pin the v5 .socket hygiene contract; collapse Bun vendored-detached leg Update every pinned description to the round-1/round-2 behavior (decisions D1-D4): - Lock lifecycle: apply.lock never outlives a command (acquire mkdirs .socket/, drop unlinks while held and prunes an empty .socket/); repair loses its lock-cleanup role; hosted scan/get take the lock around the first wet write; --lock-timeout / lock_held rows name every lock-taking subcommand. README drops the gitignore-apply.lock recipes and the repair housekeeping claims. - Vendored mode is manifest-free: scan/get --mode vendored write only .socket/vendor/**, every ledger entry carries detached:true + record, --detached is a hidden compatibility no-op (still requires vendored mode), the JSON download vocabulary is the detached one (downloaded/skipped/failed, detached:true), "whole manifest is vendored" is retired, legacy manifest records migrate into the ledger, list reads the vendor ledger ((vendored) marker, exit 0), --prune's lockfile-unused leg covers every ledger entry, standalone vendor without a manifest is a clean no-op that names the manifest. - Normal non-TTY scan without --yes/intent flag is report-only; human scan --mode hosted prints the table and confirms once. - Reversal residue rule: emptied ledgers, vendor husks and blob/diff/package stores are pruned, .socket/ goes with the lock; the zero-patch manifest, setup-owned files and .corrupt quarantines survive. Setup property 5/8 name the real write set (.socket/.gitignore, gem-plugin-stamp location, composer.json) and setup --remove's .socket/ prune; the composer feature-gate sentence is corrected. - Round-1 handoffs: redirect warning codes (composer/gem/maven/nuget/cargo), nuget "added" edit action, unreadable-vs-malformed ledger split, gem drift-keep parity + vendor_lockfile_missing, golang staged service leg, pypi symlink/changed refusal rows, pypi_pipenv_invalid_wheel retired. - CHANGELOG [Unreleased]: Changed (BREAKING) + Fixed bullets naming every user-visible change; Bun preflight bullet and matrix wording updated. - Bun backtest harness + workflow + docs/testing: vendored and vendored-detached have the same footprint, so MODES collapses to hosted/vendored, ledger_record reads state.json for vendored, --detached is no longer passed, and every cell asserts no .socket/manifest.json. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/bun-compatibility.yml | 10 +- CHANGELOG.md | 116 +++++++++++++++-- README.md | 106 ++++++++-------- crates/socket-patch-cli/CLI_CONTRACT.md | 158 +++++++++++++++--------- docs/testing/bun-compatibility.md | 39 +++--- docs/testing/pdm-compatibility.md | 5 + docs/testing/vendored-production-e2e.md | 4 +- scripts/backtest-bun-lockb.py | 2 +- scripts/backtest-bun.py | 58 ++++----- 9 files changed, 325 insertions(+), 173 deletions(-) diff --git a/.github/workflows/bun-compatibility.yml b/.github/workflows/bun-compatibility.yml index 0089fd78..bbae07f3 100644 --- a/.github/workflows/bun-compatibility.yml +++ b/.github/workflows/bun-compatibility.yml @@ -3,8 +3,10 @@ name: Bun patch compatibility # Native Bun installer matrix: builds the CLI once per OS, downloads each # pinned Bun release straight from its GitHub release (retried, SHA-256 # verified against the release's SHASUMS256.txt) and runs -# `scripts/backtest-bun.py` — hosted, vendored and vendored-detached mode -# against the public minimist free patch, verifying the INSTALLED bytes, +# `scripts/backtest-bun.py` — hosted and vendored mode (vendored is +# manifest-free: the ledger embeds the record, so the former +# vendored-detached leg collapsed into it) against the public minimist free +# patch, verifying the INSTALLED bytes, # lock stability, digest rejection and rollback on Linux, macOS and Windows. # No Socket API token is needed. See docs/testing/bun-compatibility.md. # @@ -74,7 +76,7 @@ on: required: false default: '' modes: - description: 'Space-separated modes from hosted / vendored / vendored-detached (empty = all three)' + description: 'Space-separated modes from hosted / vendored (empty = both)' required: false default: '' @@ -308,7 +310,7 @@ jobs: chmod +x native-cli/socket-patch* || true cli="native-cli/socket-patch" if [ "$RUNNER_OS" = "Windows" ]; then cli="native-cli/socket-patch.exe"; fi - modes="hosted vendored vendored-detached" + modes="hosted vendored" if [ -n "$MODES_OVERRIDE" ]; then modes="$MODES_OVERRIDE"; fi shapes_arg=() if [ -n "$SHAPES_OVERRIDE" ]; then shapes_arg=(--shapes $SHAPES_OVERRIDE); fi diff --git a/CHANGELOG.md b/CHANGELOG.md index ded4b6c6..eda8a7b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,9 @@ into the new version's section — see docs/releasing.md. ## [Unreleased] -> **Semver note:** this entry changes `rollback`'s default behavior and -> narrows the meaning of its existing `vendored: []` JSON key — both MAJOR +> **Semver note:** this entry changes `rollback`'s default behavior, narrows +> the meaning of its existing `vendored: []` JSON key, makes vendored mode +> manifest-free, and turns a plain non-TTY `scan` report-only — all MAJOR > per CLI_CONTRACT.md's semver policy — so it ships as the next major > release (v5.0). @@ -37,7 +38,7 @@ into the new version's section — see docs/releasing.md. GCs the now-unused blobs plus diff/package archives. No `--mode` needed: state is inferred from the manifest, the vendor ledger, and the redirect ledger, and rollback now runs manifest-less when a ledger holds work - (hosted-only and detached-vendored projects; the truly-empty project + (hosted-only and vendored projects; the truly-empty project keeps the "Manifest not found" exit 1, and a wired-but-ledgerless project errors naming `socket-patch repair`). Wet non-preserve runs confirm once ("Roll back N patch(es), remove them from the local manifest, and delete @@ -52,6 +53,56 @@ into the new version's section — see docs/releasing.md. always-present `warnings[]` (`{code, detail}`, now populated), `hosted` (`{reverted, failed, unsupported, editedFiles}`), `manifest` (`{removedEntries, preserved}`), `gc`, and `paths` keys. +- **Vendored mode is manifest-free.** `scan --mode vendored` and + `get --mode vendored` never write (or read) `.socket/manifest.json`: the + selected patch records are fetched into memory and every vendor-ledger entry + carries `detached: true` plus the embedded `record` as its verification + source, so a vendored project's footprint is `.socket/vendor/**` only. The + former `--detached` opt-in is now the only vendored posture — the flag is + hidden, accepted as a no-op for compatibility, and still a usage error + without vendored mode. JSON uses the detached download vocabulary for both + commands (`downloaded: N`, `detached: true`, `patches[].action` = + `downloaded` | `skipped` | `failed`). The vendor step vendors exactly what + discovery selected — the "whole manifest is vendored" re-vendor from a + committed manifest on an empty discovery is retired (`repair` verifies and + rebuilds committed vendored state) — and a legacy manifest record for a purl + a vendored run vendors is migrated into the ledger (dropped from the + manifest; an emptied manifest is left as `{"patches": {}}`). `list` now + reads the vendor ledger too, so a vendored-only project lists its patches + with a `(vendored)` marker and exits 0 instead of `manifest_not_found`; + `scan --prune`'s lockfile-unused reconcile applies to every ledger entry + (the check is about the lockfile, not the manifest); and standalone `vendor` + with no manifest is a clean exit-0 no-op whose message names the missing + manifest (and the ledger entries `repair` verifies) instead of claiming + "No .socket folder found". +- **A plain `scan` without a TTY is report-only.** When stdin is not a TTY, + `--yes` is absent, and no intent flag (`--mode`, `--apply`, `--sync`, + `--vendor`, `--redirect`, `--prune`) is given, human-mode `scan` prints the + discovery report and the "To apply a patch, run: …" hint, downloads + nothing, creates no `.socket/`, and exits 0 — it no longer auto-accepts the + apply prompt. Any intent flag, `--yes`, or a TTY keeps the previous + behavior; `rollback`/`remove`/`get`'s non-TTY auto-accept is unchanged. + Human `scan --mode hosted` now prints the results table and update + detection like the other modes and confirms once ("Redirect N package(s) + to hosted patches?", default yes, skipped by `--yes`/`--json`). +- **`apply.lock` never outlives a command, and hosted mode takes it.** Lock + acquisition creates `.socket/` when missing; the lock file is unlinked + (while still held) and an otherwise-empty `.socket/` removed when the + command exits, dry runs included, so there is nothing to `.gitignore` and + `repair` no longer has a lock-cleanup step (a leftover from a crashed run is + reclaimed and removed by the next lock-taking command; a live holder is + still `lock_held`, exit 1). `scan`/`get --mode hosted` now acquire the lock + around their first wet write — never on `--dry-run` or when nothing would + be written, so previews create no `.socket/` — and report `lock_held` like + the other lock holders; the GC legs of `scan --prune` and `vendor` honor + `--lock-timeout` and report a lock I/O error instead of silently skipping + on it. +- **Retired:** the legacy `.socket/cargo-patches` redirect takeover in the + cargo vendor backend (never shipped in a tagged release — such + `[patch.crates-io]` entries now refuse as `user_authored_patch_entry`), the + `VITEST` telemetry kill-switch (set `SOCKET_TELEMETRY_DISABLED=1` in test + harnesses), and the `pypi_pipenv_invalid_wheel` refusal code (the Pipenv + backend takes the resolved version instead of parsing the wheel filename). ### Added @@ -268,6 +319,55 @@ into the new version's section — see docs/releasing.md. ### Fixed +- **Reversal leaves no `.socket/` residue.** `rollback`, `remove`, + `vendor --revert`, the hosted unwind and the GC sweeps now prune what they + empty: an emptied redirect or vendor ledger is deleted together with the + empty `.socket/vendor//` and `.socket/vendor/` directories (per-entry + vendored reverts prune their ecosystem husk; a `redirect-state.json.corrupt` + quarantine keeps its directory), emptied `blobs/`, `diffs/` and `packages/` + stores are removed, and `.socket/` itself goes with the lock when nothing is + left — so a fully unwound hosted or vendored project has no `.socket/` at + all. Deliberately kept: the zero-patch `.socket/manifest.json` + (`{"patches": {}}` + its `setup` block — `list`/`apply`/`vex` exit codes + depend on it) and the `setup`-owned `.socket/.gitignore`, + `gem-plugin-stamp` and `bundler-plugin/` (rollback never undoes setup). + `setup --remove` now also removes an emptied `.socket/`. +- **A normal `scan` never creates `.socket/`.** Report-only, `--dry-run`, + zero-discovery and no-op runs (hosted or otherwise) no longer scaffold the + directory or a lock file; a GC pass checks for a manifest before it locks. +- **`apply --silent` on an all-unmatched manifest prints its error line** — + errors are never muted by `--silent`; and the no-manifest early exits of + `apply` and `vendor` name the missing `.socket/manifest.json` instead of + "No .socket folder found" (the folder may legitimately hold setup files or + vendored state). +- **Hosted redirect hygiene.** Missing project files no longer skip silently: + `redirect_composer_no_lockfile`, `redirect_gem_no_gemfile` (neither manifest + nor lock present) and `redirect_maven_no_pom` (no `pom.xml`, no Gradle + build) warn once per run; a present-but-corrupt `packages.lock.json` warns + `redirect_nuget_lock_unparseable` before any config mutation; a `Cargo.lock` + with several same-name+version `[[package]]` blocks and no `source` + disambiguation warns `redirect_cargo_lock_pkg_ambiguous` and skips + transactionally; a registry override of the wrong kind now warns the arm's + missing-override code for nuget/gem/golang (previously a silent skip); the + ledger's `redirect_nuget_source` edit records `action: "added"` when + `nuget.config` was authored from scratch; hosted-revert lockfile restores are + atomic and mode-preserving (including `bun.lockb`), and a FIFO or symlink + squatting on a lockfile is refused instead of wedging the revert. +- **Vendor backend parity.** Gem reverts follow every other backend's + drift-keep rule (genuine drift keeps artifact + ledger entry; converged files + are silent; a missing `Gemfile`/`Gemfile.lock` warns `vendor_lockfile_missing` + and still removes the artifact); the golang service leg stages its download + and, when a re-download of a wired present copy fails, keeps the copy and + directive instead of tearing them down; poetry/pipenv/requirements refuse + symlinked targets (`pypi_{poetry,pipenv,requirements}_symlink_unsupported`) + and every pypi flavor refuses a project file that changed between plan and + write (`pypi_{poetry,pdm,pipenv,uv}_changed`) instead of clobbering it; + `pyproject.toml` edits made by `setup` preserve CRLF line endings; an + unreadable (EACCES / squatting directory or FIFO) redirect ledger is + reported as unreadable and left in place instead of being quarantined as + "malformed"; a blob-cleanup pass keeps sweeping after one unremovable file + and reports the first error afterwards; the ledgers skip byte-identical + rewrites. - **Bun refusal safety:** hosted compatibility is checked before removing an existing vendored patch, including during dry-run. Vendored preflight exemptions require live local lock tuples; a ledger retained by @@ -300,12 +400,12 @@ into the new version's section — see docs/releasing.md. and `repair` on such a lock keep working; a corrupt `.socket/vendor/state.json` met by that preflight is reported as `vendor_state_unreadable` rather than a Bun lock code. `scan --mode vendored`, - `get --mode vendored` (search and uuid paths) and `--detached` runs now + `get --mode vendored` (search and uuid paths) now preflight the Bun lock BEFORE any download: a malformed binary, unreadable, unsupported-version or pre-version-2 workspace lock marks the npm patches `failed` with the vendor refusal code and detail, fetches nothing and - records no patch — the `scan` / `get ` path still writes an unchanged - `.socket/manifest.json` and exits `partial_failure`, `get --mode + records no patch — the `scan` / `get ` path writes nothing under + `.socket/` and exits `partial_failure`, `get --mode vendored` exits 1 with `status: "error"` and writes nothing — where previously the record landed in the manifest and the vendor step failed afterwards (and a detached run over an alias install misreported @@ -330,8 +430,8 @@ into the new version's section — see docs/releasing.md. rewrite keeps CRLF on the rewritten `bun.lock` line. Real-Bun coverage now runs in CI: the hermetic hosted and vendored suites on Linux, macOS and Windows (Bun 1.4.2, plus 1.1.45 and 1.2.23 lock-era legs), and - the production native matrix — 16 releases from 0.8.1 to 1.4.2 in hosted, - vendored and detached-vendored mode — on pull requests and `main` (rows + the production native matrix — 16 releases from 0.8.1 to 1.4.2 in hosted + and vendored mode — on pull requests and `main` (rows carry `cliRevision` and `cliBuildSha` provenance), with the corrected digest boundary (Bun verifies URL/local tarball sha512 from 1.3.10, not 1.3.14). Bun 1.1.39–1.3.9 also re-save a hosted URL or diff --git a/README.md b/README.md index cce2b42e..f93f694d 100644 --- a/README.md +++ b/README.md @@ -175,8 +175,7 @@ automatically: ```bash socket-patch setup # e.g. adds a postinstall script for npm projects -echo '.socket/apply.lock' >> .gitignore # lock state, not part of the patch record -git add .gitignore .socket package.json # npm example — setup prints which files it changed +git add .socket package.json # npm example — setup prints which files it changed git commit -m "apply Socket security patches" ``` @@ -215,13 +214,17 @@ committed: | Path | Contents | |------|----------| -| `.socket/manifest.json` | The record of downloaded patches: PURLs, file hashes, vulnerability metadata ([format](#manifest-format)) | -| `.socket/blobs/` | Patched file contents, named by git-sha256 hash | -| `.socket/vendor/` | Vendored package artifacts and the vendor/redirect ledgers (only in vendored/hosted modes) | - -> Mutating commands also leave a `.socket/apply.lock` file there between runs. It is -> lock state, not part of the patch record — add it to your `.gitignore` -> ([`repair`](#repair) deletes it). +| `.socket/manifest.json` | Agent mode: the record of downloaded patches — PURLs, file hashes, vulnerability metadata ([format](#manifest-format)) | +| `.socket/blobs/` | Agent mode: patched file contents, named by git-sha256 hash | +| `.socket/vendor/` | Vendored package artifacts and the vendor/redirect ledgers — the **only** state vendored and hosted modes write (the vendor ledger embeds the patch records; neither mode touches `manifest.json`) | + +> While a command runs it holds a transient advisory lock, `.socket/apply.lock`, and +> removes it when it finishes — the file never outlives the command, so there is nothing +> to `.gitignore`. A crashed run can leave one behind; the next command reclaims and +> removes it. Nothing in the table is written until there is something to record: a +> report-only `scan`, a `--dry-run`, or a run that changes nothing leaves no `.socket/` at +> all, and a full [`rollback`](#rollback) removes everything it created (only the +> zero-patch `manifest.json` and any [`setup`](#setup) files stay). ### Three patch modes @@ -232,7 +235,7 @@ The same patched bytes can reach your build three different ways. The modes diff | Mode | Where the patch lives | Install-time requirement | Trade-off | |------|----------------------|--------------------------|-----------| | **agent** — `scan --mode agent` (or [`apply`](#apply)) | `.socket/` manifest + blobs, committed; the CLI re-applies after each install | The `socket-patch` CLI must run (install hook via [`setup`](#setup), or an `apply` step in CI) | Small repo footprint (per-file blobs, not whole packages); no lockfile edits; the only mode that needs CI / install-hook changes | -| **vendored** — `scan --mode vendored` (or [`vendor`](#vendor)) | Patched packages committed under `.socket/vendor/`; the lockfile is rewired to consume them | **None** — the package manager installs the committed bytes | Fully airgapped and hermetic, at the cost of repo size | +| **vendored** — `scan --mode vendored` (or [`vendor`](#vendor)) | Patched packages committed under `.socket/vendor/` (with a ledger that embeds the patch records — no manifest); the lockfile is rewired to consume them | **None** — the package manager installs the committed bytes | Fully airgapped and hermetic, at the cost of repo size | | **hosted** — `scan --mode hosted` | No patched bytes in your repo: the lockfile is rewritten so **only** the patched dependencies resolve to Socket-hosted, integrity-pinned packages on `patch.socket.dev`; the edits + patch records are ledgered in `.socket/vendor/redirect-state.json` (commit it — [`vex`](#vex) reads it, and [`rollback`](#rollback) replays its recorded pre-redirect originals to unwind the redirect, see [Undo things](#undo-things)) | Installs must be able to reach `patch.socket.dev` (no CLI, no install hook) | Smallest possible diff (lockfile + ledger); not for airgapped installs | Every mode pins the patched bytes: in agent mode the CLI verifies every file on each @@ -343,8 +346,7 @@ Go, Maven, NuGet, Deno) have no hook and are patched on demand instead. ```bash # Vendored: commit the patched packages themselves (airgap-friendly) socket-patch scan --json --mode vendored --yes -echo '.socket/apply.lock' >> .gitignore -git add .gitignore .socket package-lock.json # your lockfile may differ +git add .socket package-lock.json # your lockfile may differ # Hosted: smallest diff — patched deps resolve from patch.socket.dev socket-patch scan --json --mode hosted --yes @@ -406,7 +408,7 @@ and repair; pick by what you want back: | [`remove`](#remove) | Everything `rollback` does, **plus** it deletes the manifest entry and reverts any vendoring — **permanent**, the patch is fully gone in one command | | [`vendor --revert`](#vendor) | **Un-vendors wholesale**: restores the recorded original lockfile fragments byte-for-byte and removes the `.socket/vendor/` artifacts — works without a manifest | | [`scan --prune`](#scan) | **Reconciles, doesn't reverse**: drops manifest entries for packages that have left the project and garbage-collects orphan blob/diff/archive files — installed patches stay | -| [`repair`](#repair) (alias `gc`) | **Restores health, not originals**: re-downloads missing blobs, rebuilds missing/corrupt vendored artifacts, cleans up unused ones, and removes the leftover `apply.lock` file (housekeeping — mutating commands leave it behind after every run) | +| [`repair`](#repair) (alias `gc`) | **Restores health, not originals**: re-downloads missing blobs, rebuilds missing/corrupt vendored artifacts, and cleans up unused ones | And `setup --remove` reverts the install hooks that `setup` added. @@ -428,9 +430,9 @@ And `setup --remove` reverts the install hooks that `setup` added. | [`setup`](#setup) | Wire install hooks so patches re-apply automatically | | [`rollback`](#rollback) | Restore original files (keeps the manifest) | | [`get`](#get) | Fetch and apply a patch by UUID / CVE / GHSA / PURL / name (alias: `download`) | -| [`list`](#list) | List all patches in the local manifest | +| [`list`](#list) | List recorded patches: manifest entries plus vendor-ledger and redirect-ledger records | | [`remove`](#remove) | Remove a patch: roll back files + delete the manifest entry | -| [`repair`](#repair) | Download missing blobs, clean up unused ones, tidy lock state (alias: `gc`) | +| [`repair`](#repair) | Download missing blobs, rebuild vendored artifacts, clean up unused ones (alias: `gc`) | ### Global options @@ -466,7 +468,7 @@ settings, described in [Configuration sources](#configuration-sources) below. | `-s, --silent` | `SOCKET_SILENT` | Suppress non-error output. | | `--dry-run` | `SOCKET_DRY_RUN` | Preview the operation without making any mutations. | | `-y, --yes` | `SOCKET_YES` | Skip interactive confirmation prompts. | -| `--lock-timeout ` | `SOCKET_LOCK_TIMEOUT` | Seconds to wait for `.socket/apply.lock` before giving up. `0`/unset = a single non-blocking try; a positive value retries with backoff. Only meaningful for mutating commands (`apply`, `rollback`, `repair`, `remove`). | +| `--lock-timeout ` | `SOCKET_LOCK_TIMEOUT` | Seconds to wait for `.socket/apply.lock` before giving up. `0`/unset = a single non-blocking try; a positive value retries with backoff. Only meaningful for the commands that take the lock — `apply`, `rollback`, `repair`, `remove`, `vendor`, and `scan`/`get` in vendored or hosted mode. The lock file exists only while a command runs. | | `--debug` | `SOCKET_DEBUG` | Emit verbose debug logs to stderr. | | `--no-telemetry` | `SOCKET_TELEMETRY_DISABLED` | Disable anonymous usage telemetry. | @@ -516,12 +518,15 @@ it finds. `scan` is the entry point for all three [patch modes](#three-patch-mod - `--mode agent` downloads and applies the selected patches in place; - `--mode vendored` discovers, downloads, and builds + wires the committable `.socket/vendor/` artifacts in one pass (re-vendoring automatically when a newer patch - is selected); + is selected); it is manifest-free — the vendor ledger embeds the patch records and + nothing else is written under `.socket/`; - `--mode hosted` rewrites lockfiles / registry configs so only the patched dependencies resolve to Socket-hosted packages. -Without a mode, interactive `scan` prompts before applying, and `scan --json` is -read-only (discovery plus an `updates[]` array; no mutation). +Without a mode, interactive `scan` prompts before applying (in a TTY — when stdin is not a +TTY and neither `--yes` nor a mode/`--prune` flag is given, it is report-only: it prints what +it found plus the "To apply a patch, run: …" hint, writes nothing, and exits 0), and +`scan --json` is read-only (discovery plus an `updates[]` array; no mutation). `scan --mode agent --prune` is the single command bots need for full auto-update: it discovers patches, applies them, and garbage-collects orphan blob files plus manifest @@ -536,8 +541,8 @@ socket-patch scan [options] | Flag | Env var | Description | |------|---------|-------------| | `--mode ` | — | Selects one of the three [patch modes](#three-patch-modes), summarized above. Combining `--mode` with a legacy boolean flag of a *different* mode is an error (exit 2); the same mode spelled both ways is accepted. | -| `--prune` | — | Garbage-collect after the scan: remove manifest entries for packages no longer present in the crawl (installed trees + lockfiles — a wiped `node_modules` alone doesn't prune lockfile-listed entries) and delete orphan blob/diff/package-archive files. Off by default. [Vendored](#vendor) packages are exempt from the crawl-based prune (an absent installed copy is their normal state), but a vendored entry whose dependency has left the lockfile is reverted and its manifest entry dropped. Orthogonal to `--mode` — combines with any mode. | -| `--detached` | — | With `--mode vendored`: skip all `.socket/manifest.json` writes — the vendor ledger embeds the patch records instead. For projects that want the vendored patches *only* in the lockfile + `.socket/vendor/`. Detached patches are invisible to `apply`/`rollback`/`repair`; undo them with `remove ` or `vendor --revert`. | +| `--prune` | — | Garbage-collect after the scan: remove manifest entries for packages no longer present in the crawl (installed trees + lockfiles — a wiped `node_modules` alone doesn't prune lockfile-listed entries) and delete orphan blob/diff/package-archive files. Off by default. [Vendored](#vendor) packages are exempt from the crawl-based prune (an absent installed copy is their normal state), but a vendored entry whose dependency has left the lockfile is reverted (and any manifest entry it still had dropped). Orthogonal to `--mode` — combines with any mode. | +| `--detached` | — | Hidden compatibility no-op. Vendored mode is manifest-free by default: the vendor ledger (`.socket/vendor/state.json`) embeds the patch records and `.socket/manifest.json` is never written, so this former opt-in changes nothing. Still an error without `--mode vendored`. | | `--batch-size ` | `SOCKET_BATCH_SIZE` | Packages per API request (default: `100`). | | `--all-releases` | `SOCKET_ALL_RELEASES` | Store patches for every release/distribution variant, not just the installed one — PyPI wheel/sdist, RubyGems platform, Maven classifier. Makes the manifest portable across environments (e.g. cross-platform CI caches). | | `--vex ` | `SOCKET_VEX` | On a successful scan, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX generation](#inline-vex-on-apply--scan--vendor). | @@ -583,9 +588,6 @@ socket-patch scan --json --mode agent --prune --yes --vex socket.vex.json # integrity-verified against the lockfile before vendoring. socket-patch scan --json --mode vendored --yes -# Same, but keep the manifest out of it entirely -socket-patch scan --json --mode vendored --detached --yes - # Preview a vendored run (would_vendor / would_revendor / already_vendored) socket-patch scan --json --mode vendored --yes --dry-run @@ -686,9 +688,10 @@ socket-patch vex --no-verify --output socket.vex.json [vendored mode](#three-patch-modes) (`scan --mode vendored` runs discovery + this engine in one pass). Instead of patching installed packages in place (machine-local state), `vendor` ejects each patched package into `.socket/vendor///…` and -rewires your lockfile so the project consumes the vendored copy. Commit `.socket/` — the -vendored artifacts plus the manifest that [`vex`](#vex), [`list`](#list), and -[`repair`](#repair) read — along with the lockfile edits, and **every fresh checkout +rewires your lockfile so the project consumes the vendored copy. Commit `.socket/vendor/` — +the vendored artifacts plus the ledger whose embedded patch records [`vex`](#vex), +[`list`](#list), and [`repair`](#repair) read (vendored mode writes nothing else under +`.socket/`) — along with the lockfile edits, and **every fresh checkout builds with the patched dependency**: no `socket-patch` binary, no Socket API access, no install hook required on the consuming machine. @@ -722,8 +725,10 @@ it: `updates[]` as the signal to re-run `scan --mode vendored`. - [`vex`](#vex) attests vendored patches by verifying the **committed artifact** (marked `(vendored)` in the impact statement) — no `setup` install hook needed. -- Re-running `vendor` is idempotent; patches dropped from the manifest are auto-reverted - on the next run. +- Re-running `vendor` is idempotent. Standalone `vendor` (no flags) is driven by + `.socket/manifest.json` — patches dropped from that manifest are auto-reverted on the + next run — so on a project vendored by `scan --mode vendored` (no manifest) it is a + clean no-op; use [`repair`](#repair) to verify or rebuild the committed artifacts there. **Examples:** ```bash @@ -733,8 +738,7 @@ socket-patch vendor # Preview without writing anything socket-patch vendor --dry-run -# Then make it stick: commit .socket/ (vendor artifacts + manifest) and the lockfile -# (gitignore .socket/apply.lock — see "How Socket Patch works") +# Then make it stick: commit .socket/ (vendor artifacts + ledger) and the lockfile git add .socket package-lock.json && git commit -m "vendor Socket patches" # Undo everything (restores the original lockfile byte-for-byte) @@ -967,7 +971,9 @@ socket-patch get CVE-2024-12345 --json -y ### `list` -List all patches in the local manifest. +List all patches recorded locally: the manifest's entries plus the vendor ledger's +(marked `(vendored)`) and the hosted redirect ledger's records, so it works on +manifest-less vendored or hosted projects too. **Usage:** ```bash @@ -1010,8 +1016,9 @@ Package: pkg:npm/flatted@3.3.1 Remove a patch from the manifest (rolls back files first by default). If the package is [vendored](#vendor), `remove` also **reverts the vendoring** — the lockfile is restored byte-for-byte and the `.socket/vendor/` artifact is deleted — so the patch is fully gone -in one command. Detached-vendored patches (from `scan --mode vendored --detached`) are -removable by PURL or UUID too, even though they have no manifest entry. +in one command. Patches vendored by `scan --mode vendored` have no manifest entry and are +removable by PURL or UUID all the same (reverting the vendoring *is* the removal, so +`--skip-rollback` is refused for them). **Usage:** ```bash @@ -1024,7 +1031,7 @@ socket-patch remove [options] **Command-specific options** (plus all [Global options](#global-options)): | Flag | Env var | Description | |------|---------|-------------| -| `--skip-rollback` | `SOCKET_SKIP_ROLLBACK` | Only update the manifest, do not restore original files (for vendored packages this also leaves the vendor wiring + artifact in place). | +| `--skip-rollback` | `SOCKET_SKIP_ROLLBACK` | Only update the manifest, do not restore original files (for a vendored package that still has a manifest entry this also leaves the vendor wiring + artifact in place; refused for manifest-less vendored patches, where the revert *is* the removal). | **Examples:** ```bash @@ -1043,7 +1050,8 @@ socket-patch remove "pkg:npm/lodash@4.17.20" --json ### `repair` -Download missing blobs, clean up unused blobs, and reset the advisory lock state. +Download missing blobs, rebuild missing or corrupt vendored artifacts, and clean up unused +blobs. Alias: `gc` @@ -1053,12 +1061,10 @@ free space. It also rebuilds missing or corrupt vendored artifacts. For the comb workflow (discover + apply + GC in one pass), use `scan --json --mode agent --prune --yes` instead. -As its final step, `repair` removes the leftover `.socket/apply.lock` file that mutating -commands retain between runs (skipped under `--dry-run`). A leftover file from a crashed -run never blocks anything — the OS releases a dead process's lock automatically — so this -is pure housekeeping. If another `socket-patch` process is actively running, `repair` -refuses up front with `lock_held` (exit 1); it never steals a live lock — wait for the -other process to finish, or budget a wait with `--lock-timeout`. +Like every other mutating command, `repair` takes the `.socket/apply.lock` advisory lock +while it runs and removes it when it finishes. If another `socket-patch` process is +actively running, `repair` refuses up front with `lock_held` (exit 1); it never steals a +live lock — wait for the other process to finish, or budget a wait with `--lock-timeout`. **Usage:** ```bash @@ -1098,9 +1104,8 @@ place — without bumping the package version. patched file's hash on disk so the attestation only covers patches that are actually applied. [Vendored](#vendor) patches are verified against the **committed artifact** instead of the installed tree (their impact statement carries a `(vendored)` marker), - and need no `setup` install hook to be attested. Detached-vendored patches - (`scan --mode vendored --detached`) - attest from the vendor ledger's embedded records, and + and need no `setup` install hook to be attested. Patches vendored by + `scan --mode vendored` attest from the vendor ledger's embedded records, and [hosted-mode](#three-patch-modes) patches attest from the redirect ledger (`.socket/vendor/redirect-state.json`, marker `(redirected)` — hash-verified against the installed tree post-install), so `vex` works even with no manifest file at all. @@ -1155,8 +1160,8 @@ trivy image --vex socket.vex.json ``` Apply patches first (in any mode) — `vex` errors with `no_patches` when there is nothing -to attest (an empty manifest, no detached-vendored patches, and no hosted redirect -records). +to attest (an empty or missing manifest, no vendored ledger entries, and no hosted +redirect records). ### Inline VEX on `apply` / `scan` / `vendor` @@ -1170,7 +1175,7 @@ socket-patch apply --vex socket.vex.json # Discover, apply, prune, and attest — the full auto-update-bot pass socket-patch scan --json --mode agent --prune --yes --vex socket.vex.json -# Vendor and attest — works manifest-less with --detached too +# Vendor and attest — manifest-less by construction socket-patch scan --json --mode vendored --yes --vex socket.vex.json ``` @@ -1223,7 +1228,10 @@ socket-patch apply --json | jq '.status' ``` When stdin is not a TTY (e.g. in CI pipelines), interactive prompts auto-proceed instead -of blocking. Progress indicators and ANSI colors are automatically suppressed when output +of blocking — with one deliberate exception: a plain `scan` (no `--mode`/`--apply`/`--sync`/ +`--vendor`/`--prune` and no `--yes`) is report-only there. It prints what it found and the +"To apply a patch, run: …" hint, writes nothing, and exits 0; add `--yes` or a mode flag +to mutate. Progress indicators and ANSI colors are automatically suppressed when output is piped. The exact JSON shapes, exit codes, and stability guarantees are specified in diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b4420f0e..34b85f7d 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -15,11 +15,13 @@ This document defines the **public surface** of the `socket-patch` binary. Anyth | `setup` | — | Wire automatic-patching install hooks (npm/pypi/gem) | | `rollback` | — | **Full-state rollback (v5.0, MAJOR)**: restore original files AND unwind vendored/hosted lockfile wiring, remove the rolled-back entries from the manifest, and GC their blobs/archives; takes optional variadic positional `targets` (PURL \| UUID \| path glob). See [Rollback command contract](#rollback-command-contract-v50) | | `get` | `download` | Fetch + apply patch; requires positional `identifier` | -| `list` | — | Print patches in the local manifest, plus the hosted redirect ledger's records (labeled; see the `manifest_not_found` row and the action matrix) | +| `list` | — | Print patches in the local manifest, plus the vendor ledger's (v5.0) and the hosted redirect ledger's records (labeled; see the `manifest_not_found` row and the action matrix) | | `remove` | — | Remove patch from manifest (rolls back first); requires positional `identifier` | -| `repair` | `gc` | Download missing blobs, rebuild missing/corrupt vendored artifacts, clean up unused ones, and delete the leftover `<.socket>/apply.lock` as a final housekeeping step (skipped under `--dry-run`; refuses with `lock_held` when a live process holds the lock) | +| `repair` | `gc` | Download missing blobs, rebuild missing/corrupt vendored artifacts, and clean up unused ones (refuses with `lock_held` when a live process holds the lock; see "Lock lifecycle" below) | -**Removed in v4.0:** the `unlock` subcommand (fold: `repair` now cleans up the lock file; a leftover lock from a crashed run never blocks acquisition — the OS releases a dead holder's advisory lock — so there is no stale-lock state to inspect or clear before a mutating command). +**Removed in v4.0:** the `unlock` subcommand (a leftover lock from a crashed run never blocks acquisition — the OS releases a dead holder's advisory lock — so there is no stale-lock state to inspect or clear before a mutating command; `repair` briefly owned lock-file cleanup in v4.x, and since v5.0 every lock-taking command removes its own lock file on exit). + +**Lock lifecycle (v5.0).** `<.socket>/apply.lock` never outlives the command that took it: acquisition creates `.socket/` when it is missing, the guard's drop unlinks the file WHILE the lock is still held (so a waiter can never lock an orphaned inode), releases it, and then removes `.socket/` itself if that left the directory empty — a run that had nothing to persist leaves no `.socket/` behind, and there is nothing to `.gitignore`. A leftover file from a crashed (SIGKILLed) run is reclaimed in place and removed by the next lock-taking command. The lock is taken by `apply`, `rollback`, `remove`, `repair`, `vendor`, and `scan`/`get` in vendored **and hosted** mode — hosted acquires it around its first wet write (the takeover pre-reverts), never on `--dry-run` and never when the run would write nothing, so hosted previews and no-op runs create no `.socket/`. Dry runs of the other commands may still take the lock; it is residue-free either way. A live holder is `lock_held` (exit 1); a directory or special file squatting on `.socket/` or on the lock path is a lock I/O error — `lock_io` (exit 1, `failed to open lock file at : …`; `scan --mode vendored` may report its own pre-lock `socket_dir_unwritable` for a file squatting on `.socket/`) — never `lock_held`. **Bare-UUID fallback.** `socket-patch ` is rewritten to `socket-patch get `. The UUID shape checked is the standard 8-4-4-4-12 hex pattern (case-insensitive). See [`src/lib.rs::looks_like_uuid`](src/lib.rs). @@ -49,9 +51,9 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha | `--json` | `-j` | `SOCKET_JSON` | `false` | bool | Machine-readable output | | `--verbose` | `-v` | `SOCKET_VERBOSE` | `false` | bool | Extra detail | | `--silent` | `-s` | `SOCKET_SILENT` | `false` | bool | Errors only | -| `--dry-run` | — | `SOCKET_DRY_RUN` | `false` | bool | Preview, no mutations | +| `--dry-run` | — | `SOCKET_DRY_RUN` | `false` | bool | Preview, no mutations (a dry run may still take the transient `apply.lock`, removed again on exit — see "Lock lifecycle"; hosted and vendored previews never leave a `.socket/`) | | `--yes` | `-y` | `SOCKET_YES` | `false` | bool | Skip prompts | -| `--lock-timeout` | — | `SOCKET_LOCK_TIMEOUT` | (none) | seconds (u64) | How long to wait for `<.socket>/apply.lock`. Unset and `0` both mean a single non-blocking try; a positive value retries with a 100 ms backoff. Only meaningful on the mutating subcommands | +| `--lock-timeout` | — | `SOCKET_LOCK_TIMEOUT` | (none) | seconds (u64) | How long to wait for `<.socket>/apply.lock`. Unset and `0` both mean a single non-blocking try; a positive value retries with a 100 ms backoff. Only meaningful on the lock-taking subcommands — `apply`, `rollback`, `repair`, `remove`, `vendor`, and `scan`/`get` in vendored or hosted mode | | `--debug` | — | `SOCKET_DEBUG` | `false` | bool | Verbose debug logs to stderr | | `--no-telemetry` | — | `SOCKET_TELEMETRY_DISABLED` | `false` | bool | Disable anonymous usage telemetry | | `--no-trust-lockfile-config` | — | `SOCKET_NO_TRUST_LOCKFILE_CONFIG` | `false` | bool | Opt out of hosted mode's automatic `trustLockfile: true` write to `pnpm-workspace.yaml` (see the pnpm trust-config note under the scan arguments) | @@ -76,7 +78,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `scan` | `--mode ` | — | The documented selector for the three patch-application modes. Each value is equivalent to one legacy boolean spelling: `hosted` == `--redirect`, `vendored` == `--vendor`, `agent` == `--apply` (`--sync` counts as an agent spelling). Combining `--mode` with a boolean of a DIFFERENT mode is a usage error (exit 2, enforced in `resolve_mode_flags` — clap's `conflicts_with` is value-independent); the same mode spelled both ways is accepted. `--prune` is an orthogonal GC knob and never conflicts — but hosted mode runs no GC, so `--mode hosted --prune` emits an explicit `redirect_prune_ignored` warning (JSON `redirect.warnings[]` + stderr) instead of silently dropping the flag | | `scan` | `--redirect` | — | Hosted mode's legacy boolean spelling (**hidden from `--help`** and **deprecated** — `--mode hosted` is the documented spelling; this alias is scheduled for removal in v4): rewrite lockfiles / registry configs so ONLY the patched dependencies resolve to Socket's hosted patch server; no artifact bytes land in the repo. Conflicts with `--apply`/`--sync`/`--vendor` | | `scan` | `--apply` / `--prune` / `--sync` | — | Mode selectors (sync = apply + prune); `--apply` == `--mode agent` | -| `scan` | `--vendor` / `--detached` | — | Vendor every patched dependency instead of applying in place (`--vendor` == `--mode vendored`; conflicts with `--apply`/`--sync`, combines with `--prune`); `--detached` additionally skips all manifest writes — the vendor ledger embeds the patch records (requires vendored mode in either spelling) | +| `scan` | `--vendor` / `--detached` | — | Vendor every patched dependency instead of applying in place (`--vendor` == `--mode vendored`; conflicts with `--apply`/`--sync`, combines with `--prune`). Vendored mode is manifest-free (v5.0): the vendor ledger embeds the patch records and `.socket/manifest.json` is never written. `--detached` — the former opt-in for exactly that — is **hidden** and retained for compatibility as a no-op; it is still a usage error (exit 2) without vendored mode in either spelling | | `scan` | `--batch-size` | `SOCKET_BATCH_SIZE` | API batch chunk size (default `100`) | | `get`, `scan` | `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package — PyPI wheel/sdist (`artifact_id`), RubyGems (`platform`), Maven (`classifier`) — not just the one(s) matching the locally-installed distribution. On `scan` this makes the stored manifest portable across environments (e.g. cross-platform CI caches). On `get` (v3.6) it ALSO disables the coarse installed-**version** narrowing of CVE/GHSA fan-outs (see "get --mode and installed narrowing"): every found version's patch is fetched, installed or not | | `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--one-off`; `--mode ` | `SOCKET_SAVE_ONLY`, `SOCKET_ONE_OFF` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default `agent` = today's save+apply flow, unchanged. `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | @@ -96,13 +98,13 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Takeover reconciliation (npm family, bun included)**: vendoring over a hosted-redirected purl (`vendor`, `scan --mode vendored`, `get --mode vendored`) first REVERTS that purl's hosted lockfile edits to their pre-redirect registry values through the per-purl redirect revert, drops the purl's record + package edits from `redirect-state.json`, and then vendors — so the vendor ledger records the PRISTINE registry fragment as its wiring `original` and `vendor --revert` lands back on registry state, never on an expiring hosted URL. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; the human path prints `Warning (vendor_takeover_reverted_redirect): …`). `--dry-run` PROBES the same revert against an in-memory ledger clone instead of promising it: a clean probe reports `vendor_would_revert_redirect`, and a drifted lock or an undecidable ledger edit surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose hosted edits cannot be cleanly reverted fails `redirect_revert_failed` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place, the remedy in the detail). **bun** participates like every other npm-family flavor: binary `redirect_bun_lockb_package` snapshots are claimed by their recorded package identity and restore individual binary resolutions; its text `redirect_bun_lock_package` edits are claimed by the recorded line's spec — the registry spec `@`, or a hosted URL whose tarball leaf is `-.tgz` — so a sibling version's or an aliased sibling's edit is neither claimed nor a refusal, and only an edit that mentions the package without being a bun packages-entry line refuses (remedy: an unscoped `socket-patch rollback`, whose whole-ledger replay unwinds bun.lock hosted edits; never hand-edit the ledger). The same claim rule serves scoped `rollback ` / `remove ` of one of several hosted bun records (see "Hosted unwind coverage"). Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on bun locks the target mode accepts. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the per-purl hosted revert, so a hosted-redirected purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring, the redirect ledger and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The separate run-level `vendor_supersedes_redirect` warning covers the reconcile-only case — a live lock that already proves vendored won over a stale hosted ledger record (the vendor wiring then holds the hosted-spliced fragment as `original`) — and fires exactly once, on the run that drops the stale records. -`scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + the `updates` array + the `redirectState` state block below). No effect outside `--json` mode — the non-JSON path always prompts the user interactively. +`scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + the `updates` array + the `redirectState` state block below). No effect outside `--json` mode. The non-JSON path prompts the user interactively in a TTY; when stdin is NOT a TTY (CI, a pipe), `--yes` is absent, and no intent flag (`--mode`, `--apply`, `--sync`, `--vendor`, `--redirect`, `--prune`) is given, a human-mode `scan` is **report-only** (v5.0): it prints the discovery report and the existing "To apply a patch, run: …" hint, downloads nothing, writes nothing (no `.socket/`), and exits 0. Any intent flag, `--yes`, or a TTY keeps the previous behavior (prompt in a TTY, auto-proceed otherwise). Only `scan` gained this pre-check — `rollback`/`remove`/`get`'s non-TTY auto-accept is unchanged. **Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--apply`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the hosted redirect ledger (`.socket/vendor/redirect-state.json`) holds ≥ 1 `records` entry: `{ mode, ledger, records: [{purl, ledgerKey, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning — a hosted-wired project's report-only scan used to be byte-identical to a never-touched project's. `mode` is the constant `"hosted"` (the mode's documented name, whatever opaque `mode` string the ledger itself carries — pre-rename ledgers say `"redirect"`) and `ledger` the ledger's repo-relative path. `records` lists every ledger record (sorted by ledger key): each entry's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the records↔proof join is a plain string compare, and `ledgerKey` preserves the ledger's verbatim key (percent-encoded scoped names, `?platform=` qualifiers) for consumers addressing the ledger itself. `wiringLive` is the subset of this run's *counted* purls (post-`--ecosystems`-filter) whose hosted lockfile wiring the LIVE lock still proves — the same proof, computed once per run, that feeds `hosted_wiring_retained`. Consumers must treat the split as exactly that: records are the ledger's word, `wiringLive` the live lock's proof — a record with no proof means the wiring was unwound, the lock is unreadable, or the purl was not crawled/queried this run (an `--ecosystems` filter, a zero discovery), never "still live". The key is omitted when the ledger is absent or its `records` are empty (an edits-only ledger asserts no patches), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A malformed ledger degrades to "nothing to consult" (no block) with a stderr warning, muted by `--silent`. Hosted-mode runs carry the `redirect` sub-object instead (the run's own result; the ledger is re-persisted mid-run), and vendored-mode runs carry the takeover warnings (their reconciliation may retire records mid-run) — neither duplicates a pre-run snapshot that could go stale. **Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--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 (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); 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 hosted redirect ledger records scanned package(s) whose hosted lockfile wiring the live lock still proves (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, or migrate via `scan --mode vendored`) and never advises hand-deleting the ledger. The warning keys on ledger *records* still live at scan time — a flow that pre-reverted the redirect (retiring the records) retires the warning with them, even while the append-only `edits` (revert originals) remain. The interactive 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. -`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob, diff, and package-archive files from `.socket/`. 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) and the runtime-gated maven/nuget crawlers with their gate off are 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). The pass also reconciles vendored state (runs FIRST, under the apply lock — lock contention skips it without failing the scan): vendored entries whose patch is gone from the manifest are reverted, vendored entries whose dependency is no longer in the lockfile graph are reverted AND their manifest entries dropped (detached entries are exempt from both — they are manifest- and lockfile-invisible by design; a missing or undeterminable lockfile keeps the entry, fail-safe), and orphan `.socket/vendor//` dirs with no ledger entry are swept. The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview). `keptVendoredEntries` lists drift-kept entries 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 `revertableVendoredEntries` 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, diff, and package-archive files from `.socket/`. 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) and the runtime-gated maven/nuget crawlers with their gate off are 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). The pass also reconciles vendored state (runs FIRST, under the apply lock — lock contention skips it without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the manifest existence gate runs BEFORE the lock, so a bare project never gets a `.socket/`): (a) ledger entries still tracked by a manifest record (legacy manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — entries carrying an embedded `record` (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). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview). `keptVendoredEntries` lists drift-kept entries 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 `revertableVendoredEntries` 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. @@ -114,11 +116,11 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Path-scoped scans (`scan [PATHS]...`, v5.0)**: optional variadic positional path globs scope DISCOVERY at the **purl level** — a package is in scope iff ANY of its crawled installed copies sits under a matching path, and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` with `--mode hosted` or `--mode vendored` is a usage error (exit 2, `resolve_mode_flags`: "path targeting … applies to agent-mode and read-only scans" — their lockfile rewiring is whole-project by construction); `PATHS` with `--apply`/`--sync`/`--prune`/`--global` is fine. Every scan JSON shape (success, zero-package, and error alike) gains an additive always-present `paths` key echoing the patterns verbatim (empty array when unscoped). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on `scan` (exit 0)**. -`scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download (manifest written, as `--apply`) → vendor every patched dependency via the same engine as the `vendor` command (under the same lock). The whole manifest is vendored, so a package vendored at an older patch uuid is **re-vendored automatically** (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs are `already_vendored` skips. With `--prune`, GC runs **before** the vendor step so stale manifest entries don't fail vendoring with `package_not_installed`. JSON output gains a `download` sub-object (the download phase; no `applied` field — nothing is applied in place) and a `vendor` sub-object (a full vendor Envelope). The download phase writes only `.socket/manifest.json`; patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` — plus, additive, `would_refuse` (+`errorCode`, `error`) for npm purls the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patch(es)?". +`scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and rebuilds committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. With `--prune`, GC runs **before** the vendor step. JSON output gains a `download` sub-object — the detached download envelope `{found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (no `applied` field — nothing is applied in place; `detached: true` is pinned and always present) — and a `vendor` sub-object (a full vendor Envelope). Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` — plus, additive, `would_refuse` (+`errorCode`, `error`) for npm purls the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patch(es)?". -`scan --vendor --detached` performs the same vendoring **without ever writing `.socket/manifest.json`**: records are fetched into memory (`download.detached: true`), the artifacts are built + wired, and the ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as the verification source. Detached patches are invisible to apply and repair (nothing is in the manifest), exempt from `vendor`'s manifest reconcile, and exit via `remove ` (which reverts them), `vendor --revert`, or — as of v5.0 — `rollback`, whose vendored leg reverts detached ledger entries alongside manifest-tracked ones (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). Idempotent re-runs reuse the embedded record and skip the patch-view fetch entirely. +**Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger with a `(vendored)` marker, exit 0 on a vendored-only project), `vex` (attested from the embedded records), `repair` (health-checked and rebuilt from the ledger), `scan --prune` (lockfile-driven reconcile) and `setup --check`'s patch-consistency property (consulted from the embedded records). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches embedded-record entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). The hidden `--detached` flag (`scan --vendor --detached`) names exactly this — the only — vendored posture and is accepted as a no-op for compatibility. -`scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL, or — for golang — the `patch.socket.dev/gopatch/` module path) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. +`scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL, or — for golang — the `patch.socket.dev/gopatch/` module path) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` (exit 1), rendered like every other lock holder. **Human mode (v5.0)**: `scan --mode hosted` prints the results table and update detection like the other modes and confirms once — `Redirect N package(s) to hosted patches?`, default yes, skipped by `--yes`/`--json` — before rewriting anything (parity with the agent/vendored arms and with `get --mode hosted`). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang where it used to skip silently, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock` / `bun.lockb`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). @@ -128,17 +130,17 @@ The rewriter reads a fixed set of candidate files from the project root: the npm **Mode ledgers (contract surfaces).** Each committable mode persists its state at a stable repo-relative path; external tools (and the depscan backend's GitHub-app PR flows) read and write these files, so path + schema are part of the contract: -* `.socket/vendor/state.json` — the **vendored**-mode ledger (see "Ownership, state, and reversal" below): wiring edits with verbatim pre-vendor originals, artifact fingerprints, optional `detached` records. -* `.socket/vendor/redirect-state.json` — the **hosted**-mode ledger (`RedirectState` in `socket-patch-core/src/patch/redirect/state.rs`): `{ version, mode: "hosted", edits[], records{} }`. `edits` are recorded `FileEdit`s (append-only across re-runs — merge, never clobber: the pre-redirect originals a future revert needs live here); `records` maps PURL → the full manifest `PatchRecord` so a post-install `vex` can attest redirected patches with no manifest entry. The `mode` string is opaque to the loader (pre-rename ledgers carrying `"redirect"` still load; a hosted re-run normalizes them to `"hosted"`). Written identically by this CLI and by the depscan backend's hosted PR flow (`github-patch-pr-hosted.ts`). +* `.socket/vendor/state.json` — the **vendored**-mode ledger (see "Ownership, state, and reversal" below): wiring edits with verbatim pre-vendor originals, artifact fingerprints, and — for every entry written by `scan`/`get --mode vendored` — `detached: true` plus the embedded patch `record` (standalone `vendor` fed by an agent-mode manifest records neither). +* `.socket/vendor/redirect-state.json` — the **hosted**-mode ledger (`RedirectState` in `socket-patch-core/src/patch/redirect/state.rs`): `{ version, mode: "hosted", edits[], records{} }`. `edits` are recorded `FileEdit`s (append-only across re-runs — merge, never clobber: the pre-redirect originals a future revert needs live here; v5.0: a byte-identical re-save is skipped, which still satisfies the rule); `records` maps PURL → the full manifest `PatchRecord` so a post-install `vex` can attest redirected patches with no manifest entry. The `mode` string is opaque to the loader (pre-rename ledgers carrying `"redirect"` still load; a hosted re-run normalizes them to `"hosted"`). Written identically by this CLI and by the depscan backend's hosted PR flow (`github-patch-pr-hosted.ts`). **get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — this is the per-advisory selector hosted/vendored previously lacked (the old workaround, `get --save-only` then `vendor`, still works but is superseded). Semantics: -* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite, `redirect-state.json` ledger (merge-never-clobber), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids` only) all identical to `scan --mode hosted`. **No manifest write, no blobs** — the ledger is the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, corrupt/unwritable ledger, file writes) exit 1. Human prompt: `Redirect N package(s) to the hosted patch server?` (scan hosted has no prompt; get keeps its confirm gate, `--yes`/`--json`/non-TTY auto-accept as usual). -* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture (writes ONLY `.socket/manifest.json`; blobs held in memory; the nested apply never runs), then scan's vendor step runs — apply lock, **whole-manifest scope including `reconcile_dropped`**: every manifest record is verified/re-vendored and records whose patches left the manifest may have their vendored state reverted, exactly like `scan --mode vendored` (a stderr `[note]` names the count of other affected records; this blast radius is deliberate parity, stated loudly). JSON: get's envelope (with `applied` dropped — structurally zero under save-only) gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (the pre-failure reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patch(es)?`. Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths, `scan --mode vendored`, and `--detached` runs: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — a malformed, unreadable or unsupported `bun.lockb` → `vendor_bun_lockb_invalid`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes `{action:"failed", errorCode:, error:}` with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the records ride `patches[]` / `download.patches[]`, the download phase still writes `.socket/manifest.json` (unchanged — an empty `{"patches": {}}` on a fresh project; a record seeded for another purl survives, re-serialized), the vendor step still runs (no event for the refused purl — unless `.socket/manifest.json` already held its record, in which case the vendor step's own preflight, shared with `vendor`, reports it `failed` with the same code and leaves any hosted wiring untouched), exit `partial_failure`/1. **`--detached`**: the same `download.patches[]` records with `download.downloaded: 0`, and no manifest at all (previously the view was fetched first and, for an alias install, the engine misreported `package_not_installed`). **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the record save and the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path, `--detached` and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, detached parity, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. +* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite, `redirect-state.json` ledger (merge-never-clobber), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids` only) all identical to `scan --mode hosted`, and (v5.0) under the same `apply.lock` acquisition — taken around the first wet write, never on `--dry-run` or when nothing would be written. **No manifest write, no blobs** — the ledger is the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, corrupt/unwritable ledger, file writes) exit 1. Human prompt: `Redirect N package(s) to the hosted patch server?` (get keeps its confirm gate, `--yes`/`--json`/non-TTY auto-accept as usual; as of v5.0 human `scan --mode hosted` prompts too — see the hosted section above). +* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture — **manifest-free (v5.0)**: the selected records are fetched into memory (`download_patch_records`; blobs held in memory; nothing under `.socket/` is written; the nested apply never runs), then scan's vendor step runs under the apply lock over exactly the selected records, like `scan --mode vendored` (no whole-manifest scope and no `[note]` about other records — that blast radius is retired with the manifest; a legacy manifest record for a vendored purl is migrated out of `.socket/manifest.json` the same way scan does it). JSON: get's envelope takes the detached download envelope's shape — `{status, found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (`applied` is absent; `detached: true` is pinned) — and gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (a pre-failure takeover reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patch(es)?`. Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — a malformed, unreadable or unsupported `bun.lockb` → `vendor_bun_lockb_invalid`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes `{action:"failed", errorCode:, error:}` with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the records ride `patches[]` / `download.patches[]` with `downloaded: 0`, the download phase writes nothing under `.socket/` (v5.0 — a pre-existing `.socket/manifest.json`, including a record seeded for another purl, is left byte-untouched; previously the run re-serialized the manifest), the vendor step still runs over the remaining records (no event for the refused purl), exit `partial_failure`/1. **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. * **Installed-version narrowing** (all modes, `get`'s search path): a CVE/GHSA fan-out returns one patch record per patched VERSION; get keeps only versions present here and emits calm `skipped` records (`errorCode: "package_not_installed"`) for the rest — never an error exit. Presence = installed on disk (qualified-aware resolver) ∪ already tracked in the manifest (record maintenance keeps working on hosts without an installed copy); hosted/vendored modes additionally count lockfile-resolved deps and vendor-ledger purls (mirroring scan's discovery supplements, including their `--global` gate). **Exempt** (no narrowing): UUID identifiers, exact-versioned PURL identifiers (explicit intent), `--save-only` runs (record-only has no installation precondition — the fresh-clone record→vendor flow keeps working), `--all-releases`, and the package-name path (already installed-derived). When EVERY found patch is filtered out, get exits 0 with the additive status **`not_installed`** (`{status:"not_installed", found:N, downloaded:0, applied:0, patches:[], warnings?}`) — never `no_match`, which remains pinned to the fuzzy package-name path. PnP layouts are surfaced, not misreported: yarn-PnP npm results skip with `errorCode: "yarn_pnp_unsupported"` in every mode; pnpm-PnP skips carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the refusal's own remedy — keeps ONLY the versions the raw `pnpm-lock.yaml` text actually resolves (boundary-anchored probe over the v5/v6/v9 key spellings, so a large fan-out never requests grants for every version ever patched), labels a JUDGED miss `package_not_installed` exactly like a non-PnP project (the layout blocked nothing — the lock was read and the version isn't resolved), and reserves the layout code for an unreadable lock (no judgment possible). When EVERY narrowed-out result is a PnP refusal, the human terminal names the layout instead of claiming "not installed" and never advises `--all-releases` (which cannot make PnP patchable); the JSON status stays `not_installed` — consumers dispatch on the per-record `errorCode`. Hosted mode also runs the per-release VARIANT filter (`filter_to_installed_releases`) on its search path before requesting grants — agent/vendored runs get it inside the download engines — with the same keep-all-plus-warning fallbacks (surfaced as `(release_narrowing)`-prefixed strings in `warnings[]`). An ecosystem this binary has no crawler for is likewise never judged: its results are KEPT (absence from a crawl that never looked carries no information — the same fail-safe as scan's prune GC). The human `Found patches:` listing deliberately shows ALL found patches (pre-narrowing, main's behavior) with the `[skip]` lines following; machine output (the prompt count, the JSON envelope) uses the kept set. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines. -* **Deliberate divergences from scan** (documented, not drift): get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan auto-picks); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached`, no `--prune`; get does not run scan's pre-confirm vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Plain agent-mode `get` continues to ignore `--dry-run` (pre-existing; hosted/vendored honor it — see below). +* **Deliberate divergences from scan** (documented, not drift): get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan auto-picks); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached` (moot — `get --mode vendored` is manifest-free by construction), no `--prune`; get does not run scan's pre-confirm vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Plain agent-mode `get` continues to ignore `--dry-run` (pre-existing; hosted/vendored honor it — see below). -`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and (v3.6) `get --mode hosted|vendored` — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no ledger write, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `repair --dry-run` also skips the final lock-file deletion. `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted unwind (the redirect engines resolve every inverse and drift check exactly like a wet run, flush nothing to disk, and claim the IN-MEMORY ledger clone exactly like a wet run — so the composed preview, per-purl reverts then whole-ledger replay, sees the same intermediate state a wet run would; the ON-DISK ledger is untouched), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. +`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and (v3.6) `get --mode hosted|vendored` — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no ledger write, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted unwind (the redirect engines resolve every inverse and drift check exactly like a wet run, flush nothing to disk, and claim the IN-MEMORY ledger clone exactly like a wet run — so the composed preview, per-purl reverts then whole-ledger replay, sees the same intermediate state a wet run would; the ON-DISK ledger is untouched), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. The hidden alias `--no-apply` on `get --save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts. @@ -154,7 +156,7 @@ Contract details: * **Fail-the-command**: if `--vex` was requested but generation fails (product PURL undetectable, empty/missing manifest, all patches unverified, unwritable path), the command exits non-zero **even when the apply/scan itself succeeded**. In `--json` mode the failure surfaces in the envelope's `error` (`apply`) / top-level `error` (`scan`), with a stable code (`product_undetected`, `no_applicable_patches`, `write_failed`, …). * **Built from the post-run manifest**, verified against on-disk state (unless `--vex-no-verify`). Generated for real applies, `--dry-run`, and read-only `scan` alike. * **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`. -* `apply`'s no-manifest early exit (the "No .socket folder found" success no-op) does **not** trigger VEX generation — there is nothing to attest. +* `apply`'s no-manifest early exit (the `noManifest` success no-op; v5.0: its human line names the missing `.socket/manifest.json`, not the folder — `.socket/` may legitimately hold setup files or vendored state) does **not** trigger VEX generation — there is nothing to attest. * **Stale-doc removal (v3.5)**: a run that ends in a VEX error removes a recognizably-OpenVEX file (JSON whose `@context` names openvex.dev) already sitting at the output path — a pipeline reusing one path can never ship yesterday's attestation for a now-unpatched tree. Unrelated files at the path are never touched; a mid-write partial that no longer parses as JSON is left for downstream parsers to reject loudly. * **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install; the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. @@ -206,14 +208,23 @@ in particular, are behavior changes that gate a version bump when implemented). in-scope ecosystems are *actually in a correctly patched state* — install hooks present **and** on-disk patch consistency verified (the `apply --check` invariant: every manifest file's hash matches `afterHash`). *(Implemented — `run_check` appends a `patch` entry per installed-but-drifted PURL via - `append_patch_consistency_entries`; uninstalled packages and zero-file records are not drift.)* + `append_patch_consistency_entries`; uninstalled packages and zero-file records are not drift. + v5.0: vendored patches are consulted from the vendor ledger's embedded `record`s and verified + against the committed artifact — a manifest-less vendored project is checked the same way.)* 5. **In-repo and committable.** `setup` writes only inside the working tree: `package.json`, - `pyproject.toml`/`requirements.txt`, the `Gemfile` + generated `.socket/bundler-plugin/`. Every - artifact is git-committable. It never writes outside + `pyproject.toml`/`requirements.txt`, `composer.json` (the `post-install-cmd`/`post-update-cmd` + hooks), the `Gemfile` + the generated `.socket/bundler-plugin/{plugins.rb,socket-patch.gemspec}` + and `.socket/.gitignore` (one line ignoring the machine-local stamp), and `.socket/manifest.json` + only when `--exclude` persists an exclusion (property 9). Every artifact is git-committable. + `setup --check` and an already-configured `setup` write nothing. It never writes outside `--cwd` — no `$HOME`, no global `site-packages` (the Python `.pth` wheel is installed later by the - user's package manager, not by `setup`; the gem patch stamp is written under `Bundler.bundle_path` - by the plugin at `bundle install` time, not by `setup`). *(Implemented.)* + user's package manager, not by `setup`; the gem patch stamp is written by the plugin at + `bundle install` time, not by `setup`, at `.socket/gem-plugin-stamp` — machine-local, hence the + `.gitignore` line; the legacy stamp under `Bundler.bundle_path` is deleted by the plugin). These + files are **setup-owned residue**: `rollback`/`remove` never undo `setup`, so `.socket/.gitignore`, + `.socket/bundler-plugin/` and `gem-plugin-stamp` survive a full reversal (see the residue rule + under the rollback contract). *(Implemented — `crates/socket-patch-core/src/setup/gem/mod.rs`.)* 6. **Clone-portable.** Because all setup state is committed files, a fresh checkout on another host — CI, a deploy, a teammate's machine — inherits the setup state unchanged; `setup --check` passes on @@ -237,8 +248,11 @@ in particular, are behavior changes that gate a version bump when implemented). 8. **Graceful, exact remove.** `setup --remove` (optionally per-ecosystem via `--ecosystems`) restores the repo to its exact pre-setup state: manifests byte-for-byte, sibling scripts/dependencies preserved, keys that became empty dropped. Afterward `setup --check` reports needs-configuration - again. *(Implemented for the manifest edits — npm `package.json` and Python deps round-trip - byte-for-byte.)* + again. For gem projects it also removes the plugin dir, the stamp and its `.gitignore` line, and + (v5.0) prunes an emptied `.socket/` (non-recursive `remove_dir` — a `.socket/` still holding a + manifest, blobs, vendored state or a user-authored `.gitignore` is kept), so a project that never + ran `apply` is back to its pre-setup tree. *(Implemented for the manifest edits — npm + `package.json` and Python deps round-trip byte-for-byte.)* 9. **Nested workspaces, with exclude.** Setup applies to every subproject below the repo root: npm / yarn / pnpm / bun workspace members are all discovered and configured (pnpm is root-package-only by @@ -255,9 +269,9 @@ in particular, are behavior changes that gate a version bump when implemented). ### Per-ecosystem setup support -`setup` installs an automatic-repatch hook for the three ecosystems with a usable post-install / -startup hook (npm, pypi, gem) — plus **composer** when the binary is built with the opt-in `composer` -feature. The remaining ecosystems are **apply-only**: `socket-patch apply` patches them on demand, but +`setup` installs an automatic-repatch hook for the four ecosystems with a usable post-install / +startup hook (npm, pypi, gem, composer — every ecosystem is built in unconditionally; there are no +ecosystem feature gates). The remaining ecosystems are **apply-only**: `socket-patch apply` patches them on demand, but there is no hook for `setup` to install, so `setup` is a `no_files` no-op for them. These are exactly the ecosystems for which property 7's **manual** declaration is intended (so their hand-applied patches still show up in VEX). @@ -455,6 +469,8 @@ per service outcome: | 401 / 403 grant / 5xx / network error | local build + `vendor_prebuilt_unavailable` | refuse | | `--offline` | local build | refuse (`vendor_service_offline_conflict`) | +**golang service leg staging (v5.0)**: the module zip is downloaded, extracted and `h1:`-verified in a `.socket-stage` sibling and swapped into place only afterwards; a failed re-download of a WIRED, present copy keeps the copy and its `replace` directive (previously both were torn down), while a missing copy still drops the dangling directive. + Coverage today: **npm** (all lock flavors), **pypi** (wheel — sdist falls back / refuses), **cargo** (download + extract the `.crate`), **golang** (download + extract the module zip, verify the `h1:` dirhash, wire the `replace`), **composer** (download + extract the dist zip), **gem** (download + @@ -498,8 +514,10 @@ or temporary patch files. Pre-existing `.socket/` artifacts (from a prior `apply are read in place; already-vendored purls re-stage patch content from the committed artifact itself (uuid-matched against the ledger, every harvested blob self-verified by its afterHash — so in-sync re-runs and fresh clones of vendored projects need no network); anything still missing is fetched -into memory via the patch-view endpoint. A vendored project's `.socket/` holds only -`manifest.json` (omitted in detached mode) and `vendor/`. +into memory via the patch-view endpoint. A vendored project's `.socket/` holds only `vendor/` +(v5.0 — vendored runs never write `manifest.json`; one exists only when standalone `vendor` was fed +by an agent-mode manifest, or as the `{"patches": {}}` husk left after a legacy record migrated +into the ledger). **Vendored artifact repair (v3.5)**: `repair` health-checks every ledger entry — per-file afterHashes inside the artifact plus, for file-shaped artifacts (`.tgz`/`.whl`), the whole file @@ -514,7 +532,8 @@ artifact is re-verified against the recorded fingerprint before the run counts i event; a mismatch removes the artifact and fails with `vendor_artifact_rebuild_failed`). Lockfile references to `.socket/vendor///...` with NO ledger coverage (the ledger was deleted wholesale) are RECONSTRUCTED: the uuid comes from the path (the recovery rule above), the -record from the manifest — or the patch API, yielding a *detached* entry with the record embedded +record from the manifest — or the patch API, yielding an entry with the record embedded (the same +`detached: true` + `record` shape every `scan`/`get --mode vendored` entry has) — and a fresh ledger entry is persisted with the rebuilt artifact's fingerprint. When nothing is installed and the ledger is gone, npm-family reconstruction has one more rung: the REWIRED lockfile still records the integrity of the packed vendored tarball, so the pristine copy is @@ -624,10 +643,11 @@ worse, lets a warm cache silently serve unpatched bytes): `source`/`checksum`, requirement lines, uv specifiers). Those are not recoverable offline, so `--revert` never guesses at unrecorded fragments: a missing ledger is an empty ledger (clean no-op plus the orphan-dir sweep), and entries whose recorded fragments no longer match are left - alone with warnings. Entries written by `scan --vendor --detached` additionally carry - `detached: true` and `record` (an embedded copy of the patch record — same committed-file trust - class as the manifest; artifact verification still re-hashes against its afterHashes and the - uuid-in-path cross-checks). + alone with warnings. Every entry written by `scan`/`get --mode vendored` (v5.0: the only + vendored posture) carries `detached: true` and `record` (an embedded copy of the patch record — + same committed-file trust class as the manifest; artifact verification still re-hashes against + its afterHashes and the uuid-in-path cross-checks); only standalone `vendor` fed by an agent-mode + manifest records neither. * **Re-vendor carries originals forward**: re-vendoring under a newer patch uuid rewrites the previous run's own wiring (`original: None` from the backend — it must never record a dangling `.socket/vendor/` pointer as pre-vendor state); the engine merges the TRUE pre-vendor originals @@ -635,11 +655,19 @@ worse, lets a warm cache silently serve unpatched bytes): still restores the registry fragments byte-for-byte. The old uuid's now-orphaned artifact dir is removed (`vendor_stale_artifact_removed`) unless another entry still references it. * `vendor --revert` restores the originals (fragments that no longer match — a user re-resolved — - are left alone with a `vendor_lock_entry_drifted` warning), removes the artifacts, prunes the - ledger, and sweeps orphan uuid dirs. It works without a manifest. + are left alone with a `vendor_lock_entry_drifted` warning; the drift-kept artifact and entry stay, + every backend alike — gem included as of v5.0, where a MISSING `Gemfile`/`Gemfile.lock` instead + warns `vendor_lockfile_missing` and still removes the artifact), removes the artifacts, prunes the + ledger, sweeps orphan uuid dirs, and (v5.0) prunes the now-empty `.socket/vendor//` and + `.socket/vendor/` levels — `.socket/` itself is removed by the lock guard when nothing else is + left. It works without a manifest: with no manifest and no ledger it is a clean exit-0 no-op. * Re-running `vendor` is idempotent (byte-stable lockfiles, deterministic artifacts → - `already_vendored` skips). Patches dropped from the manifest are auto-reverted at the start of - the next `vendor` run (`vendor_reconciled` events). + `already_vendored` skips). Manifest-tracked entries whose patches were dropped from the manifest + are auto-reverted at the start of the next `vendor` run (`vendor_reconciled` events); entries with + an embedded `record` have no manifest record and are exempt. Standalone `vendor` (no flags) is fed + by `.socket/manifest.json` only: with no manifest it is a clean exit-0 no-op whose human line names + the missing manifest (and, when `.socket/vendor` exists, says how many ledger entries are tracked + and that `repair` verifies them) — it never re-vendors from the ledger. * **remove reverts vendoring**: `remove ` on a vendored patch restores the recorded lockfile fragments, deletes the artifact, and drops the ledger entry (envelope events `removed`/`vendor_reverted`, which do NOT bump `summary.removed` — that count stays "manifest @@ -650,9 +678,9 @@ worse, lets a warm cache silently serve unpatched bytes): artifact, the ledger entry (byte-identical — its already-reverted wiring records replay as silent no-ops on a later revert, per the liveness contract, and a re-vendor re-wires from the live lock probe), AND the manifest entry (`skipped`/`vendor_state_preserved`; `summary.removed` - stays 0), and skips all GC — equivalent to `rollback --preserve-state`. Detached entries - are removable by purl/uuid through the same command even though they have no manifest record - (`--skip-rollback` is refused there: reverting IS the removal). **Drift-keep fix (v5.0, + stays 0), and skips all GC — equivalent to `rollback --preserve-state`. Ledger entries with + no manifest record (every `scan`/`get --mode vendored` entry) are removable by purl/uuid through + the same command (`--skip-rollback` is refused there: reverting IS the removal). **Drift-keep fix (v5.0, bugfix)**: when the revert drift-keeps (`kept_artifact` — the lock changed under us and the backend left wiring + artifact alone), the manifest entry for that purl is now ALSO kept (`skipped`/`vendor_revert_kept`) — previously `remove` dropped it, stranding a live ledger @@ -665,7 +693,7 @@ worse, lets a warm cache silently serve unpatched bytes): redirect ledger unwinds those redirects too — per-purl for the supported ecosystems (cargo + npm-family), via the whole-ledger reverse replay when the identifier covers EVERY record (the same eligibility rule as `rollback`). A hosted-only match works with no manifest at all - (mirroring the detached-vendored escape). Unsupported-ecosystem hosted targets fail closed + (mirroring the manifest-less vendored escape). Unsupported-ecosystem hosted targets fail closed BEFORE the manifest mutation with top-level `hosted_revert_unsupported` (exit 1; remedy: unscoped `socket-patch rollback`, or re-run `scan --mode hosted`); a failed unwind or ledger persist is `hosted_revert_failed` (exit 1, manifest not modified). Successful unwinds ride the @@ -701,9 +729,12 @@ worse, lets a warm cache silently serve unpatched bytes): (`warnings[]` + stderr) that a `vendor` run must refresh the artifact — while `get … --mode vendored` (v3.6) re-vendors at the new uuid in the same run instead of warning (the vendor step immediately resolves the drift the warning describes). -* **Old-binary skew caveat**: a pre-detached `socket-patch` binary running `vendor` against a - checkout with detached entries cannot see the `detached` flag and will reconcile-revert them. - The ledger schema itself stays parseable both ways (additive optional fields). +* **Old-binary skew caveat**: EVERY `scan`/`get --mode vendored` entry is now detached-shaped, so a + `socket-patch` binary that predates the `detached` flag (pre-4.0) running `vendor` against such a + checkout cannot see the flag and will reconcile-revert every vendored entry; a 4.x binary honors + the flag but drives its own re-vendor from the manifest and finds nothing to do. Pin the CLI + version in CI when mixing generations. The ledger schema itself stays parseable both ways + (additive optional fields). ### Caveats (documented behavior, not bugs) @@ -740,20 +771,20 @@ worse, lets a warm cache silently serve unpatched bytes): A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatched and cleans up the local state, in phases under one `apply.lock` acquisition: -1. **State discovery.** A missing manifest is no longer fatal when the vendor or redirect ledger holds work (`rollback` runs manifest-less on hosted-only / detached-vendored projects). The **truly-empty** project — all three stores absent — keeps the legacy "Manifest not found" exit 1 (JSON: the legacy `{status: "error", error: "Manifest not found", path}` shape). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors naming `socket-patch repair` (reconstruct the ledger, then roll back). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent leg still restores files; an unreadable redirect ledger skips only the hosted leg (`redirect_state_unreadable` warning, naming the quarantine remedy). Either drives `partial_failure` exit 1; an emergency restore is never blocked by an unrelated corrupt ledger. When the ONLY state on disk is an unreadable ledger, the run fails closed naming the store. +1. **State discovery.** A missing manifest is no longer fatal when the vendor or redirect ledger holds work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` project is manifest-less). The **truly-empty** project — all three stores absent — keeps the legacy "Manifest not found" exit 1 (JSON: the legacy `{status: "error", error: "Manifest not found", path}` shape). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors naming `socket-patch repair` (reconstruct the ledger, then roll back). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent leg still restores files; an unreadable redirect ledger skips only the hosted leg (`redirect_state_unreadable` warning; v5.0 distinguishes a ledger that cannot be READ — EACCES, a directory or FIFO squatting on the path — which is reported as such and left in place with a fix-the-permissions remedy, from MALFORMED JSON, which is quarantined to `redirect-state.json.corrupt` with the restore remedy). Either drives `partial_failure` exit 1; an emergency restore is never blocked by an unrelated corrupt ledger. When the ONLY state on disk is an unreadable ledger, the run fails closed naming the store. 2. **Agent leg** — the existing in-place restore machinery, unchanged: multi-copy restore, release-variant narrowing, the before-blob gate (+ on-demand download; a gate abort still exits 1 with per-package `missing_blob` failure results **and** skips manifest cleanup + GC entirely — nothing was restored, and the retry's revert data must survive), local-go redirect drop, and the `not_installed` exit-0 asymmetry verbatim. Vendor-owned purls are still excluded here (see the vendored-mode section) — they are handled by the next leg instead of being punted to other commands. -3. **Vendored leg** — each in-scope ledger entry (detached included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted, ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (`vendoredKept`, exit 1 — the system is still patched); a failure is recorded and other entries proceed. +3. **Vendored leg** — each in-scope ledger entry (embedded-record entries included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted (and its emptied `.socket/vendor//` husk pruned, v5.0), ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (`vendoredKept`, exit 1 — the system is still patched); a failure is recorded and other entries proceed. 4. **Hosted leg** — see "Hosted unwind coverage" below. 5. **Manifest cleanup** — entries are removed ONLY for in-scope purls whose legs fully succeeded, were not-installed, or were release-variant siblings narrowed away by an attempted variant that succeeded (half a variant group never lingers — `remove` parity); drift-kept and failed purls keep their records, and a failed variant holds its whole group. No-op removals never rewrite the file. A failed write surfaces as `manifest_write_failed` (warning + `partial_failure` exit 1; GC still runs against the unchanged manifest). 6. **GC** — `cleanup_unused_blobs` + diff/package-archive sweeps against the post-removal manifest, with beforeHash blobs pinned (synthetic afterHash-slot records) for (a) removed-but-not-installed entries (a crawler miss must not destroy the only local revert data — `remove` parity) and (b) EVERY entry remaining in the post-removal manifest — still-active patches (failed, drift-kept, eco-/path-excluded) keep their revert data, so a scoped or failed run never destroys the blobs a later rollback needs; only blobs referenced solely by genuinely-removed entries are swept. GC errors warn (`cleanup_failed`) and continue — they never affect the exit (repair's posture). -**Confirmation prompt.** A wet, non-preserve run with work prompts once, remove-style, composing only the clauses that apply: `[Roll back N patch(es) and remove them from the local manifest][, and delete M vendored artifact(s) (K detached — their embedded patch records are the only local copy)][, and unwind H hosted redirect(s)]?` — default yes, auto-accepted under `--yes`/`--json`/non-TTY (the shared `confirm` semantics; CI unaffected). Decline prints `Rollback cancelled.` and exits 0. `--dry-run` and `--preserve-state` runs are prompt-free (they delete no local state). +**Confirmation prompt.** A wet, non-preserve run with work prompts once, remove-style, composing only the clauses that apply: `[Roll back N patch(es) and remove them from the local manifest][, and delete M vendored artifact(s) (K detached — their embedded patch records are the only local copy)][, and unwind H hosted redirect(s)]?` (every `scan`/`get --mode vendored` entry is a detached one, so on a manifest-free project K == M) — default yes, auto-accepted under `--yes`/`--json`/non-TTY (the shared `confirm` semantics; CI unaffected). Decline prints `Rollback cancelled.` and exits 0. `--dry-run` and `--preserve-state` runs are prompt-free (they delete no local state). ### `--preserve-state` (opt-out, both `rollback` and `remove`) Restore the system but keep the local patch state for a later re-apply: manifest entries kept, vendored artifacts + ledger entries kept byte-identical (only the lockfile wiring is reverted; the already-reverted wiring records replay as silent no-ops on a later revert, and a re-vendor re-wires from the live lock), and all blob/archive GC skipped. **Hosted redirects have no preservable local state**: their ledger records describe live wiring only, so a preserve run still unwinds them and drops the records either way — surfaced as the `hosted_state_not_preservable` warning (re-run `scan --mode hosted` to re-wire). Caveat (documented): preserved vendored entries may be reclaimed by an explicit later `scan --prune` (user-invoked GC); `vendor` re-runs re-wire them. -**Replay fail-closed carve-outs (v5.0)**: the gem SECTION-MOVE record (`redirect_gemfile_lock_gem_source`) refuses in the replay — the writer records only the bare remote URLs, not the moved spec block, so a URL swap cannot invert the move (remedy: `scan --mode hosted` normalize). A socket-owned go.mod `replace` folded into a `replace ( … )` BLOCK and later refreshed also refuses (the ledger records the single-line spelling). Both keep their records + edits for a retry. **Ledger persistence rule**: rollback and remove persist the mutated redirect ledger whenever it changed — INCLUDING on partial-failure exits — so lockfile writes that already flushed are never stranded against a stale on-disk ledger. **Lock discipline**: all three state stores are LOADED under the apply lock (only cheap existence probes run before it), so a concurrent run's writes are never clobbered by a stale pre-lock snapshot. +**Replay fail-closed carve-outs (v5.0)**: the gem SECTION-MOVE record (`redirect_gemfile_lock_gem_source`) refuses in the replay — the writer records only the bare remote URLs, not the moved spec block, so a URL swap cannot invert the move (remedy: `scan --mode hosted` normalize). A socket-owned go.mod `replace` folded into a `replace ( … )` BLOCK and later refreshed also refuses (the ledger records the single-line spelling). Both keep their records + edits for a retry. **Ledger persistence rule**: rollback and remove persist the mutated redirect ledger whenever it changed — INCLUDING on partial-failure exits — so lockfile writes that already flushed are never stranded against a stale on-disk ledger. **Lock discipline**: all three state stores are LOADED under the apply lock (only cheap existence probes run before it), so a concurrent run's writes are never clobbered by a stale pre-lock snapshot. **Residue rule (v5.0)**: a reversal that empties a ledger deletes the file — `redirect-state.json` and/or `vendor/state.json` — and prunes the emptied `.socket/vendor//` and `.socket/vendor/` directories (non-recursive, so a `redirect-state.json.corrupt` quarantine or any other stray file keeps its directory alive — the one sanctioned `.socket/vendor/` residue); emptied `blobs/`, `diffs/` and `packages/` stores are removed by the GC sweep; `.socket/` itself is removed by the lock guard when the run leaves it empty, so a fully unwound hosted or vendored project has no `.socket/` at all. What legitimately survives a full reversal: `.socket/manifest.json` at `{"patches": {}}` (+ its `setup` block — never deleted, see the exit-code section), the setup-owned `.socket/.gitignore`, `gem-plugin-stamp` and `bundler-plugin/`, and `.corrupt` quarantine files. ### Hosted unwind coverage @@ -863,7 +894,7 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_SILENT` | `--silent` / `-s` | `false` | — | | `SOCKET_DRY_RUN` | `--dry-run` | `false` | — | | `SOCKET_YES` | `--yes` / `-y` | `false` | — | -| `SOCKET_LOCK_TIMEOUT` | `--lock-timeout` | (none) | Seconds to wait for `apply.lock`; unset/`0` = single non-blocking try. | +| `SOCKET_LOCK_TIMEOUT` | `--lock-timeout` | (none) | Seconds to wait for `apply.lock` on the lock-taking subcommands (incl. hosted/vendored `scan`/`get`); unset/`0` = single non-blocking try. | | `SOCKET_DEBUG` | `--debug` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_DEBUG`). | | `SOCKET_TELEMETRY_DISABLED` | `--no-telemetry` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_TELEMETRY_DISABLED`). | | `SOCKET_FORCE` | `apply --force` / `-f`, `--update --force` | `false` | Local to `apply` and `--update`. | @@ -1036,8 +1067,8 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendored` | `skipped` | apply (every ecosystem) + scan `--apply`: the package is managed by `socket-patch vendor`; the command yields ownership (scan also skips the download). v5.0: rollback no longer yields — its vendored leg reverts these entries by default, and its `vendored: []` array is reserved-empty (a corrupt vendor ledger surfaces via the `vendor_state_unreadable` warning + exit 1 — the skip cannot name purls, since naming them needs the ledger). Scan `--apply --json` additionally surfaces one run-level `vendored_ownership_retained` warning naming the skipped purls (additive; exit/status unchanged). | | `vendor_reverted` | `removed` | remove: vendoring reverted (lock fragments restored, artifact + ledger entry gone) as part of removing the patch. | | `vendor_revert_failed` | top-level error | remove: the vendor revert failed; the manifest was NOT modified. | -| `vendor_state_retained` | `skipped` | remove `--skip-rollback`: vendor wiring + artifact deliberately left in place (the next `vendor` run reconciles the dropped entry). Also the top-level error code when `--skip-rollback` targets a detached-only patch. | -| `hosted_state_retained` | (top-level error) | remove `--skip-rollback` targeting a hosted-only patch (no manifest entry): unwinding the redirect is the only possible removal, so the combination is refused (exit 1), mirroring the detached-only refusal above. | +| `vendor_state_retained` | `skipped` | remove `--skip-rollback`: vendor wiring + artifact deliberately left in place (the next `vendor` run reconciles the dropped entry). Also the top-level error code when `--skip-rollback` targets a vendored patch with no manifest record (every `scan`/`get --mode vendored` entry). | +| `hosted_state_retained` | (top-level error) | remove `--skip-rollback` targeting a hosted-only patch (no manifest entry): unwinding the redirect is the only possible removal, so the combination is refused (exit 1), mirroring the manifest-less vendored refusal above. | | `vendor_state_preserved` | `skipped` | remove `--preserve-state` (v5.0): lockfile unwired; artifact, ledger entry, and manifest entry all kept for a later re-apply. Rollback's counterpart is the `vendoredPreserved: []` envelope array. | | `vendor_revert_kept` | `skipped` + top-level error | remove (v5.0): the vendored revert drift-kept (`kept_artifact`), so the ledger entry AND the manifest entry were both kept. ANY drift-keep makes the run a `partialFailure` (exit 1) — part of the requested removal did not happen; when EVERY matching entry drift-kept, the top-level error carries this code (`summary.removed` stays 0; the identifier DID match, so never `not_found`). Remedy: re-run `scan --mode vendored` to normalize, then remove. Rollback's counterpart is the `vendoredKept: []` envelope array (also exit 1). | | `hosted_reverted` | `removed` | remove (v5.0): a hosted lockfile redirect was unwound as part of removing the patch (`verified` on dry-run). Bypasses `summary.removed` like `vendor_reverted`. | @@ -1047,7 +1078,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `hosted_state_not_preservable` | rollback `warnings[]` | rollback `--preserve-state` (v5.0): hosted redirects were unwound and their ledger records dropped anyway — hosted has no preservable local state; re-run `scan --mode hosted` to re-wire. (`remove --preserve-state` prints the same note on stderr.) | | `out_of_scope_copies_restored` | rollback `warnings[]` | path-scoped rollback (v5.0): a selected patch had installed copies outside the given patterns; ALL copies were restored (patches are per-package). Informational — never flips the exit. | | `path_scope_excluded_supplements` | scan `warnings[]` | path-scoped scan (v5.0): lockfile-only / vendor-ledger supplement packages have no installed path and were excluded from the scoped scan; the detail carries the count. | -| `vendor_state_unreadable` / `redirect_state_unreadable` | rollback `warnings[]`; remove top-level error | corrupt-ledger containment (v5.0). Rollback: an unreadable vendor ledger skips the vendored leg + manifest cleanup + GC; an unreadable redirect ledger skips the hosted leg (quarantine/restore remedy in the detail); either drives `partial_failure` exit 1 while the agent leg still restores files. Remove: `vendor_state_unreadable` is a hard top-level error before any mutation (an unreadable redirect ledger only warns — the identifier may match other stores). Also the Bun vendored preflight's refusal code: `get` / `scan --mode vendored`, `--detached` runs, `vendor`'s pre-takeover check and the `--dry-run` `would_refuse` preview report an unreadable `.socket/vendor/state.json` as itself (`errorCode` in `patches[]` / `download.patches[]`, or `get `'s top-level `error.code`), fail-closed — nothing is exempt — instead of a Bun lock code. | +| `vendor_state_unreadable` / `redirect_state_unreadable` | rollback `warnings[]`; remove top-level error | corrupt-ledger containment (v5.0). Rollback: an unreadable vendor ledger skips the vendored leg + manifest cleanup + GC; an unreadable redirect ledger skips the hosted leg (quarantine/restore remedy in the detail); either drives `partial_failure` exit 1 while the agent leg still restores files. Remove: `vendor_state_unreadable` is a hard top-level error before any mutation (an unreadable redirect ledger only warns — the identifier may match other stores). Also the Bun vendored preflight's refusal code: `get` / `scan --mode vendored`, `vendor`'s pre-takeover check and the `--dry-run` `would_refuse` preview report an unreadable `.socket/vendor/state.json` as itself (`errorCode` in `patches[]` / `download.patches[]`, or `get `'s top-level `error.code`), fail-closed — nothing is exempt — instead of a Bun lock code. | | `manifest_write_failed` | rollback `warnings[]` | rollback (v5.0): the post-rollback manifest update could not be written; no entries were removed (`manifest.removedEntries: []`) and the run exits `partial_failure` 1. | | `redirect_pnpm_trust_scaffold_modified` | rollback/remove `warnings[]` | hosted replay (v5.0): the redirect-created `pnpm-workspace.yaml` scaffold was modified since; the file was kept and only the `trustLockfile: true` line removed. | | `vendor_stale_artifact_removed` | `removed` | vendor / scan `--vendor`: re-vendor under a newer patch uuid removed the previous uuid's orphaned artifact dir. | @@ -1087,7 +1118,9 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_pipenv_skipped` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): no entry for the package, pipfile-spec < 6, an unparseable lock or a digest-less patch — nothing rewritten here; the sibling rewriters proceed. | | `redirect_pipenv_installer_unknown` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): the lock was rewritten with the modern `file` reference because no `pipenv` answered on PATH; Pipenv 7–11 projects need `path` — put that pipenv on PATH or set `SOCKET_PIPENV_MAJOR`. | | `pypi_pipenv_installer_unsupported` | `failed` | vendor (pipenv): the installed Pipenv is older than 2018 and cannot consume vendored wheel references — upgrade Pipenv or use hosted mode. | -| `pypi_pipenv_version_mismatch` / `pypi_pipenv_invalid_wheel` | `failed` | vendor (pipenv): a category pins a different version than the patch (or the wheel filename carries no version) — refused before any write. | +| `pypi_pipenv_version_mismatch` | `failed` | vendor (pipenv): a category pins a different version than the patch — refused before any write. (`pypi_pipenv_invalid_wheel` retired in v5.0: the backend takes the orchestrator's resolved version instead of parsing the wheel filename.) | +| `pypi_poetry_symlink_unsupported` / `pypi_pipenv_symlink_unsupported` / `pypi_requirements_symlink_unsupported` | `failed` | vendor (pypi, v5.0): a target file (`pyproject.toml` / `poetry.lock`, `Pipfile` / `Pipfile.lock`, or any planned `requirements*.txt`) is a symlink — refused before any write on wire AND on revert (the revert keeps the artifact, `kept_artifact`); the twins of the existing pdm/uv symlink refusals. | +| `pypi_poetry_changed` / `pypi_pdm_changed` / `pypi_pipenv_changed` / `pypi_uv_changed` | `failed` | vendor (pypi, v5.0): the lock / project file changed between the read that planned the edit and the first write — refused before any write (worded like `pypi_lock_changed`: " changed during vendoring; re-run"). | | `pypi_pipenv_stale_install` | `skipped` (warning) | vendor (pipenv): the vendored twin of `redirect_pypi_stale_install` — the project's venv still holds the upstream release Pipenv will not reinstall over; the detail names the `pipenv run pip uninstall -y && pipenv sync` remedy. | | `pypi_pipenv_installer_unknown` | `skipped` (warning) | vendor (pipenv): no `pipenv` answered on PATH; the vendored references assume Pipenv 2018 or later (7–11 cannot consume them — use hosted mode there); `SOCKET_PIPENV_MAJOR` pins the release. | | `vendor_lock_entry_relocked` | revert `warnings[]` | vendor `--revert` / rollback (pipenv): a relock regenerated the wired entry to a registry reference, or removed it; the record is retired (artifact removed, ledger entry dropped) instead of drift-kept. | @@ -1108,7 +1141,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | Code | Subcommands | Meaning | |-----------------------|----------------------------------|---------| -| `manifest_not_found` | list, remove, repair, rollback | `.socket/manifest.json` doesn't exist. v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the only `.socket/` trace is a hosted-mode `redirect-state.json`. `list` likewise no longer fires this on a hosted-only project: when the hosted redirect ledger holds ≥ 1 `records` entry, the records are listed (exit 0, labeled `details.mode: "hosted"` + `details.ledger`; when the manifest exists too, both stores are shown, purl-sorted with the manifest entry first on a tie). Both stores always come from the SAME project: the ledger is resolved against the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory — exactly `--cwd` for the default path), so `--manifest-path` into another project reads that project's ledger, never the local one. The error still fires when NEITHER store has a record — an edits-only ledger asserts no patches — and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless of ledger records (corruption is never masked). A malformed ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (read-only consumer posture; the hosted write path hard-errors instead). v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the redirect ledger holds work (its error is the legacy `{status: "error", error: "Manifest not found", path}` shape, not this envelope code); only the truly-empty project — all three stores absent — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` also proceeds manifest-less when the identifier matches a detached vendored entry or a hosted redirect-ledger record. | +| `manifest_not_found` | list, remove, repair, rollback | `.socket/manifest.json` doesn't exist. v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the only `.socket/` trace is a hosted-mode `redirect-state.json`. `list` likewise no longer fires this on a hosted-only project: when the hosted redirect ledger holds ≥ 1 `records` entry, the records are listed (exit 0, labeled `details.mode: "hosted"` + `details.ledger`; when the manifest exists too, both stores are shown, purl-sorted with the manifest entry first on a tie). v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records with a `(vendored)` marker in human mode (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. All stores always come from the SAME project: the ledger is resolved against the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory — exactly `--cwd` for the default path), so `--manifest-path` into another project reads that project's ledger, never the local one. The error still fires when NONE of the three stores has a record — an edits-only ledger asserts no patches — and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless of ledger records (corruption is never masked). A malformed ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (read-only consumer posture; the hosted write path hard-errors instead). v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the redirect ledger holds work (its error is the legacy `{status: "error", error: "Manifest not found", path}` shape, not this envelope code); only the truly-empty project — all three stores absent — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` also proceeds manifest-less when the identifier matches a vendor-ledger entry or a hosted redirect-ledger record. | | `manifest_invalid` | list, remove | Manifest exists but is unparseable. | | `manifest_unreadable` | list, remove | I/O error reading manifest. | | `apply_failed` | apply | apply pipeline error before any patch ran. | @@ -1121,7 +1154,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified |--------------|---| | `apply` | `Applied` · `Updated` · `Skipped` (already_patched / package_not_installed / vendored) · `Failed` · `Verified` (dry-run) | | `vendor` | `Applied` (= vendored; `command` routes) · `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 redirect-ledger records additionally carry `details.mode: "hosted"` — the constant mode name, whatever opaque mode string the ledger itself carries — and `details.ledger: ".socket/vendor/redirect-state.json"`, both additive and absent on manifest entries) | +| `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`; hosted redirect-ledger records additionally carry `details.mode: "hosted"` — the constant mode name, whatever opaque mode string the ledger itself carries — and `details.ledger: ".socket/vendor/redirect-state.json"`, 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 marks them `(vendored)`) | | `repair`/`gc`| `Downloaded` (or `Verified` on dry-run) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run) · `Skipped` (vendor_uuid_mismatch) · `Removed` (or `Verified`) · `Failed` events | | `remove` | `Removed` (per purl; `Verified` on dry-run) · artifact-level `Removed`/`Verified` event (with `details.blobsRemoved`, `details.rolledBack`) | | `--update` | `Downloaded` → `Updated` (success) · `Skipped` (already_latest) · `Verified` (dry-run check, reason update_check) — see the Self-update contract section for details fields and top-level error codes | @@ -1198,6 +1231,13 @@ installed-version narrowing; see "get --mode and installed narrowing"), the same calm-skip vocabulary as scan's pre-download partitions. Absent on the classic "already in manifest" skip. +Vendored mode (v5.0) uses the detached download vocabulary instead: +`get --mode vendored`'s `patches[]` and `scan --mode vendored`'s +`download.patches[]` carry `action: "downloaded" | "skipped" | "failed"` +(no `added`/`updated`/`oldUuid` — the vendor ledger, not the manifest, +tracks patch generations) beside the same metadata keys, and the +enclosing object carries `downloaded: N` and `detached: true`. + Additive: a `failed` record may ALSO carry `errorCode` beside `error` — today exactly the vendored-mode Bun preflight refusals (`vendor_bun_lockb_invalid`, `vendor_lockfile_missing`, @@ -1349,7 +1389,7 @@ socket-patch apply --json | jq ' Exit `0` when `status` is `success`, `noManifest`, or `notFound`-with-zero-failed. Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) or `error`. -`apply` with no manifest at all is a clean exit-0 no-op (`status: "noManifest"`), and an **empty** manifest (zero patches) is a plain `success` exit 0 — this is load-bearing for the install hooks, which run `apply` on every install. Pinned by `tests/in_process_edge_cases.rs` and `tests/cli_dry_run_paths_e2e.rs`. **One carve-out**: a yarn-berry Plug'n'Play layout (`.pnp.*` loader at `--cwd`) refuses with the loud `yarn_pnp_unsupported` error (exit 1) even when no manifest exists — `scan` cannot discover PnP packages (they live inside `.yarn/cache/*.zip`, no `node_modules/`) and therefore never writes a manifest, so without the carve-out the documented refusal was unreachable and a PnP project's only signal was the calm noManifest exit. Pinned by `tests/e2e_safety_yarn_pnp.rs`. +`apply` with no manifest at all is a clean exit-0 no-op (`status: "noManifest"`), and an **empty** manifest (zero patches) is a plain `success` exit 0 — this is load-bearing for the install hooks, which run `apply` on every install. A fully rolled-back agent project therefore keeps `.socket/manifest.json` at `{"patches": {}}` (+ its `setup` block): the v5.0 residue rule never deletes a zero-patch manifest, precisely so these hook exits (and `list`'s 0-vs-1 below) never flip. Pinned by `tests/in_process_edge_cases.rs` and `tests/cli_dry_run_paths_e2e.rs`. **One carve-out**: a yarn-berry Plug'n'Play layout (`.pnp.*` loader at `--cwd`) refuses with the loud `yarn_pnp_unsupported` error (exit 1) even when no manifest exists — `scan` cannot discover PnP packages (they live inside `.yarn/cache/*.zip`, no `node_modules/`) and therefore never writes a manifest, so without the carve-out the documented refusal was unreachable and a PnP project's only signal was the calm noManifest exit. Pinned by `tests/e2e_safety_yarn_pnp.rs`. ## Exit codes @@ -1359,7 +1399,7 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) | `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, etc.) | | `2` | Usage error: clap parse failures (unknown flag/value, missing required arg — including the clap-enforced `setup --check --remove` conflict) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `scan PATHS` combined with `--mode hosted`/`--mode vendored` (same enforcement point), `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). **Carve-out**: `get`'s self-enforced conflicts have always exited `1` via its error envelope (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`) and the v3.6 `--mode hosted\|vendored --save-only` conflict deliberately follows that get-internal precedent — changing the existing ones to `2` would be a MAJOR exit-code change | -`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing. Every mutating subcommand returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. +`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing (a manifest-less project whose vendor or redirect ledger holds records is NOT "missing": `list` reads all three stores and exits 0 — see the `manifest_not_found` row). Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. `vex` exit codes are tri-state: diff --git a/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index 15de8b6e..ca21434f 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -38,11 +38,11 @@ other npm lockfile flavors. | Input | Hosted (`scan` / `get --mode hosted`) | Vendored (`scan` / `get --mode vendored`, `vendor`) | Agent / discovery | |-------|------|------|------| -| Text `bun.lock`, lockfileVersion 0, 1 or 2, no `workspace:` packages | Registry 4-tuple `["name@ver", "", {deps}, "sha512-…"]` → URL 3-tuple `["name@https://patch.socket.dev/…/name-ver.tgz", {deps}, "sha512-"]`; the `{deps}` meta object (dependencies, bin, …), the lock's version line and its line endings are kept verbatim. | Same entry → local 3-tuple `["name@.socket/vendor/npm//name-ver.tgz", {deps}, "sha512-"]`, tarball committed under `.socket/vendor/npm//`; `--detached` keeps the record in `.socket/vendor/state.json` only. | The installed tree is patched in place; the lock's registry tuples are inventoried, so lockfile-only packages join discovery. | +| Text `bun.lock`, lockfileVersion 0, 1 or 2, no `workspace:` packages | Registry 4-tuple `["name@ver", "", {deps}, "sha512-…"]` → URL 3-tuple `["name@https://patch.socket.dev/…/name-ver.tgz", {deps}, "sha512-"]`; the `{deps}` meta object (dependencies, bin, …), the lock's version line and its line endings are kept verbatim. | Same entry → local 3-tuple `["name@.socket/vendor/npm//name-ver.tgz", {deps}, "sha512-"]`, tarball committed under `.socket/vendor/npm//`; the patch record lives in `.socket/vendor/state.json` (the ledger embeds it — vendored mode writes no `.socket/manifest.json`). | The installed tree is patched in place; the lock's registry tuples are inventoried, so lockfile-only packages join discovery. | | Version-0 lock (Bun 1.1.39–1.1.45 `--save-text-lockfile`) with `workspace:` packages — 2-tuple entries `"consumer": ["consumer@workspace:packages/consumer", { "dependencies": { … } }]` | Refused `redirect_bun_workspace_unsupported`, lock untouched, exit 0. Remedy: delete `bun.lock` and re-lock with Bun ≥ 1.2 (writes lockfileVersion 1, which hosted mode accepts; 2 on Bun ≥ 1.4). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (root → member — the matrix's `workspace` shape, the only shape it was measured on); otherwise Bun 1.2.0 keeps version 0 and 1.2.23+ fail to resolve (see [In-place re-versioning](#installer-boundaries-measured)). | Refused `vendor_bun_workspace_unsupported` (pre-version-2 policy, next row); its remedy tail for a version-0 lock says to re-lock with Bun ≥ 1.2 before trying `--mode hosted`, which refuses version 0 too. | Works. | | Version-1 lock (Bun 1.2–1.3 default) with `workspace:` packages — 1-tuple entries `["consumer@workspace:packages/consumer"]` | Rewritten (golden `lock-v1-workspace`; matrix 1.2.0–1.3.14 `workspace` / `workspace-nested`). | Refused `vendor_bun_workspace_unsupported` before any write. Policy, not a grammar limit: Bun 1.2.x–1.3.x resolve a workspace member's local-tarball path relative to the MEMBER (our root-relative tuple ENOENTs on `bun install`), 1.4.x relative to the lockfile, and a committed lockfileVersion-2 lock is the only proof that every consumer runs Bun ≥ 1.4 (1.3.x cannot parse v2). A deliberate over-approximation: a package declared only by the workspace ROOT vendors and installs on v1 too, but the lock cannot cheaply prove which workspace declares a hoisted entry. Remedy in the detail: delete `bun.lock`, re-run `bun install` with Bun ≥ 1.4 (an in-place `bun install` keeps the existing version), or — version 1 — use `--mode hosted`, which accepts version-1 workspace locks (a version-0 lock is told to re-lock with Bun ≥ 1.2 first). NOT refused: purls the vendor ledger wires at the selected uuid, purls whose every matching lock tuple already points into `.socket/vendor/npm/` (any uuid — a superseding patch re-pins in place; the lock-derived rule the engine uses), in-sync re-runs and `repair` rebuilds. `vendor` and the vendor step run the same preflight BEFORE a hosted → vendored takeover's revert, so a hosted-redirected purl on such a lock stays hosted-patched (`failed vendor_bun_workspace_unsupported`, lock and ledgers untouched; `vendor --dry-run` previews the same code). A `.socket/vendor/state.json` the preflight cannot read is `vendor_state_unreadable`, fail-closed. | Works. | | Version-2 lock (Bun 1.4+) with `workspace:` packages, nested versions included | Rewritten (golden `lock-v2-workspace-nested` — provenance: its nested same-version `consumer/left-pad` entry is a synthetic, grammar-valid extension of the 1.4.2 capture; bun hoists identical resolutions and never writes that entry itself, but bun 1.4.2 installs the fixture unchanged, and it is the only case pinning the rewrite of every matching tuple in one lock). | Vendored (matrix 1.4.0 / 1.4.2 `workspace`, `workspace-nested`, `already-vendored-workspace`). | Works. | -| Binary `bun.lockb` (binary format revisions 1, 2 and 3) | Package resolution and integrity records are rewritten in place. The CLI does not spawn Bun or produce a text lock. | Native local-tarball wiring, committed artifact, detached mode, repair and hosted ⇄ vendored takeover. | Registry package records are inventoried directly, including lockfile-only projects without `node_modules`. | +| Binary `bun.lockb` (binary format revisions 1, 2 and 3) | Package resolution and integrity records are rewritten in place. The CLI does not spawn Bun or produce a text lock. | Native local-tarball wiring, committed artifact, repair and hosted ⇄ vendored takeover. | Registry package records are inventoried directly, including lockfile-only projects without `node_modules`. | | Truncated, corrupt or unrecognized binary `bun.lockb` | Refused with `redirect_bun_lockb_invalid`, preserving the lock. | Refused with `vendor_bun_lockb_invalid` before downloads or artifact creation. | The inventory reports the malformed lock. | | `bun.lock` with a `lockfileVersion` ≥ 3, no integer version, or a `packages` section outside bun's single-line grammar | Refused `redirect_bun_lock_unsupported`. | Refused `vendor_lockfile_version_unsupported` (preflight and engine). | The inventory skips the lock. | @@ -51,16 +51,16 @@ One detail text serves both modes for the version gate: a newer version says lockfileVersion 0–2" (re-locking with a newer Bun would reproduce it); a missing integer says "re-lock with Bun ≥ 1.2". -**Pre-download preflight (vendored).** `scan --mode vendored`, -`get --mode vendored` (search and uuid paths) and `--detached` runs check +**Pre-download preflight (vendored).** `scan --mode vendored` and +`get --mode vendored` (search and uuid paths) check `bun.lock` / `bun.lockb` ONCE before any patch download when the selection holds an npm purl. A refused project marks every npm result `failed` with the vendor code + detail, fetches nothing and records no patch: the `scan` / -`get ` path still writes an unchanged `.socket/manifest.json` (an empty -`{"patches": {}}` on a fresh project; a record seeded for another purl -survives) and exits `partial_failure` / 1; `get --mode vendored` exits -1 with `status: "error"` and `error: {code, message}` before creating -`.socket/` at all; detached runs never write a manifest. `--silent` keeps the +`get ` path writes nothing under `.socket/` (vendored mode is +manifest-free — a `.socket/manifest.json` seeded for another purl is left +byte-untouched) and exits `partial_failure` / 1; `get --mode vendored` +exits 1 with `status: "error"` and `error: {code, message}`, likewise without +creating `.socket/`. `--silent` keeps the code-tagged refusal on stderr; `--dry-run` previews it as the additive `would_refuse` action. Agent-mode `get --save-only` is not preflighted. @@ -151,7 +151,7 @@ limitation is recorded explicitly in each matrix row. Newer readers also consume unchanged 1.1.45 binary lock. The Rust tests assert cold frozen installs from an empty cache, exact patched and bystander bytes, preservation of the binary file, hosted and vendored reruns, both takeover directions, dry-run immutability, -artifact repair, detached mode and byte-exact rollback. Extended cells cover npm +artifact repair, manifest-free vendored scans and byte-exact rollback. Extended cells cover npm aliases, overridden transitives, workspace members, multiple versions, root and workspace scripts, and GitHub resolutions. Workspace cells also exercise missing and corrupt copies, with and without the local ledger. @@ -307,7 +307,7 @@ python3 scripts/backtest-bun.py \ --cli target/debug/socket-patch \ --cli-revision "$(git rev-parse HEAD)" \ --output /tmp/bun-compatibility \ - --modes hosted vendored vendored-detached + --modes hosted vendored ``` Use `--versions 1.4.2 --shapes workspace-nested` for a focused reproduction, @@ -369,18 +369,20 @@ explicit informational allowlist (`vendor_prebuilt_downloaded`, supported FAILS on unexpected warnings, `redirect_bun_entry_not_found` or `redirect_revert_failed`. Exit codes are recorded for every invocation and asserted: supported → 0; hosted refusals → 0 with `redirect.redirected == 0` -(the documented hosted-refusal posture); vendored, detached and `get` refusals +(the documented hosted-refusal posture); vendored and `get` refusals → non-zero, with `download.downloaded == 0` and no stray manifest record. **Every supported case verifies:** -- the ledger (or manifest) record names the expected published patch uuid; +- the ledger record (redirect ledger for hosted, vendor ledger for vendored — no + mode writes `.socket/manifest.json`, and every cell asserts its absence) names + the expected published patch uuid; - a fresh `bun install --frozen-lockfile` and a fresh ordinary `bun install` (empty caches, no `node_modules`) install the record's exact `afterHash` bytes and leave the lockfile byte-identical; - the repeat run is a no-op with the documented envelope — hosted: `status: success`, `redirect.redirected == 1`, no non-informational warning; - vendored / detached: `summary.applied == 0`, `summary.skipped == 1`, + vendored: `summary.applied == 0`, `summary.skipped == 1`, `summary.failed == 0`, one `already_vendored` event, no `failed` action — and preserves the lock bytes; - `registryDigestEnforced`: before the CLI runs, a copy of the project with a @@ -396,8 +398,9 @@ asserted: supported → 0; hosted refusals → 0 with `redirect.redirected == 0` retain `bun.lockb` without creating a text lock. The original lock presence and SHA-256 are both checked. -The runner captures the exact project manifests, lockfiles, optional -`.socket/manifest.json`, CLI JSON, exit codes, file hashes and assertion +The runner captures the exact project manifests, lockfiles, the ledgers (and a +`.socket/manifest.json` only where the `preexisting-manifest` shape seeded one), +CLI JSON, exit codes, file hashes and assertion results (`captures/--/`), plus provenance (`cliRevision` — the branch-resolvable commit the row is about; `cliBuildSha` — the commit actions/checkout actually built, `refs/pull/N/merge` on a pull @@ -450,7 +453,7 @@ table above. | Claim | Real-Bun matrix (`backtest-bun.py`) | Real-Bun hermetic suites (`ci.yml` `e2e`) | Bun-less unit / CLI tests | |---|---|---|---| | Text lock 0 / 1 / 2 rewritten and installed, both modes | 1.1.39–1.4.2 | `e2e_redirect_bun_build` + `e2e_vendor_bun_build` on 1.4.2 (3 OS), 1.1.45 and 1.2.23 (Linux); the fixture asserts the lock version matches the era table, the v1-on-1.4 leg proves a committed v1 lock keeps installing | goldens `lock-v0`, `basic` (v1), `lock-v2`; `bun_lock.rs`, `lock_inventory.rs` | -| Native binary formats 1 / 2 / 3: inventory, hosted, vendored, detached, repair, takeovers, rollback | `direct`, `legacy-lockb`, conversion shapes; dedicated `backtest-bun-lockb.py` | `e2e_bun_lockb` across writer / reader revisions | `bun_lockb.rs` and committed real binary fixtures; native CLI tests | +| Native binary formats 1 / 2 / 3: inventory, hosted, vendored, repair, takeovers, rollback | `direct`, `legacy-lockb`, conversion shapes; dedicated `backtest-bun-lockb.py` | `e2e_bun_lockb` across writer / reader revisions | `bun_lockb.rs` and committed real binary fixtures; native CLI tests | | Version-0 workspace hosted refusal + remedy | `text-workspace` (1.1.39–1.1.45) | — | golden `lock-v0-workspace-refusal` (+ `expected-warnings.json`), `redirect/mod.rs` unit tests | | Pre-v2 workspace vendored refusal (policy) + remedy; version 2 supported incl. nested | v1: 1.2.0–1.3.14 `workspace*`; v0: `text-workspace`; v2: 1.4.x | `e2e_vendor_bun_build` scoped leg (deps + bin meta survive) | `bun_lock.rs` (`legacy_workspace_tarballs_refuse_before_writes`, in-sync / rebuild exemptions), `in_process_vendor_bun`, `repair_vendor_flavors_e2e` over {0, 1, 2} × workspace shapes | | Digest boundary 1.3.10 (registry tuples 1.2.0) | 1.3.9 vs 1.3.10 cells, `registryDigestEnforced` | tampered twins in both suites, pinned from both sides | — | @@ -458,7 +461,7 @@ table above. | Mode conversion both directions; scoped `rollback` / `remove` | `hosted-then-vendored`, `vendored-then-hosted` | `mode_migration_bun` (1.4.2 × 3 OS, 1.3.14) | `in_process_vendor_bun_takeover`, `takeover.rs`, `covgap_commands_rollback` | | CRLF lockfiles preserved (hosted line, vendored, rollback) | `crlf-lock` | — | golden `lock-v2-crlf`, `bun_lock.rs` | | Bun 0.8.1 / 1.0.0 peer / transitive upstream limitation | recorded per cell; no selected target is installed, exit 0 and lock unchanged | — | — | -| Pre-download preflight envelopes, `--silent`, `--dry-run` `would_refuse`, detached parity | `get-uuid` / `get-search` / `workspace-get-uuid` / `workspace-get-search` refusals (exit codes, `downloaded == 0`) | — | `in_process_vendor_bun` (exact uuid-path envelope), `scan_vendor_e2e`, `get_modes_e2e`, `vendor_flow.rs` | +| Pre-download preflight envelopes, `--silent`, `--dry-run` `would_refuse`, manifest-free footprint | `get-uuid` / `get-search` / `workspace-get-uuid` / `workspace-get-search` refusals (exit codes, `downloaded == 0`) | — | `in_process_vendor_bun` (exact uuid-path envelope), `scan_vendor_e2e`, `get_modes_e2e`, `vendor_flow.rs` | Not measured: a `--cwd ` run (the member holds no `bun.lock`, so the preflight passes and the engine refuses diff --git a/docs/testing/pdm-compatibility.md b/docs/testing/pdm-compatibility.md index 8211792a..78a5a8c9 100644 --- a/docs/testing/pdm-compatibility.md +++ b/docs/testing/pdm-compatibility.md @@ -108,6 +108,11 @@ the lock and `pyproject.toml` byte for byte. `.github/workflows/pdm-compatibilit runs it on Linux, Windows and macOS across every PDM major family. The matrix needs no Socket API token (the `urllib3@1.26.18` patch is a free tier). +> **Note (v5.0):** the "refused vendored scan still writes a `.socket/manifest.json` +> record" observation in the notes column below describes the 4.0.0 binary the run +> was captured with. Vendored mode is manifest-free since v5.0 — `scan --mode vendored` +> never writes `.socket/manifest.json` — so the note disappears on the next regeneration. + diff --git a/docs/testing/vendored-production-e2e.md b/docs/testing/vendored-production-e2e.md index 4728abfe..3c97c99c 100644 --- a/docs/testing/vendored-production-e2e.md +++ b/docs/testing/vendored-production-e2e.md @@ -171,8 +171,8 @@ The bun leg (`bun_vendored_install_proof`) is therefore on-demand production coverage. The per-PR real-Bun evidence for vendored mode is the hermetic `e2e_vendor_bun_build` suite in `ci.yml`'s `e2e` matrix (Bun 1.4.2 on three OSes, 1.1.45 and 1.2.23 on Linux) plus the production native matrix in -`bun-compatibility.yml` (16 releases × 3 OS in hosted, vendored and -vendored-detached mode) — see [Bun compatibility](bun-compatibility.md). +`bun-compatibility.yml` (16 releases × 3 OS in hosted and vendored mode — +vendored is manifest-free) — see [Bun compatibility](bun-compatibility.md). ### Environment knobs diff --git a/scripts/backtest-bun-lockb.py b/scripts/backtest-bun-lockb.py index 7d078cdb..a2bf3e5b 100644 --- a/scripts/backtest-bun-lockb.py +++ b/scripts/backtest-bun-lockb.py @@ -3,7 +3,7 @@ The Rust suite uses a local patch service and real Bun installers. Each reader must accept hosted and vendored binary rewrites, both takeover directions, -dry runs, idempotence, artifact repair, detached scans and byte-exact rollback. +dry runs, idempotence, artifact repair, manifest-free vendored scans and byte-exact rollback. Fresh frozen installs use empty caches and compare installed package bytes. Modern releases write native binary locks using install.saveTextLockfile=false. diff --git a/scripts/backtest-bun.py b/scripts/backtest-bun.py index cc368927..27254699 100644 --- a/scripts/backtest-bun.py +++ b/scripts/backtest-bun.py @@ -44,7 +44,7 @@ 1.1.39 first text lock (lockfileVersion 0, --save-text-lockfile) 1.2.0 text default, lockfileVersion 1; 1.4.0: lockfileVersion 2 version-0 workspace lock hosted refuses (redirect_bun_workspace_unsupported) - pre-v2 workspace lock vendored/detached refuse (vendor_bun_workspace_unsupported) + pre-v2 workspace lock vendored refuses (vendor_bun_workspace_unsupported) bun.lockb native binary inventory and package-record rewrites 1.3.10 URL/local tarball sha512 enforced (registry tuples are enforced on every text-lock release) @@ -91,7 +91,9 @@ 'lockfile-only', 'production', 'get-uuid', 'get-search', 'hosted-then-vendored', 'vendored-then-hosted', 'already-vendored-workspace', 'preexisting-manifest'] -MODES = ['hosted', 'vendored', 'vendored-detached'] +# Vendored mode is manifest-free (the vendor ledger embeds the record), so the +# former `vendored-detached` leg collapsed into `vendored`: same footprint. +MODES = ['hosted', 'vendored'] PURL = 'pkg:npm/minimist@1.2.2' UUID = '80630680-4da6-45f9-bba8-b888e0ffd58c' # The registry slot bun writes for a non-default registry: the full tarball URL. @@ -408,8 +410,6 @@ def cell_applies(version, shape, mode): return False # the matrix release would be the legacy writer itself if shape in ('crlf-lock', 'custom-registry') and v < TEXT_DEFAULT_FROM: return False # both need a default text bun.lock to edit - if shape in GET_SHAPES and mode == 'vendored-detached': - return False # `get` has no --detached if shape == 'already-vendored-workspace' and v < TEXT_DEFAULT_FROM: return False # this shape explicitly inspects text re-save syntax if shape in ('hosted-then-vendored', 'vendored-then-hosted') and mode == 'hosted': @@ -424,7 +424,7 @@ def expected_outcome(version, shape, mode): supported: whether the patch must land; codes: the EXACT refusal-code set (after removing INFORMATIONAL); exit: 'zero' (supported, hosted refusals, - upstream limitations) or 'nonzero' (vendored / detached / get refusals); + upstream limitations) or 'nonzero' (vendored / get refusals); limitation: the row annotation for an unsupported cell; rerun: the main command is a documented no-op re-run (already_vendored) rather than a first application.""" @@ -523,8 +523,8 @@ def downloaded_count(envelope): def rerun_clean(code, envelope, mode): """The documented no-op re-run: hosted re-confirms the wiring (redirected 1, - nothing rewritten, no warnings beyond advisories); vendored / detached - skips exactly one already_vendored purl with nothing failed.""" + nothing rewritten, no warnings beyond advisories); vendored skips exactly + one already_vendored purl with nothing failed.""" if code != 0 or envelope.get('status') != 'success': return False codes, _ = envelope_codes(envelope) @@ -541,18 +541,18 @@ def rerun_clean(code, envelope, mode): def ledger_record(project, mode): - """The patch record the mode's ledger holds for PURL (None when absent).""" + """The patch record the mode's ledger holds for PURL (None when absent). + + Both ledgers embed the record — hosted under `records`, vendored under the + entry's `record`; vendored mode never writes `.socket/manifest.json`.""" path = project / ('.socket/vendor/redirect-state.json' if mode == 'hosted' - else '.socket/vendor/state.json' if mode == 'vendored-detached' - else '.socket/manifest.json') + else '.socket/vendor/state.json') if not path.is_file(): return None state = json.loads(path.read_text(encoding='utf-8')) if mode == 'hosted': return state.get('records', {}).get(PURL) - if mode == 'vendored-detached': - return state.get('entries', {}).get(PURL, {}).get('record') - return state.get('patches', {}).get(PURL) + return state.get('entries', {}).get(PURL, {}).get('record') def load_json(path): @@ -616,7 +616,7 @@ def main(): parser.add_argument('--tools', type=Path) parser.add_argument('--versions', nargs='+', default=VERSIONS) parser.add_argument('--shapes', nargs='+', default=SHAPES, choices=SHAPES) - parser.add_argument('--modes', nargs='+', default=['hosted', 'vendored'], choices=MODES) + parser.add_argument('--modes', nargs='+', default=MODES, choices=MODES) parser.add_argument('--jobs', type=int, default=4) args = parser.parse_args() root = args.output.resolve() @@ -695,11 +695,8 @@ def env_for(binary, cache): env = env_for(bun, 'cache') def cli_command(verb, run_mode): - command = [cli, *verb, '--mode', 'vendored' if run_mode == 'vendored-detached' else run_mode, - '--cwd', project, '--json', '--yes', '--no-telemetry'] - if run_mode == 'vendored-detached': - command.append('--detached') - return command + return [cli, *verb, '--mode', run_mode, + '--cwd', project, '--json', '--yes', '--no-telemetry'] def install(binary, label, flags=(), cache=None): remove_node_modules(project) @@ -839,7 +836,7 @@ def install(binary, label, flags=(), cache=None): checks['unchangedLockPresence'] = all((project / name).exists() == (name in original) for name in ['bun.lock', 'bun.lockb']) # Hosted refusals exit 0 with redirected 0 (documented posture); - # vendored / detached / get refusals exit non-zero and never fetch. + # vendored / get refusals exit non-zero and never fetch. checks['exitCodeContract'] = code == 0 if expected['exit'] == 'zero' else code != 0 if expected['exit'] == 'nonzero': checks['noDownloadOnRefusal'] = downloaded_count(envelope) == 0 @@ -848,6 +845,9 @@ def install(binary, label, flags=(), cache=None): if seeded is not None: checks['preexistingManifestPreserved'] = ( after is not None and after.get('patches', {}).get(OTHER_PURL) == seeded['patches'][OTHER_PURL]) + else: + # No mode writes .socket/manifest.json (vendored is manifest-free). + checks['noManifest'] = after is None else: if expected['rerun']: checks['rerunClean'] = rerun_clean(code, envelope, main_mode) @@ -862,8 +862,9 @@ def install(binary, label, flags=(), cache=None): raise RuntimeError(f'No ledger record for {PURL} in {main_mode} mode') row['patchUuid'] = record['uuid'] checks['publishedPatch'] = record['uuid'] == UUID - if main_mode == 'vendored-detached': - checks['noManifest'] = not manifest.exists() + # Neither ledger-backed mode writes .socket/manifest.json: vendored + # is manifest-free and hosted persists only the redirect ledger. + checks['noManifest'] = not manifest.exists() patched_lock = lock.read_bytes() lockb_origin = lock.name == 'bun.lockb' lock_text = patched_lock.decode('utf-8', errors='replace') @@ -892,16 +893,9 @@ def install(binary, label, flags=(), cache=None): vendor_ledger = load_json(project / '.socket/vendor/state.json') checks['vendorLedgerEntryGone'] = (vendor_ledger is None or PURL not in vendor_ledger.get('entries', {})) - # Observed contract: the hosted takeover unwinds the vendored - # wiring, ledger entry and artifact but leaves the vendored-era - # manifest record in place (rollback removes it); detached - # vendoring never wrote one. - after = load_json(manifest) - if mode == 'vendored': - checks['manifestRecordKeptAfterHostedTakeover'] = ( - after is not None and PURL in after.get('patches', {})) - else: - checks['noManifest'] = after is None + # The hosted takeover unwinds the vendored wiring, ledger entry + # and artifact; neither mode ever wrote a manifest record + # (`noManifest` above covers the whole cell). if shape == 'custom-registry': checks['registrySlotDropped'] = REGISTRY_SLOT not in lock_text if shape == 'crlf-lock': From f09f6434e7f4bb38023b95bc324aa7f2aed46412 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 19:56:07 -0400 Subject: [PATCH 12/44] refactor(core): land the core-integration handoffs, restore the VITEST gate, drop cow.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - telemetry: REVERSE the round-1 ruling — restore the `VITEST=true` kill-switch (+ its two integration tests). socket-cli's vitest suite (packages/cli/test/integration/cli/cmd-patch*.test.mts) spawns this binary with inherited env and no SOCKET_TELEMETRY_DISABLED; the gate is load-bearing, and its doc comment now names that dependency. - patch: delete `patch/cow.rs` (no production caller — the rename-over write in `utils::fs::atomic_write_bytes` is the documented CoW defense, its doc now carries the guarantee), the `pub mod cow`, and the cow_* tests in the CLI's e2e_safety_internals.rs; `pkg_managers.rs` / `sidecars/cargo.rs` / `package.rs` comments repointed. The cargo sidecar writes through `apply_file_patch_at`; the callerless `apply_file_patch` wrapper is gone. `apply_lock::is_lock_contended` is pub(crate) and update/swap.rs uses it instead of its inline errno compare. - utils::fs: the last private guarded-read twins are gone (package_json/find.rs read_project_file_to_string, sidecars/cargo.rs read_regular_file, update/state.rs read_state_bytes) and the four crawler inline open+read expansions collapse onto read_regular_to_string; `open_regular_file_sync` is pub(crate) so vendor/verify.rs's wheel audit uses it instead of its own O_NONBLOCK open. - vendor: `common::prune_empty_vendor_levels` is now a thin wrapper over `utils::socket_dir::prune_empty_dirs` (stop dir = `.socket/`), npm_common's inline eco/vendor rmdir pair goes through it, and every per-unit revert (npm/yarn/pnpm/bun/bun-binary/maven/nuget/composer/gem/pypi) removes its uuid dir with `remove_tree_and_prune`. One `state::write_marker_or_warn` emits the single `vendor_marker_write_failed` code at all 17 marker sites (cargo/golang/pypi's `marker_write_failed` retired); pypi's fresh path no longer fails a fully-wired vendor on a marker write error (test inverted). vendor/mod.rs: D2 wording + re-export placement. - redirect: `RedirectState::record_keys_for`, `utils::purl::canonical_purl` and the shared `purl_name_version` / `parse_name_version` replace the repeated canon closures and takeover.rs's `parse_npm_purl`; the edit anchor probe reads `Value::String` payloads in place. `HATCH_FILES` is the one list for the hatch planner and the redirect overlay. - api/blob_fetcher: the cache dir is created inside `write_cache_entry_atomic` on the first verified download — the three up-front create_dir_all blocks and `all_failed_result` are gone, so a fetch that lands nothing leaves no `.socket/blobs/` (covgap tests rewritten; new all-404 residue test). Co-Authored-By: Claude Fable 5.1 --- .../tests/e2e_safety_internals.rs | 552 +----------------- .../socket-patch-core/src/api/blob_fetcher.rs | 63 +- .../src/crawlers/composer_crawler.rs | 23 +- .../src/crawlers/npm_crawler.rs | 10 +- .../src/crawlers/pkg_managers.rs | 16 +- .../src/crawlers/python_crawler.rs | 12 +- .../src/crawlers/ruby_crawler.rs | 14 +- .../src/package_json/find.rs | 28 +- .../src/package_json/update.rs | 7 +- crates/socket-patch-core/src/patch/apply.rs | 17 - .../socket-patch-core/src/patch/apply_lock.rs | 2 +- crates/socket-patch-core/src/patch/cow.rs | 473 --------------- crates/socket-patch-core/src/patch/mod.rs | 1 - crates/socket-patch-core/src/patch/package.rs | 4 +- .../src/patch/redirect/mod.rs | 2 +- .../src/patch/redirect/state.rs | 42 +- .../src/patch/redirect/takeover.rs | 19 +- .../src/patch/sidecars/cargo.rs | 58 +- crates/socket-patch-core/src/telemetry.rs | 15 +- crates/socket-patch-core/src/update/state.rs | 44 +- crates/socket-patch-core/src/update/swap.rs | 14 +- crates/socket-patch-core/src/utils/fs.rs | 17 +- crates/socket-patch-core/src/utils/hatch.rs | 7 +- crates/socket-patch-core/src/utils/purl.rs | 24 +- .../src/vendor/bun_binary.rs | 26 +- .../socket-patch-core/src/vendor/bun_lock.rs | 30 +- crates/socket-patch-core/src/vendor/cargo.rs | 24 +- crates/socket-patch-core/src/vendor/common.rs | 28 +- .../src/vendor/composer_lock.rs | 23 +- crates/socket-patch-core/src/vendor/gem.rs | 22 +- crates/socket-patch-core/src/vendor/golang.rs | 18 +- .../src/vendor/maven_repo.rs | 23 +- crates/socket-patch-core/src/vendor/mod.rs | 12 +- .../src/vendor/npm_common.rs | 7 +- .../socket-patch-core/src/vendor/npm_lock.rs | 29 +- .../src/vendor/nuget_feed.rs | 23 +- .../socket-patch-core/src/vendor/pnpm_lock.rs | 33 +- .../src/vendor/pnpm_lock_legacy.rs | 33 +- crates/socket-patch-core/src/vendor/pypi.rs | 88 ++- crates/socket-patch-core/src/vendor/state.rs | 19 + crates/socket-patch-core/src/vendor/verify.rs | 25 +- .../src/vendor/yarn_berry_lock.rs | 26 +- .../src/vendor/yarn_classic_lock.rs | 23 +- .../tests/covgap_api_blob_fetcher.rs | 150 +++-- .../tests/telemetry_helpers_e2e.rs | 41 +- 45 files changed, 553 insertions(+), 1614 deletions(-) delete mode 100644 crates/socket-patch-core/src/patch/cow.rs diff --git a/crates/socket-patch-cli/tests/e2e_safety_internals.rs b/crates/socket-patch-cli/tests/e2e_safety_internals.rs index 912aa020..3bd22782 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_internals.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_internals.rs @@ -1,5 +1,5 @@ -//! Integration coverage for the handful of `cow` + `sidecars` -//! defensive paths that the apply-CLI path cannot reach. +//! Integration coverage for the handful of `sidecars` defensive paths +//! that the apply-CLI path cannot reach. //! //! These guards (empty patched list, unknown ecosystem, lstat //! permission-denied, etc.) live in the public API surface of @@ -14,10 +14,8 @@ //! visible in the test binary list and lets coverage tooling see the //! same code path one consumer would. //! -//! No network. No toolchain. Unix-gated for the chmod-based test; -//! the rest are portable. +//! No network. No toolchain. Portable. -use socket_patch_core::patch::cow::{break_hardlink_if_needed, CowAction}; use socket_patch_core::patch::sidecars::dispatch_fixup; // ── dispatch_fixup guards ───────────────────────────────────────────── @@ -157,547 +155,3 @@ async fn dispatch_fixup_nuget_with_nonexistent_pkg_path() { "non-existent pkg_path must yield no sidecar record" ); } - -// ── cow.rs guards ───────────────────────────────────────────────────── - -/// `break_hardlink_if_needed` on a path that doesn't exist returns -/// `CowAction::NoFile` (the explicit-NotFound arm). Belt-and-braces -/// case to keep the integration coverage of the lstat arms -/// next to its sibling tests. -#[tokio::test] -async fn cow_missing_path_yields_no_file() { - let tmp = tempfile::tempdir().unwrap(); - let action = break_hardlink_if_needed(&tmp.path().join("does-not-exist.txt")) - .await - .expect("lstat NotFound is the explicit early-return arm"); - assert!(matches!(action, CowAction::NoFile)); -} - -/// `break_hardlink_if_needed` on a path inside a `chmod 0000` -/// parent directory fails the initial `symlink_metadata` call -/// with `EACCES` (search permission denied) — not `NotFound` — -/// hitting the generic `Err(e) => return Err(e)` arm of cow.rs. -/// Covers `cow.rs:59`. -/// -/// Skipped under uid 0 because the root user bypasses directory -/// search permission checks, which would silently turn this into -/// a NoFile (NotFound) result and false-pass the test. -#[cfg(unix)] -#[tokio::test] -async fn cow_lstat_permission_denied_propagates_io_error() { - use std::os::unix::fs::PermissionsExt; - use std::process::Command; - if Command::new("id") - .arg("-u") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim() == "0") - .unwrap_or(false) - { - eprintln!("SKIP: root bypasses dir-search permission checks"); - return; - } - - let tmp = tempfile::tempdir().unwrap(); - let locked = tmp.path().join("locked"); - std::fs::create_dir(&locked).unwrap(); - let target = locked.join("file.txt"); - std::fs::write(&target, b"content").unwrap(); - - // Drop search (x) permission so lstat on `target` fails with - // EACCES rather than NotFound. Keep read for the directory - // itself just to be defensive — Unix specifies that EACCES on - // path resolution comes from missing `x` on a parent. - let mut perms = std::fs::metadata(&locked).unwrap().permissions(); - perms.set_mode(0o000); - std::fs::set_permissions(&locked, perms).unwrap(); - - let result = break_hardlink_if_needed(&target).await; - - // Restore so tempdir cleanup can recurse. - let mut restore = std::fs::metadata(&locked).unwrap().permissions(); - restore.set_mode(0o755); - let _ = std::fs::set_permissions(&locked, restore); - - let err = result.expect_err("expected I/O error from locked-dir lstat"); - // EACCES from search-permission denial maps to PermissionDenied on - // every Unix (and decisively NOT NotFound — if it were, cow would - // have returned NoFile and the .expect_err above would have fired). - // Asserting the exact kind closes the loophole where a mis-mapped - // errno (Other/InvalidInput/wrapped) would slip past a bare - // `!= NotFound` check. - assert_eq!( - err.kind(), - std::io::ErrorKind::PermissionDenied, - "lstat on a search-denied parent must surface as PermissionDenied; got {err:?}" - ); -} - -/// Symlink branch read-fails-fast (cow.rs:66): when the symlink -/// target doesn't exist, the read-through propagates NotFound -/// rather than entering the remove/rewrite dance. Covers the -/// symlink-branch `?` propagation on the read step. -#[cfg(unix)] -#[tokio::test] -async fn cow_symlink_to_missing_target_propagates_read_error() { - let tmp = tempfile::tempdir().unwrap(); - let link = tmp.path().join("dangling"); - let absent = tmp.path().join("does-not-exist"); - std::os::unix::fs::symlink(&absent, &link).unwrap(); - - let err = break_hardlink_if_needed(&link) - .await - .expect_err("read through dangling symlink must propagate the error"); - assert_eq!(err.kind(), std::io::ErrorKind::NotFound); - // The dangling link itself must still exist — read-fail-fast must - // never enter the remove/rewrite dance that could destroy it. - let meta = - std::fs::symlink_metadata(&link).expect("dangling symlink must survive a read-fail-fast"); - assert!( - meta.file_type().is_symlink(), - "read-through failure must leave the symlink untouched, got {meta:?}" - ); -} - -/// Symlink branch rename-fails arm: when the symlink itself carries -/// the `uchg` (user-immutable) flag, `read(path)` follows the link -/// and succeeds and the stage file is created fine, but the atomic -/// `rename(stage, path)` over the immutable symlink is refused with -/// EPERM. The error propagates, the stage is cleaned up, and — the -/// key invariant — the original symlink is left intact (CoW never -/// destructively unlinks before the replacement is committed). -/// -/// macOS-only: BSD `chflags -h` is the only userspace tool that -/// can set flags on a symlink without dereferencing. Linux's -/// `chattr +i` only works on regular files and needs root. -#[cfg(target_os = "macos")] -#[tokio::test] -async fn cow_symlink_unremovable_propagates_remove_error() { - use std::process::Command; - if Command::new("id") - .arg("-u") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim() == "0") - .unwrap_or(false) - { - eprintln!("SKIP: root bypasses chflags uchg restrictions"); - return; - } - - let tmp = tempfile::tempdir().unwrap(); - let target = tmp.path().join("real-file.txt"); - std::fs::write(&target, b"content").unwrap(); - let link = tmp.path().join("immutable-link"); - std::os::unix::fs::symlink(&target, &link).unwrap(); - - // -h applies the flag to the symlink itself, not its target. - // Without it, chflags follows the link and sets uchg on the - // regular file — wrong test. - let status = Command::new("chflags") - .arg("-h") - .arg("uchg") - .arg(&link) - .status() - .expect("chflags"); - assert!(status.success()); - - let result = break_hardlink_if_needed(&link).await; - - // Clear so tempdir cleanup can recurse. - let _ = Command::new("chflags") - .arg("-h") - .arg("nouchg") - .arg(&link) - .status(); - - let err = result.expect_err("rename over immutable symlink must propagate EPERM"); - assert_eq!( - err.kind(), - std::io::ErrorKind::PermissionDenied, - "rename over an immutable (uchg) symlink must surface EPERM as PermissionDenied; got {err:?}" - ); - - // Regression (atomicity): the failed break must NOT have destroyed - // the original. The path still exists and is still the symlink. - let meta = std::fs::symlink_metadata(&link) - .expect("failed CoW must leave the original symlink in place"); - assert!( - meta.file_type().is_symlink(), - "original symlink must survive a failed break, got {meta:?}" - ); - // And it must still resolve to the untouched target content — the - // break neither rewrote nor truncated the link's destination. - assert_eq!( - std::fs::read(&link).unwrap(), - b"content", - "symlink must still resolve to its original target content" - ); - // And no stage litter left behind. - let leftover: Vec<_> = std::fs::read_dir(tmp.path()) - .unwrap() - .filter_map(|e| e.ok()) - .filter(|e| e.file_name().to_string_lossy().starts_with(".socket-cow-")) - .collect(); - assert!( - leftover.is_empty(), - "stage litter left behind: {leftover:?}" - ); -} - -/// Hardlink branch read-fails arm (cow.rs:84): a hardlinked file -/// chmod'd to 0000 fails the read step. break_hardlink_if_needed -/// gets past lstat (mode bits don't affect lstat results) and the -/// `nlink > 1` check, then `read(path)` returns EACCES. -/// -/// Skipped under uid 0 — root bypasses mode-bit access checks. -#[cfg(unix)] -#[tokio::test] -async fn cow_hardlink_unreadable_propagates_read_error() { - use std::os::unix::fs::PermissionsExt; - use std::process::Command; - if Command::new("id") - .arg("-u") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim() == "0") - .unwrap_or(false) - { - eprintln!("SKIP: root bypasses chmod 0000 restrictions"); - return; - } - - let tmp = tempfile::tempdir().unwrap(); - let a = tmp.path().join("a.txt"); - std::fs::write(&a, b"data").unwrap(); - let b = tmp.path().join("b.txt"); - std::fs::hard_link(&a, &b).unwrap(); - - // chmod 0000 on either link affects the inode (both fail). - let mut p = std::fs::metadata(&a).unwrap().permissions(); - p.set_mode(0o000); - std::fs::set_permissions(&a, p).unwrap(); - - let result = break_hardlink_if_needed(&b).await; - - // Restore so tempdir cleanup can read+unlink. - let mut restore = std::fs::metadata(&a).unwrap().permissions(); - restore.set_mode(0o644); - let _ = std::fs::set_permissions(&a, restore); - - let err = result.expect_err("read of unreadable hardlinked file must propagate"); - assert_eq!( - err.kind(), - std::io::ErrorKind::PermissionDenied, - "read of a chmod-0000 hardlinked file must surface EACCES as PermissionDenied; got {err:?}" - ); - // Atomicity: the failed read must not have replaced or destroyed - // either link — both still share the original inode (nlink == 2). - { - use std::os::unix::fs::MetadataExt; - let restored_meta = std::fs::metadata(&a).unwrap(); - assert_eq!( - restored_meta.nlink(), - 2, - "a failed CoW read must leave both hardlinks intact, got nlink {}", - restored_meta.nlink() - ); - assert_eq!( - std::fs::read(&a).unwrap(), - b"data", - "original content must be untouched after a failed CoW read" - ); - } -} - -/// `write_via_stage_rename` stage-write failure (cow.rs:111): the -/// hardlink branch reads the file content successfully, then -/// `tokio::fs::write(&stage, bytes)` fails because the parent -/// directory is r-x-only (write permission revoked after setup). -/// -/// Goes through the nlink>1 path so we don't touch the symlink -/// branch's remove_file (which would also fail on a no-write -/// parent, taking us down a different code path). -/// -/// Skipped under uid 0. -#[cfg(unix)] -#[tokio::test] -async fn cow_stage_write_failure_propagates() { - use std::os::unix::fs::PermissionsExt; - use std::process::Command; - if Command::new("id") - .arg("-u") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim() == "0") - .unwrap_or(false) - { - eprintln!("SKIP: root bypasses chmod 0500 restrictions"); - return; - } - - let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path().join("pkg"); - std::fs::create_dir(&dir).unwrap(); - let a = dir.join("orig.txt"); - std::fs::write(&a, b"content").unwrap(); - let b = dir.join("link.txt"); - std::fs::hard_link(&a, &b).unwrap(); - - // Drop write permission on the parent so stage-file creation - // (parent/.socket-cow-*) fails — keeping read+execute so - // lstat, the nlink check, and `read(path)` all succeed first. - let mut p = std::fs::metadata(&dir).unwrap().permissions(); - p.set_mode(0o500); - std::fs::set_permissions(&dir, p).unwrap(); - - let result = break_hardlink_if_needed(&b).await; - - // Restore so tempdir cleanup works. - let mut restore = std::fs::metadata(&dir).unwrap().permissions(); - restore.set_mode(0o755); - let _ = std::fs::set_permissions(&dir, restore); - - let err = result.expect_err("stage write into read-only parent must fail"); - assert_eq!( - err.kind(), - std::io::ErrorKind::PermissionDenied, - "stage create in a no-write (0o500) parent must surface EACCES as PermissionDenied; got {err:?}" - ); - // Atomicity: the failed stage write must not have disturbed the - // original — both hardlinks survive with their original content and - // no `.socket-cow-*` litter is left behind. - { - use std::os::unix::fs::MetadataExt; - assert_eq!( - std::fs::metadata(&a).unwrap().nlink(), - 2, - "failed stage write must leave both hardlinks intact" - ); - assert_eq!(std::fs::read(&a).unwrap(), b"content"); - assert_eq!(std::fs::read(&b).unwrap(), b"content"); - } - let leftover: Vec<_> = std::fs::read_dir(&dir) - .unwrap() - .filter_map(|e| e.ok()) - .filter(|e| e.file_name().to_string_lossy().starts_with(".socket-cow-")) - .collect(); - assert!( - leftover.is_empty(), - "stage litter left behind: {leftover:?}" - ); -} - -/// Symlink-branch `write_via_stage_rename` stage-create failure arm: -/// after `read(symlink)` succeeds, `write_via_stage_rename` fails to -/// create its `.socket-cow-*` stage file because the parent directory -/// has a macOS ACL that denies `add_file` while still allowing -/// `delete_child` — a state POSIX mode bits can't express (write perm -/// on a dir is monolithic for create+delete). -/// -/// This same ACL is what made the old, destructive flow dangerous: -/// the previous code did `remove_file(symlink)` (a `delete_child`, -/// which the ACL *allows*) BEFORE the stage write, so the link was -/// gone the instant the denied stage create failed — destroying the -/// package file with no rollback. The current flow stages first and -/// never pre-unlinks, so this asserts the original symlink survives. -/// macOS-only because BSD extended ACLs (`chmod +a`) are the only -/// userspace mechanism for this kind of fine-grained denial; Linux's -/// POSIX.1e ACLs can't split create-vs-delete on directories. -#[cfg(target_os = "macos")] -#[tokio::test] -async fn cow_symlink_stage_write_failure_propagates() { - use std::process::Command; - - if Command::new("id") - .arg("-u") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim() == "0") - .unwrap_or(false) - { - eprintln!("SKIP: root bypasses ACL deny entries"); - return; - } - - let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path().join("pkg"); - std::fs::create_dir(&dir).unwrap(); - let target = dir.join("orig.txt"); - std::fs::write(&target, b"shared bytes").unwrap(); - let link = dir.join("link"); - std::os::unix::fs::symlink(&target, &link).unwrap(); - - // Get the current user name for the ACL entry. - let user = std::env::var("USER").unwrap_or_else(|_| "$(id -un)".to_string()); - - // Add a deny-add_file ACL: blocks creation of new files in `dir` - // while leaving `delete_child` (remove_file) intact. POSIX mode - // bits couldn't express this — `chmod 0500` would block both. - let status = Command::new("chmod") - .arg("+a") - .arg(format!("{user} deny add_file")) - .arg(&dir) - .status() - .expect("chmod +a"); - assert!(status.success(), "ACL set must succeed"); - - let result = break_hardlink_if_needed(&link).await; - - // Strip the ACL so tempdir cleanup works. - let _ = Command::new("chmod").arg("-a#").arg("0").arg(&dir).status(); - - let err = result.expect_err( - "with deny-add_file ACL, write_via_stage_rename's stage create must fail, \ - surfacing the stage-write `?` Err arm", - ); - assert_eq!( - err.kind(), - std::io::ErrorKind::PermissionDenied, - "deny-add_file ACL must surface the stage create as PermissionDenied; got {err:?}" - ); - - // Regression (atomicity / rollback): the old code unlinked the - // symlink before this denied stage write, leaving the package file - // gone. The current code stages first, so the original symlink must - // still be present after the failure. - let meta = std::fs::symlink_metadata(&link) - .expect("failed CoW must leave the original symlink in place"); - assert!( - meta.file_type().is_symlink(), - "original symlink must survive a failed stage write, got {meta:?}" - ); - assert_eq!( - std::fs::read(&link).unwrap(), - b"shared bytes", - "symlink must still resolve to its original target content" - ); -} - -/// `break_hardlink_if_needed` failure-cleanup arm (cow.rs:116-120): -/// when `rename(stage, path)` inside `write_via_stage_rename` -/// fails, the function must `remove_file(stage)` before -/// propagating the error so we don't leak a `.socket-cow-…` -/// turd in the package directory. -/// -/// macOS-only: we use BSD-style `chflags uchg ` to set the -/// user-immutable flag on the cow target. The kernel then refuses -/// `rename(stage, target)` with EPERM even though the user owns -/// the file — the cow code's lstat/read/remove flow upstream -/// works fine (reads succeed on immutable files, hardlink creation -/// doesn't touch them), but the final stage→target rename hits the -/// kernel's immutable-bit refusal. After the test, we clear the -/// flag so tempdir cleanup can recurse. -/// -/// Linux's analogue is `chattr +i`, but that requires CAP_LINUX_IMMUTABLE -/// (root in most setups), so the Linux variant lives outside the -/// integration suite. On macOS dev/CI uid=0 also bypasses uchg, so -/// skip there too. -#[cfg(target_os = "macos")] -#[tokio::test] -async fn cow_rename_failure_runs_stage_cleanup() { - use std::os::unix::fs::MetadataExt; - use std::process::Command; - - if Command::new("id") - .arg("-u") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim() == "0") - .unwrap_or(false) - { - eprintln!("SKIP: root bypasses chflags uchg restrictions"); - return; - } - - let tmp = tempfile::tempdir().unwrap(); - let target = tmp.path().join("file.txt"); - std::fs::write(&target, b"original").unwrap(); - - // Create a hardlink so cow takes the nlink>1 branch (which - // calls write_via_stage_rename without first remove_file'ing - // the target — exactly the rename-collision-into-target - // shape we want). - let link = tmp.path().join("hardlink.txt"); - std::fs::hard_link(&target, &link).unwrap(); - assert_eq!( - std::fs::metadata(&target).unwrap().nlink(), - 2, - "test setup: target must have nlink=2 to drive cow's hardlink branch" - ); - - // Make `target` immutable so the final rename(stage, target) - // fails. `chflags` is the only way to set BSD file flags from - // the shell — there's no portable Rust API. - let chflags_status = Command::new("chflags") - .arg("uchg") - .arg(&target) - .status() - .expect("chflags binary must exist on macOS"); - assert!( - chflags_status.success(), - "chflags uchg must succeed for a file we own" - ); - - let cow_result = break_hardlink_if_needed(&target).await; - - // Restore the flag so tempdir cleanup can unlink the file. - let _ = Command::new("chflags").arg("nouchg").arg(&target).status(); - - // The cow attempt itself returned the rename error — that's the - // contract: when stage commit fails, the caller learns of the - // failure rather than silently succeeding on a half-state. - let err = cow_result.expect_err("immutable target must cause rename failure"); - assert_eq!( - err.kind(), - std::io::ErrorKind::PermissionDenied, - "rename over a uchg-immutable target must surface EPERM as PermissionDenied, got {err:?}" - ); - - // Atomicity / rollback (the contract this test exists to police): - // a failed stage->target rename must leave the ORIGINAL target - // completely intact — same inode (no replacement committed), same - // nlink (sibling hardlink still attached), same bytes. The old - // litter-only assertion below would stay green even if a regression - // truncated or replaced the original, so assert the survival - // explicitly here first. - let surv = std::fs::symlink_metadata(&target) - .expect("failed rename must leave the original target in place"); - assert!( - surv.file_type().is_file(), - "original target must remain a regular file, got {surv:?}" - ); - assert_eq!( - surv.nlink(), - 2, - "no new inode may be committed on rename failure — both links must survive" - ); - assert_eq!( - std::fs::read(&target).unwrap(), - b"original", - "failed CoW rename must leave the original target content byte-for-byte intact" - ); - assert_eq!( - std::fs::read(&link).unwrap(), - b"original", - "the sibling hardlink must also be untouched after a failed CoW" - ); - - // The cleanup arm (cow.rs:117-119) ran: no `.socket-cow-…` - // file should be left behind in the package directory. - let leftover_stages: Vec<_> = std::fs::read_dir(tmp.path()) - .unwrap() - .filter_map(|e| e.ok()) - .filter(|e| e.file_name().to_string_lossy().starts_with(".socket-cow-")) - .collect(); - assert!( - leftover_stages.is_empty(), - "stage cleanup must remove all .socket-cow-* turds; found {leftover_stages:?}" - ); -} diff --git a/crates/socket-patch-core/src/api/blob_fetcher.rs b/crates/socket-patch-core/src/api/blob_fetcher.rs index 2489d41c..f984eb74 100644 --- a/crates/socket-patch-core/src/api/blob_fetcher.rs +++ b/crates/socket-patch-core/src/api/blob_fetcher.rs @@ -112,42 +112,13 @@ pub async fn fetch_missing_blobs( return FetchMissingBlobsResult::default(); } - // Ensure blobs directory exists - if let Err(e) = tokio::fs::create_dir_all(blobs_path).await { - return all_failed_result( - missing.iter(), - &format!("Cannot create blobs directory: {}", e), - ); - } - + // `blobs_path` is created by the first successful write + // (`write_cache_entry_atomic`), never up front: a fetch that lands + // nothing leaves no `.socket/blobs/` husk behind. let hashes: Vec = missing.into_iter().collect(); download_hashes(&hashes, blobs_path, client, on_progress).await } -/// Build a [`FetchMissingBlobsResult`] whose entries are all failures -/// for the same reason. Used by the early-return branches that hit a -/// blocker (e.g. cannot create blobs dir) before any download attempt. -fn all_failed_result<'a>( - items: impl IntoIterator, - error: &str, -) -> FetchMissingBlobsResult { - let results: Vec = items - .into_iter() - .map(|hash| BlobFetchResult { - hash: hash.clone(), - success: false, - error: Some(error.to_string()), - }) - .collect(); - let failed = results.len(); - FetchMissingBlobsResult { - total: failed, - failed, - results, - ..FetchMissingBlobsResult::default() - } -} - /// Download specific blobs identified by their hashes. /// /// Useful for fetching `beforeHash` blobs during rollback, where only a @@ -164,15 +135,9 @@ pub async fn fetch_blobs_by_hash( return FetchMissingBlobsResult::default(); } - // Ensure blobs directory exists - if let Err(e) = tokio::fs::create_dir_all(blobs_path).await { - return all_failed_result( - hashes.iter(), - &format!("Cannot create blobs directory: {}", e), - ); - } - - // Filter out hashes that already exist on disk + // Filter out hashes that already exist on disk (an absent `blobs_path` + // simply means none do; the dir is created by the first successful + // write, never up front). let mut to_download: Vec = Vec::new(); let mut skipped: usize = 0; let mut results: Vec = Vec::new(); @@ -271,13 +236,8 @@ async fn fetch_missing_diff_archives( return FetchMissingBlobsResult::default(); } - if let Err(e) = tokio::fs::create_dir_all(archives_dir).await { - return all_failed_result( - missing.iter(), - &format!("Cannot create archives directory: {}", e), - ); - } - + // `archives_dir` is created by the first successful write, never up + // front (see `fetch_missing_blobs`). let uuids: Vec = missing.into_iter().collect(); let total = uuids.len(); let mut downloaded = 0usize; @@ -422,6 +382,13 @@ async fn write_cache_entry_atomic(dest: &Path, bytes: &[u8]) -> std::io::Result< .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "blob".to_string()); + // The cache directory (`.socket/blobs/`, `.socket/diffs/`) is created + // here, on the first verified download, and nowhere earlier: a fetch + // that lands nothing (all 404, offline, every hash mismatched) must not + // leave an empty directory behind for the user to commit. An + // uncreatable parent surfaces as this entry's write failure, like any + // other disk error. + tokio::fs::create_dir_all(parent).await?; // Leading dot keeps the stage out of editor/glob views; the uuid suffix // keeps concurrent writers of the same entry from colliding. let stage = parent.join(format!(".socket-dl-{}-{}", stem, uuid::Uuid::new_v4())); diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler.rs b/crates/socket-patch-core/src/crawlers/composer_crawler.rs index 504ad648..5a298a42 100644 --- a/crates/socket-patch-core/src/crawlers/composer_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/composer_crawler.rs @@ -384,18 +384,14 @@ fn normalize_config_vendor_dir(raw: &str) -> Option { (!segments.is_empty()).then(|| segments.join("/")) } -/// Read `config.vendor-dir` from a composer.json on disk. Opened with -/// [`crate::utils::fs::open_regular_file`] for the same reason +/// Read `config.vendor-dir` from a composer.json on disk. Read with +/// [`crate::utils::fs::read_regular_to_string`] for the same reason /// installed.json is: the manifest belongs to the untrusted project, and /// a FIFO planted at that path would wedge a plain read forever. async fn read_config_vendor_dir(manifest_path: &Path) -> Option { - use tokio::io::AsyncReadExt; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(manifest_path) + let content = crate::utils::fs::read_regular_to_string(manifest_path) .await .ok()?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await.ok()?; parse_config_vendor_dir(&content) } @@ -528,8 +524,6 @@ fn is_safe_composer_name(name: &str) -> bool { /// `version`, or extra unexpected fields) is skipped rather than /// discarding every package in the file. async fn read_installed_json(vendor_path: &Path) -> Vec { - use tokio::io::AsyncReadExt; - let installed_path = vendor_path.join("composer").join("installed.json"); // The path lives inside the (untrusted) vendor tree: a planted FIFO @@ -539,17 +533,12 @@ async fn read_installed_json(vendor_path: &Path) -> Vec { // mode (`--global` / `--global-prefix`) hands the vendor directory // straight here having only checked `is_dir`, and local mode's // `is_file` probe is a separate stat that the file can change under. - // Open via `open_regular_file` — non-blocking on Unix, rejecting - // FIFOs/devices/directories (see its docs). Twin of the npm + // Read via `read_regular_to_string` — non-blocking open on Unix, + // rejecting FIFOs/devices/directories (see its docs). Twin of the npm // crawler's `read_package_json` guard. - let Ok((mut file, metadata)) = crate::utils::fs::open_regular_file(&installed_path).await - else { + let Ok(content) = crate::utils::fs::read_regular_to_string(&installed_path).await else { return Vec::new(); }; - let mut content = String::with_capacity(metadata.len() as usize); - if file.read_to_string(&mut content).await.is_err() { - return Vec::new(); - } let root: serde_json::Value = match serde_json::from_str(&content) { Ok(v) => v, diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index a994c618..cb186fbb 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -32,18 +32,14 @@ struct PackageJsonPartial { /// Read and parse a `package.json` file, returning `(name, version)` if valid. pub async fn read_package_json(pkg_json_path: &Path) -> Option<(String, String)> { - use tokio::io::AsyncReadExt; - // The path lives inside the (untrusted) package tree: a planted FIFO // would make a plain `read_to_string` open block forever waiting for a - // writer, wedging scan (crawl_all) and apply (find_by_purls). Open via - // `open_regular_file` — non-blocking on Unix, rejecting + // writer, wedging scan (crawl_all) and apply (find_by_purls). Read via + // `read_regular_to_string` — non-blocking open on Unix, rejecting // FIFOs/devices/directories (see its docs). - let (mut file, metadata) = crate::utils::fs::open_regular_file(pkg_json_path) + let content = crate::utils::fs::read_regular_to_string(pkg_json_path) .await .ok()?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await.ok()?; // npm and Node both tolerate a leading UTF-8 BOM in package.json // (Windows-authored packages ship them), but serde_json rejects it — // a BOM'd install would be invisible to scan and unpatchable. diff --git a/crates/socket-patch-core/src/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index 52b872b2..7498a56c 100644 --- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs +++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs @@ -6,11 +6,12 @@ //! 1. **pnpm**: `node_modules/` is typically a symlink into the //! content-addressed global store. Patching the link target would //! corrupt every other project on the machine that points at the -//! same store entry. The CoW guard in -//! [`crate::patch::cow::break_hardlink_if_needed`] is what -//! actually fixes this; this detector just lets the CLI surface a -//! one-line "we detected pnpm, applied with CoW" notice so users -//! understand the layout was handled. +//! same store entry. The rename-over write in +//! [`crate::utils::fs::atomic_write_bytes`] is what actually fixes +//! this (the rename replaces only the directory entry, so the shared +//! inode is never written through); this detector just lets the CLI +//! surface a one-line "we detected pnpm, applied with CoW" notice so +//! users understand the layout was handled. //! //! 2. **yarn-berry / Plug'n'Play**: packages do not live on disk at //! all — they're inside `.yarn/cache/.zip` and resolved via @@ -44,8 +45,9 @@ pub enum NpmPkgManager { /// bun-managed project — `bun.lock` (text, current default) or /// `bun.lockb` (binary, legacy) at the project root. Bun /// hard-links from `~/.bun/install/cache/` into `node_modules/` - /// by default on Linux/macOS, so apply must CoW the link before - /// rewriting (handled generically by `break_hardlink_if_needed`). + /// by default on Linux/macOS, so apply must never write through the + /// shared inode (handled generically by the rename-over write in + /// `utils::fs::atomic_write_bytes`). /// The operator gets a heads-up event so it's clear which package /// manager the patch landed against. Bun, diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index df675e8c..aac4f4d5 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -89,19 +89,13 @@ pub async fn read_python_metadata(dist_info_path: &Path) -> Option<(String, Stri /// Returns `None` if the file is absent, unreadable, or does not yield a /// non-empty `Name` and `Version` before the header/body separator. async fn parse_metadata_headers(dist_info_path: &Path) -> Option<(String, String)> { - use tokio::io::AsyncReadExt; - let metadata_path = dist_info_path.join("METADATA"); // The path lives inside the (untrusted) package tree: a planted FIFO // would make a plain `read_to_string` open block forever waiting for a - // writer, wedging scan (crawl_all) and apply (find_by_purls). Open via - // `open_regular_file` — non-blocking on Unix, rejecting + // writer, wedging scan (crawl_all) and apply (find_by_purls). Read via + // `read_regular_to_string` — non-blocking open on Unix, rejecting // FIFOs/devices/directories (see its docs). - let (mut file, metadata) = crate::utils::fs::open_regular_file(&metadata_path) - .await - .ok()?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await.ok()?; + let content = read_regular_to_string(&metadata_path).await.ok()?; let mut name: Option = None; let mut version: Option = None; diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index c0fac444..042bed49 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -409,18 +409,16 @@ impl RubyCrawler { /// `$BUNDLE_APP_CONFIG/config`, else `/.bundle/config`, resolved by /// the shared [`crate::setup::gem::bundler_app_config_dir`] rule. async fn app_config_bundle_path(cwd: &Path, app_config_env: Option<&OsStr>) -> Option { - use tokio::io::AsyncReadExt; - let config = crate::setup::gem::bundler_app_config_dir(cwd, app_config_env).join("config"); // The config lives inside the (untrusted) project tree: a planted // FIFO would make a plain `read_to_string` open block forever // waiting for a writer, wedging scan (crawl_all) and apply/get - // (find_by_purls path discovery). Open via `open_regular_file` — - // non-blocking on Unix, rejecting FIFOs/devices/directories (see - // its docs) — same as the npm/composer/python crawlers. - let (mut file, metadata) = crate::utils::fs::open_regular_file(&config).await.ok()?; - let mut contents = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut contents).await.ok()?; + // (find_by_purls path discovery). Read via `read_regular_to_string` + // — non-blocking open on Unix, rejecting FIFOs/devices/directories + // (see its docs) — same as the npm/composer/python crawlers. + let contents = crate::utils::fs::read_regular_to_string(&config) + .await + .ok()?; parse_bundle_config_path(&contents) } diff --git a/crates/socket-patch-core/src/package_json/find.rs b/crates/socket-patch-core/src/package_json/find.rs index 2c8739ac..8d429616 100644 --- a/crates/socket-patch-core/src/package_json/find.rs +++ b/crates/socket-patch-core/src/package_json/find.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use tokio::fs; use super::detect::{strip_bom, PackageManager}; -use crate::utils::fs::{entry_file_type, is_dir, list_dir_entries}; +use crate::utils::fs::{entry_file_type, is_dir, list_dir_entries, read_regular_to_string}; /// Detect the package manager based on lockfiles in the project root. /// The accepted pnpm marker spellings (including the `pnpm-lock.yml` @@ -106,23 +106,6 @@ pub async fn find_package_json_files(start_path: &Path) -> PackageJsonFindResult } } -/// Read a manifest/config that lives inside the (untrusted) project tree. -/// A planted FIFO would make a plain `read_to_string` open block forever -/// waiting for a writer, wedging `setup`'s discovery — and the workspace -/// walk reads the package.json of *every* glob-discovered member. Open via -/// [`open_regular_file`](crate::utils::fs::open_regular_file) — non-blocking -/// on Unix, rejecting FIFOs/devices/directories (see its docs) — same as the -/// npm/composer/python/ruby crawlers. Shared with `update.rs`, which reads -/// the same discovered manifests back for editing. -pub(super) async fn read_project_file_to_string(path: &Path) -> std::io::Result { - use tokio::io::AsyncReadExt; - - let (mut file, metadata) = crate::utils::fs::open_regular_file(path).await?; - let mut content = String::with_capacity(metadata.len() as usize); - file.read_to_string(&mut content).await?; - Ok(content) -} - /// Detect workspace configuration from package.json. async fn detect_workspaces(package_json_path: &Path) -> WorkspaceConfig { let default = WorkspaceConfig { @@ -139,7 +122,12 @@ async fn detect_workspaces(package_json_path: &Path) -> WorkspaceConfig { // workspace to "no workspace". let dir = package_json_path.parent().unwrap_or(Path::new(".")); let pnpm_workspace = dir.join("pnpm-workspace.yaml"); - if let Ok(yaml_content) = read_project_file_to_string(&pnpm_workspace).await { + // Every manifest/config read here lives inside the (untrusted) project + // tree — and the workspace walk reads the package.json of *every* + // glob-discovered member — so reads go through the FIFO-safe + // `read_regular_to_string` (non-blocking open, regular-file check): a + // planted FIFO fails fast instead of wedging `setup`'s discovery. + if let Ok(yaml_content) = read_regular_to_string(&pnpm_workspace).await { let patterns = parse_pnpm_workspace_patterns(&yaml_content); return WorkspaceConfig { ws_type: WorkspaceType::Pnpm, @@ -147,7 +135,7 @@ async fn detect_workspaces(package_json_path: &Path) -> WorkspaceConfig { }; } - let content = match read_project_file_to_string(package_json_path).await { + let content = match read_regular_to_string(package_json_path).await { Ok(c) => c, Err(_) => return default, }; diff --git a/crates/socket-patch-core/src/package_json/update.rs b/crates/socket-patch-core/src/package_json/update.rs index 97636d76..8b0207e9 100644 --- a/crates/socket-patch-core/src/package_json/update.rs +++ b/crates/socket-patch-core/src/package_json/update.rs @@ -1,8 +1,7 @@ use std::path::Path; use super::detect::{remove_package_json_content, update_package_json_content, PackageManager}; -use super::find::read_project_file_to_string; -use crate::utils::fs::atomic_write_bytes_preserving_mode; +use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; /// Result of updating a single package.json. #[derive(Debug, Clone)] @@ -34,7 +33,7 @@ pub async fn update_package_json( // Guarded read: a FIFO planted as package.json would make a plain // `read_to_string` open block forever waiting for a writer — discovery // lists any path whose metadata stats, so it reaches here unopened. - let content = match read_project_file_to_string(package_json_path).await { + let content = match read_regular_to_string(package_json_path).await { Ok(c) => c, Err(e) => { return UpdateResult { @@ -117,7 +116,7 @@ pub async fn remove_package_json(package_json_path: &Path, dry_run: bool) -> Rem let path_str = package_json_path.display().to_string(); // Guarded read — see the matching note in `update_package_json`. - let content = match read_project_file_to_string(package_json_path).await { + let content = match read_regular_to_string(package_json_path).await { Ok(c) => c, Err(e) => { return RemoveResult { diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs index fa201b09..3b2f2a6a 100644 --- a/crates/socket-patch-core/src/patch/apply.rs +++ b/crates/socket-patch-core/src/patch/apply.rs @@ -503,23 +503,6 @@ pub(crate) async fn apply_file_patch_at( restore_file_permissions(&filepath, existing_meta.as_ref()).await } -/// Single-copy [`apply_file_patch_at`] with the post-write warning dropped -/// — the entry point the cargo checksum sidecar writes through. The pnpm -/// peer-variant fan-out lives at package level (`apply_package_patch`, -/// `rollback_package_patch`), where every copy gets its own verify; a -/// `.cargo-checksum.json` can never live in a pnpm store, so the per-file -/// store discovery this wrapper used to run on every call was pure waste. -pub(crate) async fn apply_file_patch( - pkg_path: &Path, - file_name: &str, - patched_content: &[u8], - expected_hash: &str, -) -> Result<(), std::io::Error> { - apply_file_patch_at(pkg_path, file_name, patched_content, expected_hash) - .await - .map(|_ownership_warning| ()) -} - /// Guard that temporarily grants owner-write on a directory so the /// stage+rename write path can create and move files inside it, then /// restores the directory's original mode. diff --git a/crates/socket-patch-core/src/patch/apply_lock.rs b/crates/socket-patch-core/src/patch/apply_lock.rs index 2399a70a..f0907678 100644 --- a/crates/socket-patch-core/src/patch/apply_lock.rs +++ b/crates/socket-patch-core/src/patch/apply_lock.rs @@ -409,7 +409,7 @@ fn fail(socket_dir: &Path, path: PathBuf, source: std::io::Error) -> LockError { /// `flock(2)`/`LockFileEx` failure are constructed from an OS error /// code. A non-OS error (`raw_os_error() == None`) can never be /// contention, so it correctly falls through to `Io`. -fn is_lock_contended(err: &std::io::Error) -> bool { +pub(crate) fn is_lock_contended(err: &std::io::Error) -> bool { err.raw_os_error() == fs2::lock_contended_error().raw_os_error() } diff --git a/crates/socket-patch-core/src/patch/cow.rs b/crates/socket-patch-core/src/patch/cow.rs deleted file mode 100644 index a9b9c43e..00000000 --- a/crates/socket-patch-core/src/patch/cow.rs +++ /dev/null @@ -1,473 +0,0 @@ -//! Copy-on-write defense against package-manager hardlink farms. -//! -//! Several package managers (pnpm, bazel mirrors, nix store overlays, -//! npm linked workspaces) point multiple project trees at a single -//! content-addressed inode via symlinks or hardlinks. A naive patch -//! that opens the path in a workspace and rewrites it would mutate the -//! shared inode — corrupting every other project that references the -//! same package. -//! -//! [`break_hardlink_if_needed`] is the pre-write hook that turns these -//! shared-inode references into private file copies before any patch -//! bytes touch disk. After the call, mutating the path is safe: only -//! this project's copy changes; the store entry and every other -//! project's link survive untouched. -//! -//! The function is idempotent and fast on the common case (regular -//! file with `nlink == 1`): a single `symlink_metadata` syscall, no -//! I/O beyond that. CoW only runs when there is something to break. -//! -//! **Windows note:** we always handle symlinks the same on Windows -//! (replace with private regular file) but skip the `nlink > 1` -//! check — `std::fs::Metadata` on Windows does not expose the file -//! information that carries it, and pnpm-on-Windows typically uses -//! reflinks/copies rather than hardlinks. A follow-up could call -//! `GetFileInformationByHandle` via `windows-sys` for full Windows -//! parity. - -use std::path::Path; - -/// Outcome of [`break_hardlink_if_needed`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CowAction { - /// Path didn't exist — nothing to break, caller will create fresh. - NoFile, - /// Path was a regular private file (one link, not a symlink). - /// Caller can mutate it directly. - AlreadyPrivate, - /// Path was a symlink. We atomically replaced the link with a - /// fresh regular file holding the same content (staged in the same - /// directory and renamed over the link in one step). The link - /// target is untouched. - BrokeSymlink, - /// Path was a hardlinked regular file (`nlink > 1`). We copied - /// the content into a new inode and atomically renamed it over - /// the original. Sibling links are untouched. - BrokeHardlink, -} - -/// Ensure `path` (if it exists) points at a private inode this -/// project alone owns, so a subsequent in-place write only mutates -/// our copy. -/// -/// See module docs for the failure mode this protects against. -pub async fn break_hardlink_if_needed(path: &Path) -> std::io::Result { - // `symlink_metadata` does NOT follow symlinks — that's what we - // want, since the symlink-vs-regular branch is the whole point. - let lstat = match tokio::fs::symlink_metadata(path).await { - Ok(m) => m, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(CowAction::NoFile), - Err(e) => return Err(e), - }; - - if lstat.file_type().is_symlink() { - // Gate on the *target's* type before reading through the link: - // `read()` on a symlink to a FIFO blocks forever at `open(2)` - // waiting for a writer (the same hazard the hardlink branch - // guards against below), and a device target reads unbounded - // bytes. Non-regular targets are not cow's problem — leave the - // link untouched, matching the hardlink branch's treatment of - // non-regular inodes. `metadata` follows the link, so a - // dangling symlink still surfaces as the NotFound error the - // read-through used to produce. - let target_meta = tokio::fs::metadata(path).await?; - if !target_meta.is_file() { - return Ok(CowAction::AlreadyPrivate); - } - // Read through the symlink (this DOES follow it) to grab the - // current target content. We need it on disk as a regular - // file at `path` so the patch write lands on our copy. - let target_bytes = tokio::fs::read(path).await?; - // Stage the private copy in the same directory, then - // atomically rename it OVER the symlink. `rename(2)` operates - // on the final path component itself — it never follows the - // symlink — so this replaces the link with our regular file - // while leaving the link's *target* (the store entry / sibling - // project) untouched. - // - // We deliberately do NOT `remove_file(path)` first. Unlinking - // the symlink before the replacement is committed would open a - // window in which the package file simply does not exist: if - // the staged write then failed (ENOSPC, EPERM on an immutable - // target, a crash), the original would be gone with nothing to - // roll back to. The rename-over-symlink is a single atomic - // step — on any failure `path` still holds the original link. - // This mirrors the hardlink branch below and the apply path's - // `utils::fs::atomic_write_bytes`. - write_via_stage_rename(path, &target_bytes).await?; - return Ok(CowAction::BrokeSymlink); - } - - // Hardlink defense is Unix-only — see module docs. The break only - // makes sense for regular files: a directory always has nlink >= 2 - // (read() would fail EISDIR), and read() on a hardlinked FIFO blocks - // forever waiting for a writer. Non-regular inodes are not cow's - // problem — leave them untouched. - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - if lstat.is_file() && lstat.nlink() > 1 { - // Atomic-rename-over-self pattern: copy our content into - // a fresh inode, then rename over the original. The other - // links keep pointing at the original inode (which now - // has one fewer link but otherwise unchanged content). - let content = tokio::fs::read(path).await?; - write_via_stage_rename(path, &content).await?; - return Ok(CowAction::BrokeHardlink); - } - } - - Ok(CowAction::AlreadyPrivate) -} - -/// Write `bytes` to a temp file in `path.parent()` then rename over -/// `path`. Cross-FS-safe because the stage lives in the same -/// directory as the target, so `rename(2)` is intra-filesystem. -async fn write_via_stage_rename(path: &Path, bytes: &[u8]) -> std::io::Result<()> { - // Cow callers always pass a real file path inside a package - // directory, so `path.parent()` and `path.file_name()` are - // guaranteed `Some`: the only counterexample, `path == "/"`, - // is unreachable (lstat on "/" reports a directory, and the - // hardlink branch's `read("/")` errors long before we get here). - let parent = path - .parent() - .expect("cow stage path always has a parent — callers pass package-internal files"); - // Stage filename: leading dot so editors / globs don't pick it - // up as a real file; uuid suffix so concurrent calls don't - // collide. (The apply lock makes that practically impossible, - // but defense in depth.) - let stem = path - .file_name() - .expect("cow stage path always has a file_name — callers pass package-internal files") - .to_string_lossy(); - let stage = parent.join(format!(".socket-cow-{}-{}", stem, uuid::Uuid::new_v4())); - // Stage write. If this fails *after* creating the file (e.g. a - // mid-write ENOSPC), the partial stage would otherwise leak as a - // `.socket-cow-*` turd, so clean it up before propagating — same - // discipline as `utils::fs::atomic_write_bytes`'s write arm. - if let Err(e) = tokio::fs::write(&stage, bytes).await { - let _ = tokio::fs::remove_file(&stage).await; - return Err(e); - } - // `rename` over the target is atomic on POSIX and best-effort on - // Windows (`MoveFileExW` with REPLACE_EXISTING via std). - match tokio::fs::rename(&stage, path).await { - Ok(()) => Ok(()), - Err(e) => { - // Clean up the stage on rename failure so we don't leave - // litter in the package directory. - let _ = tokio::fs::remove_file(&stage).await; - Err(e) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn missing_file_is_noop() { - let dir = tempfile::tempdir().unwrap(); - let action = break_hardlink_if_needed(&dir.path().join("nope.txt")) - .await - .unwrap(); - assert_eq!(action, CowAction::NoFile); - } - - #[tokio::test] - async fn regular_file_with_one_link_is_already_private() { - let dir = tempfile::tempdir().unwrap(); - let p = dir.path().join("a.txt"); - tokio::fs::write(&p, b"hello").await.unwrap(); - let action = break_hardlink_if_needed(&p).await.unwrap(); - assert_eq!(action, CowAction::AlreadyPrivate); - // Content untouched. - assert_eq!(tokio::fs::read(&p).await.unwrap(), b"hello"); - } - - /// Hardlink case (Unix only — see module docs). - /// - /// Create file A, hardlink B → A. Run CoW on B. After: - /// - A's content is unchanged (the canonical store entry). - /// - B has the same bytes but lives in a new inode. - /// - Mutating B does NOT change A (the core invariant pnpm - /// safety depends on). - #[cfg(unix)] - #[tokio::test] - async fn hardlink_is_broken_and_sibling_survives_mutation() { - use std::os::unix::fs::MetadataExt; - - let dir = tempfile::tempdir().unwrap(); - let a = dir.path().join("store-a.txt"); - let b = dir.path().join("project-b.txt"); - tokio::fs::write(&a, b"original").await.unwrap(); - tokio::fs::hard_link(&a, &b).await.unwrap(); - - // Sanity: both report nlink == 2. - let a_meta_before = tokio::fs::metadata(&a).await.unwrap(); - assert_eq!(a_meta_before.nlink(), 2); - - let action = break_hardlink_if_needed(&b).await.unwrap(); - assert_eq!(action, CowAction::BrokeHardlink); - - // A is now a single-link inode. - let a_meta_after = tokio::fs::metadata(&a).await.unwrap(); - assert_eq!(a_meta_after.nlink(), 1); - // B has the same content but a different inode. - assert_eq!(tokio::fs::read(&b).await.unwrap(), b"original"); - assert_ne!( - a_meta_after.ino(), - tokio::fs::metadata(&b).await.unwrap().ino() - ); - - // Mutate B — A must NOT change. - tokio::fs::write(&b, b"patched").await.unwrap(); - assert_eq!(tokio::fs::read(&a).await.unwrap(), b"original"); - assert_eq!(tokio::fs::read(&b).await.unwrap(), b"patched"); - } - - /// Symlink case (cross-platform). The symlink → target relation - /// is what pnpm's `node_modules/` typically looks like. We - /// must replace the link with a private regular file and leave - /// the target alone. - #[cfg(unix)] - #[tokio::test] - async fn symlink_is_replaced_with_private_file() { - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("store-entry.txt"); - let link = dir.path().join("project-link.txt"); - tokio::fs::write(&target, b"shared bytes").await.unwrap(); - tokio::fs::symlink(&target, &link).await.unwrap(); - - let action = break_hardlink_if_needed(&link).await.unwrap(); - assert_eq!(action, CowAction::BrokeSymlink); - - // Link path is now a regular file with the target's content. - let link_meta = tokio::fs::symlink_metadata(&link).await.unwrap(); - assert!(link_meta.file_type().is_file()); - assert!(!link_meta.file_type().is_symlink()); - assert_eq!(tokio::fs::read(&link).await.unwrap(), b"shared bytes"); - - // Target is untouched. - let target_meta = tokio::fs::symlink_metadata(&target).await.unwrap(); - assert!(target_meta.file_type().is_file()); - assert_eq!(tokio::fs::read(&target).await.unwrap(), b"shared bytes"); - - // Mutate the link path; target stays put. - tokio::fs::write(&link, b"patched").await.unwrap(); - assert_eq!(tokio::fs::read(&target).await.unwrap(), b"shared bytes"); - } - - /// Helper: count `.socket-cow-*` stage files left in a directory. - #[cfg(unix)] - fn leftover_stage_count(dir: &Path) -> usize { - std::fs::read_dir(dir) - .unwrap() - .filter_map(|e| e.ok()) - .filter(|e| e.file_name().to_string_lossy().starts_with(".socket-cow-")) - .count() - } - - /// Realistic pnpm shape: `node_modules/` is a *symlink* into - /// the content store, and the store entry is itself *hardlinked* - /// across projects. Breaking the symlink must: - /// - leave the project path a private, single-link regular file, - /// - leave the store entry's content AND its sibling hardlink - /// completely untouched (the whole point of CoW), - /// - leave no `.socket-cow-*` stage litter behind. - #[cfg(unix)] - #[tokio::test] - async fn symlink_to_hardlinked_store_entry_is_fully_isolated() { - use std::os::unix::fs::MetadataExt; - - let dir = tempfile::tempdir().unwrap(); - // The content store entry + a sibling project's hardlink to it. - let store = dir.path().join("store-entry.txt"); - let sibling = dir.path().join("other-project-hardlink.txt"); - tokio::fs::write(&store, b"shared bytes").await.unwrap(); - tokio::fs::hard_link(&store, &sibling).await.unwrap(); - // Our project links to the store entry via a symlink. - let link = dir.path().join("our-project-link.txt"); - tokio::fs::symlink(&store, &link).await.unwrap(); - assert_eq!(tokio::fs::metadata(&store).await.unwrap().nlink(), 2); - - let action = break_hardlink_if_needed(&link).await.unwrap(); - assert_eq!(action, CowAction::BrokeSymlink); - - // Our path is now a private regular file (not a symlink), and - // its inode is distinct from the store entry. - let link_meta = tokio::fs::symlink_metadata(&link).await.unwrap(); - assert!(link_meta.file_type().is_file()); - assert!(!link_meta.file_type().is_symlink()); - assert_ne!( - link_meta.ino(), - tokio::fs::metadata(&store).await.unwrap().ino() - ); - - // Store entry + its sibling hardlink are byte-for-byte intact, - // and still share their inode (nlink unchanged at 2). - assert_eq!(tokio::fs::metadata(&store).await.unwrap().nlink(), 2); - assert_eq!(tokio::fs::read(&store).await.unwrap(), b"shared bytes"); - assert_eq!(tokio::fs::read(&sibling).await.unwrap(), b"shared bytes"); - - // Mutating our copy must not bleed into the store or its sibling. - tokio::fs::write(&link, b"patched").await.unwrap(); - assert_eq!(tokio::fs::read(&store).await.unwrap(), b"shared bytes"); - assert_eq!(tokio::fs::read(&sibling).await.unwrap(), b"shared bytes"); - - // No stage litter survives the successful break. - assert_eq!(leftover_stage_count(dir.path()), 0); - } - - /// Success-path litter check: neither the symlink break nor the - /// hardlink break may leave a `.socket-cow-*` stage file behind. - #[cfg(unix)] - #[tokio::test] - async fn break_leaves_no_stage_litter() { - let dir = tempfile::tempdir().unwrap(); - - let target = dir.path().join("t.txt"); - tokio::fs::write(&target, b"x").await.unwrap(); - let link = dir.path().join("l.txt"); - tokio::fs::symlink(&target, &link).await.unwrap(); - break_hardlink_if_needed(&link).await.unwrap(); - - let a = dir.path().join("a.txt"); - tokio::fs::write(&a, b"y").await.unwrap(); - let b = dir.path().join("b.txt"); - tokio::fs::hard_link(&a, &b).await.unwrap(); - break_hardlink_if_needed(&b).await.unwrap(); - - assert_eq!(leftover_stage_count(dir.path()), 0); - } - - /// Idempotency: breaking a symlink yields a private regular file, - /// and a second call on the now-regular path is a clean - /// `AlreadyPrivate` no-op (no re-break, no litter). - #[cfg(unix)] - #[tokio::test] - async fn idempotent_after_breaking_symlink() { - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("store.txt"); - let link = dir.path().join("link.txt"); - tokio::fs::write(&target, b"bytes").await.unwrap(); - tokio::fs::symlink(&target, &link).await.unwrap(); - - assert_eq!( - break_hardlink_if_needed(&link).await.unwrap(), - CowAction::BrokeSymlink - ); - assert_eq!( - break_hardlink_if_needed(&link).await.unwrap(), - CowAction::AlreadyPrivate - ); - assert_eq!(leftover_stage_count(dir.path()), 0); - } - - /// Non-regular inodes must never be routed into the hardlink - /// break: `read()` on a FIFO blocks forever waiting for a writer, - /// so a hardlinked FIFO (`nlink == 2`) at a patched path would - /// hang the whole apply. It must come back promptly as - /// `AlreadyPrivate` — content-copying only makes sense for - /// regular files. - #[cfg(unix)] - #[tokio::test] - async fn hardlinked_fifo_is_not_routed_into_hardlink_break() { - let dir = tempfile::tempdir().unwrap(); - let fifo = dir.path().join("pipe"); - let status = std::process::Command::new("mkfifo") - .arg(&fifo) - .status() - .unwrap(); - assert!(status.success()); - let link = dir.path().join("pipe-link"); - tokio::fs::hard_link(&fifo, &link).await.unwrap(); - - let action = tokio::time::timeout( - std::time::Duration::from_secs(2), - break_hardlink_if_needed(&link), - ) - .await - .expect("must not block reading the FIFO") - .unwrap(); - assert_eq!(action, CowAction::AlreadyPrivate); - assert_eq!(leftover_stage_count(dir.path()), 0); - } - - /// The symlink branch has the same FIFO hazard the hardlink branch - /// guards against: `read()` through a symlink whose target is a - /// FIFO blocks forever at `open(2)` waiting for a writer, hanging - /// the whole apply. A symlink to a non-regular inode is not cow's - /// problem — it must come back promptly as `AlreadyPrivate` with - /// the link untouched. - #[cfg(unix)] - #[tokio::test] - async fn symlink_to_fifo_is_not_routed_into_symlink_break() { - let dir = tempfile::tempdir().unwrap(); - let fifo = dir.path().join("pipe"); - let status = std::process::Command::new("mkfifo") - .arg(&fifo) - .status() - .unwrap(); - assert!(status.success()); - let link = dir.path().join("pipe-link"); - tokio::fs::symlink(&fifo, &link).await.unwrap(); - - let result = tokio::time::timeout( - std::time::Duration::from_secs(2), - break_hardlink_if_needed(&link), - ) - .await; - // Rescue: if the code under test wrongly opened the FIFO for - // read, give it a writer + immediate EOF so the blocked pool - // thread can exit — otherwise a regression wedges the test - // binary at runtime shutdown instead of failing the asserts - // below. (O_RDWR on a FIFO never blocks; no-op when the code - // behaved.) - drop( - std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(&fifo), - ); - let action = result - .expect("must not block opening the FIFO through the symlink") - .unwrap(); - assert_eq!(action, CowAction::AlreadyPrivate); - // The symlink itself must be left untouched. - let meta = tokio::fs::symlink_metadata(&link).await.unwrap(); - assert!(meta.file_type().is_symlink()); - assert_eq!(leftover_stage_count(dir.path()), 0); - } - - /// A directory always has `nlink >= 2` on Unix, which a bare - /// `nlink > 1` check misreads as a hardlinked file — `read()` then - /// fails EISDIR instead of the documented no-op. Directories are - /// not cow's problem; report `AlreadyPrivate` and leave them - /// untouched. - #[tokio::test] - async fn directory_is_not_routed_into_hardlink_break() { - let dir = tempfile::tempdir().unwrap(); - let d = dir.path().join("pkg-subdir"); - tokio::fs::create_dir(&d).await.unwrap(); - tokio::fs::create_dir(d.join("child")).await.unwrap(); - - let action = break_hardlink_if_needed(&d).await.unwrap(); - assert_eq!(action, CowAction::AlreadyPrivate); - assert!(tokio::fs::metadata(&d).await.unwrap().is_dir()); - } - - /// Idempotency: calling twice in a row on a regular file is fine - /// and reports `AlreadyPrivate` both times. - #[tokio::test] - async fn idempotent_on_regular_file() { - let dir = tempfile::tempdir().unwrap(); - let p = dir.path().join("x.txt"); - tokio::fs::write(&p, b"hi").await.unwrap(); - let a1 = break_hardlink_if_needed(&p).await.unwrap(); - let a2 = break_hardlink_if_needed(&p).await.unwrap(); - assert_eq!(a1, CowAction::AlreadyPrivate); - assert_eq!(a2, CowAction::AlreadyPrivate); - } -} diff --git a/crates/socket-patch-core/src/patch/mod.rs b/crates/socket-patch-core/src/patch/mod.rs index e7a41373..39474f55 100644 --- a/crates/socket-patch-core/src/patch/mod.rs +++ b/crates/socket-patch-core/src/patch/mod.rs @@ -3,7 +3,6 @@ pub mod apply_lock; // Ungated: the vendor backends (npm/pypi/gem are unconditional) stage their // patched copies with `fresh_copy`/`remove_tree`, not just the golang redirect. pub mod copy_tree; -pub mod cow; pub mod diff; pub(crate) mod file_hash; pub mod package; diff --git a/crates/socket-patch-core/src/patch/package.rs b/crates/socket-patch-core/src/patch/package.rs index 66709183..9e9ae261 100644 --- a/crates/socket-patch-core/src/patch/package.rs +++ b/crates/socket-patch-core/src/patch/package.rs @@ -57,11 +57,11 @@ pub enum ArchiveError { /// per-entry size, and entry count are all bounded. /// /// Note: we never call `tar::Archive::unpack`; the bytes are buffered -/// and later written through `apply_file_patch` to an explicit +/// and later written through `apply_file_patch_at` to an explicit /// `pkg_path.join(normalized)`. That avoids the classic /// symlink-followed-by-write class of tar-extraction attacks at the /// extraction step itself — the on-disk write site is the single, -/// hash-verified path inside `apply_file_patch`. +/// hash-verified path inside `apply_file_patch_at`. pub fn read_archive_to_map(archive_path: &Path) -> Result>, ArchiveError> { // Open non-blockingly and require a regular file. A plain `open(2)` of a // FIFO planted at the archive path waits for a writer that may never diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 4ffa2a5a..bce1ecdf 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -369,7 +369,7 @@ fn rewrite_hatch( // Overlay only the two documents the hatch planner reads, so the second // pypi dep sees the first dep's rewritten pyproject — without cloning // every candidate lockfile in `files` for it. - let mut current: BTreeMap = ["pyproject.toml", "hatch.toml"] + let mut current: BTreeMap = crate::utils::hatch::HATCH_FILES .into_iter() .filter_map(|k| files.get(k).map(|v| (k.to_owned(), v.clone()))) .collect(); diff --git a/crates/socket-patch-core/src/patch/redirect/state.rs b/crates/socket-patch-core/src/patch/redirect/state.rs index 97cdf8a6..3ad48de4 100644 --- a/crates/socket-patch-core/src/patch/redirect/state.rs +++ b/crates/socket-patch-core/src/patch/redirect/state.rs @@ -19,6 +19,7 @@ use super::FileEdit; use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; use crate::utils::fs::read_regular_to_bytes; +use crate::utils::purl::{canonical_purl, purl_name_version}; use crate::utils::socket_dir::{remove_file_and_prune, write_json_ledger}; /// Repo-relative path of the redirect ledger. @@ -57,6 +58,19 @@ impl RedirectState { records: BTreeMap::new(), } } + + /// The stored record keys whose canonical purl (qualifiers stripped, + /// percent-decoded — [`canonical_purl`]) matches `purl`, in ledger + /// order. Normally zero or one; a hand-edited ledger may carry the same + /// package under two spellings, and every caller must drop them all. + pub(crate) fn record_keys_for(&self, purl: &str) -> Vec { + let target = canonical_purl(purl); + self.records + .keys() + .filter(|k| canonical_purl(k) == target) + .cloned() + .collect() + } } impl Default for RedirectState { @@ -212,16 +226,6 @@ pub async fn save_redirect_state( write_json_ledger(&project_root.join(REDIRECT_STATE_REL), state).await } -/// `pkg:/@` → `(, )`; the name keeps any -/// namespace slashes (`@scope/pkg`). `None` when either part is missing. -/// Input must already be canonicalized (qualifiers stripped, percent-decoded). -fn purl_name_version(purl: &str) -> Option<(&str, &str)> { - let rest = purl.strip_prefix("pkg:")?; - let (_, coord) = rest.split_once('/')?; - let at = coord.rfind('@').filter(|&i| i > 0)?; - Some((&coord[..at], &coord[at + 1..])) -} - /// Drop one PURL's superseded takeover leftovers from the ledger: its /// `records` entry (canonical-purl match, qualifiers stripped and /// percent-decoded) and every recorded edit keyed to that package. This is @@ -276,9 +280,7 @@ fn purl_name_version(purl: &str) -> Option<(&str, &str)> { /// ledger via [`persist_redirect_state`] (atomic; an emptied ledger is /// deleted). pub fn drop_superseded_purl(state: &mut RedirectState, purl: &str) -> bool { - use crate::utils::purl::{normalize_purl, strip_purl_qualifiers}; - let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); - let target = canon(purl); + let target = canonical_purl(purl); if target.starts_with("pkg:cargo/") { return false; } @@ -287,12 +289,7 @@ pub fn drop_superseded_purl(state: &mut RedirectState, purl: &str) -> bool { }; let (name, version) = (name.to_string(), version.to_string()); - let record_keys: Vec = state - .records - .keys() - .filter(|k| canon(k) == target) - .cloned() - .collect(); + let record_keys = state.record_keys_for(purl); // THIS purl's patch uuid(s), captured before the records are removed — // the artifact anchor (see the doc comment). Distinct purls (including // two versions of one package) carry distinct patch uuids, so a uuid @@ -328,7 +325,12 @@ pub fn drop_superseded_purl(state: &mut RedirectState, purl: &str) -> bool { // across raw / `\/`-escaped / percent-encoded URL forms). let anchored = !uuids.is_empty() && e.new.as_ref().is_some_and(|new| { - let text = new.to_string(); + // A text-fragment payload is probed in place; only an object + // payload (a whole JSON lock entry) needs re-serializing. + let text: std::borrow::Cow<'_, str> = match new { + serde_json::Value::String(s) => std::borrow::Cow::Borrowed(s.as_str()), + other => std::borrow::Cow::Owned(other.to_string()), + }; uuids.iter().any(|uuid| text.contains(uuid.as_str())) }); !(version_exact || anchored) diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index d9fe2a84..631897bf 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -46,7 +46,7 @@ use std::sync::LazyLock; use regex::Regex; use serde_json::Value; -use crate::utils::purl::{normalize_purl, parse_cargo_purl, strip_purl_qualifiers}; +use crate::utils::purl::{canonical_purl, parse_cargo_purl, parse_name_version}; use super::staged::{flush_staged, read_rel, staged_read, Staged, StagedBytes}; use super::state::RedirectState; @@ -99,14 +99,12 @@ pub async fn revert_redirect_purl( /// percent-decoded) matches `purl`: `(record key as stored, canonical purl)`. /// Refused when the ledger records no hosted redirect for the purl. fn find_record_key(state: &RedirectState, purl: &str) -> Result<(String, String), String> { - let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); - let target = canon(purl); - let Some(record_key) = state.records.keys().find(|k| canon(k) == target).cloned() else { + let Some(record_key) = state.record_keys_for(purl).into_iter().next() else { return Err(format!( "the redirect ledger records no hosted redirect for {purl}" )); }; - Ok((record_key, target)) + Ok((record_key, canonical_purl(purl))) } /// Drop the claimed edits (by ledger index) and the purl's record from the @@ -307,14 +305,6 @@ pub async fn revert_cargo_redirect_purl( Ok(out) } -/// `pkg:npm/@` (canonical, percent-decoded form) → -/// `(name, version)`; the name keeps its `@scope/` namespace. -fn parse_npm_purl(canon: &str) -> Option<(&str, &str)> { - let rest = canon.strip_prefix("pkg:npm/")?; - let (name, version) = rest.rsplit_once('@')?; - (!name.is_empty() && !version.is_empty()).then_some((name, version)) -} - /// The npm-family text-fragment edit kinds CLAIMED BY KEY: `original`/`new` /// hold the whole lock fragment as a string, the edit's `key` embeds /// `@`, and the revert is a `replacen(new, original)`. @@ -456,7 +446,8 @@ pub async fn revert_npm_redirect_purl( dry_run: bool, ) -> Result { let (record_key, target) = find_record_key(state, purl)?; - let Some((name, version)) = parse_npm_purl(&target) else { + // `target` is canonical (percent-decoded); the name keeps its `@scope/`. + let Some((name, version)) = parse_name_version(&target, "pkg:npm/") else { return Err(format!("not an npm purl: {purl}")); }; let (name, version) = (name.to_string(), version.to_string()); diff --git a/crates/socket-patch-core/src/patch/sidecars/cargo.rs b/crates/socket-patch-core/src/patch/sidecars/cargo.rs index 60a75e58..b54d276f 100644 --- a/crates/socket-patch-core/src/patch/sidecars/cargo.rs +++ b/crates/socket-patch-core/src/patch/sidecars/cargo.rs @@ -32,8 +32,8 @@ use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use crate::hash::git_sha256::compute_git_sha256_from_bytes; -use crate::patch::apply::{apply_file_patch, is_safe_relative_subpath, normalize_file_path}; -use crate::utils::fs::open_regular_file; +use crate::patch::apply::{apply_file_patch_at, is_safe_relative_subpath, normalize_file_path}; +use crate::utils::fs::read_regular_to_bytes; use super::{SidecarError, SidecarFile, SidecarFileAction, SidecarPayload}; @@ -85,7 +85,15 @@ async fn sync_checksum( let checksum_path = pkg_path.join(CHECKSUM_FILE); // Read the existing file. NotFound is fine — no checksums to update. - let raw = match read_regular_file(&checksum_path).await { + // Both reads below go through the FIFO-safe `read_regular_to_bytes` + // (non-blocking open + regular-file check): the paths live inside the + // (untrusted) package tree, and a planted special file must fail fast + // rather than wedge the patch engine. Whole-file loads are fine — cargo + // source files are bounded (crates.io rejects `.crate`s over ~10MB + // unpacked) — and the open error passes through untouched, which the + // `dispatch_fixup_cargo_sha256_file_failure_arm` integration test drives + // via a non-existent path. + let raw = match read_regular_to_bytes(&checksum_path).await { Ok(s) => s, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { return Ok(None); @@ -133,7 +141,7 @@ async fn sync_checksum( // read-only (files `0o444` inside `0o555` dirs) for tamper // detection. A plain in-place truncating write has three defects // there, all of which the rest of the patch engine was hardened - // against (see `apply::apply_file_patch` and `rollback`): + // against (see `apply::apply_file_patch_at` and `rollback`): // // 1. **Read-only-hostile.** Opening the existing `0o444` file // `O_TRUNC` fails `EACCES`, so the fixup errored out exactly @@ -147,15 +155,19 @@ async fn sync_checksum( // 3. **Copy-on-write-unsafe.** A vendored tree hardlinked into a // shared store would have its sibling mutated in place. // - // `apply_file_patch` stages a sibling, fsyncs, and `rename(2)`s - // atomically; breaks CoW inodes; relaxes then restores BOTH the - // file's and the directory's read-only modes; and verifies the - // bytes that landed. The `expected_hash` is just the digest of the - // bytes we hand it (a self-check) — the file already exists, so - // its original mode is snapshotted and restored bit-for-bit. + // `apply_file_patch_at` stages a sibling, fsyncs, and `rename(2)`s + // atomically (the rename-over is the copy-on-write isolation for a + // hardlinked sibling — see `utils::fs::atomic_write_bytes`); relaxes + // then restores BOTH the file's and the directory's read-only modes; + // and verifies the bytes that landed. The `expected_hash` is just the + // digest of the bytes we hand it (a self-check) — the file already + // exists, so its original mode is snapshotted and restored + // bit-for-bit. The post-write ownership warning it may return is + // dropped: a `.cargo-checksum.json` is never chown-sensitive. let expected_hash = compute_git_sha256_from_bytes(&out); - apply_file_patch(pkg_path, CHECKSUM_FILE, &out, &expected_hash) + apply_file_patch_at(pkg_path, CHECKSUM_FILE, &out, &expected_hash) .await + .map(|_ownership_warning| ()) .map_err(|source| SidecarError::Io { path: checksum_path.display().to_string(), source, @@ -194,7 +206,7 @@ async fn update_entries( // hash an arbitrary out-of-tree file and embed its digest under a // bogus key in the committed checksum — an info leak that also // corrupts the checksum so cargo can no longer verify the crate. - // The apply *write* path (`apply_file_patch`) already refuses these, + // The apply *write* path (`apply_file_patch_at`) already refuses these, // but `fixup` is `pub(crate)` and reached directly via `dispatch_fixup` // and tests, so the *read* path must guard itself too. Mirror apply's // `InvalidData` refusal rather than silently skipping — an escaping @@ -210,7 +222,7 @@ async fn update_entries( } let on_disk = pkg_path.join(&normalized); - let bytes = match read_regular_file(&on_disk).await { + let bytes = match read_regular_to_bytes(&on_disk).await { Ok(bytes) => bytes, Err(e) if remove_missing && e.kind() == std::io::ErrorKind::NotFound => { // Rollback deleted this patch-added file; drop the entry @@ -239,26 +251,6 @@ async fn update_entries( Ok(()) } -/// Read a whole file, refusing anything that isn't a regular file. -/// -/// Both call sites read paths inside the (untrusted) package tree, so -/// the open goes through [`open_regular_file`] — non-blocking on Unix, -/// rejecting FIFOs/devices/directories — to keep a planted special -/// file from hanging the patch engine (see its docs). Loading the -/// whole file is fine: cargo source files are bounded (the registry -/// rejects crates whose `.crate` tarball exceeds ~10MB unpacked), and -/// the open error passes through untouched, which the -/// `dispatch_fixup_cargo_sha256_file_failure_arm` integration test -/// drives via a non-existent path. -async fn read_regular_file(path: &Path) -> std::io::Result> { - use tokio::io::AsyncReadExt; - - let (mut file, metadata) = open_regular_file(path).await?; - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut bytes).await?; - Ok(bytes) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/socket-patch-core/src/telemetry.rs b/crates/socket-patch-core/src/telemetry.rs index a65b06b4..f704f2fd 100644 --- a/crates/socket-patch-core/src/telemetry.rs +++ b/crates/socket-patch-core/src/telemetry.rs @@ -121,6 +121,12 @@ struct PatchTelemetryEvent { /// Telemetry is disabled when: /// - `SOCKET_TELEMETRY_DISABLED` is `"1"` or `"true"` /// (legacy `SOCKET_PATCH_TELEMETRY_DISABLED` still honored with warning) +/// - `VITEST` is `"true"`. Load-bearing downstream dependency, not a relic: +/// socket-cli's vitest integration suite +/// (`packages/cli/test/integration/cli/cmd-patch*.test.mts`) spawns this +/// binary with its inherited environment and sets no +/// `SOCKET_TELEMETRY_DISABLED`, so this gate is the only thing keeping +/// those runs from POSTing telemetry to the public proxy. /// - `SOCKET_OFFLINE` is `"1"` or `"true"` (airgap mode — the telemetry /// endpoint is a network call, so honoring `--offline`/`SOCKET_OFFLINE` /// here keeps every command compliant with the strict-airgap contract) @@ -135,7 +141,8 @@ pub fn is_telemetry_disabled() -> bool { ) .unwrap_or_default(); let disabled_via_env = matches!(env_value.as_str(), "1" | "true"); - disabled_via_env || is_offline_env() + let vitest = std::env::var("VITEST").unwrap_or_default() == "true"; + disabled_via_env || vitest || is_offline_env() } /// Log debug messages when debug mode is enabled. @@ -729,11 +736,13 @@ mod tests { // Save originals let orig_new = std::env::var("SOCKET_TELEMETRY_DISABLED").ok(); let orig_legacy = std::env::var("SOCKET_PATCH_TELEMETRY_DISABLED").ok(); + let orig_vitest = std::env::var("VITEST").ok(); let orig_offline = std::env::var("SOCKET_OFFLINE").ok(); // Default: not disabled std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); + std::env::remove_var("VITEST"); std::env::remove_var("SOCKET_OFFLINE"); assert!(!is_telemetry_disabled()); @@ -777,6 +786,10 @@ mod tests { Some(v) => std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", v), None => std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"), } + match orig_vitest { + Some(v) => std::env::set_var("VITEST", v), + None => std::env::remove_var("VITEST"), + } match orig_offline { Some(v) => std::env::set_var("SOCKET_OFFLINE", v), None => std::env::remove_var("SOCKET_OFFLINE"), diff --git a/crates/socket-patch-core/src/update/state.rs b/crates/socket-patch-core/src/update/state.rs index 9bd39f0a..0a2ef0c2 100644 --- a/crates/socket-patch-core/src/update/state.rs +++ b/crates/socket-patch-core/src/update/state.rs @@ -8,7 +8,7 @@ //! clock skew by degrading to "never checked". Nothing in here may ever //! fail a command — callers treat all errors as "skip the check". -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; @@ -84,44 +84,18 @@ pub fn load_state() -> UpdateCheckState { let Some(path) = state_file_path() else { return UpdateCheckState::default(); }; - let Ok(bytes) = read_state_bytes(&path) else { + // `load_state` runs synchronously at the start of every command (the + // passive notifier's guard path), and a plain `open(2)` of a FIFO + // planted at this path would wait forever for a writer, wedging the + // whole CLI before the command even starts. The shared sync reader opens + // `O_NONBLOCK` and rejects FIFOs/devices/directories on the handle, so + // the caller degrades to never-checked like any other unreadable state. + let Ok(bytes) = crate::utils::fs::read_regular_to_bytes_sync(&path) else { return UpdateCheckState::default(); }; serde_json::from_slice(&bytes).unwrap_or_default() } -/// Read the state bytes, requiring a regular file — the sync twin of -/// [`open_regular_file`](crate::utils::fs::open_regular_file). `load_state` -/// runs synchronously at the start of every command (the passive notifier's -/// guard path), and a plain `open(2)` of a FIFO planted at this path waits -/// forever for a writer, wedging the whole CLI before the command even -/// starts. `O_NONBLOCK` makes the open return immediately; the handle-based -/// `is_file` check then rejects FIFOs/devices/directories so the caller -/// degrades to never-checked like any other unreadable state. -fn read_state_bytes(path: &Path) -> std::io::Result> { - use std::io::Read; - #[cfg(unix)] - let mut file = { - use std::os::unix::fs::OpenOptionsExt; - std::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NONBLOCK) - .open(path)? - }; - #[cfg(not(unix))] - let mut file = std::fs::File::open(path)?; - let metadata = file.metadata()?; - if !metadata.is_file() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("{} is not a regular file", path.display()), - )); - } - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut bytes)?; - Ok(bytes) -} - /// Persist the state atomically (stage + fsync + rename). Errors bubble so /// callers can debug-log them, but callers must treat them as non-fatal. pub async fn save_state(state: &UpdateCheckState) -> std::io::Result<()> { @@ -254,7 +228,7 @@ mod tests { /// flakes under heavy parallel load (fork/exec starvation) and the /// syscall needs no process at all. #[cfg(unix)] - fn mkfifo(path: &Path) { + fn mkfifo(path: &std::path::Path) { use std::os::unix::ffi::OsStrExt; let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).expect("fifo path has no NUL"); diff --git a/crates/socket-patch-core/src/update/swap.rs b/crates/socket-patch-core/src/update/swap.rs index 159aa031..cba26077 100644 --- a/crates/socket-patch-core/src/update/swap.rs +++ b/crates/socket-patch-core/src/update/swap.rs @@ -51,7 +51,8 @@ pub fn acquire_update_lock() -> Result, UpdateError> { // forever waiting for a reader; O_NONBLOCK makes it return immediately // (ENXIO, or a handle the is_file check below rejects). A no-op for the // regular file this normally is — the fd is only ever flock(2)ed, never - // read or written. Same guard as state.rs's read_state_bytes. + // read or written. Same guard as `utils::fs::open_regular_file_sync`, + // which state.rs's `load_state` reads through. #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; @@ -75,10 +76,9 @@ pub fn acquire_update_lock() -> Result, UpdateError> { // every other flock(2) failure (ENOLCK on an NFS-homed state dir, // ENOTSUP on a lockless filesystem, ...) must surface with its real // cause instead of masquerading as `update_in_progress` — the same - // split `patch/apply_lock.rs` makes for apply.lock. - Err(e) if e.raw_os_error() == fs2::lock_contended_error().raw_os_error() => { - Err(UpdateError::InProgress) - } + // split `patch/apply_lock.rs` makes for apply.lock, through the same + // errno test. + Err(e) if crate::patch::apply_lock::is_lock_contended(&e) => Err(UpdateError::InProgress), Err(e) => Err(UpdateError::SwapFailed(format!( "cannot lock {}: {e}", path.display() @@ -283,8 +283,8 @@ mod tests { /// A FIFO planted at the lock path must not wedge the updater: a plain /// `O_WRONLY` open(2) of a FIFO waits forever for a reader that never /// comes, hanging `--update` with no output before it does anything. - /// Same class as the `read_state_bytes` guard one file over in - /// state.rs — same directory, even. + /// Same class as the `read_regular_to_bytes_sync` guard `load_state` + /// uses one file over in state.rs — same directory, even. #[cfg(unix)] #[test] #[serial(update_state_dir_env)] diff --git a/crates/socket-patch-core/src/utils/fs.rs b/crates/socket-patch-core/src/utils/fs.rs index fb55549f..e69f5ad8 100644 --- a/crates/socket-patch-core/src/utils/fs.rs +++ b/crates/socket-patch-core/src/utils/fs.rs @@ -208,8 +208,12 @@ pub fn read_regular_to_bytes_sync(path: &Path) -> std::io::Result> { /// The one regular-file guard: `O_NONBLOCK` open on Unix, then the /// handle-based regular-file check. The async [`open_regular_file`] and every -/// reader above run this on the blocking pool. -fn open_regular_file_sync(path: &Path) -> std::io::Result<(std::fs::File, std::fs::Metadata)> { +/// reader above run this on the blocking pool; `pub(crate)` for the few +/// synchronous callers that must keep the handle (a `zip::ZipArchive` over a +/// committed wheel) rather than read it whole. +pub(crate) fn open_regular_file_sync( + path: &Path, +) -> std::io::Result<(std::fs::File, std::fs::Metadata)> { #[cfg(unix)] let file = { use std::os::unix::fs::OpenOptionsExt as _; @@ -327,6 +331,15 @@ pub(crate) fn normalize_lexically(path: &Path) -> Option { /// sibling file, fsync it, then rename over the target (atomic on the same /// filesystem), so a reader or recovering process only ever sees the complete /// old or the complete new bytes. +/// +/// **Copy-on-write guarantee** (the single source of truth the patch engine's +/// comments point at): `rename(2)` replaces only the *directory entry*, never +/// the bytes behind the old inode. A hardlinked sibling — pnpm's +/// content-addressable store, the bun / uv caches, Go's module cache — keeps +/// the old inode and its old content untouched, and a symlink sitting at the +/// destination is replaced *as a link* by a private regular file, never +/// written through to its target. No separate hardlink-break step is needed; +/// the write path is CoW-safe by construction. pub(crate) async fn atomic_write_bytes(path: &Path, content: &[u8]) -> std::io::Result<()> { atomic_write_bytes_as(path, content, None).await } diff --git a/crates/socket-patch-core/src/utils/hatch.rs b/crates/socket-patch-core/src/utils/hatch.rs index 762725e3..57cb5314 100644 --- a/crates/socket-patch-core/src/utils/hatch.rs +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -6,6 +6,11 @@ use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::utils::python_lock::preserve_line_endings; use crate::vendor::common::pep508_name; +/// The two documents the hatch planner reads and rewrites, in the order +/// [`plan`] parses them. The redirect overlay (`redirect/mod.rs`) clones +/// exactly these from the candidate set, so the lists cannot drift. +pub const HATCH_FILES: [&str; 2] = ["pyproject.toml", "hatch.toml"]; + pub fn is_hatch(files: &BTreeMap) -> bool { files.contains_key("hatch.toml") || files.get("pyproject.toml").is_some_and(|text| { @@ -316,7 +321,7 @@ pub fn plan( ) -> Result { let name = canonicalize_pypi_name(name); let mut documents = BTreeMap::new(); - for file in ["pyproject.toml", "hatch.toml"] { + for file in HATCH_FILES { if let Some(text) = files.get(file) { documents.insert( file, diff --git a/crates/socket-patch-core/src/utils/purl.rs b/crates/socket-patch-core/src/utils/purl.rs index 3021738f..523ef83e 100644 --- a/crates/socket-patch-core/src/utils/purl.rs +++ b/crates/socket-patch-core/src/utils/purl.rs @@ -129,12 +129,34 @@ pub fn purl_qualifier<'a>(purl: &'a str, key: &str) -> Option<&'a str> { }) } +/// The ledger / lookup spelling of a purl: `?qualifiers` and `#subpath` +/// stripped, then percent-decoded per component ([`normalize_purl`]). Two +/// purls naming the same package version compare equal after this, whatever +/// URL escaping or `?artifact_id=` decoration they arrived with — the one +/// composition every ledger key match (redirect takeover, vendor GC, the +/// hosted→vendored reconciliation) goes through. +pub fn canonical_purl(purl: &str) -> String { + normalize_purl(strip_purl_qualifiers(purl)).into_owned() +} + +/// `pkg:/@` → `(, )` for ANY type; the +/// name keeps any namespace slashes (`@scope/pkg`). `None` when either part +/// is missing. Input must already be canonicalized (qualifiers stripped, +/// percent-decoded) — the redirect ledger's version-exact matcher feeds it +/// [`canonical_purl`] output. +pub(crate) fn purl_name_version(purl: &str) -> Option<(&str, &str)> { + let rest = purl.strip_prefix("pkg:")?; + let (_, coord) = rest.split_once('/')?; + let at = coord.rfind('@').filter(|&i| i > 0)?; + Some((&coord[..at], &coord[at + 1..])) +} + /// Shared split for `pkg:/@` purls: strip /// `?qualifiers`/`#subpath` FIRST (a qualifier value can itself embed an /// `@`, e.g. a `git@github.com` source URL), require `prefix`, then split /// the version off at the LAST `@` — so the name/path keeps any internal /// slashes and `@`s. -fn parse_name_version<'a>(purl: &'a str, prefix: &str) -> Option<(&'a str, &'a str)> { +pub(crate) fn parse_name_version<'a>(purl: &'a str, prefix: &str) -> Option<(&'a str, &'a str)> { let rest = strip_purl_qualifiers(purl).strip_prefix(prefix)?; let at_idx = rest.rfind('@')?; let name = &rest[..at_idx]; diff --git a/crates/socket-patch-core/src/vendor/bun_binary.rs b/crates/socket-patch-core/src/vendor/bun_binary.rs index 1eda96f5..42caaa73 100644 --- a/crates/socket-patch-core/src/vendor/bun_binary.rs +++ b/crates/socket-patch-core/src/vendor/bun_binary.rs @@ -1,13 +1,13 @@ //! Native binary Bun vendoring. Package records are edited without re-resolving //! dependencies or requiring a Bun executable. use super::bun_lockb::{BinaryPackage, BunLockb}; -use super::common::{already_patched_result, prune_empty_vendor_levels, refused}; +use super::common::{already_patched_result, refused}; use super::npm_common::{ done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, }; use super::path::parse_vendor_path; use super::state::{ - write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, + write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; use crate::manifest::schema::PatchRecord; @@ -255,12 +255,7 @@ pub(crate) async fn vendor( .await; } let marker = VendorMarker::new("npm", &coords.base_purl, record, vendored_at); - if let Err(e) = write_marker(&root.join(&coords.uuid_dir_rel), &marker).await { - warnings.push(VendorWarning::new( - "vendor_marker_write_failed", - e.to_string(), - )); - } + write_marker_or_warn(&root.join(&coords.uuid_dir_rel), &marker, &mut warnings).await; VendorOutcome::Done { result, warnings, @@ -436,14 +431,19 @@ pub(crate) async fn revert(entry: &VendorEntry, root: &Path, opts: RevertOpts) - } prune_mirror_parents(&mirror).await; } + // The last npm-family entry leaves `.socket/vendor/npm/` (and + // `.socket/vendor/`) empty: the shared helper prunes them so a + // reverted project carries no vendor residue (non-recursive: + // siblings keep them). let uuid_dir = root.join(&dir); - if let Err(e) = crate::patch::copy_tree::remove_tree(&uuid_dir).await { + if let Err(e) = crate::utils::socket_dir::remove_tree_and_prune( + &uuid_dir, + &root.join(crate::constants::SOCKET_DIR), + ) + .await + { return RevertOutcome::failed(format!("cannot remove {dir}: {e}")); } - // The last npm-family entry leaves `.socket/vendor/npm/` (and - // `.socket/vendor/`) empty: prune them so a reverted project carries - // no vendor residue (`remove_dir` keeps non-empty levels). - prune_empty_vendor_levels(&uuid_dir).await; } outcome } diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index d9568d17..955398e0 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -37,22 +37,23 @@ use base64::Engine as _; use serde_json::Value; use sha2::{Digest, Sha512}; +use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; -use crate::patch::copy_tree::remove_tree; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; +use crate::utils::socket_dir::remove_tree_and_prune; use crate::vendor::bun_lock_text::{ check_lock_version, decode_json_string, has_workspace_packages, lock_version, packages_bounds, parse_entry_line, parse_packages_section, split_name_spec, BunEntry, }; -use super::common::{already_patched_result, prune_empty_vendor_levels, refused}; +use super::common::{already_patched_result, refused}; use super::npm_common::{ done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, }; use super::path::parse_vendor_path; use super::state::{ - write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, + write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; @@ -638,12 +639,12 @@ pub(crate) async fn vendor_bun( // ── 6. Marker + ledger entry ────────────────────────────────────────── let marker = VendorMarker::new("npm", &coords.base_purl, record, vendored_at); - if let Err(e) = write_marker(&project_root.join(&coords.uuid_dir_rel), &marker).await { - warnings.push(VendorWarning::new( - "vendor_marker_write_failed", - format!("could not write the informational vendor marker: {e}"), - )); - } + write_marker_or_warn( + &project_root.join(&coords.uuid_dir_rel), + &marker, + &mut warnings, + ) + .await; let entry = VendorEntry { ecosystem: "npm".to_string(), @@ -809,14 +810,14 @@ pub(crate) async fn revert_bun_opts( // ran; the artifact dir stays behind (and the caller keeps the ledger // entry), so only the deletion is skipped. if !keep_artifact { + // The last npm-family entry leaves `.socket/vendor/npm/` (and + // `.socket/vendor/`) empty: the shared helper prunes them so a + // reverted project carries no vendor residue (non-recursive: + // siblings keep them). let uuid_dir = project_root.join(&uuid_dir_rel); - if let Err(e) = remove_tree(&uuid_dir).await { + if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } - // The last npm-family entry leaves `.socket/vendor/npm/` (and - // `.socket/vendor/`) empty: prune them so a reverted project carries - // no vendor residue (`remove_dir` keeps non-empty levels). - prune_empty_vendor_levels(&uuid_dir).await; } outcome } @@ -965,6 +966,7 @@ mod tests { use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; use crate::patch::apply::{ApplyResult, VerifyStatus}; + use crate::patch::copy_tree::remove_tree; use std::collections::HashMap; use std::path::PathBuf; diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 9ea9fd90..eaa438b6 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -32,8 +32,8 @@ use super::path::vendor_uuid_dir_rel; use super::registry_fetch::extract_tgz; use super::service_fetch::{fetch_verified_archive, ServiceArtifact}; use super::state::{ - write_marker, CargoLockOriginal, VendorArtifact, VendorEntry, VendorMarker, WiringAction, - WiringRecord, VENDOR_MARKER_FILE, + write_marker_or_warn, CargoLockOriginal, VendorArtifact, VendorEntry, VendorMarker, + WiringAction, WiringRecord, VENDOR_MARKER_FILE, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; @@ -591,12 +591,7 @@ pub async fn vendor_cargo_crate( { let marker = VendorMarker::new("cargo", strip_purl_qualifiers(purl), record, vendored_at); - if let Err(e) = write_marker(&uuid_dir, &marker).await { - warnings.push(VendorWarning::new( - "marker_write_failed", - format!("could not write the vendor marker: {e}"), - )); - } + write_marker_or_warn(&uuid_dir, &marker, &mut warnings).await; } return done(result, None, warnings); } @@ -730,14 +725,7 @@ pub async fn vendor_cargo_crate( // ── marker + ledger entry ───────────────────────────────────────────── let base_purl = strip_purl_qualifiers(purl).to_string(); let marker = VendorMarker::new("cargo", &base_purl, record, vendored_at); - if let Err(e) = write_marker(&uuid_dir, &marker).await { - // The marker is belt-and-braces metadata (never a trust input); a - // failed write must not undo a fully-wired vendor — surface it. - warnings.push(VendorWarning::new( - "marker_write_failed", - format!("could not write the vendor marker: {e}"), - )); - } + write_marker_or_warn(&uuid_dir, &marker, &mut warnings).await; let mut wiring = vec![WiringRecord { file: ".cargo/config.toml".to_string(), @@ -2928,7 +2916,7 @@ mod tests { /// A failed marker write on a FRESH vendor (a directory squatting the /// marker path makes the atomic rename fail) must not undo the - /// fully-wired vendor: success + a `marker_write_failed` warning, with + /// fully-wired vendor: success + a `vendor_marker_write_failed` warning, with /// copy, config, and lock all wired. #[tokio::test] async fn marker_write_failure_warns_but_vendor_succeeds() { @@ -2945,7 +2933,7 @@ mod tests { assert!(result.success, "{:?}", result.error); assert!(entry.is_some(), "the wired vendor still emits its entry"); assert!( - warnings.iter().any(|w| w.code == "marker_write_failed"), + warnings.iter().any(|w| w.code == "vendor_marker_write_failed"), "the failed marker write is surfaced: {warnings:?}" ); // The vendor is otherwise fully wired. diff --git a/crates/socket-patch-core/src/vendor/common.rs b/crates/socket-patch-core/src/vendor/common.rs index 71be4fd8..ce29d789 100644 --- a/crates/socket-patch-core/src/vendor/common.rs +++ b/crates/socket-patch-core/src/vendor/common.rs @@ -342,28 +342,18 @@ pub(crate) async fn swap_stage_into_place(stage: &Path, copy_dir: &Path) -> std: /// `.socket/vendor//` and `.socket/vendor/` levels a vendor run may have /// created (or a revert may have emptied), so neither a hard failure nor the /// reversal of the last entry of an ecosystem leaves a husk for the user to -/// commit. `remove_dir` refuses non-empty dirs, so live copies, markers, the -/// ledger and other entries' vendor dirs always survive; `.socket/` itself is -/// never touched (the apply lock lives there while any operation runs). +/// commit. The climb is the shared +/// [`prune_empty_dirs`](crate::utils::socket_dir::prune_empty_dirs): +/// non-recursive, so live copies, markers, the ledger and other entries' +/// vendor dirs always survive, and a uuid level already unwound wholesale +/// (`remove_tree` before the prune) still lets its parents go. `uuid_dir` is +/// `/.socket/vendor//`, so the stop dir — never removed — +/// is three levels up: `.socket/` itself, which the apply lock guard owns. pub(crate) async fn prune_empty_vendor_levels(uuid_dir: &Path) { - // The uuid level may already be gone (the unwind paths `remove_tree` it - // before pruning): NotFound must continue to the parent levels this run - // created, or they survive as committable husks. Any other error (i.e. - // non-empty: a live copy or marker) still stops the prune. - match tokio::fs::remove_dir(uuid_dir).await { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return, - } - let Some(eco_dir) = uuid_dir.parent() else { + let Some(socket_dir) = uuid_dir.ancestors().nth(3) else { return; }; - if tokio::fs::remove_dir(eco_dir).await.is_err() { - return; - } - if let Some(vendor_dir) = eco_dir.parent() { - let _ = tokio::fs::remove_dir(vendor_dir).await; - } + crate::utils::socket_dir::prune_empty_dirs(uuid_dir, socket_dir).await; } // ── pre-write guards shared by the pypi lock flavors ──────────────────────── diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index 5b876a88..ed3ff3a4 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -32,6 +32,7 @@ use std::path::Path; use serde_json::{json, Map, Value}; +use crate::constants::SOCKET_DIR; use crate::crawlers::composer_crawler::normalize_version; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; @@ -39,6 +40,7 @@ use crate::patch::copy_tree::{fresh_copy, remove_tree}; use crate::patch::path_safety::{is_safe_multi_segment, is_safe_single_segment}; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::purl::{build_composer_purl, parse_composer_purl}; +use crate::utils::socket_dir::remove_tree_and_prune; use super::common::{ already_patched_result, copy_matches_after_hashes, done, prune_empty_vendor_levels, refused, @@ -49,7 +51,7 @@ use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; use super::registry_fetch::extract_zip; use super::service_fetch::{fetch_verified_archive, ServiceArtifact}; use super::state::{ - write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, + write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; @@ -315,14 +317,7 @@ pub async fn vendor_composer( // ── marker + ledger entry ──────────────────────────────────────────── let base_purl = build_composer_purl(&vendor, &name, version); let marker = VendorMarker::new("composer", &base_purl, record, vendored_at); - if let Err(e) = write_marker(&uuid_dir, &marker).await { - // The marker is informational only (state.json is the ledger of - // record), so its failure must not fail an otherwise-wired vendor. - warnings.push(VendorWarning::new( - "vendor_marker_write_failed", - format!("could not write {}: {e}", super::state::VENDOR_MARKER_FILE), - )); - } + write_marker_or_warn(&uuid_dir, &marker, &mut warnings).await; let entry = VendorEntry { ecosystem: "composer".to_string(), @@ -465,7 +460,11 @@ pub async fn revert_composer_opts( // (and the caller keeps the ledger entry), so only the deletion is // skipped. if !dry_run && !keep_artifact { - if let Err(e) = remove_tree(&uuid_dir).await { + // The last composer entry leaves `.socket/vendor/composer/` (and + // `.socket/vendor/`) empty: the shared helper prunes them so a + // reverted project carries no vendor residue (non-recursive: + // siblings keep them). + if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { return RevertOutcome { kept_artifact: false, success: false, @@ -473,10 +472,6 @@ pub async fn revert_composer_opts( error: Some(format!("failed to remove {}: {e}", uuid_dir.display())), }; } - // The last composer entry leaves `.socket/vendor/composer/` (and - // `.socket/vendor/`) empty: prune them so a reverted project carries - // no vendor residue (`remove_dir` keeps non-empty levels). - prune_empty_vendor_levels(&uuid_dir).await; } warnings.push(VendorWarning::new( diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 92cd0283..853d8912 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -55,6 +55,7 @@ use std::path::{Path, PathBuf}; use serde_json::Value; +use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::{fresh_copy, remove_tree}; @@ -62,6 +63,7 @@ use crate::patch::path_safety::is_safe_single_segment; use crate::patch::redirect::gem_line_trailing_options; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::purl::{build_gem_purl, parse_gem_purl, purl_qualifier}; +use crate::utils::socket_dir::remove_tree_and_prune; use super::common::{ already_patched_result, copy_matches_after_hashes, done, failed_result, @@ -74,7 +76,7 @@ use super::service_fetch::{ fetch_verified_archive, fetch_verified_secondary, SecondaryArtifactResult, ServiceArtifact, }; use super::state::{ - write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, + write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; @@ -512,14 +514,7 @@ pub async fn vendor_gem( // ── marker + ledger entry ──────────────────────────────────────────── let base_purl = build_gem_purl(name, version); let marker = VendorMarker::new("gem", &base_purl, record, vendored_at); - if let Err(e) = write_marker(&uuid_dir, &marker).await { - // Informational only (state.json is the ledger of record) — a marker - // failure must not fail an otherwise-wired vendor. - warnings.push(VendorWarning::new( - "vendor_marker_write_failed", - format!("could not write {}: {e}", super::state::VENDOR_MARKER_FILE), - )); - } + write_marker_or_warn(&uuid_dir, &marker, &mut warnings).await; let gemfile_record = match &plan { GemfilePlan::Rewrite { @@ -1264,15 +1259,14 @@ pub async fn revert_gem_opts( if keep_artifact { return outcome; } - if let Err(e) = remove_tree(&uuid_dir).await { + // The last gem entry leaves `.socket/vendor/gem/` (and `.socket/vendor/`) + // empty: the shared helper prunes them so a reverted project carries no + // vendor residue (non-recursive: siblings keep them). + if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { outcome.success = false; outcome.error = Some(format!("failed to remove {}: {e}", uuid_dir.display())); return outcome; } - // The last gem entry leaves `.socket/vendor/gem/` (and `.socket/vendor/`) - // empty: prune them so a reverted project carries no vendor residue - // (`remove_dir` keeps non-empty levels). - prune_empty_vendor_levels(&uuid_dir).await; outcome } diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index 2940e8e4..02e5010c 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -38,7 +38,7 @@ use super::path::vendor_uuid_dir_rel; use super::registry_fetch::extract_zip_with_prefix; use super::service_fetch::{fetch_verified_archive, ServiceArtifact}; use super::state::{ - write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, + write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; @@ -272,12 +272,7 @@ pub async fn vendor_go_module( // a failed write only warns). let marker = VendorMarker::new("golang", strip_purl_qualifiers(purl), record, vendored_at); - if let Err(e) = write_marker(&project_root.join(&base_rel), &marker).await { - warnings.push(VendorWarning::new( - "marker_write_failed", - format!("could not write the vendor marker: {e}"), - )); - } + write_marker_or_warn(&project_root.join(&base_rel), &marker, &mut warnings).await; if wired_version_ok { warnings.push(VendorWarning::new( "vendor_artifact_rebuilt", @@ -355,14 +350,7 @@ pub async fn vendor_go_module( // ── marker + ledger entry ───────────────────────────────────────────── let base_purl = strip_purl_qualifiers(purl).to_string(); let marker = VendorMarker::new("golang", &base_purl, record, vendored_at); - if let Err(e) = write_marker(&project_root.join(&base_rel), &marker).await { - // The marker is belt-and-braces metadata (never a trust input); a - // failed write must not undo a fully-wired vendor — surface it. - warnings.push(VendorWarning::new( - "marker_write_failed", - format!("could not write the vendor marker: {e}"), - )); - } + write_marker_or_warn(&project_root.join(&base_rel), &marker, &mut warnings).await; let entry = VendorEntry { ecosystem: "golang".to_string(), diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index f3f10505..17144016 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -63,6 +63,7 @@ use serde_json::Value; use sha1::Sha1; use sha2::{Digest as _, Sha256}; +use crate::constants::SOCKET_DIR; use crate::manifest::schema::{PatchFileInfo, PatchRecord}; use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::remove_tree; @@ -72,6 +73,7 @@ use crate::utils::fs::{ read_regular_to_string, }; use crate::utils::purl::{build_maven_purl, parse_maven_purl}; +use crate::utils::socket_dir::remove_tree_and_prune; use super::common::{ already_patched_result, done, failed_result, prune_empty_vendor_levels, read_zip_artifact, @@ -81,7 +83,7 @@ use super::path::vendor_uuid_dir_rel; use super::registry_fetch::extract_zip; use super::service_fetch::{service_archive_copy, ServiceCopy}; use super::state::{ - write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, + write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; @@ -380,14 +382,7 @@ pub async fn vendor_maven( // ── marker + ledger entry ───────────────────────────────────────────── let base_purl = build_maven_purl(group_id, artifact_id, version); let marker = VendorMarker::new("maven", &base_purl, record, vendored_at); - if let Err(e) = write_marker(&uuid_dir, &marker).await { - // Informational only (state.json is the ledger of record) — a marker - // failure must not fail an otherwise-wired vendor. - warnings.push(VendorWarning::new( - "vendor_marker_write_failed", - format!("could not write {}: {e}", super::state::VENDOR_MARKER_FILE), - )); - } + write_marker_or_warn(&uuid_dir, &marker, &mut warnings).await; // The single wiring record is the authoritative revert record: it carries // the whole-file pre/post pom.xml snapshot. `Added` because we ADD a @@ -508,7 +503,11 @@ pub async fn revert_maven_opts( // (and the caller keeps the ledger entry), so only the deletion is // skipped. if !dry_run && !keep_artifact { - if let Err(e) = remove_tree(&uuid_dir).await { + // The last maven entry leaves `.socket/vendor/maven/` (and + // `.socket/vendor/`) empty: the shared helper prunes them so a + // reverted project carries no vendor residue (non-recursive: + // siblings keep them). + if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { return RevertOutcome { kept_artifact: false, success: false, @@ -516,10 +515,6 @@ pub async fn revert_maven_opts( error: Some(format!("failed to remove {}: {e}", uuid_dir.display())), }; } - // The last maven entry leaves `.socket/vendor/maven/` (and - // `.socket/vendor/`) empty: prune them so a reverted project carries - // no vendor residue (`remove_dir` keeps non-empty levels). - prune_empty_vendor_levels(&uuid_dir).await; } RevertOutcome { diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 60ce46c4..cd78b00c 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -35,10 +35,12 @@ //! and removes the artifacts. The rest of the CLI yields ownership of //! ledger-recorded purls (`apply`/`rollback` skip them, `scan --prune` //! exempts them) and `remove` reverts vendoring as part of removing a -//! patch. Detached entries (`scan --vendor --detached`) carry an embedded -//! patch record instead of a manifest entry. The path-level UUID makes "is -//! this Socket-vendored, by which patch" recoverable from the lockfile -//! string alone ([`path`]). +//! patch. Every `scan --mode vendored` / `get --mode vendored` entry is +//! detached: it embeds the patch `record` (the verification source — +//! vendored runs never write `.socket/manifest.json`), while the standalone +//! `vendor` command records `detached: false` entries that point at the +//! manifest. The path-level UUID makes "is this Socket-vendored, by which +//! patch" recoverable from the lockfile string alone ([`path`]). //! //! [`ReplaceOwner::Vendor`]: crate::vendor::go_mod_edit::ReplaceOwner @@ -71,7 +73,6 @@ pub mod pnpm_lock; pub mod pnpm_lock_legacy; pub mod pypi; mod pypi_hatch; -pub(crate) use pypi_lock::restore_document as restore_python_document; mod pypi_lock; pub mod pypi_pdm; pub mod pypi_pipenv; @@ -89,6 +90,7 @@ pub(crate) mod yarn_classic_lock; mod yarn_layering_tests; pub use path::{ecosystem_dir_for_purl, parse_vendor_path}; +pub(crate) use pypi_lock::restore_document as restore_python_document; pub use pypi_requirements::requirements_include_names; pub use state::{ carry_forward_wiring, load_state, lookup_entry, save_state, VendorEntry, VendorState, diff --git a/crates/socket-patch-core/src/vendor/npm_common.rs b/crates/socket-patch-core/src/vendor/npm_common.rs index c751cdf4..7bd1f8a8 100644 --- a/crates/socket-patch-core/src/vendor/npm_common.rs +++ b/crates/socket-patch-core/src/vendor/npm_common.rs @@ -655,12 +655,7 @@ pub(super) async fn done_failure_unstage( if !uuid_dir_preexisted { let uuid_dir = project_root.join(uuid_dir_rel); let _ = remove_tree(&uuid_dir).await; - if let Some(eco_dir) = uuid_dir.parent() { - let _ = tokio::fs::remove_dir(eco_dir).await; - if let Some(vendor_dir) = eco_dir.parent() { - let _ = tokio::fs::remove_dir(vendor_dir).await; - } - } + super::common::prune_empty_vendor_levels(&uuid_dir).await; } done_failure(purl, error) } diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index f8019a90..743d5ad6 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -18,21 +18,19 @@ use std::path::Path; use serde_json::Value; +use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; -use crate::patch::copy_tree::remove_tree; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_bytes}; +use crate::utils::socket_dir::remove_tree_and_prune; -use super::common::{ - already_patched_result, detect_indent, done, prune_empty_vendor_levels, refused, - serialize_json, -}; +use super::common::{already_patched_result, detect_indent, done, refused, serialize_json}; use super::npm_common::{ done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, }; use super::path::parse_vendor_path; use super::state::{ - write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, + write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; @@ -357,16 +355,8 @@ pub async fn vendor_npm( } // ── 9. Marker + ledger entry ───────────────────────────────────────── - // The marker is informational belt-and-braces (never a trust input), so - // a write failure downgrades to a warning rather than failing a vendor - // whose lock is already correctly wired. let marker = VendorMarker::new("npm", &base_purl, record, vendored_at); - if let Err(e) = write_marker(&project_root.join(&uuid_dir_rel), &marker).await { - warnings.push(VendorWarning::new( - "vendor_marker_write_failed", - format!("could not write the informational vendor marker: {e}"), - )); - } + write_marker_or_warn(&project_root.join(&uuid_dir_rel), &marker, &mut warnings).await; let entry = VendorEntry { ecosystem: "npm".to_string(), @@ -642,14 +632,13 @@ pub async fn revert_npm_opts( // Remove the whole validated uuid dir (tgz + marker + any @scope level) // in one tree delete — pruning by leaf would leave empty dirs behind. + // The last npm-family entry leaves `.socket/vendor/npm/` (and + // `.socket/vendor/`) empty: the shared helper prunes them so a reverted + // project carries no vendor residue (non-recursive: siblings keep them). let uuid_dir = project_root.join(&uuid_dir_rel); - if let Err(e) = remove_tree(&uuid_dir).await { + if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } - // The last npm-family entry leaves `.socket/vendor/npm/` (and - // `.socket/vendor/`) empty: prune them so a reverted project carries no - // vendor residue (`remove_dir` keeps non-empty levels). - prune_empty_vendor_levels(&uuid_dir).await; outcome } diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 2e1cbf66..298c46f8 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -50,6 +50,7 @@ use base64::Engine as _; use serde_json::Value; use sha2::{Digest as _, Sha512}; +use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::remove_tree; @@ -59,6 +60,7 @@ use crate::utils::fs::{ read_regular_to_string, }; use crate::utils::purl::{build_nuget_purl, parse_nuget_purl}; +use crate::utils::socket_dir::remove_tree_and_prune; use super::common::{ already_patched_result, done, failed_result, prune_empty_vendor_levels, read_zip_artifact, @@ -68,7 +70,7 @@ use super::path::vendor_uuid_dir_rel; use super::registry_fetch::extract_zip; use super::service_fetch::{service_archive_copy, ServiceCopy}; use super::state::{ - write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, + write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; @@ -472,14 +474,7 @@ pub async fn vendor_nuget( // ── marker + ledger entry ──────────────────────────────────────────── let base_purl = build_nuget_purl(name, version); let marker = VendorMarker::new("nuget", &base_purl, record, vendored_at); - if let Err(e) = write_marker(&uuid_dir, &marker).await { - // Informational only (state.json is the ledger of record) — a marker - // failure must not fail an otherwise-wired vendor. - warnings.push(VendorWarning::new( - "vendor_marker_write_failed", - format!("could not write {}: {e}", super::state::VENDOR_MARKER_FILE), - )); - } + write_marker_or_warn(&uuid_dir, &marker, &mut warnings).await; // The source record is the authoritative revert record: it carries the // whole-file pre/post config snapshot. When the config pre-existed it is a @@ -631,7 +626,11 @@ pub async fn revert_nuget_opts( // (and the caller keeps the ledger entry), so only the deletion is // skipped. if !dry_run && !keep_artifact { - if let Err(e) = remove_tree(&uuid_dir).await { + // The last nuget entry leaves `.socket/vendor/nuget/` (and + // `.socket/vendor/`) empty: the shared helper prunes them so a + // reverted project carries no vendor residue (non-recursive: + // siblings keep them). + if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { return RevertOutcome { kept_artifact: false, success: false, @@ -639,10 +638,6 @@ pub async fn revert_nuget_opts( error: Some(format!("failed to remove {}: {e}", uuid_dir.display())), }; } - // The last nuget entry leaves `.socket/vendor/nuget/` (and - // `.socket/vendor/`) empty: prune them so a reverted project carries - // no vendor residue (`remove_dir` keeps non-empty levels). - prune_empty_vendor_levels(&uuid_dir).await; } RevertOutcome { diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index afef5c1d..4e86031a 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -48,23 +48,22 @@ use std::path::Path; use serde_json::Value; +use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; -use crate::patch::copy_tree::remove_tree; use crate::utils::fs::{ atomic_write_bytes_preserving_mode, read_regular_to_bytes, read_regular_to_string, }; +use crate::utils::socket_dir::remove_tree_and_prune; -use super::common::{ - already_patched_result, detect_indent, done, prune_empty_vendor_levels, refused, - serialize_json, -}; +use super::common::{already_patched_result, detect_indent, done, refused, serialize_json}; use super::npm_common::{ done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, }; use super::path::parse_vendor_path; use super::state::{ - write_marker, PnpmMeta, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, + write_marker_or_warn, PnpmMeta, VendorArtifact, VendorEntry, VendorMarker, WiringAction, + WiringRecord, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; @@ -401,12 +400,12 @@ pub async fn vendor_pnpm( // ── 7. Marker + ledger entry ───────────────────────────────────────── let marker = VendorMarker::new("npm", &coords.base_purl, record, vendored_at); - if let Err(e) = write_marker(&project_root.join(&coords.uuid_dir_rel), &marker).await { - warnings.push(VendorWarning::new( - "vendor_marker_write_failed", - format!("could not write the informational vendor marker: {e}"), - )); - } + write_marker_or_warn( + &project_root.join(&coords.uuid_dir_rel), + &marker, + &mut warnings, + ) + .await; let entry = VendorEntry { ecosystem: "npm".to_string(), @@ -773,14 +772,14 @@ pub async fn revert_pnpm_opts( // ran; the artifact dir stays behind (and the caller keeps the ledger // entry), so only the deletion is skipped. if !keep_artifact { + // The last npm-family entry leaves `.socket/vendor/npm/` (and + // `.socket/vendor/`) empty: the shared helper prunes them so a + // reverted project carries no vendor residue (non-recursive: + // siblings keep them). let uuid_dir = project_root.join(&uuid_dir_rel); - if let Err(e) = remove_tree(&uuid_dir).await { + if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } - // The last npm-family entry leaves `.socket/vendor/npm/` (and - // `.socket/vendor/`) empty: prune them so a reverted project carries - // no vendor residue (`remove_dir` keeps non-empty levels). - prune_empty_vendor_levels(&uuid_dir).await; } outcome } diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs index 0b4e1656..7b487c3b 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs @@ -58,17 +58,15 @@ use std::path::Path; use serde_json::Value; +use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; -use crate::patch::copy_tree::remove_tree; use crate::utils::fs::{ atomic_write_bytes_preserving_mode, read_regular_to_bytes, read_regular_to_string, }; +use crate::utils::socket_dir::remove_tree_and_prune; -use super::common::{ - already_patched_result, detect_indent, done, prune_empty_vendor_levels, refused, - serialize_json, -}; +use super::common::{already_patched_result, detect_indent, done, refused, serialize_json}; use super::npm_common::{ done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, }; @@ -80,7 +78,8 @@ use super::pnpm_lock::{ vendor_value_is_for, yaml_key, yaml_key_like, KIND_LOCK_OVERRIDES, }; use super::state::{ - write_marker, PnpmMeta, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, + write_marker_or_warn, PnpmMeta, VendorArtifact, VendorEntry, VendorMarker, WiringAction, + WiringRecord, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; @@ -625,12 +624,12 @@ pub async fn vendor_pnpm_legacy( // ── 7. Marker + ledger entry ────────────────────────────────────────── let marker = VendorMarker::new("npm", &coords.base_purl, record, vendored_at); - if let Err(e) = write_marker(&project_root.join(&coords.uuid_dir_rel), &marker).await { - warnings.push(VendorWarning::new( - "vendor_marker_write_failed", - format!("could not write the informational vendor marker: {e}"), - )); - } + write_marker_or_warn( + &project_root.join(&coords.uuid_dir_rel), + &marker, + &mut warnings, + ) + .await; let entry = VendorEntry { ecosystem: "npm".to_string(), @@ -1451,14 +1450,14 @@ pub async fn revert_pnpm_legacy_opts( // ran; the artifact dir stays behind (and the caller keeps the ledger // entry), so only the deletion is skipped. if !keep_artifact { + // The last npm-family entry leaves `.socket/vendor/npm/` (and + // `.socket/vendor/`) empty: the shared helper prunes them so a + // reverted project carries no vendor residue (non-recursive: + // siblings keep them). let uuid_dir = project_root.join(&uuid_dir_rel); - if let Err(e) = remove_tree(&uuid_dir).await { + if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } - // The last npm-family entry leaves `.socket/vendor/npm/` (and - // `.socket/vendor/`) empty: prune them so a reverted project carries - // no vendor residue (`remove_dir` keeps non-empty levels). - prune_empty_vendor_levels(&uuid_dir).await; } outcome } diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index b612ca3c..c3c37771 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -11,11 +11,13 @@ use std::path::Path; use sha2::{Digest as _, Sha256}; use crate::api::client::ApiClient; +use crate::constants::SOCKET_DIR; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; use crate::utils::fs::{atomic_write_bytes, read_regular_to_string}; use crate::utils::purl::{parse_pypi_purl, strip_purl_qualifiers}; +use crate::utils::socket_dir::remove_tree_and_prune; use crate::utils::toml_edit_ext::has_table; use super::common::{ @@ -36,7 +38,7 @@ use super::pypi_wheel::{ }; use super::service_fetch::{fetch_verified_archive, ServiceArtifact}; use super::state::{ - write_marker, PdmMeta, PipenvMeta, PoetryMeta, UvMeta, VendorArtifact, VendorEntry, + write_marker_or_warn, PdmMeta, PipenvMeta, PoetryMeta, UvMeta, VendorArtifact, VendorEntry, VendorMarker, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; @@ -896,27 +898,16 @@ pub async fn vendor_pypi_with_pipenv_version( )); // Restore the informational marker the deleted uuid dir lost. let marker = VendorMarker::new("pypi", base, record, vendored_at); - if let Err(e) = write_marker(&project_root.join(&uuid_dir_rel), &marker).await { - warnings.push(VendorWarning::new( - "marker_write_failed", - format!("could not write the vendor marker: {e}"), - )); - } + write_marker_or_warn(&project_root.join(&uuid_dir_rel), &marker, &mut warnings).await; return done(result, None, warnings); } // Marker: artifact-side breadcrumb in the uuid dir (informational only — - // sweep/verify key off state.json + the path uuid). Written before the + // sweep/verify key off state.json + the path uuid, so a failed write is + // a warning here exactly as in every other backend). Written before the // wiring so lockfile edits stay the last mutation. let marker = VendorMarker::new("pypi", base, record, vendored_at); - if let Err(e) = write_marker(&project_root.join(&uuid_dir_rel), &marker).await { - let _ = tokio::fs::remove_dir_all(project_root.join(&uuid_dir_rel)).await; - prune_empty_vendor_levels(&project_root.join(&uuid_dir_rel)).await; - let mut result = result; - result.success = false; - result.error = Some(format!("cannot write vendor marker: {e}")); - return done(result, None, warnings); - } + write_marker_or_warn(&project_root.join(&uuid_dir_rel), &marker, &mut warnings).await; // Wiring LAST. On failure the wheel artifact is swept back out so a // failed vendor leaves no committed residue. @@ -1324,19 +1315,22 @@ pub async fn revert_pypi_opts( )); return outcome; }; - match tokio::fs::remove_dir_all(project_root.join(&uuid_dir_rel)).await { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => outcome.warnings.push(VendorWarning::new( + // Remove the unit (a missing dir is fine), then prune the + // `.socket/vendor/pypi/` and `.socket/vendor/` husks it leaves when it was + // the last entry (non-recursive: siblings keep them). A removal failure + // keeps the warning posture — the wiring restore above already succeeded + // — and skips the prune (the dir is still there). + if let Err(e) = remove_tree_and_prune( + &project_root.join(&uuid_dir_rel), + &project_root.join(SOCKET_DIR), + ) + .await + { + outcome.warnings.push(VendorWarning::new( "vendor_artifact_remove_failed", format!("could not remove {uuid_dir_rel}: {e}"), - )), + )); } - // The last pypi entry leaves `.socket/vendor/pypi/` (and `.socket/vendor/`) - // empty: prune them so a reverted project carries no vendor residue - // (`remove_dir` keeps non-empty levels, and a dir the removal above could - // not delete stops the climb). - prune_empty_vendor_levels(&project_root.join(&uuid_dir_rel)).await; outcome } @@ -4802,7 +4796,7 @@ wheels = [ "{warnings:?}" ); assert!( - !warnings.iter().any(|w| w.code == "marker_write_failed"), + !warnings.iter().any(|w| w.code == "vendor_marker_write_failed"), "rewriting the surviving marker file must succeed: {warnings:?}" ); assert!(wheel.is_file(), "wheel rebuilt at the recorded path"); @@ -4821,34 +4815,38 @@ wheels = [ .unwrap(); } - /// Fresh path: a failed marker write flips the run to failure, sweeps - /// the uuid dir, and leaves the wiring untouched (it was never written — - /// the marker lands BEFORE the wiring). + /// Fresh path: the marker is advisory on a first vendor too (parity with + /// every other backend) — a failed write is a `vendor_marker_write_failed` + /// warning riding an otherwise successful run: the wheel stays, the + /// wiring lands (the marker is written BEFORE the wiring, and its failure + /// no longer short-circuits that), and the ledger entry is emitted. #[tokio::test] - async fn fresh_marker_write_failure_sweeps_artifact_and_fails() { + async fn fresh_marker_write_failure_warns_but_vendor_succeeds() { let fx = e2e_fixture().await; plant_marker_blocker(&fx).await; let sources = PatchSources::blobs_only(&fx.blobs); let outcome = vendor_six(&fx, &sources, None).await; - let VendorOutcome::Done { result, entry, .. } = outcome else { + let VendorOutcome::Done { + result, + entry, + warnings, + } = outcome + else { panic!("expected Done, got {outcome:?}"); }; - assert!(!result.success, "the failed marker write must be reported"); + assert!(result.success, "{:?}", result.error); + let entry = entry.expect("a fully-wired vendor still emits its entry"); assert!( - result - .error - .as_deref() - .unwrap_or("") - .contains("cannot write vendor marker"), - "{:?}", - result.error + warnings.iter().any(|w| w.code == "vendor_marker_write_failed"), + "the failed marker write is surfaced: {warnings:?}" ); - assert!(entry.is_none()); assert!( - !uuid_dir_of(&fx).exists(), - "a failed fresh vendor must leave no committed residue" + fx.root.join(&entry.artifact.path).is_file(), + "the wheel is kept at the recorded path" ); - assert_eq!(read_requirements(&fx).await, "six==1.16.0\n"); + let wired = read_requirements(&fx).await; + assert_ne!(wired, "six==1.16.0\n", "the wiring still lands"); + assert!(wired.contains(".socket/vendor/pypi/"), "{wired}"); } /// In-sync rebuild path: the marker restore is advisory — its failure is @@ -4892,7 +4890,7 @@ wheels = [ "{warnings:?}" ); assert!( - warnings.iter().any(|w| w.code == "marker_write_failed"), + warnings.iter().any(|w| w.code == "vendor_marker_write_failed"), "{warnings:?}" ); assert!(fx.root.join(&entry.artifact.path).is_file()); diff --git a/crates/socket-patch-core/src/vendor/state.rs b/crates/socket-patch-core/src/vendor/state.rs index 54f7ba9c..a3c8d52a 100644 --- a/crates/socket-patch-core/src/vendor/state.rs +++ b/crates/socket-patch-core/src/vendor/state.rs @@ -554,6 +554,25 @@ pub(crate) async fn write_marker(uuid_dir: &Path, marker: &VendorMarker) -> std: atomic_write_bytes(&uuid_dir.join(VENDOR_MARKER_FILE), &bytes).await } +/// [`write_marker`], downgrading a failure to ONE `vendor_marker_write_failed` +/// warning on `warnings`. The marker is belt-and-braces metadata — never a +/// trust input (sweep/verify key off state.json + the path uuid) — so its +/// failure must not undo an otherwise fully-wired vendor. Every backend's +/// fresh and rebuild paths report it through here so the code and wording +/// cannot drift. +pub(crate) async fn write_marker_or_warn( + uuid_dir: &Path, + marker: &VendorMarker, + warnings: &mut Vec, +) { + if let Err(e) = write_marker(uuid_dir, marker).await { + warnings.push(super::VendorWarning::new( + "vendor_marker_write_failed", + format!("could not write the informational vendor marker {VENDOR_MARKER_FILE}: {e}"), + )); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/socket-patch-core/src/vendor/verify.rs b/crates/socket-patch-core/src/vendor/verify.rs index 2eb2d619..4909433c 100644 --- a/crates/socket-patch-core/src/vendor/verify.rs +++ b/crates/socket-patch-core/src/vendor/verify.rs @@ -140,24 +140,13 @@ async fn verify_dir_members(dir: &Path, record: &PatchRecord) -> Result<(), Stri } fn read_wheel_to_map(whl: &Path) -> Result>, String> { - // Open non-blockingly and require a regular file: a FIFO planted at the - // artifact path would otherwise wedge the audit in `open(2)` waiting for - // a writer that never comes (mirrors `read_archive_to_map`; O_NONBLOCK - // has no effect on regular-file reads). - #[cfg(unix)] - let file = { - use std::os::unix::fs::OpenOptionsExt; - std::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NONBLOCK) - .open(whl) - .map_err(|_| "vendor_artifact_unreadable".to_string())? - }; - #[cfg(not(unix))] - let file = std::fs::File::open(whl).map_err(|_| "vendor_artifact_unreadable".to_string())?; - if !file.metadata().map(|m| m.is_file()).unwrap_or(false) { - return Err("vendor_artifact_unreadable".to_string()); - } + // The shared guarded opener: non-blocking open + regular-file check on + // the handle, so a FIFO planted at the artifact path fails the audit + // instead of wedging it in `open(2)` waiting for a writer that never + // comes (mirrors `read_archive_to_map`). The handle is kept — the zip + // reader streams from it. + let (file, _metadata) = crate::utils::fs::open_regular_file_sync(whl) + .map_err(|_| "vendor_artifact_unreadable".to_string())?; let mut zip = zip::ZipArchive::new(file).map_err(|_| "vendor_artifact_unreadable".to_string())?; if zip.len() > MAX_WHEEL_ENTRIES { diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index 823e3989..b2a59deb 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -33,25 +33,23 @@ use std::path::Path; use serde_json::Value; use sha2::{Digest, Sha512}; +use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{normalize_file_path, PatchSources}; -use crate::patch::copy_tree::remove_tree; use crate::utils::fs::{ atomic_write_bytes_preserving_mode, read_regular_to_bytes, read_regular_to_string, }; +use crate::utils::socket_dir::remove_tree_and_prune; use crate::utils::uri::encode_uri_component; use super::berry_zip::berry_cache_checksum_10c0; -use super::common::{ - already_patched_result, detect_eol, detect_indent, prune_empty_vendor_levels, refused, - serialize_json, -}; +use super::common::{already_patched_result, detect_eol, detect_indent, refused, serialize_json}; use super::npm_common::{ done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, }; use super::path::parse_vendor_path; use super::state::{ - write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, + write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::yarn_classic_lock::{ body_field_line, lines_to_json, pattern_real_name, read_yarn_lock, replace_block, @@ -433,12 +431,7 @@ pub async fn vendor_yarn_berry( // ── 12. Marker + ledger entry ───────────────────────────────────────── let marker = VendorMarker::new("npm", &base_purl, record, vendored_at); - if let Err(e) = write_marker(&project_root.join(&uuid_dir_rel), &marker).await { - warnings.push(VendorWarning::new( - "vendor_marker_write_failed", - format!("could not write the informational vendor marker: {e}"), - )); - } + write_marker_or_warn(&project_root.join(&uuid_dir_rel), &marker, &mut warnings).await; let wiring = vec![ WiringRecord { @@ -719,14 +712,13 @@ pub async fn revert_yarn_berry_opts( } } + // The last npm-family entry leaves `.socket/vendor/npm/` (and + // `.socket/vendor/`) empty: the shared helper prunes them so a reverted + // project carries no vendor residue (non-recursive: siblings keep them). let uuid_dir = project_root.join(&uuid_dir_rel); - if let Err(e) = remove_tree(&uuid_dir).await { + if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } - // The last npm-family entry leaves `.socket/vendor/npm/` (and - // `.socket/vendor/`) empty: prune them so a reverted project carries no - // vendor residue (`remove_dir` keeps non-empty levels). - prune_empty_vendor_levels(&uuid_dir).await; outcome } diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index dd29e9af..3e599a37 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -25,18 +25,19 @@ use std::path::Path; use serde_json::Value; +use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; -use crate::patch::copy_tree::remove_tree; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; +use crate::utils::socket_dir::remove_tree_and_prune; -use super::common::{already_patched_result, detect_eol, prune_empty_vendor_levels, refused}; +use super::common::{already_patched_result, detect_eol, refused}; use super::npm_common::{ done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, }; use super::path::parse_vendor_path; use super::state::{ - write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, + write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; @@ -254,12 +255,7 @@ pub async fn vendor_yarn_classic( // ── 9. Marker + ledger entry ────────────────────────────────────────── let marker = VendorMarker::new("npm", &base_purl, record, vendored_at); - if let Err(e) = write_marker(&project_root.join(&uuid_dir_rel), &marker).await { - warnings.push(VendorWarning::new( - "vendor_marker_write_failed", - format!("could not write the informational vendor marker: {e}"), - )); - } + write_marker_or_warn(&project_root.join(&uuid_dir_rel), &marker, &mut warnings).await; let entry = VendorEntry { ecosystem: "npm".to_string(), @@ -449,14 +445,13 @@ pub async fn revert_yarn_classic_opts( return outcome; } + // The last npm-family entry leaves `.socket/vendor/npm/` (and + // `.socket/vendor/`) empty: the shared helper prunes them so a reverted + // project carries no vendor residue (non-recursive: siblings keep them). let uuid_dir = project_root.join(&uuid_dir_rel); - if let Err(e) = remove_tree(&uuid_dir).await { + if let Err(e) = remove_tree_and_prune(&uuid_dir, &project_root.join(SOCKET_DIR)).await { return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); } - // The last npm-family entry leaves `.socket/vendor/npm/` (and - // `.socket/vendor/`) empty: prune them so a reverted project carries no - // vendor residue (`remove_dir` keeps non-empty levels). - prune_empty_vendor_levels(&uuid_dir).await; outcome } diff --git a/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs index b9564fb9..085bf19d 100644 --- a/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs +++ b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs @@ -1,11 +1,12 @@ //! Coverage-gap tests for `api::blob_fetcher`'s never-executed error //! branches (audit of commit d5e1815): //! -//! * the three `create_dir_all` early-return branches and the shared -//! `all_failed_result` envelope they drive (blob_fetcher.rs ~117-119, -//! ~130-149, ~169-171, ~275-277) — driven cross-platform via ENOTDIR -//! (the target directory is routed *through a regular file*, which -//! fails even as root, unlike permission tricks); +//! * an uncreatable cache directory — the dir is created by the first +//! verified download inside `write_cache_entry_atomic`, never up front, +//! so the failure is a per-entry "Failed to write ... to disk" and a +//! fetch that lands nothing leaves no `.socket/blobs/` husk — driven +//! cross-platform via ENOTDIR (the target directory is routed *through +//! a regular file*, which fails even as root, unlike permission tricks); //! * the blob-download loop's progress callback (~471) — the diff-loop //! twin is tested in `blob_fetcher_edges_e2e.rs`, this one never ran; //! * the "Failed to write blob/archive to disk" arms (~501-508, @@ -117,31 +118,42 @@ fn dir_entry_count(dir: &Path) -> usize { std::fs::read_dir(dir).unwrap().count() } -// ── create_dir_all failure trio → all_failed_result ───────────────── +// ── uncreatable cache directory → per-entry write failure ─────────── // // The target directory path is routed through a REGULAR FILE -// (`tmp/notadir/`), so `create_dir_all` fails with ENOTDIR on every -// platform, even as root. The presence probes that run first -// (`get_missing_blobs` / `get_missing_archives`) also fail to stat -// through the file, so everything is reported missing and the branch is -// reached with a non-empty work set — making the all-failed envelope -// assertions discriminating. +// (`tmp/notadir/`), so the writer's on-demand `create_dir_all` fails +// with ENOTDIR on every platform, even as root. The presence probes that +// run first (`get_missing_blobs` / `get_missing_archives`) also fail to +// stat through the file, so everything is reported missing and every +// entry is fetched. Each download succeeds and hash-verifies; only the +// disk write fails — so the outcome is the ordinary per-entry +// "Failed to write ... to disk" arm, and the blocking file survives. /// `fetch_missing_blobs` when the blobs directory cannot be created: -/// every missing blob is reported failed with the create-dir message, -/// nothing is downloaded or skipped, and no fetch was attempted (a -/// closed-port fetch would surface a connection error instead). +/// every blob is fetched (the mocks pin one call each) and reported as a +/// per-blob disk-write failure; nothing is downloaded or skipped. #[tokio::test] -async fn fetch_missing_blobs_cannot_create_blobs_dir_reports_all_failed() { +async fn fetch_missing_blobs_uncreatable_blobs_dir_is_per_blob_write_failure() { let tmp = tempfile::tempdir().unwrap(); let notadir = tmp.path().join("notadir"); std::fs::write(¬adir, b"a regular file, not a directory").unwrap(); let blobs = notadir.join("blobs"); - let h1 = "a".repeat(64); - let h2 = "b".repeat(64); + let c1 = b"first blob body"; + let c2 = b"second blob body"; + let h1 = compute_git_sha256_from_bytes(c1); + let h2 = compute_git_sha256_from_bytes(c2); + let server = MockServer::start().await; + for (hash, body) in [(&h1, c1.to_vec()), (&h2, c2.to_vec())] { + Mock::given(method("GET")) + .and(path_matcher(format!("/patch/blob/{hash}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(body)) + .expect(1) + .mount(&server) + .await; + } let manifest = manifest_with_after_hashes(&[&h1, &h2]); - let client = dummy_client(); + let client = proxy_client(&server.uri()); let result = fetch_missing_blobs(&manifest, &blobs, &client, None).await; assert_eq!(result.total, 2, "both missing blobs are accounted for"); @@ -155,40 +167,49 @@ async fn fetch_missing_blobs_cannot_create_blobs_dir_reports_all_failed() { assert!(!entry.success); let err = entry.error.as_deref().unwrap(); assert!( - err.contains("Cannot create blobs directory"), - "early-return message expected (a fetch attempt would say \ - connection refused instead): {err}" + err.contains("Failed to write blob to disk"), + "per-blob disk-write message expected: {err}" ); } // The path through the file is untouched: still a regular file. assert!(notadir.is_file(), "the blocking file must be left alone"); } -/// `fetch_blobs_by_hash`'s own create-dir early return (the -/// rollback-path beforeHash fetcher): bypasses its skip/download -/// bookkeeping entirely. Pins the `all_failed_result` invariant +/// `fetch_blobs_by_hash` (the rollback-path beforeHash fetcher) with an +/// uncreatable blobs directory: the skip bookkeeping sees nothing present, +/// every hash is fetched, and each is a per-blob disk-write failure — /// `total == failed == results.len()`, `downloaded == skipped == 0`. #[tokio::test] -async fn fetch_blobs_by_hash_cannot_create_blobs_dir_reports_all_failed() { +async fn fetch_blobs_by_hash_uncreatable_blobs_dir_is_per_blob_write_failure() { let tmp = tempfile::tempdir().unwrap(); let notadir = tmp.path().join("notadir"); std::fs::write(¬adir, b"file blocking the path").unwrap(); let blobs = notadir.join("blobs"); - let h1 = "c".repeat(64); - let h2 = "d".repeat(64); + let c1 = b"rollback blob one"; + let c2 = b"rollback blob two"; + let h1 = compute_git_sha256_from_bytes(c1); + let h2 = compute_git_sha256_from_bytes(c2); + let server = MockServer::start().await; + for (hash, body) in [(&h1, c1.to_vec()), (&h2, c2.to_vec())] { + Mock::given(method("GET")) + .and(path_matcher(format!("/patch/blob/{hash}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(body)) + .expect(1) + .mount(&server) + .await; + } let hashes: HashSet = [h1.clone(), h2.clone()].into_iter().collect(); - let client = dummy_client(); + let client = proxy_client(&server.uri()); let result = fetch_blobs_by_hash(&hashes, &blobs, &client, None).await; - // The all_failed_result envelope: total == failed == results.len(). assert_eq!(result.total, 2); assert_eq!(result.failed, 2); assert_eq!(result.results.len(), 2); assert_eq!(result.downloaded, 0); assert_eq!( result.skipped, 0, - "the skip bookkeeping must not run when the dir cannot be created" + "nothing can be present under a path that is not a directory" ); let seen: HashSet<&str> = result.results.iter().map(|r| r.hash.as_str()).collect(); assert_eq!(seen, HashSet::from([h1.as_str(), h2.as_str()])); @@ -198,17 +219,18 @@ async fn fetch_blobs_by_hash_cannot_create_blobs_dir_reports_all_failed() { .error .as_deref() .unwrap() - .contains("Cannot create blobs directory")); + .contains("Failed to write blob to disk")); } + assert!(notadir.is_file(), "the blocking file must be left alone"); } /// `fetch_missing_sources` in Diff mode when the archives directory -/// cannot be created — the only producer of the "Cannot create archives -/// directory" message. `get_missing_archives` runs first and correctly -/// reports the uuid missing through the broken path, so the branch is -/// reached with a non-empty set. +/// cannot be created: `get_missing_archives` reports the uuid missing +/// through the broken path, the archive is fetched, and the write is a +/// per-archive "Failed to write archive to disk" failure. The blobs dir +/// is not involved and stays empty. #[tokio::test] -async fn fetch_missing_sources_diff_cannot_create_archives_dir_reports_all_failed() { +async fn fetch_missing_sources_diff_uncreatable_archives_dir_is_per_archive_write_failure() { let tmp = tempfile::tempdir().unwrap(); let blobs = tmp.path().join("blobs"); std::fs::create_dir(&blobs).unwrap(); @@ -223,8 +245,15 @@ async fn fetch_missing_sources_diff_cannot_create_archives_dir_reports_all_faile }; let uuid = "11111111-1111-4111-8111-111111111111"; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_matcher(format!("/patch/diff/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"payload".to_vec())) + .expect(1) + .mount(&server) + .await; let manifest = manifest_with_uuids(&[uuid]); - let client = dummy_client(); + let client = proxy_client(&server.uri()); let result = fetch_missing_sources(&manifest, &sources, DownloadMode::Diff, &client, None).await; @@ -238,13 +267,52 @@ async fn fetch_missing_sources_diff_cannot_create_archives_dir_reports_all_faile assert!(!entry.success); let err = entry.error.as_deref().unwrap(); assert!( - err.contains("Cannot create archives directory"), - "archive-specific create-dir message expected: {err}" + err.contains("Failed to write archive to disk"), + "per-archive disk-write message expected: {err}" ); - // No fetch was attempted, so nothing landed in the blobs dir either. + assert!(notadir.is_file(), "the blocking file must be left alone"); + // Diff mode never touches the blobs dir. assert_eq!(dir_entry_count(&blobs), 0); } +/// A fetch that lands nothing creates nothing: with every blob 404 the +/// blobs directory — and the `.socket/` above it — must not come into +/// existence. The dir is the writer's to create, on the first verified +/// download only. +#[tokio::test] +async fn fetch_missing_blobs_all_not_found_leaves_blobs_dir_absent() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join(".socket"); + let blobs = socket.join("blobs"); + + let h1 = "a".repeat(64); + let h2 = "b".repeat(64); + let server = MockServer::start().await; + for hash in [&h1, &h2] { + Mock::given(method("GET")) + .and(path_matcher(format!("/patch/blob/{hash}"))) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&server) + .await; + } + let manifest = manifest_with_after_hashes(&[&h1, &h2]); + let client = proxy_client(&server.uri()); + + let result = fetch_missing_blobs(&manifest, &blobs, &client, None).await; + assert_eq!(result.total, 2); + assert_eq!(result.failed, 2); + assert_eq!(result.downloaded, 0); + for entry in &result.results { + assert!(!entry.success); + assert!(entry.error.as_deref().unwrap().contains("not found")); + } + assert!( + !blobs.exists() && !socket.exists(), + "a fetch that lands nothing must leave no .socket/blobs/ residue" + ); +} + // ── Blob-loop progress callback ────────────────────────────────────── /// The blob-download loop invokes the progress callback once per blob diff --git a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs index acf89c0c..1b600a92 100644 --- a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs +++ b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs @@ -5,7 +5,7 @@ //! and the home-dir redaction were uncovered. //! //! Hardening notes: every disable-gate test runs inside `with_clean_env`, -//! which scrubs ALL three disabling vars first. Each test then proves +//! which scrubs ALL four disabling vars first. Each test then proves //! *causation*, not mere correlation: //! 1. clean env => NOT disabled (kills an always-`true` impl + ambient //! `SOCKET_OFFLINE=1` masking the result), @@ -21,6 +21,7 @@ use socket_patch_core::telemetry::{is_telemetry_disabled, sanitize_error_message const DISABLE_VARS: &[&str] = &[ "SOCKET_TELEMETRY_DISABLED", "SOCKET_PATCH_TELEMETRY_DISABLED", + "VITEST", "SOCKET_OFFLINE", ]; @@ -109,6 +110,44 @@ fn telemetry_not_disabled_when_socket_telemetry_disabled_falsy() { }); } +/// The `VITEST=true` kill-switch is load-bearing for a downstream consumer: +/// socket-cli's vitest integration suite spawns this binary with its +/// inherited env and sets no `SOCKET_TELEMETRY_DISABLED` (see the +/// `is_telemetry_disabled` docs). +#[test] +#[serial] +fn telemetry_disabled_when_vitest_env_is_true() { + with_clean_env(|| { + assert!(!is_telemetry_disabled(), "baseline must be enabled"); + std::env::set_var("VITEST", "true"); + assert!( + is_telemetry_disabled(), + "VITEST=true must disable telemetry" + ); + std::env::remove_var("VITEST"); + assert!( + !is_telemetry_disabled(), + "removing VITEST must re-enable telemetry" + ); + }); +} + +/// VITEST is matched strictly against `"true"` (not "1"/truthy). Pin it so a +/// regression that loosens the comparison is caught. +#[test] +#[serial] +fn telemetry_not_disabled_when_vitest_is_not_literal_true() { + with_clean_env(|| { + for v in ["1", "", "false", "True", "TRUE", "yes"] { + std::env::set_var("VITEST", v); + assert!( + !is_telemetry_disabled(), + "VITEST={v:?} must NOT disable telemetry (only literal 'true' does)" + ); + } + }); +} + #[test] #[serial] fn telemetry_disabled_legacy_socket_patch_var_honored() { From ed7386909a11d69c5244a03713db913cc2f21b31 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 19:58:48 -0400 Subject: [PATCH 13/44] cli(scan): vendored mode is manifest-free (D2), GC lock hygiene scan --mode vendored now takes the detached path unconditionally: records are fetched in memory (download_patch_records), the vendor engine gets them as detached records with embedded copies, and .socket/manifest.json is never written or read as a record source. The manifest-mode branches in both arms and in run_scan_vendor_step are gone (download_and_apply, the "whole manifest is vendored" re-vendor, reconcile_dropped, invalid_manifest, the Option error payload); --detached stays an accepted no-op and download.detached: true stays on the JSON sub-object. - run_scan_vendor_step: empty selection is a no-op BEFORE the lock (nothing to vendor => no .socket/ created); the pre-lock create_dir_all + socket_dir_unwritable code is gone (acquire owns the dir, a file squatting on .socket is lock_io); lock failures render through lock_cli::lock_failure (--lock-timeout wait clause); the guard now covers the ledger migration and the redirect-ledger reconcile in note_vendor_supersedes_redirect (was an unlocked RMW). - Legacy manifest-mode projects are migrated on their next vendored run (migrate_legacy_manifest_records): a same-uuid legacy entry is upgraded in place (detached + record), every manifest record the ledger owns is dropped, an emptied manifest stays {"patches":{}} (D4); reported as run-level warnings vendor_manifest_record_migrated / vendor_manifest_migration_failed, never as a run error. - GC runs AFTER the vendor step in both arms (the step never reads the manifest; the sweep reclaims what the run orphaned). - gc.rs: run_apply_gc gates on manifest existence BEFORE acquiring (a bare project's scan --prune never creates .socket/), honors --lock-timeout, and distinguishes Held from Io; both are recorded as an additive `skipped: {code, message}` on the apply shape instead of a silent all-zero pass. absorb_vendor_gc carries VendorGcSummary.failed as `failedVendoredEntries` (apply shape) plus a human line; the vendored half's lock marker feeds `skipped`. The dead `skipped: bool` field and its two arms are deleted. - boxed_scan_vendor_step keeps its signature for get.rs: Some(records) runs the manifest-free step, None the pre-D2 manifest-mode shim (legacy_manifest_vendor_step) until get passes its records. Tests: owned suites re-pinned to the new footprint (no manifest after vendored runs, detached entries with records, apply.lock gone, empty .socket/ gone, download vocabulary downloaded/skipped/failed); new migration unit tests + e2e; GC lock-skip / lock-io / --lock-timeout / pristine-project unit tests; covgap error tests re-fixtured (real work + external lock / dir squat / contentless view). Co-Authored-By: Claude Fable 5.1 --- .../socket-patch-cli/src/commands/scan/gc.rs | 345 +++++-- .../src/commands/scan/vendor_flow.rs | 841 +++++++++++++----- .../tests/covgap_commands_scan_vendor_flow.rs | 246 ++--- .../socket-patch-cli/tests/e2e_bun_lockb.rs | 15 +- .../tests/in_process_vendor_bun.rs | 148 +-- .../tests/in_process_vendor_bun_takeover.rs | 11 +- .../socket-patch-cli/tests/scan_vendor_e2e.rs | 332 ++++--- 7 files changed, 1314 insertions(+), 624 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index 6b43953e..c9bfc9b7 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -7,11 +7,15 @@ use socket_patch_core::manifest::cleanup_blobs::{ }; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::patch::apply_lock; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; use std::collections::HashSet; use std::path::Path; +use std::time::Duration; use crate::args::GlobalArgs; +use crate::commands::lock_cli::lock_failure; +use crate::commands::vendor::VendorGcSummary; /// Aggregated outcome of a GC pass (or preview). Serialized into the /// `scan --json` output's `gc` sub-object. See CLI_CONTRACT.md for the @@ -38,11 +42,19 @@ pub(super) struct GcSummary { /// `vendored_reverted` — this field is what lets the apply output /// explain the difference. vendored_kept: Vec, + /// Vendored entries whose wet revert FAILED: the ledger entry and the + /// artifacts were kept, nothing reclaimed. Sorted. Always empty in + /// preview mode (nothing is reverted there). + vendored_failed: Vec, /// Orphan `.socket/vendor//` dirs swept (or sweepable). vendor_orphan_dirs: usize, - /// `true` when `--no-prune` was set; the sub-object only carries the - /// `skipped: true` field in that case. - skipped: bool, + /// Set when a wet pass could not take the apply lock and so skipped + /// its manifest prune + blob sweep: `lock_held` (another run holds it + /// — the contract's skip-not-fail posture) or `lock_io` (the lock file + /// could not be created or opened). `(code, message)` exactly as + /// `lock_cli::lock_failure` renders them. Never set in preview mode + /// (the preview is lock-free and read-only). + skipped: Option<(&'static str, String)>, } impl GcSummary { @@ -50,8 +62,20 @@ impl GcSummary { self.blobs.bytes_freed + self.diffs.bytes_freed + self.packages.bytes_freed } - /// Fold a vendored-state GC pass into this summary. - fn absorb_vendor_gc(&mut self, v: crate::commands::vendor::VendorGcSummary) { + /// A summary carrying only the vendored-state half — the shape every + /// early return takes when the manifest half cannot run. + fn vendor_only(v: VendorGcSummary) -> Self { + let mut gc = GcSummary::default(); + gc.absorb_vendor_gc(v); + gc + } + + /// Fold a vendored-state GC pass into this summary. `failed` is + /// partitioned: ledger keys (every one a `pkg:` purl) are entries + /// whose revert failed; anything else is the pass-level lock-skip + /// marker `run_vendor_gc` records in place of a purl, which lands in + /// `skipped` unless this pass already recorded its own reason. + fn absorb_vendor_gc(&mut self, v: VendorGcSummary) { self.vendored_reverted = v .dropped_reverted .into_iter() @@ -60,31 +84,40 @@ impl GcSummary { self.vendored_reverted.sort(); self.vendored_kept = v.kept; self.vendored_kept.sort(); + let (failed, markers): (Vec, Vec) = + v.failed.into_iter().partition(|f| f.starts_with("pkg:")); + self.vendored_failed = failed; + self.vendored_failed.sort(); + if self.skipped.is_none() { + if let Some(marker) = markers.into_iter().next() { + self.skipped = Some(("lock_held", marker)); + } + } self.vendor_orphan_dirs = v.orphan_dirs; } - /// Serialize for a *mutating* GC pass (post-apply). + /// Serialize for a *mutating* GC pass (post-apply). `skipped` is + /// additive: present only when the lock could not be taken. fn to_apply_json(&self) -> serde_json::Value { - if self.skipped { - return serde_json::json!({ "skipped": true }); - } - serde_json::json!({ + let mut json = serde_json::json!({ "prunedManifestEntries": self.pruned, "removedBlobs": self.blobs.blobs_removed, "removedDiffArchives": self.diffs.blobs_removed, "removedPackageArchives": self.packages.blobs_removed, "revertedVendoredEntries": self.vendored_reverted, "keptVendoredEntries": self.vendored_kept, + "failedVendoredEntries": self.vendored_failed, "removedVendorOrphanDirs": self.vendor_orphan_dirs, "bytesFreed": self.total_bytes(), - }) + }); + if let Some((code, message)) = &self.skipped { + json["skipped"] = serde_json::json!({ "code": code, "message": message }); + } + json } /// Serialize for a *non-mutating* GC pass (read-only preview). fn to_preview_json(&self) -> serde_json::Value { - if self.skipped { - return serde_json::json!({ "skipped": true }); - } serde_json::json!({ "prunableManifestEntries": self.pruned, "orphanBlobs": self.blobs.blobs_removed, @@ -125,13 +158,13 @@ async fn run_gc( } } -/// Apply-mode GC: re-read the manifest written by `download_and_apply_patches`, -/// prune manifest entries for PURLs not in `scanned_purls`, write the manifest -/// back, then sweep orphan blob/diff/package files. Callers must gate on the -/// `prune` flag — when GC isn't requested, simply don't call this function and -/// don't emit a `gc` sub-object. +/// Apply-mode GC: run the vendored-state GC, then — when a manifest exists +/// — prune manifest entries for PURLs not in `scanned_purls`, write the +/// manifest back, and sweep orphan blob/diff/package files. Callers must +/// gate on the `prune` flag — when GC isn't requested, simply don't call +/// this function and don't emit a `gc` sub-object. pub(super) async fn run_apply_gc( - common: &crate::args::GlobalArgs, + common: &GlobalArgs, manifest_path: &Path, socket_dir: &Path, scanned_purls: &HashSet, @@ -141,39 +174,48 @@ pub(super) async fn run_apply_gc( // lockfile-unused vendored entries, dropping the latter's manifest // entries — so the manifest prune + blob sweep below reclaims their // blobs in this same pass (and the stale `vendored` exemption set is - // harmless: the entries it would exempt are already gone). + // harmless: the entries it would exempt are already gone). It takes + // the apply lock itself for its wet work and releases it on return. let vendor_gc = crate::commands::vendor::run_vendor_gc(common, manifest_path, /*dry_run=*/ false).await; + // No manifest ⇒ nothing to prune, and the blob sweep has no + // referenced-set to work from. Decided BEFORE the lock: `acquire` + // creates `.socket/` when it is missing, and a plain `scan --prune` on + // a project that has none (every manifest-free vendored project) must + // not conjure the directory just to find nothing to do. + if !tokio::fs::metadata(manifest_path) + .await + .is_ok_and(|m| m.is_file()) + { + return GcSummary::vendor_only(vendor_gc); + } + // The prune below is a manifest read-modify-write plus a blob/archive // sweep — the writes the apply lock serializes everywhere else (apply, - // get, remove, repair, rollback, and the vendored half above, which - // takes it internally). Run unlocked against a live holder mid-write, - // the stale read would clobber the holder's new manifest entry on - // write-back and the sweep would delete its just-downloaded blobs. - // Contention skips the pass without failing the scan — the vendored - // half's posture (acquired after it returns, since it self-locks). - let _guard = match socket_patch_core::patch::apply_lock::acquire( - socket_dir, - std::time::Duration::ZERO, - ) { + // get, remove, repair, rollback, and the vendored half above). Run + // unlocked against a live holder mid-write, the stale read would + // clobber the holder's new manifest entry on write-back and the sweep + // would delete its just-downloaded blobs. Contention skips the pass + // without failing the scan; an I/O fault on the lock file skips it + // too — both are recorded so the output explains the untouched + // manifest instead of reading as a clean all-zero pass. + let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); + let _guard = match apply_lock::acquire(socket_dir, timeout) { Ok(g) => g, - Err(_) => { - let mut gc = GcSummary::default(); - gc.absorb_vendor_gc(vendor_gc); + Err(e) => { + let mut gc = GcSummary::vendor_only(vendor_gc); + gc.skipped = Some(lock_failure(&e, timeout)); return gc; } }; - // Re-read the just-written manifest (the apply step may have added - // or updated entries we now want to consider for pruning). + // Re-read the manifest under the lock (the apply step may have added + // or updated entries we now want to consider for pruning; the probe + // above was only the cheap pre-lock gate). let mut manifest = match read_manifest(manifest_path).await { Ok(Some(m)) => m, - _ => { - let mut gc = GcSummary::default(); - gc.absorb_vendor_gc(vendor_gc); - return gc; - } + _ => return GcSummary::vendor_only(vendor_gc), }; let prunable = detect_prunable(&manifest, scanned_purls, vendored); for purl in &prunable { @@ -193,7 +235,7 @@ pub(super) async fn run_apply_gc( /// [`run_apply_gc`] but emits `prunable*`/`orphan*` field names and /// performs no mutation. async fn preview_apply_gc( - common: &crate::args::GlobalArgs, + common: &GlobalArgs, manifest_path: &Path, socket_dir: &Path, scanned_purls: &HashSet, @@ -205,11 +247,7 @@ async fn preview_apply_gc( let mut manifest = match read_manifest(manifest_path).await { Ok(Some(m)) => m, - _ => { - let mut gc = GcSummary::default(); - gc.absorb_vendor_gc(vendor_gc); - return gc; - } + _ => return GcSummary::vendor_only(vendor_gc), }; // Mirror the wet pass: an unused vendored entry's manifest keys are // dropped before the blob sweep, so drop them from the in-memory copy @@ -259,9 +297,13 @@ pub(super) async fn gc_json( } } -/// Human-readable line(s) for the vendored-state half of a GC pass; -/// prints nothing when that half did nothing. +/// Human-readable line(s) for the vendored-state half of a GC pass (and +/// the lock-skip reason, when the manifest half could not run); prints +/// nothing when there is nothing to report. pub(super) fn print_gc_vendored_line(gc: &GcSummary) { + if let Some((code, message)) = &gc.skipped { + println!("GC: skipped ({code}): {message}."); + } if !gc.vendored_reverted.is_empty() || gc.vendor_orphan_dirs > 0 { println!( "GC: reverted {} vendored entr{}; swept {} orphan vendor dir{}.", @@ -292,6 +334,21 @@ pub(super) fn print_gc_vendored_line(gc: &GcSummary) { }, ); } + // A failed revert leaves the entry and its artifacts in place; the + // backend's own error was printed as it happened, so name what was + // not reclaimed. + if !gc.vendored_failed.is_empty() { + println!( + "GC: failed to revert {} vendored entr{}: {}.", + gc.vendored_failed.len(), + if gc.vendored_failed.len() == 1 { + "y" + } else { + "ies" + }, + gc.vendored_failed.join(", "), + ); + } } /// PURL strings present in the manifest but absent from `scanned_purls`. @@ -685,6 +742,94 @@ mod tests { m.patches.contains_key("pkg:npm/gone@1.0.0"), "manifest entry must survive a lock-contended GC pass" ); + // The skip is RECORDED, not silent: contention is `lock_held`, and + // with no `--lock-timeout` the message carries no waited clause. + assert_eq!( + gc.skipped, + Some(( + "lock_held", + "another socket-patch process is operating in this directory".to_string() + )), + "a lock-contended pass must say why it pruned nothing" + ); + let json = gc.to_apply_json(); + assert_eq!(json["skipped"]["code"], "lock_held", "{json}"); + assert_eq!(json["prunedManifestEntries"], serde_json::json!([]), "{json}"); + } + + /// `--lock-timeout` reaches the GC acquire: the pass waits (and says + /// so) instead of silently try-once-skipping while the flag promised a + /// wait budget. + #[tokio::test] + async fn run_apply_gc_honors_lock_timeout_and_reports_the_wait() { + let tmp = tempfile::tempdir().unwrap(); + let (manifest_path, socket_dir, _blob) = + seed_manifest_with_blob(tmp.path(), "pkg:npm/gone@1.0.0", &"c".repeat(64)); + let _holder = + socket_patch_core::patch::apply_lock::acquire(&socket_dir, std::time::Duration::ZERO) + .expect("test holder must win the fresh lock"); + let common = crate::args::GlobalArgs { + lock_timeout: Some(1), + ..gc_common(tmp.path()) + }; + + let started = std::time::Instant::now(); + let gc = run_apply_gc( + &common, + &manifest_path, + &socket_dir, + &scanned(&[]), + &no_vendored(), + ) + .await; + + assert!( + started.elapsed() >= std::time::Duration::from_millis(900), + "the pass must wait out the configured budget before skipping" + ); + assert_eq!( + gc.skipped, + Some(( + "lock_held", + "another socket-patch process is operating in this directory (waited 1s)" + .to_string() + )) + ); + assert!(gc.pruned.is_empty()); + } + + /// A DIRECTORY squatting on `apply.lock` is an I/O fault, not + /// contention: the pass skips (nothing pruned, nothing swept) and + /// records `lock_io` — never the contention wording. + #[tokio::test] + async fn run_apply_gc_reports_lock_io_when_lock_file_is_unopenable() { + let tmp = tempfile::tempdir().unwrap(); + let after_hash = "f".repeat(64); + let (manifest_path, socket_dir, blob_path) = + seed_manifest_with_blob(tmp.path(), "pkg:npm/gone@1.0.0", &after_hash); + std::fs::create_dir_all(socket_dir.join("apply.lock")).unwrap(); + + let gc = run_apply_gc( + &gc_common(tmp.path()), + &manifest_path, + &socket_dir, + &scanned(&[]), + &no_vendored(), + ) + .await; + + let (code, message) = gc.skipped.as_ref().expect("the I/O fault must be recorded"); + assert_eq!(*code, "lock_io"); + assert!( + message.contains("apply.lock"), + "the reason names the lock file: {message}" + ); + assert!(gc.pruned.is_empty(), "pruned {:?}", gc.pruned); + assert_eq!(gc.blobs.blobs_removed, 0); + assert!(blob_path.exists(), "nothing may be swept on a lock fault"); + let m = read_manifest(&manifest_path).await.unwrap().unwrap(); + assert!(m.patches.contains_key("pkg:npm/gone@1.0.0")); + assert_eq!(gc.to_apply_json()["skipped"]["code"], "lock_io"); } #[tokio::test] @@ -792,6 +937,43 @@ mod tests { !manifest_path.exists(), "the aborted pass must not conjure a manifest file" ); + assert!( + !socket_dir.join("apply.lock").exists(), + "the manifest gate runs before the lock — no lock file may be created" + ); + assert!( + gc.skipped.is_none(), + "a missing manifest is not a lock skip: {:?}", + gc.skipped + ); + } + + /// A project with NO `.socket/` at all (a plain `scan --prune` on a + /// bare or manifest-free vendored checkout): the manifest gate returns + /// before `acquire`, which would otherwise create the directory just + /// to find nothing to prune. + #[tokio::test] + async fn run_apply_gc_creates_no_socket_dir_on_a_pristine_project() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + let manifest_path = socket_dir.join("manifest.json"); + + let gc = run_apply_gc( + &gc_common(tmp.path()), + &manifest_path, + &socket_dir, + &scanned(&[]), + &no_vendored(), + ) + .await; + + assert!(gc.pruned.is_empty()); + assert_eq!(gc.total_bytes(), 0); + assert!(gc.skipped.is_none(), "{:?}", gc.skipped); + assert!( + !socket_dir.exists(), + "a prune over a pristine project must leave no .socket/ behind" + ); } #[tokio::test] @@ -857,8 +1039,7 @@ mod tests { !manifest_path.exists(), "preview must not create a manifest file" ); - // The serialized degenerate preview is the normal all-zero shape, - // not the `skipped` one. + // The serialized degenerate preview is the normal all-zero shape. let json = gc.to_preview_json(); assert_eq!(json["prunableManifestEntries"], serde_json::json!([])); assert_eq!(json["orphanBlobs"], serde_json::json!(0)); @@ -1132,15 +1313,23 @@ mod tests { assert!(uuid_dir.exists(), "kept artifacts must survive the sweep"); } - /// The `keptVendoredEntries` plumbing in isolation: absorbed sorted, - /// serialized on the apply shape, absent from the preview shape (a - /// read-only preview cannot detect drift, so emitting a constant `[]` - /// would claim a check that never ran). + /// The `keptVendoredEntries` / `failedVendoredEntries` / `skipped` + /// plumbing in isolation: absorbed sorted, serialized on the apply + /// shape, absent from the preview shape (a read-only preview cannot + /// detect drift, reverts nothing and takes no lock, so emitting a + /// constant `[]`/marker would claim a check that never ran). The + /// vendored half's `failed` is partitioned: purls are failed reverts, + /// the pass-level lock marker becomes the `skipped` reason. #[test] fn gc_json_shapes_carry_drift_keeps_only_on_apply() { let mut gc = GcSummary::default(); - gc.absorb_vendor_gc(crate::commands::vendor::VendorGcSummary { + gc.absorb_vendor_gc(VendorGcSummary { kept: vec!["pkg:npm/b@1.0.0".into(), "pkg:npm/a@1.0.0".into()], + failed: vec![ + "pkg:npm/d@1.0.0".into(), + "vendor GC skipped: another socket-patch run holds the apply lock".into(), + "pkg:npm/c@1.0.0".into(), + ], ..Default::default() }); assert_eq!( @@ -1148,16 +1337,56 @@ mod tests { vec!["pkg:npm/a@1.0.0".to_string(), "pkg:npm/b@1.0.0".to_string()], "absorb must sort, like every other purl list" ); + assert_eq!( + gc.vendored_failed, + vec!["pkg:npm/c@1.0.0".to_string(), "pkg:npm/d@1.0.0".to_string()], + "failed reverts are absorbed sorted, the marker filtered out" + ); + assert_eq!( + gc.skipped, + Some(( + "lock_held", + "vendor GC skipped: another socket-patch run holds the apply lock".to_string() + )), + "the vendored half's lock marker is the skip reason" + ); let apply = gc.to_apply_json(); assert_eq!( apply["keptVendoredEntries"], serde_json::json!(["pkg:npm/a@1.0.0", "pkg:npm/b@1.0.0"]) ); + assert_eq!( + apply["failedVendoredEntries"], + serde_json::json!(["pkg:npm/c@1.0.0", "pkg:npm/d@1.0.0"]) + ); assert_eq!(apply["revertedVendoredEntries"], serde_json::json!([])); + assert_eq!(apply["skipped"]["code"], "lock_held", "{apply}"); let preview = gc.to_preview_json(); + for key in ["keptVendoredEntries", "failedVendoredEntries", "skipped"] { + assert!( + preview.get(key).is_none(), + "preview must not claim a check it cannot run ({key}): {preview}" + ); + } + + // A pass that took its own lock fine reports NO skip, and the + // apply shape omits the key entirely (additive: absent, not null). + let clean = GcSummary::vendor_only(VendorGcSummary::default()); + assert!(clean.skipped.is_none()); assert!( - preview.get("keptVendoredEntries").is_none(), - "preview must not claim a drift check it cannot run: {preview}" + clean.to_apply_json().get("skipped").is_none(), + "{}", + clean.to_apply_json() ); + // This pass's own reason wins over the vendored half's marker. + let mut own = GcSummary { + skipped: Some(("lock_io", "failed to open lock file".to_string())), + ..Default::default() + }; + own.absorb_vendor_gc(VendorGcSummary { + failed: vec!["vendor GC skipped: another socket-patch run holds the apply lock".into()], + ..Default::default() + }); + assert_eq!(own.skipped.as_ref().map(|(c, _)| *c), Some("lock_io")); } } diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index bfb16fb5..e1c075a8 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -1,16 +1,27 @@ //! The vendored-mode (`--mode vendored` / `--vendor`) flow driven by -//! `scan`: the shared download + GC + vendor-engine step, its JSON and +//! `scan`: the shared download → vendor-engine → GC step, its JSON and //! interactive arms, the pre-download skip partitions, and the `boxed_*` //! transient-frame constructors that keep the never-taken vendor branches //! out of `run`'s poll frame (Windows 1 MiB main-thread stack). +//! +//! Vendored mode is manifest-free: the download phase fetches the patch +//! records in memory ([`download_patch_records`]), the vendor engine +//! embeds each record in its ledger entry (`detached: true`), and +//! `.socket/manifest.json` is never written — a project vendored by an +//! older, manifest-mode CLI is migrated on its next vendored run (see +//! [`migrate_legacy_manifest_records`]). `--detached` is accepted as a +//! no-op for compatibility. use socket_patch_core::api::client::get_api_client_with_overrides; use socket_patch_core::api::types::{BatchPackagePatches, PatchSearchResult}; -use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::apply_lock; use socket_patch_core::telemetry::track_patch_vendor_failed; -use socket_patch_core::vendor::{load_state, lookup_entry, VendorServiceConfig, VendorSource}; +use socket_patch_core::utils::purl::strip_purl_qualifiers; +use socket_patch_core::vendor::{ + load_state, lookup_entry, save_state, VendorServiceConfig, VendorSource, VendorState, +}; use std::collections::{HashMap, HashSet}; use std::path::Path; use std::time::Duration; @@ -18,11 +29,12 @@ use std::time::Duration; use crate::args::GlobalArgs; use crate::commands::bun_preflight::bun_vendor_preflight_with_ledger; use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; -use crate::commands::get::{download_and_apply_patches, download_patch_records, DownloadParams}; +use crate::commands::get::{download_patch_records, DownloadParams}; +use crate::commands::lock_cli::lock_failure; use crate::commands::vendor::{ note_classic_migration_risk, reconcile_dropped, track_outcomes_for_vendor, vendor_records, }; -use crate::json_envelope::{Command as EnvelopeCommand, Envelope}; +use crate::json_envelope::{Command as EnvelopeCommand, Envelope, RunWarning}; use super::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; use super::{ @@ -30,6 +42,23 @@ use super::{ note_vendor_supersedes_redirect, ScanArgs, }; +/// Run-level warning: a `.socket/manifest.json` record for a purl the +/// vendor ledger now owns (detached entry with an embedded record) was +/// dropped — the ledger is the single owner of vendored state. +const VENDOR_MANIFEST_RECORD_MIGRATED: &str = "vendor_manifest_record_migrated"; +/// Run-level warning: the migration above could not read or rewrite the +/// manifest (or the ledger); the legacy records were left in place. +const VENDOR_MANIFEST_MIGRATION_FAILED: &str = "vendor_manifest_migration_failed"; + +/// Pretty-print one JSON document to stdout — every `--json` consumer +/// parses stdout as exactly one document. +fn print_json(v: &serde_json::Value) { + println!( + "{}", + serde_json::to_string_pretty(v).expect("serializing an in-memory JSON value cannot fail") + ); +} + /// Dry-run preview for `scan --vendor` (and `get … --mode vendored /// --dry-run`): classify each selected patch against the vendor ledger /// without writing anything or touching the network beyond discovery. @@ -135,97 +164,81 @@ fn scan_vendor_service_config( } } -/// The vendor step shared by `scan --vendor`'s JSON and interactive -/// paths: acquire the apply lock, stage patch sources, and drive -/// [`vendor_records`] — manifest mode (`detached_records: None`, records -/// come from re-reading the manifest, preceded by the same reconcile as -/// the `vendor` command) or detached mode (`Some(records)` from -/// [`download_patch_records`]; no manifest involvement at all). +/// The vendor step shared by `scan --vendor`'s JSON and interactive arms +/// (and, through [`boxed_scan_vendor_step`], `get --mode vendored`): +/// acquire the apply lock, stage the in-memory `records` (from +/// [`download_patch_records`]), drive [`vendor_records`] detached — every +/// ledger entry embeds its record; `.socket/manifest.json` is never a +/// record source — then migrate any legacy manifest records the ledger now +/// owns and run the run-level advisories, all under the lock. +/// +/// An empty `records` map (nothing selected, or everything refused/failed +/// in the download phase) is a no-op BEFORE the lock: nothing is staged, +/// the engine does not run, and no `.socket/` is created for a run with +/// nothing to vendor. /// /// `Ok((has_errors, envelope))` on a run that reached the engine; -/// `Err((code, message, envelope))` for the lock/stage/manifest failures -/// the caller folds into its own output shape (scan's ad-hoc JSON can't -/// use `acquire_or_emit`, which prints an Envelope). The error carries -/// the envelope built so far when the failure happened AFTER -/// `reconcile_dropped` ran — the reconcile mutates the on-disk ledger, -/// and its events must survive the error fold or the JSON consumer -/// never learns about the mutation. +/// `Err((code, message))` for the lock/stage failures the caller folds +/// into its own output shape (scan's ad-hoc JSON can't use +/// `acquire_or_emit`, which prints an Envelope). Nothing mutates the +/// project before staging, so an error carries no partial envelope. async fn run_scan_vendor_step( common: &GlobalArgs, manifest_path: &Path, socket_dir: &Path, - detached_records: Option<&HashMap>, -) -> Result<(bool, Envelope), (&'static str, String, Option>)> { - // The download phase created `.socket/` already in every flow that - // reaches here, but `acquire` deliberately refuses to mkdir. - if let Err(e) = tokio::fs::create_dir_all(socket_dir).await { - return Err(("socket_dir_unwritable", e.to_string(), None)); - } - let guard = apply_lock::acquire( - socket_dir, - Duration::from_secs(common.lock_timeout.unwrap_or(0)), - ) - .map_err(|e| match e { - apply_lock::LockError::Held => ( - "lock_held", - "another socket-patch process is operating in this directory".to_string(), - None, - ), - apply_lock::LockError::Io { .. } => ("lock_io", e.to_string(), None), - })?; - + records: HashMap, +) -> Result<(bool, Envelope), (&'static str, String)> { let mut env = Envelope::new(EnvelopeCommand::Vendor); env.dry_run = common.dry_run; - let (manifest, detached, mut has_errors) = match detached_records { - Some(records) => { - // Staging probes blobs by the records' hashes; a synthetic - // manifest view is all it needs. - let synth = PatchManifest { - patches: records.clone(), - setup: None, - }; - (synth, true, false) - } - None => { - let manifest = match read_manifest(manifest_path).await { - Ok(Some(m)) => m, - Ok(None) => { - // No manifest ⇒ nothing downloaded and nothing - // pre-existing to vendor: a clean no-op. Wiring from a - // previous run may still sit in the lockfile, so the - // state-based migration-risk advisory still applies. - note_classic_migration_risk(&mut env, &common.cwd, common); - note_vendor_supersedes_redirect(&mut env, &common.cwd, common).await; - drop(guard); - return Ok((false, env)); - } - Err(e) => return Err(("invalid_manifest", e.to_string(), None)), - }; - // Same placement as the `vendor` command: dropped entries - // are reverted even when zero in-scope patches remain. - let has_errors = reconcile_dropped(&manifest, common, &mut env).await; - (manifest, false, has_errors) + if records.is_empty() { + return Ok((false, env)); + } + let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); + // `acquire` creates `.socket/` itself and reports a file squatting on + // it as `LockError::Io` (→ `lock_io`). The guard lives to the end of + // the step so the ledger migration and the redirect-ledger reconcile + // inside `note_vendor_supersedes_redirect` run under the lock too. + let _guard = apply_lock::acquire(socket_dir, timeout).map_err(|e| lock_failure(&e, timeout))?; + + // Staging probes blobs by the records' hashes; a manifest VIEW over the + // in-memory records (a move, not a clone) is all it needs. + let manifest = PatchManifest { + patches: records, + setup: None, + }; + let has_errors = + stage_and_vendor(common, socket_dir, &manifest, /*detached=*/ true, &mut env).await?; + migrate_legacy_manifest_records(common, manifest_path, &manifest.patches, &mut env).await; + if has_errors { + env.mark_partial_failure(); + } + note_classic_migration_risk(&mut env, &common.cwd, common); + note_vendor_supersedes_redirect(&mut env, &common.cwd, common).await; + Ok((has_errors, env)) +} + +/// Stage `manifest`'s patch sources in memory and drive the vendor engine +/// over them. The caller holds the apply lock. `Err` is the +/// `no_local_source` fold (staging could not obtain the patch content — +/// offline, or the view fetch failed). +async fn stage_and_vendor( + common: &GlobalArgs, + socket_dir: &Path, + manifest: &PatchManifest, + detached: bool, + env: &mut Envelope, +) -> Result { + let staged = match stage_vendor_sources_in_memory(common, manifest, socket_dir, &common.cwd) + .await + { + MemStageOutcome::Ready(s) => s, + MemStageOutcome::Unavailable => { + return Err(( + "no_local_source", + "patch artifacts unavailable (offline or download failure)".to_string(), + )); } }; - let staged = - match stage_vendor_sources_in_memory(common, &manifest, socket_dir, &common.cwd).await { - MemStageOutcome::Ready(s) => s, - MemStageOutcome::Unavailable => { - // The reconcile above may have already reverted dropped - // entries on disk — hand its envelope to the error fold. - // Demote its status first: a fresh Envelope starts at - // Success and a clean reconcile leaves it there, but this - // run is aborting — a consumer reading `.vendor.status` - // inside a `"status":"error"` result must not see - // "success". - env.mark_partial_failure(); - return Err(( - "no_local_source", - "patch artifacts unavailable (offline or download failure)".to_string(), - Some(Box::new(env)), - )); - } - }; let sources = staged.as_patch_sources(); // Honor `--vendor-source` (and `--vendor-url` / `--patch-server-url`) // exactly as the `vendor` command does: build the SAME service config so @@ -237,16 +250,201 @@ async fn run_scan_vendor_step( let (client, use_public_proxy) = get_api_client_with_overrides(common.api_client_overrides()).await; let service = scan_vendor_service_config(common, Some(client), use_public_proxy); - has_errors |= boxed_vendor_records( + Ok(boxed_vendor_records( common, &manifest.patches, &sources, detached, Some(&service), - &mut env, + env, ) - .await; - drop(guard); + .await) +} + +/// Record a run-level advisory: stderr `Warning (code): detail` in human +/// mode (informational, so muted by `--silent`) and `warnings[]` on the +/// envelope for JSON consumers. +fn push_run_warning(env: &mut Envelope, common: &GlobalArgs, code: &str, detail: String) { + if !common.silent && !common.json { + eprintln!("Warning ({code}): {detail}"); + } + env.warnings.push(RunWarning { + code: code.to_string(), + detail, + }); +} + +/// The ledger key addressable as `purl`: the exact key, else the entry +/// whose resolved `base_purl` equals it (see [`lookup_entry`]). +fn ledger_key_for(state: &VendorState, purl: &str) -> Option { + if state.entries.contains_key(purl) { + return Some(purl.to_string()); + } + state + .entries + .iter() + .find(|(_, e)| e.base_purl == purl) + .map(|(k, _)| k.clone()) +} + +/// Migrate a project vendored by an older, manifest-mode CLI: the ledger +/// is now the single owner of vendored state, so (1) a legacy entry the +/// engine just found in sync at a record's uuid (an `already_vendored` +/// skip persists nothing) is upgraded in place — `detached: true` plus the +/// embedded record, the verification source every manifest-free reader +/// needs — and (2) every `.socket/manifest.json` record keyed by (or +/// sharing a qualifier-stripped base with) a ledger-owned entry is +/// dropped. An emptied manifest is left as `{"patches":{}}` (never +/// deleted: `list`/`apply`/`repair` distinguish empty from missing). +/// Idempotent, so it also heals records stranded by earlier detached +/// runs; a project with no manifest is untouched (no file is created). +/// Best-effort: failures are reported as run-level warnings, never as +/// run errors — the vendoring itself already committed. +/// +/// Caller holds the apply lock (both files are rewritten). +async fn migrate_legacy_manifest_records( + common: &GlobalArgs, + manifest_path: &Path, + records: &HashMap, + env: &mut Envelope, +) { + let mut manifest = match read_manifest(manifest_path).await { + Ok(Some(m)) => m, + Ok(None) => return, + Err(e) => { + push_run_warning( + env, + common, + VENDOR_MANIFEST_MIGRATION_FAILED, + format!( + "could not read {}: {e}; any records it holds for vendored packages \ + were left in place", + manifest_path.display() + ), + ); + return; + } + }; + if manifest.patches.is_empty() { + return; + } + // An unreadable ledger is the engine's report; nothing to migrate to. + let Ok(mut state) = load_state(&common.cwd).await else { + return; + }; + + // (1) Legacy same-uuid entries: upgrade in place. + let mut upgraded = false; + for (purl, record) in records { + let Some(key) = ledger_key_for(&state, purl) else { + continue; + }; + let entry = state.entries.get_mut(&key).expect("key listed above"); + if entry.uuid == record.uuid && !(entry.detached && entry.record.is_some()) { + entry.detached = true; + entry.record = Some(record.clone()); + upgraded = true; + } + } + if upgraded { + if let Err(e) = save_state(&common.cwd, &state).await { + push_run_warning( + env, + common, + VENDOR_MANIFEST_MIGRATION_FAILED, + format!("could not rewrite the vendor ledger: {e}; manifest records left in place"), + ); + return; + } + } + + // (2) Drop the manifest records the ledger now owns. + let mut dropped: Vec = Vec::new(); + for (purl, entry) in state + .entries + .iter() + .filter(|(_, e)| e.detached && e.record.is_some()) + { + let base = strip_purl_qualifiers(&entry.base_purl); + let keys: Vec = manifest + .patches + .keys() + .filter(|k| *k == purl || strip_purl_qualifiers(k) == base) + .cloned() + .collect(); + for k in keys { + manifest.patches.remove(&k); + dropped.push(k); + } + } + if dropped.is_empty() { + return; + } + dropped.sort(); + match write_manifest(manifest_path, &manifest).await { + Ok(()) => push_run_warning( + env, + common, + VENDOR_MANIFEST_RECORD_MIGRATED, + format!( + "{} manifest record{} moved to the vendor ledger (vendored mode is \ + manifest-free): {}", + dropped.len(), + if dropped.len() == 1 { "" } else { "s" }, + dropped.join(", ") + ), + ), + Err(e) => push_run_warning( + env, + common, + VENDOR_MANIFEST_MIGRATION_FAILED, + format!( + "could not rewrite {} after moving {} to the vendor ledger: {e}", + manifest_path.display(), + dropped.join(", ") + ), + ), + } +} + +/// COMPATIBILITY SHIM for `get --mode vendored`'s two manifest-mode callers +/// (`boxed_scan_vendor_step(.., None)`): the pre-D2 vendor step — read the +/// manifest as the work list (`invalid_manifest` on a corrupt one, a clean +/// no-op on none), reconcile dropped entries like the `vendor` command, +/// stage, vendor NON-detached. The `Some(envelope)` error payload exists +/// only because that reconcile mutates the ledger before staging can +/// fail. Deleted by the integration pass once `get` passes its records. +async fn legacy_manifest_vendor_step( + common: &GlobalArgs, + manifest_path: &Path, + socket_dir: &Path, +) -> Result<(bool, Envelope), (&'static str, String, Option>)> { + let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); + let _guard = apply_lock::acquire(socket_dir, timeout).map_err(|e| { + let (code, message) = lock_failure(&e, timeout); + (code, message, None) + })?; + let mut env = Envelope::new(EnvelopeCommand::Vendor); + env.dry_run = common.dry_run; + let manifest = match read_manifest(manifest_path).await { + Ok(Some(m)) => m, + Ok(None) => { + note_classic_migration_risk(&mut env, &common.cwd, common); + note_vendor_supersedes_redirect(&mut env, &common.cwd, common).await; + return Ok((false, env)); + } + Err(e) => return Err(("invalid_manifest", e.to_string(), None)), + }; + let mut has_errors = reconcile_dropped(&manifest, common, &mut env).await; + match stage_and_vendor(common, socket_dir, &manifest, /*detached=*/ false, &mut env).await { + Ok(engine_errors) => has_errors |= engine_errors, + Err((code, message)) => { + // The reconcile may already have reverted entries on disk — + // hand its envelope (demoted: this run is aborting) to the fold. + env.mark_partial_failure(); + return Err((code, message, Some(Box::new(env)))); + } + } if has_errors { env.mark_partial_failure(); } @@ -256,7 +454,7 @@ async fn run_scan_vendor_step( } /// The `scan --vendor` JSON path: discovery → (dry-run preview | download -/// → GC → vendor engine → embedded VEX) → print `result` → exit code. +/// → vendor engine → GC → embedded VEX) → print `result` → exit code. /// The dry-run arm skips the VEX embed (emitting a `vex.skipped` marker /// instead): a dry run vendors nothing, so there is no state to attest. /// @@ -323,75 +521,24 @@ async fn run_vendor_json_path( if args.vex.vex.is_some() { result["vex"] = serde_json::json!({ "skipped": true, "reason": "dry_run" }); } - println!( - "{}", - serde_json::to_string_pretty(&result) - .expect("serializing an in-memory JSON value cannot fail") - ); + print_json(result); return 0; } - // 1) Download phase. Manifest mode reuses the `--apply` - // download (with `save_only` — the nested apply::run never - // fires); detached mode fetches records without touching - // the manifest. Either way the vendor step still runs when - // zero patches were downloaded (re-vendor after a wipe). + // 1) Download phase: fetch the selected records in memory. The + // manifest is never written; `download.detached: true` stays on + // the sub-object for consumers that keyed on it. let params = download_params( args, /*save_only=*/ true, /*json=*/ true, /*silent=*/ true, ); - let mut has_errors = false; - let detached_records: Option> = if args.detached { - let (code, mut dl_json, records) = boxed_download_patch_records(&selected, ¶ms).await; - has_errors |= code != 0; - if let Some(obj) = dl_json.as_object_mut() { - obj.remove("status"); - } - result["download"] = dl_json; - Some(records) - } else if selected.is_empty() { - result["download"] = serde_json::json!({ - "found": 0, "downloaded": 0, "skipped": 0, - "failed": 0, "patches": [], - }); - None - } else { - let (code, mut dl_json) = boxed_download_and_apply(&selected, ¶ms).await; - has_errors |= code != 0; - if let Some(obj) = dl_json.as_object_mut() { - obj.remove("status"); - // save_only: the nested apply never ran, so the - // `applied` count is structurally zero — drop it - // rather than report a misleading 0-applied. - obj.remove("applied"); - } - result["download"] = dl_json; - None - }; + let (dl_code, dl_json, records) = boxed_download_patch_records(&selected, ¶ms).await; + let mut has_errors = dl_code != 0; + result["download"] = dl_json; - // 2) GC BEFORE the vendor step (when --prune): stale manifest - // entries would otherwise fail vendoring with - // package_not_installed; vendored entries are exempt from - // the prune itself. - if prune { - result["gc"] = gc_json( - &args.common, - manifest_path, - socket_dir, - scanned_purls, - vendored_purls, - false, - ) - .await; - } - - // 3) The vendor engine, under the same lock as apply/vendor. - let vendor_code = match boxed_scan_vendor_step( - &args.common, - manifest_path, - socket_dir, - detached_records.as_ref(), - ) - .await + // 2) The vendor engine, under the same lock as apply/vendor (a no-op + // that creates nothing when there is nothing to vendor). + let vendor_code = match boxed_vendor_step(&args.common, manifest_path, socket_dir, records) + .await { Ok((vendor_errors, venv)) => { has_errors |= vendor_errors; @@ -410,7 +557,7 @@ async fn run_vendor_json_path( serde_json::to_value(&venv).unwrap_or_else(|_| serde_json::json!({})); i32::from(has_errors) } - Err((code, message, venv)) => { + Err((code, message)) => { track_patch_vendor_failed( &message, args.common.dry_run, @@ -418,23 +565,12 @@ async fn run_vendor_json_path( telemetry_org, ) .await; - // A pre-failure reconcile already mutated the ledger on disk; - // its envelope (events included) must reach the JSON consumer - // even though the run aborts here. - if let Some(venv) = venv { - result["vendor"] = - serde_json::to_value(&*venv).unwrap_or_else(|_| serde_json::json!({})); - } result["status"] = serde_json::json!("error"); result["error"] = serde_json::json!({ "code": code, "message": message, }); - println!( - "{}", - serde_json::to_string_pretty(&result) - .expect("serializing an in-memory JSON value cannot fail") - ); + print_json(result); return 1; } }; @@ -442,20 +578,31 @@ async fn run_vendor_json_path( result["status"] = serde_json::json!("partial_failure"); } + // 3) GC AFTER the vendor step (when --prune), like the apply arm: the + // step never reads the manifest, so nothing there depends on the + // prune, and running it last lets the sweep reclaim what this run + // orphaned (a migrated legacy record's blobs, a superseded uuid dir). + if prune { + result["gc"] = gc_json( + &args.common, + manifest_path, + socket_dir, + scanned_purls, + vendored_purls, + false, + ) + .await; + } + let final_code = embed_vex_into_json(&args.common, &args.vex, manifest_path, vendor_code, result).await; - println!( - "{}", - serde_json::to_string_pretty(&result) - .expect("serializing an in-memory JSON value cannot fail") - ); + print_json(result); final_code } -/// The `scan --vendor` interactive arm: download (manifest or detached -/// mode) → pre-vendor GC → vendor engine, with human-readable output. -/// Extracted + boxed for the same Windows-1-MiB-poll-frame reason as -/// [`run_vendor_json_path`]. +/// The `scan --vendor` interactive arm: download → vendor engine → GC, +/// with human-readable output. Extracted + boxed for the same +/// Windows-1-MiB-poll-frame reason as [`run_vendor_json_path`]. #[allow(clippy::too_many_arguments)] async fn run_vendor_interactive_path( args: &ScanArgs, @@ -469,48 +616,9 @@ async fn run_vendor_interactive_path( telemetry_token: Option<&str>, telemetry_org: Option<&str>, ) -> i32 { - let mut has_errors = false; - let detached_records: Option> = if args.detached { - let (dl_code, _, records) = boxed_download_patch_records(selected, params).await; - has_errors |= dl_code != 0; - Some(records) - } else { - if !selected.is_empty() { - let (dl_code, _) = boxed_download_and_apply(selected, params).await; - has_errors |= dl_code != 0; - } - None - }; - // GC before the vendor step (see the JSON path): stale manifest - // entries would fail vendoring with package_not_installed. - if prune { - let gc = run_apply_gc( - &args.common, - manifest_path, - socket_dir, - scanned_purls, - vendored_purls, - ) - .await; - if !args.common.silent && !gc.pruned.is_empty() { - println!( - "GC: pruned {} manifest entr{}.", - gc.pruned.len(), - if gc.pruned.len() == 1 { "y" } else { "ies" }, - ); - } - if !args.common.silent { - print_gc_vendored_line(&gc); - } - } - match boxed_scan_vendor_step( - &args.common, - manifest_path, - socket_dir, - detached_records.as_ref(), - ) - .await - { + let (dl_code, _, records) = boxed_download_patch_records(selected, params).await; + let mut has_errors = dl_code != 0; + let code = match boxed_vendor_step(&args.common, manifest_path, socket_dir, records).await { Ok((vendor_errors, venv)) => { has_errors |= vendor_errors; // Run-outcome telemetry, same as the JSON arm above. @@ -524,10 +632,7 @@ async fn run_vendor_interactive_path( .await; i32::from(has_errors) } - // Human mode prints no per-event lines even on success, so the - // carried envelope has no human rendering to feed — JSON mode is - // where the reconcile events must survive (see the JSON fold above). - Err((code, message, _venv)) => { + Err((code, message)) => { track_patch_vendor_failed( &message, args.common.dry_run, @@ -536,9 +641,31 @@ async fn run_vendor_interactive_path( ) .await; eprintln!("Error ({code}): {message}"); - 1 + return 1; + } + }; + // GC after the vendor step (see the JSON arm). + if prune { + let gc = run_apply_gc( + &args.common, + manifest_path, + socket_dir, + scanned_purls, + vendored_purls, + ) + .await; + if !args.common.silent && !gc.pruned.is_empty() { + println!( + "GC: pruned {} manifest entr{}.", + gc.pruned.len(), + if gc.pruned.len() == 1 { "y" } else { "ies" }, + ); + } + if !args.common.silent { + print_gc_vendored_line(&gc); } } + code } /// Partition purls matching `skip` out of the selected set and pre-render @@ -676,39 +803,56 @@ pub(super) fn boxed_vendor_interactive_path<'a>( /// future embeds the entire vendor engine, and the vendor-path frames it /// would otherwise ride must themselves fit Windows' 1 MiB main-thread /// stack (same rationale as [`boxed_vendor_json_path`], one level down). +/// Moving the records map into the future is stack-neutral (three words). #[allow(clippy::type_complexity)] -pub(crate) fn boxed_scan_vendor_step<'a>( +fn boxed_vendor_step<'a>( common: &'a GlobalArgs, manifest_path: &'a Path, socket_dir: &'a Path, - detached_records: Option<&'a HashMap>, + records: HashMap, ) -> std::pin::Pin< - Box< - dyn std::future::Future< - Output = Result<(bool, Envelope), (&'static str, String, Option>)>, - > + 'a, - >, + Box> + 'a>, > { Box::pin(run_scan_vendor_step( common, manifest_path, socket_dir, - detached_records, + records, )) } -/// Transient-frame boxed constructors for the download-phase futures used -/// inside the vendor paths — `download_and_apply_patches`'s future embeds -/// the in-process `apply::run`, and these frames must fit Windows' 1 MiB -/// main-thread stack (same rationale as [`boxed_vendor_json_path`]). -fn boxed_download_and_apply<'a>( - selected: &'a [PatchSearchResult], - params: &'a DownloadParams, -) -> std::pin::Pin + 'a>> { - Box::pin(download_and_apply_patches(selected, params)) +/// `get --mode vendored`'s entry into the vendor step (both its arms call +/// this). `Some(records)` runs the manifest-free step over a copy of the +/// records; `None` is the pre-D2 manifest-mode step +/// ([`legacy_manifest_vendor_step`]) kept only until `get` passes its +/// records — the integration pass collapses this onto +/// [`boxed_vendor_step`]. Same transient-frame rationale as above. +#[allow(clippy::type_complexity)] +pub(crate) fn boxed_scan_vendor_step<'a>( + common: &'a GlobalArgs, + manifest_path: &'a Path, + socket_dir: &'a Path, + detached_records: Option<&'a HashMap>, +) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result<(bool, Envelope), (&'static str, String, Option>)>, + > + 'a, + >, +> { + Box::pin(async move { + match detached_records { + Some(records) => run_scan_vendor_step(common, manifest_path, socket_dir, records.clone()) + .await + .map_err(|(code, message)| (code, message, None)), + None => legacy_manifest_vendor_step(common, manifest_path, socket_dir).await, + } + }) } -/// See [`boxed_download_and_apply`]. +/// Transient-frame boxed constructor for the download-phase future used +/// inside the vendor paths, so the frame fits Windows' 1 MiB main-thread +/// stack (same rationale as [`boxed_vendor_json_path`]). #[allow(clippy::type_complexity)] fn boxed_download_patch_records<'a>( selected: &'a [PatchSearchResult], @@ -741,6 +885,223 @@ fn boxed_vendor_records<'a>( )) } +#[cfg(test)] +mod migration_tests { + use super::{ + migrate_legacy_manifest_records, VENDOR_MANIFEST_MIGRATION_FAILED, + VENDOR_MANIFEST_RECORD_MIGRATED, + }; + use crate::args::GlobalArgs; + use crate::json_envelope::{Command as EnvelopeCommand, Envelope}; + use socket_patch_core::manifest::operations::read_manifest; + use socket_patch_core::manifest::schema::PatchRecord; + use socket_patch_core::vendor::state::VendorArtifact; + use socket_patch_core::vendor::{load_state, save_state, VendorEntry, VendorState}; + use std::collections::HashMap; + use std::path::Path; + + const PURL: &str = "pkg:npm/left-pad@1.3.0"; + const QUALIFIED: &str = "pkg:npm/left-pad@1.3.0?artifact_id=x"; + const OTHER: &str = "pkg:npm/other@2.0.0"; + const UUID: &str = "11111111-1111-4111-8111-111111111111"; + const OTHER_UUID: &str = "22222222-2222-4222-8222-222222222222"; + + fn record(uuid: &str) -> PatchRecord { + PatchRecord { + uuid: uuid.into(), + exported_at: "2026-01-01T00:00:00Z".into(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: "fixture".into(), + license: "MIT".into(), + tier: "free".into(), + } + } + + fn entry(uuid: &str, detached: bool, record: Option) -> VendorEntry { + VendorEntry { + ecosystem: "npm".into(), + base_purl: PURL.into(), + uuid: uuid.into(), + artifact: VendorArtifact { + path: format!(".socket/vendor/npm/{uuid}/left-pad-1.3.0.tgz"), + sha256: String::new(), + size: None, + platform_locked: None, + file_inventory: None, + }, + wiring: Vec::new(), + lock: None, + took_over_go_patches: false, + detached, + record, + flavor: Some("package-lock".into()), + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + } + } + + async fn seed_ledger(root: &Path, e: VendorEntry) { + let mut state = VendorState::default(); + state.entries.insert(PURL.to_string(), e); + save_state(root, &state).await.unwrap(); + } + + fn seed_manifest(root: &Path, purls: &[(&str, &str)]) { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + let patches: serde_json::Map = purls + .iter() + .map(|(p, u)| (p.to_string(), serde_json::to_value(record(u)).unwrap())) + .collect(); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ "patches": patches })).unwrap(), + ) + .unwrap(); + } + + fn common(root: &Path) -> GlobalArgs { + GlobalArgs { + cwd: root.to_path_buf(), + silent: true, + ..Default::default() + } + } + + fn warning_codes(env: &Envelope) -> Vec<&str> { + env.warnings.iter().map(|w| w.code.as_str()).collect() + } + + /// The same-uuid legacy case: the engine skipped `already_vendored` + /// and persisted nothing, so the entry is upgraded in place (detached + /// + embedded record) and its manifest record moves out; an unrelated + /// agent-mode record survives. + #[tokio::test] + async fn upgrades_same_uuid_legacy_entry_and_drops_its_manifest_record() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + seed_ledger(root, entry(UUID, false, None)).await; + seed_manifest(root, &[(PURL, UUID), (OTHER, OTHER_UUID)]); + let manifest_path = root.join(".socket/manifest.json"); + let records: HashMap = [(PURL.to_string(), record(UUID))].into(); + let mut env = Envelope::new(EnvelopeCommand::Vendor); + + migrate_legacy_manifest_records(&common(root), &manifest_path, &records, &mut env).await; + + let state = load_state(root).await.unwrap(); + let e = &state.entries[PURL]; + assert!(e.detached, "{state:?}"); + assert_eq!(e.record.as_ref().map(|r| r.uuid.as_str()), Some(UUID)); + let manifest = read_manifest(&manifest_path).await.unwrap().unwrap(); + assert_eq!( + manifest.patches.keys().collect::>(), + vec![OTHER], + "only the ledger-owned record moves out" + ); + assert_eq!(warning_codes(&env), vec![VENDOR_MANIFEST_RECORD_MIGRATED]); + assert!(env.warnings[0].detail.contains(PURL), "{:?}", env.warnings); + } + + /// Every record the ledger already owns is dropped — exact key and + /// qualified variants sharing the base purl — whichever run vendored + /// them, and an emptied manifest stays on disk as `{"patches":{}}`. + #[tokio::test] + async fn drops_every_ledger_owned_record_and_leaves_an_emptied_manifest() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + seed_ledger(root, entry(OTHER_UUID, true, Some(record(OTHER_UUID)))).await; + seed_manifest(root, &[(PURL, UUID), (QUALIFIED, UUID)]); + let manifest_path = root.join(".socket/manifest.json"); + let mut env = Envelope::new(EnvelopeCommand::Vendor); + + migrate_legacy_manifest_records(&common(root), &manifest_path, &HashMap::new(), &mut env) + .await; + + assert_eq!( + std::fs::read_to_string(&manifest_path).unwrap().trim(), + "{\n \"patches\": {}\n}", + "an emptied manifest is kept, never deleted" + ); + assert_eq!(warning_codes(&env), vec![VENDOR_MANIFEST_RECORD_MIGRATED]); + let detail = &env.warnings[0].detail; + assert!(detail.contains(PURL) && detail.contains(QUALIFIED), "{detail}"); + } + + /// A record for a purl whose ledger entry is NOT ledger-owned (a + /// legacy entry at another uuid that this run did not re-vendor) is + /// left alone: dropping it would hand the entry to the `vendor` + /// command's reconcile as "dropped from the manifest". + #[tokio::test] + async fn leaves_records_of_non_owned_entries_alone() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + seed_ledger(root, entry(UUID, false, None)).await; + seed_manifest(root, &[(PURL, UUID)]); + let manifest_path = root.join(".socket/manifest.json"); + let before = std::fs::read(&manifest_path).unwrap(); + let ledger_before = std::fs::read(root.join(".socket/vendor/state.json")).unwrap(); + let records: HashMap = + [(PURL.to_string(), record(OTHER_UUID))].into(); + let mut env = Envelope::new(EnvelopeCommand::Vendor); + + migrate_legacy_manifest_records(&common(root), &manifest_path, &records, &mut env).await; + + assert_eq!(std::fs::read(&manifest_path).unwrap(), before); + assert_eq!( + std::fs::read(root.join(".socket/vendor/state.json")).unwrap(), + ledger_before + ); + assert!(env.warnings.is_empty(), "{:?}", env.warnings); + } + + /// No manifest ⇒ nothing to migrate, and nothing is created. + #[tokio::test] + async fn is_a_no_op_without_a_manifest() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let manifest_path = root.join(".socket/manifest.json"); + let records: HashMap = [(PURL.to_string(), record(UUID))].into(); + let mut env = Envelope::new(EnvelopeCommand::Vendor); + + migrate_legacy_manifest_records(&common(root), &manifest_path, &records, &mut env).await; + + assert!(!root.join(".socket").exists(), "must not conjure .socket/"); + assert!(env.warnings.is_empty(), "{:?}", env.warnings); + } + + /// A corrupt manifest is reported, not rewritten and not fatal: the + /// vendoring already committed and the manifest is not vendored + /// mode's concern. + #[tokio::test] + async fn warns_on_a_corrupt_manifest_and_leaves_it() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + seed_ledger(root, entry(UUID, true, Some(record(UUID)))).await; + std::fs::write(root.join(".socket/manifest.json"), b"{not json").unwrap(); + let manifest_path = root.join(".socket/manifest.json"); + let mut env = Envelope::new(EnvelopeCommand::Vendor); + + migrate_legacy_manifest_records(&common(root), &manifest_path, &HashMap::new(), &mut env) + .await; + + assert_eq!( + std::fs::read(&manifest_path).unwrap(), + b"{not json", + "the corrupt file is left for the operator" + ); + assert_eq!(warning_codes(&env), vec![VENDOR_MANIFEST_MIGRATION_FAILED]); + assert!( + env.warnings[0].detail.contains("manifest.json"), + "{:?}", + env.warnings + ); + } +} + #[cfg(test)] mod service_config_tests { use super::*; diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_vendor_flow.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_vendor_flow.rs index d4ba64f2..a95f9780 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_vendor_flow.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_vendor_flow.rs @@ -7,18 +7,17 @@ //! `scan_vendor_e2e.rs`); //! * the legal-but-never-executed `--dry-run --prune` combination in the //! vendor JSON path (GC preview field names, nothing mutated); -//! * every None-envelope error constructor of `run_scan_vendor_step` — -//! `lock_held`, `lock_io`, `invalid_manifest`, `socket_dir_unwritable` — -//! through the JSON error fold (which must NOT emit a `vendor` key when -//! no reconcile envelope rides the error); -//! * the interactive (non-JSON) vendor-step error arm — the only error -//! output a terminal user sees when `scan --vendor` aborts at -//! lock/stage/manifest (the JSON twin is `scan_vendor_step_error_e2e.rs`). +//! * every error constructor of `run_scan_vendor_step` — `lock_held`, +//! `lock_io` (a directory squatting on `apply.lock`; a file squatting on +//! `.socket` itself) and `no_local_source` — through the JSON error fold +//! (which must NOT emit a `vendor` key: nothing mutates before staging) +//! and the interactive `Error (code): message` line; +//! * a corrupt legacy manifest, which vendored mode reports and steps +//! around (the manifest is not its record source). //! -//! Fixtures are clones of `scan_vendor_e2e.rs` / -//! `scan_vendor_step_error_e2e.rs` (each e2e file carries its own copy — -//! the established pattern), plus `e2e_safety_lock.rs`'s external-flock -//! trick for lock contention. Mock API only; no real hosts. +//! Fixtures are clones of `scan_vendor_e2e.rs` (each e2e file carries its +//! own copy — the established pattern), plus `e2e_safety_lock.rs`'s +//! external-flock trick for lock contention. Mock API only; no real hosts. use std::fs::OpenOptions; use std::path::{Path, PathBuf}; @@ -39,9 +38,6 @@ const PURL: &str = "pkg:npm/left-pad@1.3.0"; const ENCODED: &str = "pkg%3Anpm%2Fleft-pad%401.3.0"; /// A manifest patch for a package that is NOT installed — prunable. const STALE_PURL: &str = "pkg:npm/uninstalled@1.0.0"; -/// A ledger entry with NO manifest patch — the reconcile reverts it. -const DROPPED_PURL: &str = "pkg:npm/gone@9.9.9"; -const DROPPED_UUID: &str = "33333333-3333-4333-8333-333333333333"; const BEFORE: &[u8] = b"before\n"; const AFTER: &[u8] = b"after\n"; /// base64 of AFTER, inlined as the view response's blobContent. @@ -99,8 +95,12 @@ fn write_fixture(root: &Path) { /// Mount discovery (batch), per-package search, and the full view for /// `uuid` on the mock server. async fn mount_patch_api(mock: &MockServer, uuid: &str) { - let before_hash = git_sha256(BEFORE); - let after_hash = git_sha256(AFTER); + mount_discovery(mock, uuid).await; + mount_view(mock, uuid, /*with_blob_content=*/ true).await; +} + +/// Mount discovery (batch) and the per-package search for `uuid`. +async fn mount_discovery(mock: &MockServer, uuid: &str) { Mock::given(method("POST")) .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ @@ -138,19 +138,29 @@ async fn mount_patch_api(mock: &MockServer, uuid: &str) { }))) .mount(mock) .await; +} + +/// Mount the full patch view for `uuid`. Without `with_blob_content` the +/// view carries the file hashes but no `blobContent`: the download phase +/// still records the patch (it needs only the hashes), but the vendor +/// step cannot obtain the patched bytes and staging fails +/// (`no_local_source`) — independent of how many times the view is +/// fetched along the way. +async fn mount_view(mock: &MockServer, uuid: &str, with_blob_content: bool) { + let mut file = serde_json::json!({ + "beforeHash": git_sha256(BEFORE), + "afterHash": git_sha256(AFTER), + }); + if with_blob_content { + file["blobContent"] = serde_json::json!(AFTER_B64); + } Mock::given(method("GET")) .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{uuid}"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "uuid": uuid, "purl": PURL, "publishedAt": "2026-01-01T00:00:00Z", - "files": { - "package/index.js": { - "beforeHash": before_hash, - "afterHash": after_hash, - "blobContent": AFTER_B64, - } - }, + "files": { "package/index.js": file }, "vulnerabilities": {}, "description": "Vendor patch", "license": "MIT", @@ -280,67 +290,11 @@ fn seed_stale_manifest(root: &Path) { .unwrap(); } -/// A committed manifest whose afterHash blob is NOT on disk: the vendor -/// step must fetch the patch view to stage it, and the mock refuses -/// (`mount_empty_discovery` mounts no view route). -fn seed_unstageable_manifest(root: &Path) { - let socket = root.join(".socket"); - std::fs::create_dir_all(&socket).unwrap(); - let manifest = serde_json::json!({ - "patches": { - PURL: { - "uuid": UUID, - "exportedAt": "2026-01-01T00:00:00Z", - "files": { - "package/index.js": { - "beforeHash": git_sha256(BEFORE), - "afterHash": git_sha256(AFTER), - } - }, - "vulnerabilities": {}, - "description": "Vendor patch", - "license": "MIT", - "tier": "free", - } - } - }); - std::fs::write( - socket.join("manifest.json"), - serde_json::to_string_pretty(&manifest).unwrap(), - ) - .unwrap(); -} - -/// A ledger holding one entry the manifest does not mention: the vendor -/// step's `reconcile_dropped` reverts it (and rewrites `state.json`) -/// before staging is even attempted. -fn seed_dropped_ledger_entry(root: &Path) { - let vendor = root.join(".socket/vendor"); - std::fs::create_dir_all(&vendor).unwrap(); - std::fs::write( - vendor.join("state.json"), - serde_json::to_vec_pretty(&serde_json::json!({ - "version": 1, - "entries": { DROPPED_PURL: { - "ecosystem": "npm", - "basePurl": DROPPED_PURL, - "uuid": DROPPED_UUID, - "artifact": { - "path": format!(".socket/vendor/npm/{DROPPED_UUID}/gone-9.9.9.tgz"), - }, - "wiring": [] - }} - })) - .unwrap(), - ) - .unwrap(); -} - -/// Shared assertions for the None-envelope error fold: exit 1, a JSON +/// Shared assertions for the vendor-step error fold: exit 1, a JSON /// envelope with `status: "error"`, the given `error.code`, a `download` /// sub-object (proof the run got PAST the download phase and died inside -/// the vendor step) and NO `vendor` key (no reconcile envelope rode the -/// error — `run_vendor_json_path`'s `if let Some(venv)` fall-through). +/// the vendor step) and NO `vendor` key (nothing mutates before staging, +/// so no vendor sub-object may be fabricated for the aborted step). fn assert_vendor_step_error( code: i32, stdout: &str, @@ -458,16 +412,17 @@ async fn scan_vendor_dry_run_prune_previews_gc_without_mutating() { ); } -/// An externally-held `.socket/apply.lock` fails the vendor step with the -/// contract `lock_held` code + the stable contention message, folded into -/// scan's own JSON error shape (not an `acquire_or_emit` Envelope). +/// An externally-held `.socket/apply.lock` fails the vendor step (after +/// the manifest-free download phase fetched the record) with the contract +/// `lock_held` code + the stable contention message — no `--lock-timeout`, +/// so no "(waited …)" clause — folded into scan's own JSON error shape +/// (not an `acquire_or_emit` Envelope). Nothing is vendored. #[tokio::test] async fn scan_vendor_lock_held_reports_json_error() { let mock = MockServer::start().await; - mount_empty_discovery(&mock).await; + mount_patch_api(&mock, UUID).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path()); - seed_unstageable_manifest(tmp.path()); let _external = take_external_lock(&tmp.path().join(".socket")); let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); @@ -477,6 +432,8 @@ async fn scan_vendor_lock_held_reports_json_error() { "another socket-patch process is operating in this directory", "the contention message is contract; envelope={v}" ); + assert_eq!(v["download"]["downloaded"], 1, "envelope={v}"); + assert!(!tmp.path().join(".socket/vendor").exists()); } /// A DIRECTORY squatting on `.socket/apply.lock` makes the lock file @@ -486,63 +443,118 @@ async fn scan_vendor_lock_held_reports_json_error() { #[tokio::test] async fn scan_vendor_lock_io_reports_json_error() { let mock = MockServer::start().await; - mount_empty_discovery(&mock).await; + mount_patch_api(&mock, UUID).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path()); - seed_unstageable_manifest(tmp.path()); std::fs::create_dir_all(tmp.path().join(".socket/apply.lock")).unwrap(); let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); - assert_vendor_step_error(code, &stdout, &stderr, "lock_io"); + let v = assert_vendor_step_error(code, &stdout, &stderr, "lock_io"); + assert!( + v["error"]["message"] + .as_str() + .is_some_and(|m| m.contains("apply.lock")), + "the I/O reason names the lock file; envelope={v}" + ); } -/// A corrupt committed manifest: scan's EARLY tolerant read swallows the -/// parse error (`.ok().flatten()`), so the run proceeds all the way to -/// the vendor step, whose own `read_manifest` surfaces the corruption as -/// `invalid_manifest` — the same code the `vendor` command uses. +/// A corrupt committed manifest is not vendored mode's record source: +/// scan's early tolerant read swallows the parse error, the run vendors +/// normally (exit 0), and only the post-vendor legacy-record migration +/// notices — reporting `vendor_manifest_migration_failed` on the vendor +/// envelope's `warnings[]` and leaving the file byte-identical for the +/// operator (the `vendor` command, whose work list it is, still fails +/// closed on it). #[tokio::test] -async fn scan_vendor_corrupt_manifest_reports_invalid_manifest() { +async fn scan_vendor_corrupt_manifest_is_reported_and_stepped_around() { let mock = MockServer::start().await; - mount_empty_discovery(&mock).await; + mount_patch_api(&mock, UUID).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path()); std::fs::create_dir_all(tmp.path().join(".socket")).unwrap(); std::fs::write(tmp.path().join(".socket/manifest.json"), b"{not json").unwrap(); let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); - assert_vendor_step_error(code, &stdout, &stderr, "invalid_manifest"); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!(v["status"], "success", "envelope={v}"); + assert_eq!(v["vendor"]["summary"]["applied"], 1, "envelope={v}"); + assert!( + v["vendor"]["warnings"].as_array().is_some_and(|ws| ws.iter().any(|w| { + w["code"] == "vendor_manifest_migration_failed" + && w["detail"].as_str().unwrap_or("").contains("manifest.json") + })), + "the unreadable manifest must be reported; envelope={v}" + ); + assert_eq!( + std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(), + b"{not json", + "the corrupt manifest is left for the operator" + ); + assert!(tmp + .path() + .join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")) + .is_file()); } /// A regular FILE squatting on `.socket` itself: scan's earlier phases -/// tolerate it (the manifest read degrades to None, the ledger load to an -/// empty set), so the run reaches the vendor step and dies exactly at its -/// `create_dir_all(socket_dir)` guard — `socket_dir_unwritable`. +/// tolerate it (the ledger load degrades to an empty set on a non-Bun +/// project), so the run reaches the vendor step, whose `acquire` cannot +/// create the lock directory — `lock_io`, the file left untouched. #[tokio::test] -async fn scan_vendor_socket_dir_file_reports_unwritable() { +async fn scan_vendor_socket_dir_file_reports_lock_io() { let mock = MockServer::start().await; - mount_empty_discovery(&mock).await; + mount_patch_api(&mock, UUID).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path()); std::fs::write(tmp.path().join(".socket"), b"not a dir").unwrap(); let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); - assert_vendor_step_error(code, &stdout, &stderr, "socket_dir_unwritable"); + assert_vendor_step_error(code, &stdout, &stderr, "lock_io"); + assert_eq!( + std::fs::read(tmp.path().join(".socket")).unwrap(), + b"not a dir", + "the squatting file survives" + ); } -/// The interactive (non-JSON) vendor-step error arm: same unstageable -/// fixture as `scan_vendor_step_error_e2e.rs`, `--json` dropped. The -/// human arm must exit 1 with the `Error (code): message` line on stderr -/// — and the reconcile that ran BEFORE the staging failure must still -/// have persisted its ledger rewrite (human mode reports less, it must -/// not DO less). +/// The JSON vendor-step error fold for a staging failure: the download +/// phase recorded the patch (hashes only), but the view serves no blob +/// content, so the vendor step cannot stage it and the run aborts +/// `no_local_source` with a `download` object and NO `vendor` key — +/// nothing mutated before staging — and creates nothing under `.socket/`. +#[tokio::test] +async fn scan_vendor_staging_error_reports_json_error() { + let mock = MockServer::start().await; + mount_discovery(&mock, UUID).await; + mount_view(&mock, UUID, /*with_blob_content=*/ false).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture(tmp.path()); + + let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); + let v = assert_vendor_step_error(code, &stdout, &stderr, "no_local_source"); + assert_eq!( + v["error"]["message"], + "patch artifacts unavailable (offline or download failure)", + "envelope={v}" + ); + assert_eq!(v["download"]["downloaded"], 1, "envelope={v}"); + assert!( + !tmp.path().join(".socket").exists(), + "an aborted step leaves no .socket/ behind (lock file and empty dir removed)" + ); +} + +/// The interactive (non-JSON) twin of the staging failure: exit 1 with +/// the `Error (code): message` line on stderr, no JSON envelope on +/// stdout, nothing vendored. #[tokio::test] async fn scan_vendor_staging_error_interactive_prints_error_line() { let mock = MockServer::start().await; - mount_empty_discovery(&mock).await; + mount_discovery(&mock, UUID).await; + mount_view(&mock, UUID, /*with_blob_content=*/ false).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path()); - seed_unstageable_manifest(tmp.path()); - seed_dropped_ledger_entry(tmp.path()); let (code, stdout, stderr) = run_cli( tmp.path(), @@ -561,7 +573,7 @@ async fn scan_vendor_staging_error_interactive_prints_error_line() { assert_eq!( code, 1, - "an unstageable manifest must fail the run; stdout={stdout}; stderr={stderr}" + "an unstageable record must fail the run; stdout={stdout}; stderr={stderr}" ); assert!( stderr.contains( @@ -575,12 +587,8 @@ async fn scan_vendor_staging_error_interactive_prints_error_line() { serde_json::from_str::(stdout.trim()).is_err(), "the interactive arm must not print a JSON envelope; stdout={stdout}" ); - // The pre-failure reconcile still persisted: the ledger's only entry - // was reverted, so `save_state` deleted state.json (disk truth is the - // human arm's only record of the mutation). assert!( - !tmp.path().join(".socket/vendor/state.json").exists(), - "the reconcile must persist even when the run aborts at staging; \ - stdout={stdout}; stderr={stderr}" + !tmp.path().join(".socket").exists(), + "an aborted step leaves no .socket/ behind; stdout={stdout}; stderr={stderr}" ); } diff --git a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs index 92e1af38..eed6e888 100644 --- a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs @@ -109,10 +109,11 @@ fn snapshot(root: &Path) -> BTreeMap> { if path.is_dir() { walk(root, &path, result); } else { + // Every run removes its `apply.lock` on exit, so the + // snapshot deliberately does NOT exclude it: a surviving + // lock file is a real before/after difference. let relative = path.strip_prefix(root).unwrap(); - if relative != Path::new(".socket/apply.lock") { - result.insert(relative.to_path_buf(), std::fs::read(path).unwrap()); - } + result.insert(relative.to_path_buf(), std::fs::read(path).unwrap()); } } } @@ -680,9 +681,11 @@ async fn native_binary_scan_vendored_and_detached() { result["vendor"]["summary"]["applied"], 1, "scan vendored detached={detached}: {result}" ); - assert_eq!( - fixture.project.join(".socket/manifest.json").exists(), - !detached + // Vendored mode is manifest-free either way: `--detached` is an + // accepted no-op. + assert!( + !fixture.project.join(".socket/manifest.json").exists(), + "vendored scan must not write a manifest (detached={detached})" ); fixture.frozen("scan-vendored", &fixture.patched, "minimist"); let result = cli(&fixture.project, &["vendor", "--revert"]); diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs index fc2b00f1..71d656cb 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs @@ -1,5 +1,5 @@ //! Hermetic subprocess tests for the Bun VENDORED-mode refusals and their -//! positive twins: `scan --mode vendored` (manifest-tracked and +//! positive twins: `scan --mode vendored` (with and without the no-op //! `--detached`), `get --mode vendored`, `get --mode //! vendored`, their `--dry-run` previews, `--silent`, and the agent //! `--save-only` exemption — driven through the built binary against a @@ -24,10 +24,9 @@ //! message}` and a `failed` record carrying `errorCode` AND `error`; scan //! and purl paths: `partial_failure` with the same record); ZERO //! `/patches/view/` fetches for the refused patch (request-log oracle); a -//! byte-identical `bun.lock`; no `.socket/vendor/`; and — where a manifest -//! existed — a seeded record for another purl surviving semantically (serde -//! `Value` equality: the download phase re-serializes the manifest -//! pretty-printed by contract). +//! byte-identical `bun.lock`; no `.socket/vendor/`; and — where a legacy +//! manifest existed — that manifest surviving byte-for-byte (vendored mode +//! never touches it). //! //! No `#[serial]`: the child gets a scrubbed env copy (`common::run_with_env`). @@ -239,10 +238,9 @@ fn write_installed_left_pad(dir: &Path) { } /// A schema-valid manifest holding ONE record for [`OTHER_PURL`], written -/// COMPACT (single line) so a byte-level re-serialization is detectable -/// while the semantic oracle (`Value` equality) still passes. Returns the -/// seeded record. -fn seed_other_manifest_record(root: &Path) -> serde_json::Value { +/// COMPACT (single line) so any rewrite — even a semantically identical +/// re-serialization — is detectable byte-for-byte. Returns the bytes. +fn seed_other_manifest_record(root: &Path) -> Vec { let socket = root.join(".socket"); std::fs::create_dir_all(&socket).unwrap(); let record = serde_json::json!({ @@ -260,12 +258,9 @@ fn seed_other_manifest_record(root: &Path) -> serde_json::Value { "tier": "free", }); let manifest = serde_json::json!({ "patches": { OTHER_PURL: record } }); - std::fs::write( - socket.join("manifest.json"), - serde_json::to_string(&manifest).unwrap(), - ) - .unwrap(); - record + let bytes = serde_json::to_vec(&manifest).unwrap(); + std::fs::write(socket.join("manifest.json"), &bytes).unwrap(); + bytes } // --------------------------------------------------------------------------- @@ -491,13 +486,11 @@ async fn assert_scan_refuses(shape: LockShape, code: &str) { "{shape:?}: a refused patch must never be fetched" ); assert_refusal_left_tree_alone(tmp.path(), &lock_before); - // Today's documented contract (CLI_CONTRACT.md `scan --vendor`: "the - // download phase writes only `.socket/manifest.json`"): the manifest - // exists and is EMPTY — no record was claimed for the refused purl. - assert_eq!( - manifest_value(tmp.path()), - Some(serde_json::json!({ "patches": {} })), - "{shape:?}" + // Vendored mode is manifest-free, and a fully refused run has nothing + // to vendor: nothing at all is created under `.socket/`. + assert!( + !tmp.path().join(".socket").exists(), + "{shape:?}: a refused run must create nothing under .socket/" ); } @@ -521,17 +514,14 @@ async fn scan_vendored_refuses_malformed_v3_lock_before_download() { assert_scan_refuses(LockShape::MalformedV3, VERSION_CODE).await; } -/// A refused scan on a project that ALREADY tracks another patch: that -/// record survives semantically (the download phase re-serializes the -/// manifest pretty-printed — documented, so the oracle is `Value` -/// equality, not bytes), and the refused purl is still not recorded. +/// A refused scan on a project with a legacy (agent-mode) manifest: the +/// manifest is not vendored mode's business — it survives byte-for-byte +/// (never re-serialized, never fetched for), and the refused purl is still +/// not recorded anywhere. #[tokio::test] async fn scan_vendored_refusal_preserves_seeded_manifest_record() { let mock = MockServer::start().await; mount_patch_api(&mock).await; - // The vendor step stages every manifest record's content in memory - // from the view endpoint, so the seeded record needs a view too. - mount_view(&mock, OTHER_UUID, OTHER_PURL).await; let tmp = tempfile::tempdir().unwrap(); write_bun_project(tmp.path(), LockShape::V1Workspace); let seeded = seed_other_manifest_record(tmp.path()); @@ -542,18 +532,16 @@ async fn scan_vendored_refusal_preserves_seeded_manifest_record() { let v = parse_single_json_doc(&stdout); assert_refused_record(&v["download"]["patches"][0], WS_CODE, &v); assert_eq!(view_requests_for(&mock, UUID).await, 0); - assert_refusal_left_tree_alone(tmp.path(), &lock_before); - - let manifest = manifest_value(tmp.path()).expect("manifest survives"); - let patches = manifest["patches"].as_object().unwrap(); assert_eq!( - patches.keys().collect::>(), - vec![OTHER_PURL], - "exactly the seeded record remains: {manifest}" + view_requests_for(&mock, OTHER_UUID).await, + 0, + "a legacy manifest record is never staged by a vendored scan" ); + assert_refusal_left_tree_alone(tmp.path(), &lock_before); assert_eq!( - patches[OTHER_PURL], seeded, - "the seeded record must survive field for field: {manifest}" + std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(), + seeded, + "the legacy manifest must survive byte-for-byte" ); } @@ -709,8 +697,9 @@ async fn get_uuid_vendored_refusal_human_names_code_on_stderr() { // --------------------------------------------------------------------------- /// The search path shares `scan`'s download phase: `partial_failure`, the -/// same `failed` record (with `errorCode` + `error`), zero fetches, the -/// vendor step still runs over the (empty) manifest, `applied` dropped. +/// same `failed` record (with `errorCode` + `error`), zero fetches, an +/// empty vendor envelope, `applied` dropped — and, vendored mode being +/// manifest-free, no manifest. #[tokio::test] async fn get_purl_vendored_refuses_v1_workspace_before_fetch() { let mock = MockServer::start().await; @@ -737,8 +726,8 @@ async fn get_purl_vendored_refuses_v1_workspace_before_fetch() { assert_refusal_left_tree_alone(tmp.path(), &lock_before); assert_eq!( manifest_value(tmp.path()), - Some(serde_json::json!({ "patches": {} })), - "the search path shares scan's manifest-writing download phase" + None, + "vendored mode never writes a manifest" ); } @@ -941,8 +930,17 @@ async fn scan_vendored_v2_workspace_lock_vendors() { let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "{v}"); assert_eq!(v["download"]["downloaded"], 1, "{v}"); - assert_eq!(v["download"]["patches"][0]["action"], "added", "{v}"); + assert_eq!(v["download"]["detached"], true, "{v}"); + assert_eq!( + v["download"]["patches"][0]["action"], "downloaded", + "{v}" + ); assert_eq!(v["vendor"]["summary"]["applied"], 1, "{v}"); + assert_eq!( + manifest_value(tmp.path()), + None, + "vendored mode never writes a manifest" + ); let lock = String::from_utf8(lock_bytes(tmp.path())).unwrap(); let tgz_rel = format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); @@ -964,6 +962,8 @@ async fn scan_vendored_v2_workspace_lock_vendors() { .unwrap(); assert_eq!(state["entries"][PURL]["uuid"], UUID, "{state}"); assert_eq!(state["entries"][PURL]["flavor"], "bun", "{state}"); + assert_eq!(state["entries"][PURL]["detached"], true, "{state}"); + assert_eq!(state["entries"][PURL]["record"]["uuid"], UUID, "{state}"); } /// A lockfileVersion-0 single-package lock (bun 1.1.39–1.1.45 opt-in text @@ -982,8 +982,11 @@ async fn get_uuid_vendored_v0_direct_lock_vendors_and_rollback_restores_bytes() assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "{v}"); - assert_eq!(v["patches"][0]["action"], "added", "{v}"); + // Vendored mode is manifest-free for `get` too: the record is fetched + // in memory (`downloaded`), never recorded in a manifest. + assert_eq!(v["patches"][0]["action"], "downloaded", "{v}"); assert_eq!(v["vendor"]["summary"]["applied"], 1, "{v}"); + assert_eq!(manifest_value(tmp.path()), None, "{v}"); let tgz_rel = format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); let lock = String::from_utf8(lock_bytes(tmp.path())).unwrap(); assert!(lock.contains(&format!("\"left-pad@{tgz_rel}\"")), "{lock}"); @@ -1095,8 +1098,9 @@ async fn preserved_ledger_does_not_bypass_bun_refusal_after_rollback() { } /// The download phase must NOT refuse a purl the ledger already wires at -/// the selected uuid: the re-run classifies it `skipped` (already in the -/// manifest) exactly as on a non-Bun project, instead of `failed`. Pinned +/// the selected uuid: the re-run classifies it `skipped` (the ledger's +/// embedded record is reused) exactly as on a non-Bun project, instead of +/// `failed`. Pinned /// independently of the vendor step below so the CLI half of the /// exemption is guarded even while the engine half lands separately. #[tokio::test] @@ -1155,8 +1159,10 @@ async fn already_vendored_v1_workspace_rerun_is_already_vendored_exit_zero() { /// remedy a Bun 1.2/1.3 team cannot follow — while the engine would have /// re-vendored the already-local tuple in place. The lock-derived /// exemption sees every instance is ours and lets the run through: the -/// record is `updated`, the engine re-pins the tuple at the new uuid, the -/// lock stays at lockfileVersion 1 with its workspace entry intact. +/// record is fetched (`downloaded` — vendored mode is manifest-free, so +/// the download vocabulary is the detached one for `get` too), the engine +/// re-pins the tuple at the new uuid, the lock stays at lockfileVersion 1 +/// with its workspace entry intact. #[tokio::test] async fn superseding_uuid_on_already_vendored_v1_workspace_is_revendored_not_refused() { const SUPERSEDING_UUID: &str = "33333333-3333-4333-8333-333333333333"; @@ -1171,9 +1177,12 @@ async fn superseding_uuid_on_already_vendored_v1_workspace_is_revendored_not_ref assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "{v}"); - assert_eq!(v["patches"][0]["action"], "updated", "{v}"); - assert_eq!(v["patches"][0]["oldUuid"], UUID, "{v}"); + assert_eq!(v["patches"][0]["action"], "downloaded", "{v}"); assert!(v["patches"][0].get("errorCode").is_none(), "{v}"); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "vendored mode never writes a manifest" + ); assert_eq!(v["vendor"]["summary"]["applied"], 1, "{v}"); assert_eq!(v["vendor"]["summary"]["failed"], 0, "{v}"); assert!( @@ -1218,10 +1227,10 @@ async fn superseding_uuid_on_already_vendored_v1_workspace_is_revendored_not_ref /// The same upgraded project with its vendor ledger LOST (`state.json` /// deleted — the shape `repair` reconstructs from): the ledger exemption /// has nothing to match, but the lock still says every instance is ours, -/// so the download phase must NOT refuse the in-sync re-run — it -/// classifies `skipped` (already in the manifest) and hands the ledgerless -/// wiring to the engine, whose verdict (not the preflight's) decides the -/// run. Nothing here may raise the workspace code. +/// so the download phase must NOT refuse the in-sync re-run — with no +/// embedded record left to reuse it fetches the record (`downloaded`) and +/// hands the ledgerless wiring to the engine, whose verdict (not the +/// preflight's) decides the run. Nothing here may raise the workspace code. #[tokio::test] async fn wiped_ledger_on_already_vendored_v1_workspace_is_not_refused_at_preflight() { let mock = MockServer::start().await; @@ -1234,7 +1243,7 @@ async fn wiped_ledger_on_already_vendored_v1_workspace_is_not_refused_at_preflig let v = parse_single_json_doc(&stdout); let rec = &v["download"]["patches"][0]; assert_eq!( - rec["action"], "skipped", + rec["action"], "downloaded", "an in-sync purl must not be refused for a lost ledger (exit {exit}): {v}\n{stderr}" ); assert!(rec.get("errorCode").is_none(), "{v}"); @@ -1489,14 +1498,14 @@ async fn repair_rebuilds_a_deleted_artifact_through_a_digestless_lock() { /// `scan --mode vendored --cwd `: the member directory /// holds no bun.lock, so the Bun preflight passes (it cannot see a Bun -/// project), the download phase RECORDS the patch in -/// `/.socket/manifest.json`, and the vendor engine then refuses -/// `vendor_lockfile_missing` (the flavor router finds no lockfile at cwd). -/// Pre-existing, flavor-agnostic behaviour (`--cwd` is the lockfile root -/// by contract) — pinned here so any change to it is deliberate. The root -/// tree is never touched. +/// project), the download phase fetches the record in memory, and the +/// vendor engine then refuses `vendor_lockfile_missing` (the flavor router +/// finds no lockfile at cwd) — so nothing is written under the member +/// either. Pre-existing, flavor-agnostic behaviour (`--cwd` is the +/// lockfile root by contract) — pinned here so any change to it is +/// deliberate. The root tree is never touched. #[tokio::test] -async fn scan_vendored_from_workspace_member_cwd_records_then_engine_refuses_lockfile_missing() { +async fn scan_vendored_from_workspace_member_cwd_fetches_then_engine_refuses_lockfile_missing() { let mock = MockServer::start().await; mount_patch_api(&mock).await; let tmp = tempfile::tempdir().unwrap(); @@ -1513,7 +1522,10 @@ async fn scan_vendored_from_workspace_member_cwd_records_then_engine_refuses_loc let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "partial_failure", "{v}"); assert_eq!(v["download"]["downloaded"], 1, "{v}"); - assert_eq!(v["download"]["patches"][0]["action"], "added", "{v}"); + assert_eq!( + v["download"]["patches"][0]["action"], "downloaded", + "{v}" + ); let events = v["vendor"]["events"].as_array().unwrap(); assert!( events @@ -1521,10 +1533,14 @@ async fn scan_vendored_from_workspace_member_cwd_records_then_engine_refuses_loc .any(|e| e["purl"] == PURL && e["errorCode"] == MISSING_CODE), "the engine refuses from the member dir: {v}" ); - let member_manifest = manifest_value(&member).expect("member manifest written"); assert_eq!( - member_manifest["patches"][PURL]["uuid"], UUID, - "{member_manifest}" + manifest_value(&member), + None, + "vendored mode never writes a manifest" + ); + assert!( + !member.join(".socket").exists(), + "a refused vendoring leaves nothing under the member's .socket/" ); assert_eq!(lock_bytes(tmp.path()), lock_before, "root lock untouched"); assert!(!tmp.path().join(".socket").exists(), "no root .socket/"); diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs index 1af76791..aa46ae43 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs @@ -457,8 +457,15 @@ async fn bun_hosted_then_scan_vendored_takeover_round_trips_to_registry() { find_event(vendor, "applied", None); assert_no_event_code(vendor, "redirect_revert_failed"); assert_pure_vendored(root); - let manifest: Value = serde_json::from_str(&read(root, ".socket/manifest.json")).unwrap(); - assert_eq!(manifest["patches"][PURL]["uuid"], UUID, "{manifest:#}"); + // Vendored mode is manifest-free: the ledger entry (with its embedded + // record) is the only record of the vendored patch. + let state: Value = serde_json::from_str(&read(root, ".socket/vendor/state.json")).unwrap(); + assert_eq!(state["entries"][PURL]["uuid"], UUID, "{state:#}"); + assert_eq!(state["entries"][PURL]["record"]["uuid"], UUID, "{state:#}"); + assert!( + !root.join(".socket/manifest.json").exists(), + "a vendored scan must not write a manifest" + ); // C: a re-run is an in-sync no-op with no second takeover. let (code, env) = scan_mode(root, &server.uri(), "vendored", &[]); diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index a976bb03..58df29d0 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -1,7 +1,9 @@ -//! End-to-end tests for `scan --vendor` (and `--detached`) — the bot -//! workflow that discovers patches, downloads them, and vendors each -//! patched package into the committable `.socket/vendor/` tree instead -//! of applying in place. Mock API + a real npm lockfile fixture, driven +//! End-to-end tests for `scan --vendor` — the bot workflow that discovers +//! patches, fetches their records in memory, and vendors each patched +//! package into the committable `.socket/vendor/` tree instead of +//! applying in place. Vendored mode is manifest-free: the ledger's +//! embedded records are the only state written (`--detached` is an +//! accepted no-op). Mock API + a real npm lockfile fixture, driven //! through the built binary. use std::path::{Path, PathBuf}; @@ -195,26 +197,25 @@ fn run_scan_vendor(root: &Path, mock_uri: &str, extra: &[&str]) -> (i32, String, run_cli_env(root, &argv, &[]) } -/// Vendor flows hold patch content in MEMORY: `.socket/` must end up with -/// nothing beyond the manifest and the committed vendor artifacts — no -/// `blobs/`, `diffs/`, `packages/`, or stray temp files. +/// Vendored mode writes ONLY `.socket/vendor/**`: no manifest, no +/// `blobs/`, `diffs/`, `packages/`, no stray temp files — and no +/// `apply.lock`, which every run removes on exit. fn assert_socket_dir_lean(root: &Path) { let entries: Vec = std::fs::read_dir(root.join(".socket")) .expect(".socket exists") .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) - .filter(|n| n != "apply.lock") .collect(); - assert!( - entries - .iter() - .all(|n| n == "manifest.json" || n == "vendor"), - "vendoring must not write blobs or temp files into .socket; found: {entries:?}" + assert_eq!( + entries, + vec!["vendor".to_string()], + "vendored mode must write only .socket/vendor; found: {entries:?}" ); } #[tokio::test] -async fn scan_vendor_manifest_mode_end_to_end() { - // scan --vendor: discover → download (manifest written) → vendor. +async fn scan_vendor_end_to_end_is_manifest_free() { + // scan --vendor: discover → fetch records in memory → vendor. The + // ledger (with embedded records) is the only state written. let mock = MockServer::start().await; mount_patch_api(&mock, UUID).await; let tmp = tempfile::tempdir().unwrap(); @@ -225,17 +226,15 @@ async fn scan_vendor_manifest_mode_end_to_end() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success", "envelope={v}"); - // Download phase: manifest written with the patch, blob staged. + // Download phase: the record fetched in memory, nothing written. let dl = v["download"].as_object().expect("download sub-object"); assert_eq!(dl["downloaded"], 1, "download={dl:?}"); assert_eq!(dl["failed"], 0, "download={dl:?}"); - let manifest: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), - ) - .unwrap(); - assert_eq!( - manifest["patches"][PURL]["uuid"], UUID, - "manifest={manifest}" + assert_eq!(dl["detached"], true, "download={dl:?}"); + assert_eq!(dl["patches"][0]["action"], "downloaded", "download={dl:?}"); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "vendored mode never writes a manifest" ); // Vendor phase: a full vendor Envelope with one applied event. @@ -244,8 +243,9 @@ async fn scan_vendor_manifest_mode_end_to_end() { assert_eq!(venv["status"], "success", "vendor={venv:?}"); assert_eq!(venv["summary"]["applied"], 1, "vendor={venv:?}"); - // Disk: tarball at the contract path, ledger entry NOT detached, - // lock rewired to consume the vendored artifact. + // Disk: tarball at the contract path, ledger entry DETACHED with the + // embedded record (the verification source), lock rewired to consume + // the vendored artifact. let tgz = tmp .path() .join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")); @@ -256,11 +256,14 @@ async fn scan_vendor_manifest_mode_end_to_end() { .unwrap(); let entry = &state["entries"][PURL]; assert_eq!(entry["uuid"], UUID, "state={state}"); - assert!( - entry["detached"].is_null(), - "manifest-mode entries are not detached: {state}" + assert_eq!( + entry["detached"], true, + "every vendored entry is ledger-owned: {state}" + ); + assert_eq!( + entry["record"]["uuid"], UUID, + "the embedded record is the verification source: {state}" ); - assert!(entry["record"].is_null(), "no embedded record: {state}"); let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); assert!( lock.contains(&format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")), @@ -274,11 +277,13 @@ async fn scan_vendor_manifest_mode_end_to_end() { ); assert_socket_dir_lean(tmp.path()); - // Idempotent re-run: already_vendored skip, zero new applies. + // Idempotent re-run: the embedded record is reused (no view fetch), + // already_vendored skip, zero new applies. let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v2: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); assert_eq!(v2["status"], "success", "envelope={v2}"); + assert_eq!(v2["download"]["skipped"], 1, "envelope={v2}"); assert_eq!(v2["vendor"]["summary"]["applied"], 0, "envelope={v2}"); let events = v2["vendor"]["events"].as_array().expect("events"); assert!( @@ -337,45 +342,27 @@ fn seed_committed_manifest(root: &Path) { .unwrap(); } -/// CONTRACT (CLI_CONTRACT.md, `scan --vendor`): "The whole manifest is -/// vendored" — and `run_vendor_json_path` says so in code ("the vendor -/// step still runs when zero patches were downloaded (re-vendor after a -/// wipe)"). `scan/mod.rs`'s `selected.is_empty() && !vendor` guard encodes -/// the same intent for the interactive arm. -/// -/// But the interactive arm never reaches that guard on an empty discovery: -/// the earlier `all_packages_with_patches.is_empty()` / -/// `downloadable_count == 0` / `all_search_results.is_empty()` returns fire -/// first and exit before the vendor dispatch. Same fixture, same mock, only -/// `--json` differing must not decide whether the vendor tree gets rebuilt. +/// Vendored mode takes its work from DISCOVERY, never from a committed +/// manifest: with nothing discovered there is nothing to vendor, so +/// `scan --vendor` is a clean no-op that creates nothing — no +/// `.socket/vendor/`, no `apply.lock` — and a legacy manifest is left +/// byte-identical. Both arms agree (the interactive arm exits before the +/// vendor dispatch; the JSON arm's vendor step skips itself before taking +/// the lock). Rebuilding committed vendored state is `repair`'s job; +/// migrating a legacy manifest-mode project is a NON-empty vendored run's +/// (see `scan_vendor_migrates_legacy_manifest_mode_project`). #[tokio::test] -async fn scan_vendor_rebuilds_committed_manifest_when_discovery_is_empty() { +async fn scan_vendor_with_empty_discovery_is_a_no_op() { let mock = MockServer::start().await; mount_empty_discovery(&mock).await; let uri = mock.uri(); - // --- JSON arm (the documented behavior) --- - let json_tmp = tempfile::tempdir().unwrap(); - write_fixture(json_tmp.path()); - seed_committed_manifest(json_tmp.path()); - let (json_code, json_out, json_err) = run_scan_vendor(json_tmp.path(), &uri, &[]); - let json_tgz = json_tmp - .path() - .join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")); - assert_eq!(json_code, 0, "stdout={json_out}; stderr={json_err}"); - assert!( - json_tgz.is_file(), - "baseline: scan --json --vendor must re-vendor the committed manifest \ - even when discovery returns no patches; stdout={json_out}; stderr={json_err}" - ); - - // --- Interactive arm (same inputs, no --json) --- - let tty_tmp = tempfile::tempdir().unwrap(); - write_fixture(tty_tmp.path()); - seed_committed_manifest(tty_tmp.path()); - let (tty_code, tty_out, tty_err) = run_cli_env( - tty_tmp.path(), - &[ + for json in [true, false] { + let tmp = tempfile::tempdir().unwrap(); + write_fixture(tmp.path()); + seed_committed_manifest(tmp.path()); + let manifest_before = std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(); + let mut argv = vec![ "scan", "--vendor", "--yes", @@ -385,28 +372,125 @@ async fn scan_vendor_rebuilds_committed_manifest_when_discovery_is_empty() { "fake-token", "--org", ORG_SLUG, - ], - &[], + ]; + if json { + argv.push("--json"); + } + let (code, stdout, stderr) = run_cli_env(tmp.path(), &argv, &[]); + assert_eq!(code, 0, "json={json}; stdout={stdout}; stderr={stderr}"); + if json { + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!(v["download"]["found"], 0, "{v}"); + assert_eq!(v["download"]["detached"], true, "{v}"); + assert_eq!(v["vendor"]["summary"]["applied"], 0, "{v}"); + } + assert!( + !tmp.path().join(".socket/vendor").exists(), + "json={json}: nothing discovered ⇒ nothing vendored; stdout={stdout}; stderr={stderr}" + ); + assert!( + !tmp.path().join(".socket/apply.lock").exists(), + "json={json}: a run with nothing to vendor takes no lock" + ); + assert_eq!( + std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(), + manifest_before, + "json={json}: a committed manifest is not vendored mode's record source" + ); + } +} + +/// A project vendored by an older, manifest-mode CLI (manifest record + +/// NON-detached ledger entry at the same uuid): the next vendored run +/// migrates it — the ledger entry gains `detached: true` plus the embedded +/// record, the manifest record moves out (an emptied manifest stays as +/// `{"patches":{}}`), the run says so in `vendor.warnings[]` — and the run +/// after that is a fetch-free `skipped` re-run with nothing left to +/// migrate. +#[tokio::test] +async fn scan_vendor_migrates_legacy_manifest_mode_project() { + let mock = MockServer::start().await; + mount_patch_api(&mock, UUID).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture(tmp.path()); + // The legacy state, produced by the (still manifest-driven) standalone + // `vendor` command from a committed manifest + blob. + seed_committed_manifest(tmp.path()); + let (code, venv, stderr) = run_vendor(tmp.path(), &["--vendor-source", "build"]); + assert_eq!(code, 0, "legacy setup: {venv:#} {stderr}"); + let state_path = tmp.path().join(".socket/vendor/state.json"); + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + assert_eq!(state["entries"][PURL]["uuid"], UUID, "{state}"); + assert!( + state["entries"][PURL]["detached"].is_null(), + "legacy setup must be manifest-tracked: {state}" + ); + + let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 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}"); + // A legacy entry carries no record, so the view is fetched once more… + assert_eq!(v["download"]["downloaded"], 1, "{v}"); + // …and the engine finds artifact + wiring already in sync. + let events = v["vendor"]["events"].as_array().expect("events"); + assert!( + events + .iter() + .any(|e| e["action"] == "skipped" && e["errorCode"] == "already_vendored"), + "{v}" ); - let tty_tgz = tty_tmp + assert!( + v["vendor"]["warnings"].as_array().is_some_and(|ws| ws.iter().any(|w| { + w["code"] == "vendor_manifest_record_migrated" + && w["detail"].as_str().unwrap_or("").contains(PURL) + })), + "the migration must be announced: {v}" + ); + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + let entry = &state["entries"][PURL]; + assert_eq!(entry["detached"], true, "upgraded in place: {state}"); + assert_eq!(entry["record"]["uuid"], UUID, "record embedded: {state}"); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + manifest, + serde_json::json!({ "patches": {} }), + "the record moved to the ledger; an emptied manifest is kept, not deleted" + ); + assert!(tmp .path() - .join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")); - assert_eq!(tty_code, 0, "stdout={tty_out}; stderr={tty_err}"); + .join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")) + .is_file()); + + // Migrated: the re-run reuses the embedded record (no view fetch) and + // has nothing left to warn about. + let before_reqs = mock.received_requests().await.unwrap().len(); + let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v2: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(v2["download"]["skipped"], 1, "{v2}"); assert!( - tty_tgz.is_file(), - "scan --vendor (interactive) must re-vendor the committed manifest too — \ - --json must not decide whether the vendor step runs; stdout={tty_out}; stderr={tty_err}" + v2["vendor"].get("warnings").is_none(), + "nothing left to migrate: {v2}" ); + let after_reqs = mock.received_requests().await.unwrap(); assert!( - tty_tmp.path().join(".socket/vendor/state.json").is_file(), - "the ledger must be written by the interactive arm; stdout={tty_out}; stderr={tty_err}" + !after_reqs[before_reqs..] + .iter() + .any(|r| r.url.path().contains("/patches/view/")), + "a migrated project re-runs without re-fetching the view" ); } #[tokio::test] async fn scan_vendor_detached_mode_writes_no_manifest() { - // scan --vendor --detached: the ledger (with embedded records) is the - // only state — .socket/manifest.json is never created. + // scan --vendor --detached: the flag is a compatibility no-op — the run + // is the same manifest-free flow, embedded-record ledger and all. let mock = MockServer::start().await; mount_patch_api(&mock, UUID).await; let tmp = tempfile::tempdir().unwrap(); @@ -839,9 +923,9 @@ async fn mount_scoped_patch_api(mock: &MockServer, uuid: &str) { } /// The production patches API serves scoped purls percent-encoded -/// (`pkg:npm/%40scope/...`) and scan stores them verbatim as manifest keys. +/// (`pkg:npm/%40scope/...`) and scan stores them verbatim as ledger keys. /// The whole pipeline — download, vendor lookup against the literal -/// `node_modules/@scope/...` install, lock rewiring, prune exemption — must +/// `node_modules/@scope/...` install, lock rewiring, GC exemption — must /// bridge the two spellings. (Flowise regression: `%40modelcontextprotocol` /// failed with `package not installed`.) #[tokio::test] @@ -851,26 +935,27 @@ async fn scan_vendor_resolves_percent_encoded_scoped_purl() { let tmp = tempfile::tempdir().unwrap(); write_scoped_fixture(tmp.path()); - // --prune in the same run: the freshly-downloaded ENCODED manifest - // entry must not be GC'd against the literal crawler purl. + // --prune in the same run: the freshly-vendored ENCODED entry must not + // be GC'd against the literal crawler purl (nor by the lockfile-usage + // probe — the lock consumes its artifact). let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &["--prune"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success", "envelope={v}"); - // Manifest keyed by the verbatim encoded purl — and NOT pruned. - let manifest: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), - ) - .unwrap(); - assert_eq!( - manifest["patches"][SCOPED_API_PURL]["uuid"], UUID, - "manifest={manifest}" + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "vendored mode never writes a manifest" ); assert_eq!( v["gc"]["prunedManifestEntries"], serde_json::json!([]), - "the encoded entry must not look prunable: {v}" + "nothing looks prunable: {v}" + ); + assert_eq!( + v["gc"]["revertedVendoredEntries"], + serde_json::json!([]), + "the just-vendored entry is lock-visible and must not be reverted: {v}" ); // Vendored: artifact under the DECODED scope dir, lock rewired. @@ -903,16 +988,19 @@ async fn scan_vendor_resolves_percent_encoded_scoped_purl() { /// 1. The wired lock entry VANISHED (an uninstall is one drift flavor — /// the live lock no longer matches anything the wiring recorded), so /// the backend revert keeps the artifacts (`RevertOutcome:: -/// kept_artifact`, residual #131) and the GC must keep the ledger and -/// manifest entries too — pre-fix it pruned the ledger, dropped the -/// manifest records, reported the purl in `revertedVendoredEntries`, -/// and the orphan sweep then destroyed the kept artifacts (with the -/// recorded pre-vendor originals, the state a later `git checkout` of -/// the vendored lock still points at). +/// kept_artifact`, residual #131) and the GC must keep the ledger entry +/// too — pre-fix it pruned the ledger, reported the purl in +/// `revertedVendoredEntries`, and the orphan sweep then destroyed the +/// kept artifacts (with the recorded pre-vendor originals, the state a +/// later `git checkout` of the vendored lock still points at). /// 2. Undoing the drift (restoring the pre-vendor registry lock — the /// keep warning's documented remediation) converges every recorded -/// fragment, and the same prune then reverts fully: ledger entry + -/// manifest entry dropped, artifact dir removed, lock untouched. +/// fragment, and the same prune then reverts fully: ledger entry +/// dropped, artifact dir removed, lock untouched. +/// +/// Every vendored entry is ledger-owned (`detached`), and the lockfile- +/// usage leg of the GC judges entries by the LIVE lock, so being detached +/// exempts nothing here. #[tokio::test] async fn scan_prune_reverts_unused_vendored_entry() { let mock = MockServer::start().await; @@ -933,6 +1021,10 @@ async fn scan_prune_reverts_unused_vendored_entry() { let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "vendored mode never writes a manifest" + ); // Simulate `npm uninstall left-pad` + re-lock: drop the dep from the // lock graph and remove the installed copy. The override-free npm @@ -991,16 +1083,6 @@ async fn scan_prune_reverts_unused_vendored_entry() { state["entries"][PURL].is_object(), "ledger entry must be kept: {state}" ); - let manifest: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), - ) - .unwrap(); - assert!( - manifest["patches"] - .as_object() - .is_some_and(|m| m.contains_key(PURL)), - "manifest entry must be kept: {manifest}" - ); assert!( tmp.path() .join(format!(".socket/vendor/npm/{UUID}")) @@ -1024,8 +1106,8 @@ async fn scan_prune_reverts_unused_vendored_entry() { "gc must report the reverted entry: {v}" ); - // Ledger empty (an emptied state file may be removed outright), - // manifest entry dropped, artifact gone. + // Ledger empty (an emptied state file is removed outright), artifact + // gone. match std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")) { Ok(text) => { let state: serde_json::Value = serde_json::from_str(&text).unwrap(); @@ -1037,16 +1119,6 @@ async fn scan_prune_reverts_unused_vendored_entry() { Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => panic!("unexpected state.json read error: {e}"), } - let manifest: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), - ) - .unwrap(); - assert!( - manifest["patches"] - .as_object() - .is_none_or(|m| !m.contains_key(PURL)), - "manifest entry dropped: {manifest}" - ); assert!( !tmp.path() .join(format!(".socket/vendor/npm/{UUID}")) @@ -1714,11 +1786,11 @@ fn write_bun_v1_workspace_fixture(root: &Path) { .unwrap(); } -/// The manifest-tracked vendored scan refuses a v1 workspace lock IN THE -/// DOWNLOAD PHASE: the record is `failed` with the vendor code + detail, -/// nothing is fetched (request-log oracle), the lock is byte-identical, -/// nothing is vendored, and the manifest — written by contract — holds no -/// record for the refused purl. +/// The vendored scan refuses a v1 workspace lock IN THE DOWNLOAD PHASE: +/// the record is `failed` with the vendor code + detail, nothing is +/// fetched (request-log oracle), the lock is byte-identical, nothing is +/// vendored — and a run with nothing left to vendor creates nothing under +/// `.socket/` at all. #[tokio::test] async fn scan_vendored_bun_v1_workspace_refuses_in_download_phase() { let mock = MockServer::start().await; @@ -1756,15 +1828,9 @@ async fn scan_vendored_bun_v1_workspace_refuses_in_download_phase() { lock_before, "bun.lock must be byte-identical" ); - assert!(!tmp.path().join(".socket/vendor").exists()); - let manifest: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), - ) - .unwrap(); - assert_eq!( - manifest, - serde_json::json!({ "patches": {} }), - "no record may be claimed for the refused purl" + assert!( + !tmp.path().join(".socket").exists(), + "a fully refused run vendors nothing and creates nothing under .socket/" ); } From 8ea9a9c9c8b47f6b897610441dd5f2609bcdf852 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 19:51:40 -0400 Subject: [PATCH 14/44] cli(scan/hosted): D1 apply lock, takeover symlink pre-check, one candidate vector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosted engine (`scan --mode hosted`, `get --mode hosted`) fixes per the .socket hygiene decisions and the cli-scan-hosted triage: - D1: acquire `/apply.lock` before the redirect-ledger load, only on a WET run with at least one granted reference — the only runs that can write (takeover pre-reverts, ledger merge, lockfile writes). Dry runs and zero-grant runs never lock, so previews create no `.socket/`; a granted run that writes nothing leaves none either (the guard's drop unlinks + prunes). Contention renders through `lock_cli::lock_failure` into the hosted envelope (top-level `errorCode: lock_held|lock_io`) plus `Error (): …` on stderr and the `--lock-timeout` hint for a live holder. - P1 ordering bug: a symlinked wiring file was detached by the wet vendored→hosted takeover revert BEFORE the fail-closed symlink guard ran. A pre-check over each takeover entry's recorded wiring files now refuses first (same code, dry-run parity); the general guard and the bun.lockb check share one `refuse_symlinked_file` helper. - Vendor ledger loaded once for the takeover and retained in memory per reverted purl (no per-purl reload under the lock); the bun lock preflight only reads when an npm purl actually has a vendored entry. - Redirect ledger held as ONE in-memory value: no whole-records clone for the stale-install probes, no second `RedirectState` construction. - The `(purl, uuid, url, index, suffixed, go_module)` 6-tuple and the parallel `overrides` vector collapse into `Vec`; the rewriters' slice is materialized once after the last filter, so the two hand-synced retains are gone. - Candidate-file reads are skipped when no candidate survived (the rewrite is provably empty); everything after the rewrite still runs. - `pdm_drives` (core export) replaces both hand-rolled pdm predicates. - `redirect_json_block` / `prune_ignored_warning` helpers are the one spelling of the hosted `redirect` block and prune warning (mod.rs's zero-discovery arm to adopt them). Tests: new `hosted_lock_held_refuses_before_any_write` (wet JSON + human refusal, dry-run and all-skipped runs never contend, no `.socket/` left) and `takeover_refuses_symlinked_wiring_file_before_reverting` (link, target, vendored ledger and artifact byte-identical, wet + dry-run + human) in covgap_commands_scan_hosted.rs; the envelope unit test now builds its block through the shared helper. Co-Authored-By: Claude Fable 5.1 --- .../src/commands/scan/hosted.rs | 771 ++++++++++-------- .../tests/covgap_commands_scan_hosted.rs | 216 ++++- 2 files changed, 658 insertions(+), 329 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index fe862692..a0700899 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -4,8 +4,11 @@ //! discovery, then returns without touching the apply/vendor branches. use std::path::Path; +use std::time::Duration; use socket_patch_core::api::types::BatchPackagePatches; +use socket_patch_core::patch::apply_lock::{acquire, LockError, LockGuard}; +use socket_patch_core::patch::redirect::DepOverride; use crate::commands::vex::generate_vex_from_manifest_path; @@ -398,6 +401,106 @@ fn build_redirect_json_envelope( result } +/// The nested `redirect` block of every hosted `--json` envelope — the ONE +/// spelling of its key set (`mode`, `redirected`, `rewrittenFiles`, +/// `skipped`, `warnings`, `dryRun`), shared by the ≥1-package path here and +/// the zero-discovery arm in `run`, so the two cannot drift by convention. +/// `mode` is `"hosted"` (the final mode name for `--redirect`): an additive +/// key so consumers dispatch on the mode without inferring it from which +/// sub-object is present. +pub(super) fn redirect_json_block( + redirected: usize, + rewritten: Vec, + skipped: Vec, + warnings: Vec, + dry_run: bool, +) -> serde_json::Value { + serde_json::json!({ + "mode": "hosted", + "redirected": redirected, + "rewrittenFiles": rewritten, + "skipped": skipped, + "warnings": warnings, + "dryRun": dry_run, + }) +} + +/// The `redirect_prune_ignored` warning object (`--prune` is a no-op in +/// hosted mode; see the constants' doc in `run`'s module). +pub(super) fn prune_ignored_warning() -> serde_json::Value { + serde_json::json!({ + "code": super::REDIRECT_PRUNE_IGNORED, + "detail": super::REDIRECT_PRUNE_IGNORED_DETAIL, + }) +} + +/// The fail-closed refusal for a symlinked rewrite target (both the general +/// SYMLINK GUARD and the takeover pre-check in [`run_redirect_selected`]): +/// stderr line + `--json` envelope, exit 1. The writers stage next to the +/// path and rename over it, which REPLACES a symbolic link with a detached +/// regular copy — the link target goes stale and a revert restores bytes but +/// never the link — so nothing may be written. +fn refuse_symlinked_file( + common: &crate::args::GlobalArgs, + scan_result: Option, + linked: &str, +) -> i32 { + let message = format!( + "{linked} is a symbolic link; socket-patch rewrites files in place with an atomic \ + rename, which would replace the link — replace the link with a regular file (or \ + run socket-patch in the directory it points to) and re-run; nothing was written" + ); + eprintln!("Error (redirect_symlinked_file_unsupported): {message}"); + if common.json { + emit_json_error_with_code( + scan_result, + Some("redirect_symlinked_file_unsupported"), + &message, + ); + } + 1 +} + +/// The apply lock for a WET hosted run: the same `/apply.lock` +/// `apply`/`rollback`/`remove`/`vendor` hold, so the takeover pre-reverts, +/// the ledger merge and the lockfile writes never race them. `acquire` +/// creates a missing `.socket/` and the guard's drop unlinks the lock file +/// and prunes an otherwise-empty `.socket/`, so a run that ends up writing +/// nothing leaves no residue. Contention / IO failures render through the +/// shared [`crate::commands::lock_cli::lock_failure`] mapping — the +/// `lock_held` / `lock_io` codes and the "(waited …)" clause match every +/// other mutating command — into the hosted error envelope (NOT +/// `acquire_or_emit`, whose `Envelope` would replace the classic scan / get +/// object) plus the stderr line and, for a live holder, the wait hint. +fn acquire_hosted_lock( + common: &crate::args::GlobalArgs, + scan_result: &mut Option, +) -> Result { + let manifest_path = common.resolved_manifest_path(); + let socket_dir = manifest_path + .parent() + .expect("manifest path names a file, so it has a parent"); + let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); + match acquire(socket_dir, timeout) { + Ok(guard) => Ok(guard), + Err(err) => { + let (code, message) = crate::commands::lock_cli::lock_failure(&err, timeout); + // Errors print even under --silent ("errors only", never + // "nothing"): exit 1 with no message would be undiagnosable. + eprintln!("Error ({code}): {message}"); + if matches!(err, LockError::Held) { + eprintln!( + " Wait for it to finish, or retry with --lock-timeout to wait for the lock." + ); + } + if common.json { + emit_json_error_with_code(scan_result.take(), Some(code), &message); + } + Err(1) + } + } +} + /// The installed-tree probes' outcome: warnings for both output channels, /// plus the stale purls STRUCTURALLY, so the same-run `--vex` can exclude /// them from `assume_applied` — an envelope must never attest a CVE its own @@ -786,8 +889,9 @@ pub(super) async fn run_redirect( } /// The hosted-redirect engine over an ALREADY-SELECTED `(purl, uuid)` set: -/// reference grants → DepOverride build → vendored→hosted takeover pre-revert -/// → candidate-file read → rewrite → pnpm trust config → +/// reference grants → DepOverride build → apply lock (wet runs with a grant) +/// → ledger load → vendored→hosted takeover pre-revert (symlink-checked +/// first) → candidate-file read → rewrite → pnpm trust config → /// confirmation probe → ledger merge-then-persist → file writes → gem stale /// probe → warnings → optional VEX. Shared VERBATIM by `scan --mode hosted` /// (whose `run_redirect` wrapper selects via `discover_selected`) and @@ -810,28 +914,24 @@ pub(crate) async fn run_redirect_selected( ) -> i32 { use socket_patch_core::manifest::schema::PatchRecord; use socket_patch_core::patch::redirect::{ - rewrite_registry_redirect_with_pipenv_version, DepOverride, RedirectState, + rewrite_registry_redirect_with_pipenv_version, RedirectState, }; let mut skipped: Vec = Vec::new(); - let mut overrides: Vec = Vec::new(); - // (purl, uuid, artifact_url, registry index_url, maven suffixed version, - // go module path) per granted reference — used AFTER the rewrite to decide - // which deps were actually redirected (their target URL / index / suffixed - // version / socket module path landed in a file) before persisting records - // or attesting anything. The fifth element is Some only for fail-closed - // maven overrides; the sixth only for golang (whose go.mod/go.sum edits - // carry the content-addressed `patch.socket.dev/gopatch/` module - // path, never the artifact or index URL). - type RedirectCandidate = ( - String, - String, - String, - Option, - Option, - Option, - ); - let mut candidates: Vec = Vec::new(); + /// One granted reference: the purl it was granted for plus the rewriter + /// override built from it. The purl is what the takeover, the skip + /// records and the confirmation probe key on; everything the probe + /// needs AFTER the rewrite to decide whether the dep was actually + /// redirected (artifact URL, registry index URL, fail-closed maven's + /// suffixed version, golang's content-addressed module path) already + /// rides the override. The single vector is filtered in place by every + /// withhold/refusal step, and the rewriters' `overrides` slice is + /// materialized from it once, after the last filter. + struct Candidate { + purl: String, + dep: DepOverride, + } + let mut candidates: Vec = Vec::new(); if !selected.is_empty() { let uuids: Vec = selected.iter().map(|(_, uuid)| uuid.clone()).collect(); @@ -906,23 +1006,6 @@ pub(crate) async fn run_redirect_selected( integrity.go_mod_h1 = Some(gomod_h1); } } - candidates.push(( - purl.to_string(), - sel_uuid.clone(), - url.clone(), - reference - .registry_override - .as_ref() - .map(|o| o.index_url.clone()), - reference - .registry_override - .as_ref() - .and_then(|o| o.identifiers.maven_suffixed_version.clone()), - reference - .registry_override - .as_ref() - .and_then(|o| o.identifiers.go_module_path.clone()), - )); // The grant token is never a top-level reference field — it only // rides the URLs the reference endpoint hands back, as the path // level before the patch uuid. Recover it so the rewriters' @@ -944,21 +1027,69 @@ pub(crate) async fn run_redirect_selected( socket_patch_core::patch::redirect::grant_token_path_segment(&url, sel_uuid) }) .unwrap_or_default(); - overrides.push(DepOverride { - ecosystem, - name, - namespace: None, - version, - token, - patch_uuid: sel_uuid.clone(), - artifact_url: url, - berry_zip_url: berry_zip.and_then(|a| a.url.clone()), - registry_override: reference.registry_override.clone(), - integrity, + candidates.push(Candidate { + purl: purl.to_string(), + dep: DepOverride { + ecosystem, + name, + namespace: None, + version, + token, + patch_uuid: sel_uuid.clone(), + artifact_url: url, + berry_zip_url: berry_zip.and_then(|a| a.url.clone()), + registry_override: reference.registry_override.clone(), + integrity, + }, }); } } + // Text retains Bun's precedence when both lock spellings are present; + // the takeover reverts rewrite locks in place (never create or remove + // one), so the probe holds for the binary-lock decision below too. + let bun_lock_present = common.cwd.join("bun.lock").exists(); + // Check binary lock symlinks before a mode takeover changes any wiring. + if candidates.iter().any(|c| c.dep.ecosystem == "npm") + && !bun_lock_present + && socket_patch_core::utils::fs::first_symlink(&common.cwd, ["bun.lockb"]) + .await + .is_some() + { + // Atomic replacement cannot preserve a link; previews refuse too. + let message = "bun.lockb is a symbolic link; replace it with a regular file (or run \ + socket-patch in the directory it points to) before patching; nothing \ + was written"; + eprintln!("Error (redirect_symlinked_file_unsupported): {message}"); + if common.json { + emit_json_error_with_code( + scan_result.take(), + Some("redirect_symlinked_file_unsupported"), + message, + ); + } + return 1; + } + + // The apply lock (see `acquire_hosted_lock`), taken only by a WET run + // that holds at least one granted reference — the only runs that can + // write anything: the takeover pre-reverts (lockfiles + the vendored + // ledger), the redirect-ledger merge and the lockfile writes. Dry runs + // and zero-grant runs never touch `.socket/`, so they never lock (a + // preview must not create `.socket/`, flip to `lock_held` under a + // concurrent wet run, or fail on a read-only checkout). Acquired BEFORE + // the ledger load so load → merge → persist is one critical section + // (rollback's rule: a ledger a run will persist is loaded under the + // lock) and held to the end of the function. + let _lock: Option = if !common.dry_run && !candidates.is_empty() { + match acquire_hosted_lock(common, &mut scan_result) { + Ok(guard) => Some(guard), + Err(code) => return code, + } + } else { + None + }; + // Load the existing redirect ledger before any file changes, including // Cargo takeover reverts. It stores the originals a future revert needs, so // a malformed (torn/hand-mangled) ledger must abort the run while the @@ -967,9 +1098,16 @@ pub(crate) async fn run_redirect_selected( // overwriting that revert data. The malformed file is moved aside to // redirect-state.json.corrupt (never clobbered) so recovery stays // possible; a dry-run reports the same hard error but moves nothing. - let existing_ledger = + // + // Held as the ONE in-memory ledger for the whole run: the write below + // merges into it in place, and the stale-install probes read its + // records (persisted ones included — their fallback judgment source + // when this run's /patches/view fetch fails transiently: the warning + // must keep firing until the stale materialization is gone, not until + // the first flaky fetch). + let mut ledger = match socket_patch_core::patch::redirect::load_redirect_state(&common.cwd).await { - Ok(state) => state, + Ok(state) => state.unwrap_or_else(RedirectState::new), Err(mut corrupt) => { if !common.dry_run { corrupt.quarantine().await; @@ -983,41 +1121,6 @@ pub(crate) async fn run_redirect_selected( } }; - // Snapshot the persisted patch records BEFORE the ledger value is - // consumed by the write below: they are the gem stale-install probe's - // fallback judgment source when this run's /patches/view fetch fails - // transiently (the warning must keep firing until the stale - // materialization is gone, not until the first flaky fetch). - let ledger_records: std::collections::BTreeMap< - String, - socket_patch_core::manifest::schema::PatchRecord, - > = existing_ledger - .as_ref() - .map(|l| l.records.clone()) - .unwrap_or_default(); - - // Check binary lock symlinks before a mode takeover changes any wiring. - if overrides.iter().any(|o| o.ecosystem == "npm") - && !common.cwd.join("bun.lock").exists() - && socket_patch_core::utils::fs::first_symlink(&common.cwd, ["bun.lockb"]) - .await - .is_some() - { - // Atomic replacement cannot preserve a link; previews refuse too. - let message = "bun.lockb is a symbolic link; replace it with a regular file (or run \ - socket-patch in the directory it points to) before patching; nothing \ - was written"; - eprintln!("Error (redirect_symlinked_file_unsupported): {message}"); - if common.json { - emit_json_error_with_code( - scan_result.take(), - Some("redirect_symlinked_file_unsupported"), - message, - ); - } - return 1; - } - // Cross-mode takeover: a purl this run is about to redirect may still be // VENDORED — for cargo a committed `[patch.crates-io]` path entry, a // detached Cargo.lock entry, a committed copy, and a vendored ledger @@ -1046,16 +1149,45 @@ pub(crate) async fn run_redirect_selected( // wet run reverts FIRST) and counted as redirected below, so the // preview's envelope matches the wet run's outcome. let mut dry_run_takeover: Vec<(String, String)> = Vec::new(); - if !candidates.iter().any(|(p, ..)| takeover_capable(p)) { + if !candidates.iter().any(|c| takeover_capable(&c.purl)) { // No takeover-capable candidates — nothing to reconcile. } else { use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); - let vendor_state = socket_patch_core::vendor::load_state(&common.cwd).await; + // Loaded ONCE and mutated in place per reverted purl (the wet loop + // saves after each revert): this run holds the apply lock, so no + // other writer can move the on-disk ledger under it. + let mut vendor_state = socket_patch_core::vendor::load_state(&common.cwd).await; + // Each takeover-capable candidate with its vendored ledger entry, if + // any (cloned out so the loop can mutate the state). + let takeover: Vec<(&Candidate, Option)> = + candidates + .iter() + .filter(|c| takeover_capable(&c.purl)) + .map(|c| { + let entry = vendor_state + .as_ref() + .ok() + .and_then(|s| { + socket_patch_core::vendor::lookup_entry( + &s.entries, + strip_purl_qualifiers(&c.purl), + ) + }) + .cloned(); + (c, entry) + }) + .collect(); // Compatibility must be known before the takeover removes a live // patch. In particular, a v0 workspace can keep an existing local - // tuple even though hosted mode cannot replace it with a URL. - let bun_takeover_refusal = if candidates.iter().any(|(p, ..)| p.starts_with("pkg:npm/")) { + // tuple even though hosted mode cannot replace it with a URL. Only + // an npm purl WITH a vendored entry can be taken over, so the bun + // locks are read here only when one exists — the candidate-file + // read below covers every other run. + let bun_takeover_refusal = if takeover + .iter() + .any(|(c, entry)| entry.is_some() && c.purl.starts_with("pkg:npm/")) + { match socket_patch_core::utils::fs::read_regular_to_string(&common.cwd.join("bun.lock")) .await { @@ -1084,23 +1216,38 @@ pub(crate) async fn run_redirect_selected( } else { None }; + // A bun-refused npm purl is never dispatched (see the loop), so its + // wiring is not a write target here. + let bun_refused = |c: &Candidate| { + bun_takeover_refusal.is_some() && c.purl.starts_with("pkg:npm/") + }; + // SYMLINK PRE-CHECK for the takeover reverts — the same rule as the + // SYMLINK GUARD below, applied to the files the reverts rewrite + // (each ledger entry's recorded wiring): the revert backends stage + // and rename over the lock like the rewriters do, so a symlinked + // package-lock.json would be detached — or a later refusal would + // find the purl neither vendored nor hosted — before the general + // guard ever ran. Checked in one pass BEFORE any revert dispatches + // (and under --dry-run too) so "nothing was written" stays true. + let revert_targets = takeover + .iter() + .filter_map(|(c, entry)| entry.as_ref().filter(|_| !bun_refused(c))) + .flat_map(|entry| entry.wiring.iter().map(|w| w.file.as_str())); + if let Some(linked) = + socket_patch_core::utils::fs::first_symlink(&common.cwd, revert_targets).await + { + return refuse_symlinked_file(common, scan_result.take(), linked); + } let patch_entries = socket_patch_core::vendor::cargo_config::read_patch_entries(&common.cwd).await; let mut refused: Vec = Vec::new(); - for (purl, uuid, ..) in &candidates { - if !takeover_capable(purl) { - continue; - } - let stripped = strip_purl_qualifiers(purl); - let ledger_entry = vendor_state - .as_ref() - .ok() - .and_then(|s| socket_patch_core::vendor::lookup_entry(&s.entries, stripped)) - .cloned(); + for (candidate, ledger_entry) in &takeover { + let purl = &candidate.purl; + let uuid = &candidate.dep.patch_uuid; if let Some(entry) = ledger_entry { if let Some(warning) = bun_takeover_refusal .as_ref() - .filter(|_| purl.starts_with("pkg:npm/")) + .filter(|_| bun_refused(candidate)) { refused.push(purl.clone()); if !takeover_pre_warnings @@ -1123,7 +1270,7 @@ pub(crate) async fn run_redirect_selected( // revert itself while reporting `redirected: 0` for a // migration the wet run lands. let outcome = - crate::commands::vendor::dispatch_revert_one(&entry, &common.cwd, true) + crate::commands::vendor::dispatch_revert_one(entry, &common.cwd, true) .await; if !outcome.success { refused.push(purl.clone()); @@ -1150,7 +1297,7 @@ pub(crate) async fn run_redirect_selected( continue; } let outcome = - crate::commands::vendor::dispatch_revert_one(&entry, &common.cwd, false).await; + crate::commands::vendor::dispatch_revert_one(entry, &common.cwd, false).await; if !outcome.success { refused.push(purl.clone()); takeover_pre_warnings.push(serde_json::json!({ @@ -1164,29 +1311,18 @@ pub(crate) async fn run_redirect_selected( })); continue; } - // Drop the reverted entry and persist per purl so a crash - // mid-run leaves a ledger matching the on-disk wiring. - // Re-loaded fresh each iteration (each iteration saves): the - // saved file is the truth. - let mut state = match socket_patch_core::vendor::load_state(&common.cwd).await { - Ok(s) => s, - Err(e) => { - refused.push(purl.clone()); - takeover_pre_warnings.push(serde_json::json!({ - "code": "redirect_vendored_revert_failed", - "detail": format!( - "{purl}: vendored wiring reverted but the vendored \ - ledger could not be re-read ({e}); NOT redirected — \ - fix .socket/vendor/state.json and re-run" - ), - })); - continue; - } - }; + // Drop the reverted entry from the in-memory ledger and + // persist per purl so a crash mid-run leaves a ledger + // matching the on-disk wiring. The entry stays dropped even + // when the save fails: its wiring and artifact ARE gone, so + // a later successful save in this loop writes the truth. + let state = vendor_state + .as_mut() + .expect("a vendored ledger entry was looked up in this state, so it loaded"); state .entries .retain(|k, e| canon(k) != canon(purl) && canon(&e.base_purl) != canon(purl)); - if let Err(e) = socket_patch_core::vendor::save_state(&common.cwd, &state).await { + if let Err(e) = socket_patch_core::vendor::save_state(&common.cwd, state).await { // The wiring is reverted but the ledger still claims it; // redirecting now would leave a ledger asserting wiring // that is gone. Fail closed for this purl. @@ -1239,48 +1375,32 @@ pub(crate) async fn run_redirect_selected( } } } - if !refused.is_empty() { - for purl in &refused { - if let Some((_, uuid, ..)) = candidates.iter().find(|(p, ..)| p == purl) { - let reason = bun_takeover_refusal - .as_ref() - .filter(|_| purl.starts_with("pkg:npm/")) - .map_or("vendored_revert_failed", |w| w.code.as_str()); - skipped.push(serde_json::json!({ - "purl": purl, "uuid": uuid, "reason": reason, - })); - } + for purl in &refused { + if let Some(c) = candidates.iter().find(|c| &c.purl == purl) { + let reason = bun_takeover_refusal + .as_ref() + .filter(|_| bun_refused(c)) + .map_or("vendored_revert_failed", |w| w.code.as_str()); + skipped.push(serde_json::json!({ + "purl": purl, "uuid": c.dep.patch_uuid, "reason": reason, + })); } } // Purls leaving the rewrite set: refused takeovers, plus the dry-run // takeover previews (still vendored on disk — the wet run reverts // them before the rewriters ever see their files). - let withheld: Vec<&String> = refused + let withheld: std::collections::HashSet<&str> = refused .iter() - .chain(dry_run_takeover.iter().map(|(p, _)| p)) + .map(String::as_str) + .chain(dry_run_takeover.iter().map(|(p, _)| p.as_str())) .collect(); if !withheld.is_empty() { - let withheld_names: std::collections::HashSet<(String, String, String)> = candidates - .iter() - .filter(|(p, ..)| withheld.contains(&p)) - .filter_map(|(p, ..)| parse_purl_simple(p)) - .collect(); - candidates.retain(|(p, ..)| !withheld.contains(&p)); - overrides.retain(|o| { - // Overrides built here carry the full coordinate in `name` - // (namespace unset) — the same shape parse_purl_simple emits. - let coord = match o.namespace.as_deref() { - Some(ns) if !ns.is_empty() => format!("{ns}/{}", o.name), - _ => o.name.clone(), - }; - !withheld_names.contains(&(o.ecosystem.clone(), coord, o.version.clone())) - }); + candidates.retain(|c| !withheld.contains(c.purl.as_str())); } } - // The binary lock is read and rewritten directly. Text retains Bun's - // precedence when both filenames are present. - let binary_bun = !common.cwd.join("bun.lock").exists() && common.cwd.join("bun.lockb").exists(); + // The binary lock is read and rewritten directly. + let binary_bun = !bun_lock_present && common.cwd.join("bun.lockb").exists(); // Read the project's candidate files, run the rewriters. Every read goes // through the FIFO-safe reader (non-blocking open + fstat regular-file // check): a FIFO planted under any candidate name — pyproject.toml, @@ -1288,33 +1408,15 @@ pub(crate) async fn run_redirect_selected( // hosted` forever in a plain `read_to_string` open(2) waiting for a // writer. A non-regular file now reads as "unreadable" and is skipped // exactly like a missing one. + // + // Skipped entirely when no candidate survived (every reference skipped, + // refused or withheld as a dry-run takeover preview): the rewriters + // place nothing and warn about nothing without a dep, so the ~45 reads + // would only feed an empty rewrite. Everything after the rewrite still + // runs — the previews are counted, the skips and warnings reported, a + // requested VEX still attempted. use socket_patch_core::utils::fs::read_regular_to_string; let mut files: std::collections::BTreeMap = std::collections::BTreeMap::new(); - for name in REDIRECT_CANDIDATE_FILES { - if *name == "bun.lockb" { - continue; - } - if let Ok(content) = read_regular_to_string(&common.cwd.join(name)).await { - files.insert((*name).to_string(), content); - } - } - - if let Ok(paths) = socket_patch_core::utils::python_lock::python_lock_paths(&common.cwd) { - for path in paths { - if let Some(script_path) = path - .strip_suffix(".py.lock") - .map(|prefix| format!("{prefix}.py")) - { - if let Ok(content) = read_regular_to_string(&common.cwd.join(&script_path)).await { - files.insert(script_path, content); - } - } - if let Ok(content) = read_regular_to_string(&common.cwd.join(&path)).await { - files.insert(path, content); - } - } - } - // Rush monorepos have no root package.json/lock pair: the single pnpm // source-of-truth lock lives at common/config/rush/pnpm-lock.yaml, and // (when subspaces are enabled) one lock per subspace under @@ -1323,29 +1425,59 @@ pub(crate) async fn run_redirect_selected( // rewritten in place, and the write-back below is already path-generic. let mut rush_warnings: Vec = Vec::new(); let mut rush_lock_keys: Vec = Vec::new(); - if common.cwd.join("rush.json").is_file() { - let common_lock = socket_patch_core::constants::npm_family::RUSH_COMMON_LOCK_REL; - if let Ok(content) = read_regular_to_string(&common.cwd.join(common_lock)).await { - files.insert(common_lock.to_string(), content); - rush_lock_keys.push(common_lock.to_string()); + if !candidates.is_empty() { + for name in REDIRECT_CANDIDATE_FILES { + if *name == "bun.lockb" { + continue; + } + if let Ok(content) = read_regular_to_string(&common.cwd.join(name)).await { + files.insert((*name).to_string(), content); + } } - let subspaces_dir = common.cwd.join("common/config/subspaces"); - if let Ok(read_dir) = std::fs::read_dir(&subspaces_dir) { - // read_dir order is unspecified — sort for deterministic output. - let mut subspace_dirs: Vec = read_dir - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) - .map(|e| e.path()) - .collect(); - subspace_dirs.sort(); - for dir in subspace_dirs { - let Some(name) = dir.file_name().and_then(|n| n.to_str()) else { - continue; - }; - let key = format!("common/config/subspaces/{name}/pnpm-lock.yaml"); - if let Ok(content) = read_regular_to_string(&dir.join("pnpm-lock.yaml")).await { - files.insert(key.clone(), content); - rush_lock_keys.push(key); + + if let Ok(paths) = socket_patch_core::utils::python_lock::python_lock_paths(&common.cwd) { + for path in paths { + if let Some(script_path) = path + .strip_suffix(".py.lock") + .map(|prefix| format!("{prefix}.py")) + { + if let Ok(content) = + read_regular_to_string(&common.cwd.join(&script_path)).await + { + files.insert(script_path, content); + } + } + if let Ok(content) = read_regular_to_string(&common.cwd.join(&path)).await { + files.insert(path, content); + } + } + } + + if common.cwd.join("rush.json").is_file() { + let common_lock = socket_patch_core::constants::npm_family::RUSH_COMMON_LOCK_REL; + if let Ok(content) = read_regular_to_string(&common.cwd.join(common_lock)).await { + files.insert(common_lock.to_string(), content); + rush_lock_keys.push(common_lock.to_string()); + } + let subspaces_dir = common.cwd.join("common/config/subspaces"); + if let Ok(read_dir) = std::fs::read_dir(&subspaces_dir) { + // read_dir order is unspecified — sort for deterministic output. + let mut subspace_dirs: Vec = read_dir + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .map(|e| e.path()) + .collect(); + subspace_dirs.sort(); + for dir in subspace_dirs { + let Some(name) = dir.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let key = format!("common/config/subspaces/{name}/pnpm-lock.yaml"); + if let Ok(content) = read_regular_to_string(&dir.join("pnpm-lock.yaml")).await + { + files.insert(key.clone(), content); + rush_lock_keys.push(key); + } } } } @@ -1356,7 +1488,11 @@ pub(crate) async fn run_redirect_selected( // it rides the same atomic-write / ledger-first machinery as the locks. let mut python_metadata = std::collections::BTreeMap::new(); let mut unavailable_python_artifacts = std::collections::BTreeSet::new(); - for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") { + for dep in candidates + .iter() + .map(|c| &c.dep) + .filter(|dep| dep.ecosystem == "pypi") + { let Some(sha256) = dep.integrity.sha256.as_deref() else { continue; }; @@ -1408,7 +1544,10 @@ pub(crate) async fn run_redirect_selected( } } } - overrides.retain(|dep| !unavailable_python_artifacts.contains(&dep.artifact_url)); + candidates.retain(|c| !unavailable_python_artifacts.contains(&c.dep.artifact_url)); + // The rewriters' override slice — materialized ONCE, after the last + // candidate filter, so it can never disagree with `candidates`. + let overrides: Vec = candidates.iter().map(|c| c.dep.clone()).collect(); // The Pipfile.lock reference shape depends on the installing Pipenv // (`path` for 7–11, `file` from 2018 on), so the installed release is // probed (`pipenv --version`, up to 10 s) — but only when a pypi patch @@ -1726,7 +1865,7 @@ pub(crate) async fn run_redirect_selected( // `confirmed_pdm_uuids` and never consults this probe, so dropping the file // here is always safe; `pdm.lock` only ever carries pypi URLs. let pdm_inactive = files.contains_key("pdm.lock") - && (files.contains_key("uv.lock") || files.contains_key("poetry.lock")); + && !socket_patch_core::patch::redirect::pdm_drives(&files); let final_texts: Vec<&String> = files .iter() .filter(|(name, _)| !(pdm_inactive && name.as_str() == "pdm.lock")) @@ -1741,89 +1880,88 @@ pub(crate) async fn run_redirect_selected( .collect(); let confirmed: Vec<(String, String)> = candidates .iter() - .filter( - |(purl, uuid, artifact_url, index_url, suffixed_version, go_module_path)| { - if binary_bun && purl.starts_with("pkg:npm/") { - return rewrite.confirmed_bun_binary_uuids.contains(uuid); - } - if rewrite.refused_pipenv_uuids.contains(uuid) { - return false; - } - // pdm is transactional like cargo: a refused uuid is never - // confirmed, and when `pdm.lock` is the PyPI install driver - // (no `uv.lock` / `poetry.lock`) a pypi dep is confirmed ONLY - // by the pdm rewriter's own report — the URL landing in a - // sibling `requirements.txt` the project does not install from - // pins nothing. When uv/poetry drive, their own lock proof - // below still confirms them. This check precedes the hatch - // gate: a PDM project may declare `hatchling` as its build - // backend, which registers every pypi uuid as hatch-owned while - // the lock's presence keeps hatch from confirming any of them. - if rewrite.refused_pdm_uuids.contains(uuid) { - return false; - } - if purl.starts_with("pkg:pypi/") - && files.contains_key("pdm.lock") - && !files.contains_key("uv.lock") - && !files.contains_key("poetry.lock") - { - return rewrite.confirmed_pdm_uuids.contains(uuid); - } - if rewrite.python_lock_uuids.contains(uuid) { - return rewrite.confirmed_python_lock_uuids.contains(uuid) - && !rewrite.refused_python_lock_uuids.contains(uuid); - } - if rewrite.hatch_uuids.contains(uuid) { - return rewrite.confirmed_hatch_uuids.contains(uuid); - } - // A Pipfile.lock rewrite confirms its own uuids (the sibling - // requirements.txt rewriter may have had nothing to do). - if purl.starts_with("pkg:pypi/") { - return rewrite.confirmed_pipenv_uuids.contains(uuid) - || rewrite.confirmed_requirements_uuids.contains(uuid); - } - if rewrite.refused_pnpm_uuids.contains(uuid) { - return false; - } - // Cargo is transactional: the rewriter reports exactly which - // patch uuids FULLY landed (manifest pin + lock + registry - // block). Substring presence must never confirm a cargo dep — - // the `[registries.…]` config block contains the index URL while - // pinning nothing, so a config-block-only rewrite would be - // attested with zero enforcement in any build. - if purl.starts_with("pkg:cargo/") { - return rewrite.confirmed_cargo_uuids.contains(uuid); - } - let encoded = socket_patch_core::utils::uri::encode_uri_component(artifact_url); - final_texts.iter().any(|text| { - // The rewriters' own predicate — raw, or the `\/`-escaped - // slashes an old composer.lock spells them with — so a - // writer's spelling can never be one this probe misses. It - // was: the composer rewriter emitted `\/`-escaped urls this - // probe never looked for, so a fully successful composer - // redirect reported `redirected: 0`, fetched no patch record - // into the ledger, and left the patch unattestable by `vex`. - socket_patch_core::patch::redirect::artifact_url_present(text, artifact_url) - // The berry rewriter writes the URL percent-encoded into the - // lock's `::__archiveUrl=` binding, so the raw form is absent. - || text.contains(encoded.as_str()) - || index_url.as_deref().is_some_and(|iu| text.contains(iu)) - // Fail-closed maven pins the globally-unique - // `-socket.` suffixed version (never the `.pom` URL), - // so match on that string. - || suffixed_version - .as_deref() - .is_some_and(|sv| text.contains(sv)) - // golang pins the content-addressed - // `patch.socket.dev/gopatch/` module path into - // go.mod + go.sum (no URL ever lands in either file). - || go_module_path - .as_deref() - .is_some_and(|gm| text.contains(gm)) - }) - }, - ) - .map(|(purl, uuid, _, _, _, _)| (purl.clone(), uuid.clone())) + .filter(|c| { + let purl = c.purl.as_str(); + let uuid = c.dep.patch_uuid.as_str(); + if binary_bun && purl.starts_with("pkg:npm/") { + return rewrite.confirmed_bun_binary_uuids.contains(uuid); + } + if rewrite.refused_pipenv_uuids.contains(uuid) { + return false; + } + // pdm is transactional like cargo: a refused uuid is never + // confirmed, and when `pdm.lock` is the PyPI install driver + // (no `uv.lock` / `poetry.lock`) a pypi dep is confirmed ONLY + // by the pdm rewriter's own report — the URL landing in a + // sibling `requirements.txt` the project does not install from + // pins nothing. When uv/poetry drive, their own lock proof + // below still confirms them. This check precedes the hatch + // gate: a PDM project may declare `hatchling` as its build + // backend, which registers every pypi uuid as hatch-owned while + // the lock's presence keeps hatch from confirming any of them. + if rewrite.refused_pdm_uuids.contains(uuid) { + return false; + } + if purl.starts_with("pkg:pypi/") + && socket_patch_core::patch::redirect::pdm_drives(&files) + { + return rewrite.confirmed_pdm_uuids.contains(uuid); + } + if rewrite.python_lock_uuids.contains(uuid) { + return rewrite.confirmed_python_lock_uuids.contains(uuid) + && !rewrite.refused_python_lock_uuids.contains(uuid); + } + if rewrite.hatch_uuids.contains(uuid) { + return rewrite.confirmed_hatch_uuids.contains(uuid); + } + // A Pipfile.lock rewrite confirms its own uuids (the sibling + // requirements.txt rewriter may have had nothing to do). + if purl.starts_with("pkg:pypi/") { + return rewrite.confirmed_pipenv_uuids.contains(uuid) + || rewrite.confirmed_requirements_uuids.contains(uuid); + } + if rewrite.refused_pnpm_uuids.contains(uuid) { + return false; + } + // Cargo is transactional: the rewriter reports exactly which + // patch uuids FULLY landed (manifest pin + lock + registry + // block). Substring presence must never confirm a cargo dep — + // the `[registries.…]` config block contains the index URL while + // pinning nothing, so a config-block-only rewrite would be + // attested with zero enforcement in any build. + if purl.starts_with("pkg:cargo/") { + return rewrite.confirmed_cargo_uuids.contains(uuid); + } + // The override's own targets: artifact URL; per-dependency + // registry index URL; fail-closed maven's globally-unique + // `-socket.` suffixed version (never the `.pom` URL); + // golang's content-addressed `patch.socket.dev/gopatch/` + // module path (go.mod + go.sum never carry a URL). + let artifact_url = c.dep.artifact_url.as_str(); + let registry = c.dep.registry_override.as_ref(); + let index_url = registry.map(|o| o.index_url.as_str()); + let suffixed_version = + registry.and_then(|o| o.identifiers.maven_suffixed_version.as_deref()); + let go_module_path = registry.and_then(|o| o.identifiers.go_module_path.as_deref()); + let encoded = socket_patch_core::utils::uri::encode_uri_component(artifact_url); + final_texts.iter().any(|text| { + // The rewriters' own predicate — raw, or the `\/`-escaped + // slashes an old composer.lock spells them with — so a + // writer's spelling can never be one this probe misses. It + // was: the composer rewriter emitted `\/`-escaped urls this + // probe never looked for, so a fully successful composer + // redirect reported `redirected: 0`, fetched no patch record + // into the ledger, and left the patch unattestable by `vex`. + socket_patch_core::patch::redirect::artifact_url_present(text, artifact_url) + // The berry rewriter writes the URL percent-encoded into the + // lock's `::__archiveUrl=` binding, so the raw form is absent. + || text.contains(encoded.as_str()) + || index_url.is_some_and(|iu| text.contains(iu)) + || suffixed_version.is_some_and(|sv| text.contains(sv)) + || go_module_path.is_some_and(|gm| text.contains(gm)) + }) + }) + .map(|c| (c.purl.clone(), c.dep.patch_uuid.clone())) .collect(); // Dry-run mode-takeover previews were withheld from the rewriters (their // lock fragments still carry the vendored wiring the wet run reverts @@ -1864,20 +2002,7 @@ pub(crate) async fn run_redirect_selected( ) .await { - let message = format!( - "{linked} is a symbolic link; socket-patch rewrites files in place with an atomic \ - rename, which would replace the link — replace the link with a regular file (or \ - run socket-patch in the directory it points to) and re-run; nothing was written" - ); - eprintln!("Error (redirect_symlinked_file_unsupported): {message}"); - if common.json { - emit_json_error_with_code( - scan_result.take(), - Some("redirect_symlinked_file_unsupported"), - &message, - ); - } - return 1; + return refuse_symlinked_file(common, scan_result.take(), linked); } if !common.dry_run { @@ -1919,7 +2044,6 @@ pub(crate) async fn run_redirect_selected( // pre-redirect originals never reached any ledger (a healing re-run // records no edits for already-redirected entries). if !rewrite.edits.is_empty() || !records.is_empty() { - let mut ledger = existing_ledger.unwrap_or_else(RedirectState::new); // Ledgers written before the mode-string rename carry // `"mode": "redirect"`; normalize on rewrite so the on-disk // ledger converges on the documented "hosted" name (the @@ -2081,7 +2205,7 @@ pub(crate) async fn run_redirect_selected( common.global_prefix.clone(), &confirmed, &records, - &ledger_records, + &ledger.records, &gem_artifact_shas, ) .await @@ -2094,7 +2218,7 @@ pub(crate) async fn run_redirect_selected( &confirmed, &rewrite.confirmed_pipenv_uuids, &records, - &ledger_records, + &ledger.records, ) .await }; @@ -2125,10 +2249,7 @@ pub(crate) async fn run_redirect_selected( // path warns once up front in `run` (before this flow is entered). let mut prune_warnings: Vec = Vec::new(); if prune_requested { - prune_warnings.push(serde_json::json!({ - "code": super::REDIRECT_PRUNE_IGNORED, - "detail": super::REDIRECT_PRUNE_IGNORED_DETAIL, - })); + prune_warnings.push(prune_ignored_warning()); } // Emit an OpenVEX attestation when `--vex` was requested. The redirected @@ -2200,17 +2321,8 @@ pub(crate) async fn run_redirect_selected( // scan envelopes — same top-level scan keys (scannedPackages, // totalPatches, canAccessPaidPatches) plus the `packages` enumeration — // instead of the bare `{status, redirect}` it used to emit. - let redirect = serde_json::json!({ - // Final mode naming: `--redirect` IS hosted mode. Additive key so - // JSON consumers can dispatch on the mode without inferring it from - // which sub-object is present. - "mode": "hosted", - "redirected": confirmed.len(), - "rewrittenFiles": rewritten, - "skipped": skipped, - "warnings": warnings, - "dryRun": common.dry_run, - }); + let redirect = + redirect_json_block(confirmed.len(), rewritten, skipped, warnings, common.dry_run); let mut result = build_redirect_json_envelope(scan_result.take(), redirect); if let Some(statements) = vex_statements { result["vex"] = serde_json::json!({ @@ -2338,7 +2450,8 @@ mod tests { plan_workspace_trust, pnpm_heal_root, pnpm_lock_carries_hosted_redirect, pnpm_lock_version_major, pnpm_trust_configured_detail, pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, pnpm_trust_workspace_unreadable_detail, - read_workspace_for_trust, TrustPlan, REDIRECT_CANDIDATE_FILES, + prune_ignored_warning, read_workspace_for_trust, redirect_json_block, TrustPlan, + REDIRECT_CANDIDATE_FILES, }; use socket_patch_core::constants::npm_family; use socket_patch_core::patch::redirect::DepOverride; @@ -2759,14 +2872,16 @@ mod tests { // `--json` envelope must carry the SAME top-level scan keys as a // zero-discovery / non-hosted scan (the old bare `{status, redirect}` // dropped them) AND nest the redirect summary under `redirect`. - let redirect = serde_json::json!({ - "mode": "hosted", - "redirected": 1, - "rewrittenFiles": ["package-lock.json"], - "skipped": [], - "warnings": [], - "dryRun": false, - }); + // Built through the ONE spelling of the block (the production + // site and `run`'s zero-discovery arm use the same helper), so the + // key set asserted below is the tested single source. + let redirect = redirect_json_block( + 1, + vec!["package-lock.json".to_string()], + Vec::new(), + vec![prune_ignored_warning()], + false, + ); let envelope = build_redirect_json_envelope(Some(classic_scan_result()), redirect); // Classic scan keys survive — the bug was that they did not. diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs index 1aa7a8bc..cc1675b8 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs @@ -292,6 +292,13 @@ snapshots: /// `vendor::load_state` parses). Empty wiring — the classifiers and reverts /// under test never need recorded lock fragments. fn write_vendor_state(root: &Path, purl: &str, uuid: &str, flavor: &str) { + write_vendor_state_wired(root, purl, uuid, flavor, json!([])); +} + +/// [`write_vendor_state`] with explicit `wiring` records (the camelCase +/// `WiringRecord` shape) — for the takeover tests whose revert must have a +/// lock fragment to restore. +fn write_vendor_state_wired(root: &Path, purl: &str, uuid: &str, flavor: &str, wiring: Value) { let state = json!({ "version": 1, "entries": { @@ -302,7 +309,7 @@ fn write_vendor_state(root: &Path, purl: &str, uuid: &str, flavor: &str) { "artifact": { "path": format!(".socket/vendor/npm/{uuid}/{NAME}-{VERSION}.tgz") }, - "wiring": [], + "wiring": wiring, "flavor": flavor } } @@ -561,6 +568,213 @@ async fn wet_takeover_refuses_unrevertable_vendored_flavor_fail_closed() { ); } +// ──────────── takeover symlink pre-check (before any revert dispatches) ──────────── + +/// A vendored→hosted takeover must NOT revert a vendored purl whose recorded +/// wiring file is a symbolic link: the npm revert stages and renames over +/// package-lock.json exactly like the rewriters do, so it would DETACH the +/// link (and remove the committed artifact) before the general symlink +/// guard — which runs after the takeover — could refuse. The pre-check +/// refuses the whole run first, with the guard's own code and "nothing was +/// written" wording, under `--dry-run` too: the link, its target, the +/// vendored ledger and the artifact stay byte-identical, no redirect ledger +/// appears, and the apply lock the wet run took is gone again. +#[cfg(unix)] +#[tokio::test] +async fn takeover_refuses_symlinked_wiring_file_before_reverting() { + const VUUID: &str = "77777777-7777-4777-8777-777777777777"; + const CODE: &str = "redirect_symlinked_file_unsupported"; + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_npm_project(root, NAME); + // The vendored shape the revert restores from: the live lock entry + // resolves through the committed artifact; the wiring records the + // registry original the revert would write back. + let registry_resolved = format!("https://registry.npmjs.org/{NAME}/-/{NAME}-{VERSION}.tgz"); + let vendored_resolved = format!("file:.socket/vendor/npm/{VUUID}/{NAME}-{VERSION}.tgz"); + let vendored_lock = std::fs::read_to_string(root.join("package-lock.json")) + .unwrap() + .replace(®istry_resolved, &vendored_resolved) + .replace(UPSTREAM_SHA512, PATCHED_SHA512); + write_vendor_state_wired( + root, + PURL, + VUUID, + "package-lock", + json!([{ + "file": "package-lock.json", + "kind": "npm_lock_entry", + "action": "rewritten", + "key": format!("node_modules/{NAME}"), + "original": { + "version": VERSION, "resolved": registry_resolved, "integrity": UPSTREAM_SHA512 + }, + "new": { + "version": VERSION, "resolved": vendored_resolved, "integrity": PATCHED_SHA512 + } + }]), + ); + let artifact = root + .join(".socket/vendor/npm") + .join(VUUID) + .join(format!("{NAME}-{VERSION}.tgz")); + std::fs::create_dir_all(artifact.parent().unwrap()).unwrap(); + std::fs::write(&artifact, b"tgz").unwrap(); + // The lock lives in a shared dir; the project holds a relative symlink. + let shared = root.join("shared"); + std::fs::create_dir_all(&shared).unwrap(); + std::fs::write(shared.join("package-lock.json"), &vendored_lock).unwrap(); + std::fs::remove_file(root.join("package-lock.json")).unwrap(); + std::os::unix::fs::symlink("shared/package-lock.json", root.join("package-lock.json")).unwrap(); + let state_before = std::fs::read(root.join(".socket/vendor/state.json")).unwrap(); + + let is_symlink = |p: &Path| { + std::fs::symlink_metadata(p) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + }; + let assert_untouched = |doc: &Value| { + assert!( + is_symlink(&root.join("package-lock.json")), + "the link must survive — a revert's rename-over would have detached it: {doc:#}" + ); + assert_eq!( + std::fs::read_to_string(shared.join("package-lock.json")).unwrap(), + vendored_lock, + "the link target must be byte-identical: {doc:#}" + ); + assert_eq!( + std::fs::read(root.join(".socket/vendor/state.json")).unwrap(), + state_before, + "the vendored ledger must be byte-identical: {doc:#}" + ); + assert!(artifact.is_file(), "the committed artifact must survive: {doc:#}"); + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "no redirect ledger may be written: {doc:#}" + ); + assert!( + !root.join(".socket/apply.lock").exists(), + "the apply lock never outlives the run: {doc:#}" + ); + }; + + for dry_run in [false, true] { + let extra: &[&str] = if dry_run { &["--dry-run"] } else { &[] }; + let (code, doc) = scan_hosted_json(root, &server.uri(), extra, &[]); + assert_eq!(code, 1, "dry_run={dry_run}: a symlinked revert target fails the run: {doc:#}"); + assert_eq!(doc["status"], "error", "dry_run={dry_run}: {doc:#}"); + assert_eq!(doc["errorCode"], CODE, "dry_run={dry_run}: {doc:#}"); + let error = doc["error"].as_str().unwrap_or_default(); + assert!( + error.starts_with("package-lock.json is a symbolic link") + && error.ends_with("nothing was written"), + "dry_run={dry_run}: the refusal names the link and promises no write: {error}" + ); + assert_untouched(&doc); + } + + // Human arm: the guard's stderr line, same code. + let (code, _stdout, stderr) = scan_hosted(root, &server.uri(), &[], &[]); + assert_eq!(code, 1, "stderr=\n{stderr}"); + assert!( + stderr.contains(&format!("Error ({CODE}): package-lock.json is a symbolic link")), + "the human refusal must carry the stable code; stderr=\n{stderr}" + ); + assert_untouched(&json!(null)); +} + +// ───────────────────── apply lock (hosted holds it, lazily) ───────────────────── + +/// `scan --mode hosted` takes the same `.socket/apply.lock` every other +/// mutating command holds — but only when it could write. A WET run with a +/// granted reference refuses a held lock with `lock_held` (the hosted +/// envelope's top-level `errorCode`, the shared contention message, exit 1) +/// BEFORE the ledger load or any file write; the human arm prints the +/// `Error (lock_held):` line plus the `--lock-timeout` hint. A `--dry-run`, +/// and a wet run whose references are all skipped, never contend: nothing +/// they do touches `.socket/`, so they succeed under the held lock. Once the +/// holder releases, `.socket/` is gone — the hosted runs created nothing. +#[tokio::test] +async fn hosted_lock_held_refuses_before_any_write() { + use std::time::Duration; + + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + // Same discovery, but the reference endpoint grants nothing: every + // selection is skipped as `not_found`, so the run has nothing to write. + let no_grant = MockServer::start().await; + mock_discovery(&no_grant, PURL, UUID).await; + mock_reference_results(&no_grant, json!({})).await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_npm_project(root, NAME); + let lock_before = std::fs::read(root.join("package-lock.json")).unwrap(); + let holder = + socket_patch_core::patch::apply_lock::acquire(&root.join(".socket"), Duration::ZERO) + .unwrap(); + const HELD: &str = "another socket-patch process is operating in this directory"; + + // Wet --json: refused before anything is written. + let (code, doc) = scan_hosted_json(root, &server.uri(), &[], &[]); + assert_eq!(code, 1, "a held lock refuses the wet run: {doc:#}"); + assert_eq!(doc["status"], "error", "{doc:#}"); + assert_eq!(doc["errorCode"], "lock_held", "{doc:#}"); + assert_eq!(doc["error"], HELD, "no --lock-timeout: no waited clause; {doc:#}"); + assert_eq!( + doc["redirect"]["mode"], "hosted", + "the hosted error envelope keeps its redirect block: {doc:#}" + ); + + // Wet human: the stderr line carries the stable code and the hint. + let (code, _stdout, stderr) = scan_hosted(root, &server.uri(), &[], &[]); + assert_eq!(code, 1, "stderr=\n{stderr}"); + assert!( + stderr.contains(&format!("Error (lock_held): {HELD}")), + "stderr=\n{stderr}" + ); + assert!( + stderr.contains("--lock-timeout"), + "the wait hint must accompany a live holder; stderr=\n{stderr}" + ); + + // --dry-run under the held lock: a preview never locks (it writes + // nothing), so it previews the rewrite instead of contending. + let (code, doc) = scan_hosted_json(root, &server.uri(), &["--dry-run"], &[]); + assert_eq!(code, 0, "a dry run never contends: {doc:#}"); + assert_eq!(doc["redirect"]["dryRun"], true, "{doc:#}"); + assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + + // Zero grants under the held lock: nothing to write, so no lock taken. + let (code, doc) = scan_hosted_json(root, &no_grant.uri(), &[], &[]); + assert_eq!(code, 0, "an all-skipped run never contends: {doc:#}"); + assert_eq!(doc["redirect"]["redirected"], 0, "{doc:#}"); + assert!( + doc["redirect"]["skipped"] + .as_array() + .is_some_and(|s| s.iter().any(|e| e["reason"] == "not_found")), + "{doc:#}" + ); + + assert_eq!( + std::fs::read(root.join("package-lock.json")).unwrap(), + lock_before, + "none of the runs may touch the lockfile" + ); + assert!(!root.join(".socket/vendor/redirect-state.json").exists()); + drop(holder); + assert!( + !root.join(".socket").exists(), + "the hosted runs created nothing under .socket/, so the released lock leaves no dir" + ); +} + // ───────────── cargo wiring-without-ledger refusal (1104-1114) ───────────── /// A cargo purl with SOCKET-OWNED `[patch.crates-io]` wiring in From f966776f7443106d8d9eff351b9f81913b55cd48 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 20:04:21 -0400 Subject: [PATCH 15/44] refactor(cli/get): manifest-free vendored get, one fetch loop, locked agent RMW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D2 for `get --mode vendored`: both identifier paths run the detached download phase (`download_patch_records_with`: records fetched into memory, no manifest, no blobs — the uuid path hands its already-fetched view in as `prefetched`, so the proxy-fallback client is never re-hit) into scan's detached vendor step. `save_patch_record`'s vendored posture, the `Patch record saved to` block and the whole-manifest `[note]` are gone; the JSON envelope uses the detached vocabulary (`downloaded` / `skipped` / `failed`, `detached: true`) with `oldUuid` derived from the vendor ledger, and the human `[fetch]` line carries `(replacing )`. Both download engines share one fetch loop (`fetch_selected_patches` over a `RecordStore::{Ledger, Manifest}`), which also serves the views the release-variant narrowing already fetched instead of fetching them again. Agent engine hygiene: the manifest read-modify-write and blob writes run under the apply lock (released before the nested apply, which takes its own), `.socket/blobs` is created lazily at the first persisted blob, an unchanged manifest is never rewritten (all-skipped/all-failed runs leave no `.socket/` behind), and a same-uuid re-get writes nothing. The nested apply inherits every caller flag (`--lock-timeout` and `--verbose` were dropped). `download_and_apply_patches_with` / `download_patch_records_with` take the run's client (+ lock/verbosity flags); the two-arg functions stay as wrappers for scan and the integration tests. Also: `is_valid_blob_hash` re-exported from core (local copy deleted), `effective_org_slug` plumbing removed, identifier regexes compiled once (UUID via `looks_like_uuid`), the install-hint literal, the unreachable empty-selection branch and the three `DownloadParams` literals collapsed. Tests re-pinned to the new behaviour: ledger assertions in the nine e2e_vendor build suites, in_process_get_modes and get_modes_e2e; lock-first engine failures, no-residue all-failed runs and the detached vendor step's hands-off posture toward legacy manifest/ledger state in covgap_commands_get; new unit tests for the nested-apply arg builders, the prefetched-view engine call and the ledger-derived `oldUuid`. Co-Authored-By: Claude Fable 5.1 --- crates/socket-patch-cli/src/commands/get.rs | 2206 +++++++++-------- .../tests/covgap_commands_get.rs | 665 ++--- .../tests/e2e_vendor_bun_build.rs | 20 +- .../tests/e2e_vendor_cargo_build.rs | 25 +- .../tests/e2e_vendor_composer_build.rs | 24 +- .../tests/e2e_vendor_gem_build.rs | 27 +- .../tests/e2e_vendor_golang_build.rs | 23 +- .../tests/e2e_vendor_npm_build.rs | 29 +- .../tests/e2e_vendor_pnpm_build.rs | 13 +- .../tests/e2e_vendor_pypi_build.rs | 23 +- .../tests/e2e_vendor_yarn_berry_build.rs | 13 +- .../socket-patch-cli/tests/get_modes_e2e.rs | 34 +- .../tests/in_process_get_modes.rs | 47 +- 13 files changed, 1714 insertions(+), 1435 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 219675e0..8b2b1293 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1,7 +1,7 @@ use clap::Args; use regex::Regex; use socket_patch_core::api::client::{ - build_proxy_fallback_client, get_api_client_with_overrides, is_fallback_candidate, + build_proxy_fallback_client, get_api_client_with_overrides, is_fallback_candidate, ApiClient, }; use socket_patch_core::api::ranking::{cmp_search_results, severity_order}; use socket_patch_core::api::types::{ @@ -13,17 +13,24 @@ use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{ PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, }; +// Re-exported for `fetch_stage`, which imports the blob-hash guard from here. +pub(crate) use socket_patch_core::patch::apply::is_valid_blob_hash; use socket_patch_core::patch::apply::select_installed_variants; +use socket_patch_core::patch::apply_lock::{self, LockError}; use socket_patch_core::telemetry::{track_patch_fetch_failed, track_patch_fetched}; use socket_patch_core::utils::purl::{is_purl, normalize_purl, strip_purl_qualifiers}; +use socket_patch_core::vendor::{load_state, lookup_entry, VendorEntry}; use std::collections::HashMap; use std::fmt; use std::path::{Path, PathBuf}; +use std::sync::LazyLock; +use std::time::Duration; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::bun_preflight::{ bun_vendor_preflight, bun_vendor_preflight_with_ledger, BunVendorRefusal, }; +use crate::commands::lock_cli::lock_failure; use crate::ecosystem_dispatch::{ crawl_all_ecosystems, find_packages_for_rollback, partition_purls, }; @@ -252,17 +259,35 @@ fn report_error(json: bool, message: impl std::fmt::Display) { } } -/// A blob hash must be a SHA-256 hex string — the same shape `fetch_blob` -/// enforces before splicing a hash into a URL. Enforced here because the -/// hash comes from an untrusted API response and is used as a filesystem -/// path component: anything else (`../../x`, an absolute path) would -/// escape the blobs directory via `Path::join`. -pub(crate) fn is_valid_blob_hash(hash: &str) -> bool { - hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) +/// Report a failed apply-lock acquire in get's legacy error shape — the +/// `{status: "error", error: ""}` envelope every other hard error +/// here uses, plus the stable `errorCode` (`lock_held` / `lock_io`) the +/// other lock sites emit — and return the envelope for the caller's +/// early-return guard. The message/code mapping is +/// [`crate::commands::lock_cli::lock_failure`]'s, so the waited clause and +/// the I/O rendering cannot drift from `apply`'s. +fn report_lock_failure(json: bool, err: &LockError, timeout: Duration) -> serde_json::Value { + let (code, message) = lock_failure(err, timeout); + let envelope = serde_json::json!({ + "status": "error", + "errorCode": code, + "error": message, + }); + if json { + print_json(&envelope); + } else { + eprintln!("Error: {message}"); + } + envelope } /// Decode a base64 string and write it to `blobs_dir/hash`. Returns a /// formatted error string referencing `file_path` and `label` on failure. +/// +/// `blobs_dir` is created here, lazily — only once a blob is actually +/// about to be persisted — so a run that records nothing (every fetch +/// failed, every patch skipped, undecodable content) leaves no empty +/// `.socket/blobs/` behind. async fn write_blob_entry( blobs_dir: &Path, b64: &str, @@ -277,6 +302,9 @@ async fn write_blob_entry( } let decoded = base64_decode(b64).map_err(|e| format!("Failed to decode {label} for {file_path}: {e}"))?; + tokio::fs::create_dir_all(blobs_dir) + .await + .map_err(|e| format!("Failed to create blobs directory: {e}"))?; tokio::fs::write(blobs_dir.join(hash), &decoded) .await .map_err(|e| format!("Failed to write {label} for {file_path}: {e}")) @@ -485,11 +513,12 @@ pub struct GetArgs { /// `agent` (default; record in `.socket/manifest.json` + blobs and /// apply in place), `hosted` (rewrite lockfiles so the patched deps /// resolve to Socket's hosted patch server; no manifest, no blobs — - /// state lives in the redirect ledger), or `vendored` (record in the - /// manifest, then commit patched artifacts under `.socket/vendor/` and - /// rewire the lockfile). Hosted/vendored runs produce the same on-disk - /// result as `scan --mode hosted|vendored` selecting the same patch. - /// No env binding, matching `scan --mode`. + /// state lives in the redirect ledger), or `vendored` (commit patched + /// artifacts under `.socket/vendor/` and rewire the lockfile; no + /// manifest, no blobs — the vendor ledger carries the records). + /// Hosted/vendored runs produce the same on-disk result as + /// `scan --mode hosted|vendored` selecting the same patch. No env + /// binding, matching `scan --mode`. #[arg(long = "mode", value_enum)] pub mode: Option, } @@ -515,18 +544,22 @@ impl fmt::Display for IdentifierType { } } -fn detect_identifier_type(identifier: &str) -> Option { - let uuid_re = Regex::new(r"(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") - .expect("hardcoded UUID regex must compile"); - let cve_re = Regex::new(r"(?i)^CVE-\d{4}-\d+$").expect("hardcoded CVE regex must compile"); - let ghsa_re = Regex::new(r"(?i)^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$") - .expect("hardcoded GHSA regex must compile"); +/// Case-insensitive advisory-id shapes, compiled once. The UUID shape is +/// [`crate::looks_like_uuid`] (the same 8-4-4-4-12 hex check the argv +/// rewrite uses), so the two detectors cannot drift. +static CVE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)^CVE-\d{4}-\d+$").expect("hardcoded CVE regex must compile")); +static GHSA_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$") + .expect("hardcoded GHSA regex must compile") +}); - if uuid_re.is_match(identifier) { +fn detect_identifier_type(identifier: &str) -> Option { + if crate::looks_like_uuid(identifier) { Some(IdentifierType::Uuid) - } else if cve_re.is_match(identifier) { + } else if CVE_RE.is_match(identifier) { Some(IdentifierType::Cve) - } else if ghsa_re.is_match(identifier) { + } else if GHSA_RE.is_match(identifier) { Some(IdentifierType::Ghsa) } else if is_purl(identifier) { Some(IdentifierType::Purl) @@ -723,6 +756,52 @@ pub struct DownloadParams { pub persist_blobs: bool, } +impl DownloadParams { + /// `--silent` is "errors only" and `--json` owns stdout: every + /// informational print in the engines is gated on this. + fn quiet(&self) -> bool { + self.json || self.silent + } + + /// The `.socket/` directory the manifest lives in (lock + blobs root). + fn socket_dir(&self) -> PathBuf { + self.manifest_path + .parent() + .unwrap_or(Path::new(".")) + .to_path_buf() + } + + fn crawler_options(&self) -> CrawlerOptions { + CrawlerOptions { + cwd: self.cwd.clone(), + global: self.global, + global_prefix: self.global_prefix.clone(), + } + } +} + +/// Run-level context the download engines need but `DownloadParams` +/// cannot carry (it is built as a full struct literal by scan and by the +/// integration tests): the run's API client — built once, proxy fallback +/// included, so the engines never rebuild it from flags and repeat the org +/// auto-resolve round-trip — and the flags the nested apply must inherit. +pub struct DownloadRun<'a> { + pub api_client: &'a ApiClient, + /// `--lock-timeout`: the wait budget for the manifest-write lock here + /// and for the nested apply's own acquire. + pub lock_timeout: Option, + /// `--verbose`, forwarded to the nested apply. + pub verbose: bool, +} + +fn crawler_options_for(common: &GlobalArgs) -> CrawlerOptions { + CrawlerOptions { + cwd: common.cwd.clone(), + global: common.global, + global_prefix: common.global_prefix.clone(), + } +} + /// Narrow a selection of patches down to the release variant(s) present /// in each locally-installed distribution. /// @@ -744,16 +823,27 @@ pub struct DownloadParams { /// /// Both fallbacks push a human-readable warning. /// -/// Returns the kept patches plus any warnings to surface to the caller -/// (also printed to stderr here, in human mode). With `--all-releases` -/// set this is a verbatim pass-through. +/// Returns the kept patches, any warnings to surface to the caller (also +/// printed to stderr here unless `quiet`), and the patch views fetched to +/// hash-match the KEPT variants (uuid-keyed) — the download loop serves +/// those from memory instead of fetching every view a second time. Only +/// successful fetches are cached: a variant whose view errored or 404'd is +/// re-fetched by the loop so the failure surfaces per patch as before. +/// With `--all-releases` set this is a verbatim pass-through. async fn filter_to_installed_releases( selected: &[PatchSearchResult], - params: &DownloadParams, - api_client: &socket_patch_core::api::client::ApiClient, -) -> (Vec, Vec) { - if params.all_releases { - return (selected.to_vec(), Vec::new()); + all_releases: bool, + crawler_options: &CrawlerOptions, + quiet: bool, + api_client: &ApiClient, +) -> ( + Vec, + Vec, + HashMap, +) { + let mut views: HashMap = HashMap::new(); + if all_releases { + return (selected.to_vec(), Vec::new(), views); } // Group release-variant ecosystem selections (PyPI / RubyGems / Maven) @@ -787,7 +877,7 @@ async fn filter_to_installed_releases( } if multi.is_empty() { - return (kept, warnings); + return (kept, warnings, views); } // Discover the on-disk path for each multi-variant base. The crawler @@ -801,12 +891,7 @@ async fn filter_to_installed_releases( .collect(); // All collected PURLs are PyPI; no ecosystem filter needed. let partitioned = partition_purls(&all_qualified, None); - let crawler_options = CrawlerOptions { - cwd: params.cwd.clone(), - global: params.global, - global_prefix: params.global_prefix.clone(), - }; - let paths = find_packages_for_rollback(&partitioned, &crawler_options, true).await; + let paths = find_packages_for_rollback(&partitioned, crawler_options, true).await; for (base, variants) in multi { // Any variant's resolved path works — they all map to the same @@ -824,13 +909,15 @@ async fn filter_to_installed_releases( }; // Fetch each variant's file hashes (the view carries them) so we - // can hash-match against the installed distribution. + // can hash-match against the installed distribution. The view is + // kept for the download loop — it is the same GET it would issue. let mut candidates: Vec<(String, HashMap)> = Vec::new(); for s in &variants { // org slug is already stored in the client. match api_client.fetch_patch(None, &s.uuid).await { Ok(Some(patch)) => { candidates.push((s.purl.clone(), files_with_both_hashes(&patch))); + views.insert(s.uuid.clone(), patch); } // On a fetch error/miss, keep the variant so the main // download loop can record the failure as it would today. @@ -863,12 +950,17 @@ async fn filter_to_installed_releases( } } - if !params.json && !params.silent { + if !quiet { for w in &warnings { eprintln!(" [note] {w}"); } } - (kept, warnings) + // Narrowed-out variants are never downloaded: drop their views (each + // carries every file's base64 content) so only the kept ones ride on. + let kept_uuids: std::collections::HashSet<&str> = + kept.iter().map(|s| s.uuid.as_str()).collect(); + views.retain(|uuid, _| kept_uuids.contains(uuid.as_str())); + (kept, warnings, views) } /// Does this purl carry an exact version (`pkg:type/name@version`)? An @@ -994,12 +1086,7 @@ async fn filter_to_installed_purls( .collect() }; let partitioned = partition_purls(&bases, None); - let crawler_options = CrawlerOptions { - cwd: common.cwd.clone(), - global: common.global, - global_prefix: common.global_prefix.clone(), - }; - let found = find_packages_for_rollback(&partitioned, &crawler_options, true).await; + let found = find_packages_for_rollback(&partitioned, &crawler_options_for(common), true).await; let mut present: HashSet = found.keys().map(|k| canon(k)).collect(); // Manifest membership counts as presence (read-only probe: a corrupt @@ -1151,9 +1238,9 @@ fn fold_narrowing_into_result( /// The API-client overrides for a download run: the caller's CLI flags with /// the override org slug defaulted to `--org` when none was given. /// -/// Shared by the client built here AND by the nested `apply` step, which -/// constructs its own client and must resolve to the same endpoint/token — -/// see [`run_nested_apply`]. +/// Shared by the client the plain engine wrappers build AND by the nested +/// `apply` step, which constructs its own client and must resolve to the +/// same endpoint/token — see [`nested_apply_args_from_params`]. fn resolved_api_overrides( params: &DownloadParams, ) -> socket_patch_core::api::client::ApiClientEnvOverrides { @@ -1164,230 +1251,387 @@ fn resolved_api_overrides( overrides } -/// Build the API client for a download run. -async fn api_client_for(params: &DownloadParams) -> socket_patch_core::api::client::ApiClient { +/// Build the API client for a download run driven without a run-level +/// client (the plain `download_*` wrappers other commands call). +async fn api_client_for(params: &DownloadParams) -> ApiClient { get_api_client_with_overrides(resolved_api_overrides(params)) .await .0 } -/// Download and apply a set of selected patches. -/// -/// Used by both `get` and `scan` commands. Returns (exit_code, json_result). -/// Download patches and their blobs WITHOUT touching the manifest, and -/// return the fetched records keyed by purl — the `scan --vendor -/// --detached` download phase, where the vendor ledger (not the manifest) -/// carries the records. Honors the same installed-release narrowing as -/// [`download_and_apply_patches`]. A purl already vendored DETACHED at the -/// selected uuid skips the network fetch and reuses the ledger's embedded -/// record, so idempotent re-runs stay cheap (mirrors what -/// `decide_patch_action` does for the manifest-tracked flow). -pub(crate) async fn download_patch_records( - selected: &[PatchSearchResult], - params: &DownloadParams, -) -> (i32, serde_json::Value, HashMap) { - let api_client = api_client_for(params).await; +/// Which state store the shared fetch loop classifies each selected patch +/// against — the one non-presentational difference between the vendored +/// and agent download engines. +#[derive(Clone, Copy)] +enum RecordStore<'a> { + /// The vendor ledger (`scan` / `get --mode vendored`, the detached + /// posture): a detached entry already at the selected uuid is reused + /// without a fetch (`skipped`); a fetched patch is `downloaded`, with + /// `oldUuid` when the ledger wires the purl at another uuid. + Ledger(&'a HashMap), + /// `.socket/manifest.json` (agent mode): the fetched view is classified + /// by [`decide_patch_action`] — `added` / `updated` (+ `oldUuid`) / + /// `skipped` (the same uuid is already recorded). + Manifest(&'a PatchManifest), +} - let socket_dir = params - .manifest_path - .parent() - .unwrap_or(Path::new(".")) - .to_path_buf(); - let blobs_dir = socket_dir.join("blobs"); - if params.persist_blobs { - if let Err(e) = tokio::fs::create_dir_all(&blobs_dir).await { - let err = format!("Failed to create blobs directory: {}", e); - report_error(params.json, &err); - return ( - 1, - serde_json::json!({"status": "error", "error": err}), - HashMap::new(), - ); +/// A fetched patch the shared loop accepted — recordable files, blobs +/// persisted when asked — handed to the engine wrapper to record. +struct FetchedPatch { + patch: PatchResponse, + files: HashMap, + action: PatchAction, +} + +/// What the shared fetch loop produced over one selection. +struct FetchBatch { + /// Selection size after installed-release narrowing. + found: usize, + skipped: usize, + failed: usize, + /// Fetched, recordable patches in selection order. + fetched: Vec, + /// Ledger store only: `(purl, record)` reused from a detached entry + /// already at the selected uuid (no fetch). + reused: Vec<(String, PatchRecord)>, + /// Per-patch JSON records in selection order (the contract vocabulary). + patches_json: Vec, + /// Release-narrowing fallbacks (uninstalled base, no matching variant). + warnings: Vec, +} + +impl FetchBatch { + /// Record a per-patch failure. `line` is the stderr text — an error, so + /// exempt from `--silent`; JSON runs carry the detail in the envelope + /// instead — or `None` when the failure already printed its own detail. + fn fail( + &mut self, + json: bool, + line: Option, + purl: &str, + uuid: &str, + error: &str, + error_code: Option<&str>, + ) { + if let (false, Some(line)) = (json, line) { + eprintln!(" {line}"); + } + let mut record = serde_json::json!({ + "purl": purl, + "uuid": uuid, + "action": "failed", + }); + if let Some(code) = error_code { + record["errorCode"] = serde_json::json!(code); } + record["error"] = serde_json::json!(error); + self.patches_json.push(record); + self.failed += 1; } +} - let (selected, narrow_warnings) = - filter_to_installed_releases(selected, params, &api_client).await; +/// The fetch loop both download engines share: installed-release +/// narrowing, the caller's Bun refusal, the per-store skip decision, the +/// view fetch (served from `prefetched` when the narrowing or the caller +/// already holds the view), the no-applicable-files guardrail, optional +/// blob persistence, and every per-patch failure record. Every pinned +/// stderr line and JSON action lives here once. +async fn fetch_selected_patches( + selected: &[PatchSearchResult], + params: &DownloadParams, + api_client: &ApiClient, + store: RecordStore<'_>, + blobs_dir: Option<&Path>, + bun_refusal: Option<&BunVendorRefusal>, + mut prefetched: HashMap, +) -> FetchBatch { + let quiet = params.quiet(); + // Narrow multi-release selections to the installed distribution unless + // --all-releases was passed (a no-op for non-variant ecosystems and + // single-variant packages). The views it fetched serve the loop below. + let (selected, warnings, views) = filter_to_installed_releases( + selected, + params.all_releases, + ¶ms.crawler_options(), + quiet, + api_client, + ) + .await; + prefetched.extend(views); + if matches!(store, RecordStore::Manifest(_)) && !quiet { + eprintln!("\nDownloading {} patch(es)...", selected.len()); + } - // The ledger load outcome is handed to the preflight AS a result: an - // unreadable ledger must surface as `vendor_state_unreadable` from the - // one refusal this phase emits (fail closed, nothing exempt), not be - // flattened into an empty ledger that then reports a Bun lock remedy. - // For the idempotency lookup below it degrades to empty (no detached - // entry to reuse — the vendor step reports the corruption itself). - let vendor_state = socket_patch_core::vendor::load_state(¶ms.cwd).await; - - // The same Bun preflight the manifest-tracked download runs (see - // `download_and_apply_patches`): a detached run feeds the same vendor - // engine, so it must refuse the same projects BEFORE fetching. Without - // it the patch view was downloaded for nothing and — for a package - // installed under an alias directory, resolvable only through the - // unreadable bun.lockb inventory — the vendor step then misreported - // `package_not_installed` instead of the real `vendor_bun_*` code. - // `persist_blobs` is never set on this (vendor-only) path; the gate - // mirrors the manifest-tracked download's posture defensively. - let bun_refusal = if params.persist_blobs { - None - } else { - bun_vendor_preflight_with_ledger( - ¶ms.cwd, - &selected, - vendor_state.as_ref().map(|s| &s.entries), - ) - .await + let mut batch = FetchBatch { + found: selected.len(), + skipped: 0, + failed: 0, + fetched: Vec::new(), + reused: Vec::new(), + patches_json: Vec::new(), + warnings, }; - let vendor_state = vendor_state.unwrap_or_default(); - - let mut records: HashMap = HashMap::new(); - let mut downloaded = 0usize; - let mut skipped = 0usize; - let mut failed = 0usize; - let mut patch_records_json: Vec = Vec::new(); for search_result in &selected { - // Idempotency: a detached entry already at this uuid carries its - // own record — no view fetch needed. - let existing = - socket_patch_core::vendor::lookup_entry(&vendor_state.entries, &search_result.purl) - .filter(|e| e.detached && e.uuid == search_result.uuid); - if let Some(record) = existing.and_then(|e| e.record.clone()) { - if !params.json && !params.silent { - eprintln!(" [skip] {} (already vendored)", search_result.purl); + let (purl, uuid) = (search_result.purl.as_str(), search_result.uuid.as_str()); + + // Idempotency (ledger store): a detached entry already at this uuid + // carries its own record — no view fetch needed. + if let RecordStore::Ledger(entries) = store { + if let Some(record) = lookup_entry(entries, purl) + .filter(|e| e.detached && e.uuid == uuid) + .and_then(|e| e.record.clone()) + { + if !quiet { + eprintln!(" [skip] {purl} (already vendored)"); + } + batch.patches_json.push(serde_json::json!({ + "purl": purl, + "uuid": uuid, + "action": "skipped", + })); + batch.reused.push((purl.to_string(), record)); + batch.skipped += 1; + continue; } - patch_records_json.push(serde_json::json!({ - "purl": search_result.purl, - "uuid": search_result.uuid, - "action": "skipped", - })); - records.insert(search_result.purl.clone(), record); - skipped += 1; + } + + // Code-tagged so a `--silent` operator can grep the stable code. + if let Some(refusal) = bun_refusal.filter(|r| r.applies_to(purl)) { + batch.fail( + params.json, + Some(format!( + "[error] {purl} ({}): {}", + refusal.code, refusal.detail + )), + purl, + uuid, + &refusal.detail, + Some(refusal.code), + ); continue; } - if let Some(refusal) = bun_refusal - .as_ref() - .filter(|r| r.applies_to(&search_result.purl)) - { - // Errors are exempt from --silent ("errors only"); JSON runs - // carry the code + detail in the envelope instead. - if !params.json { + // The view: from memory when the narrowing (or the uuid path's own + // identifier fetch) already fetched it, else the network. org slug + // is already stored in the client. + let view = match prefetched.remove(uuid) { + Some(patch) => Ok(Some(patch)), + None => api_client.fetch_patch(None, uuid).await, + }; + let patch = match view { + Ok(Some(patch)) => patch, + Ok(None) => { + batch.fail( + params.json, + Some(format!("[fail] {purl} (could not fetch details)")), + purl, + uuid, + "could not fetch details", + None, + ); + continue; + } + Err(e) => { + batch.fail( + params.json, + Some(format!("[fail] {purl} ({e})")), + purl, + uuid, + &e.to_string(), + None, + ); + continue; + } + }; + + // Classify against the store BEFORE anything is written. `Skipped` + // early-continues; `Updated` is preserved so the per-patch record + // can carry `oldUuid`. + let action = match store { + RecordStore::Manifest(manifest) => { + decide_patch_action(manifest, &patch.purl, &patch.uuid) + } + RecordStore::Ledger(entries) => match lookup_entry(entries, &patch.purl) { + Some(entry) if entry.uuid != patch.uuid => PatchAction::Updated { + old_uuid: entry.uuid.clone(), + }, + _ => PatchAction::Added, + }, + }; + if action == PatchAction::Skipped { + if !quiet { eprintln!( - " [error] {} ({}): {}", - search_result.purl, refusal.code, refusal.detail + " [skip] {} (already in manifest)", + normalize_purl(&patch.purl) ); } - failed += 1; - patch_records_json.push(serde_json::json!({ - "purl": search_result.purl, - "uuid": search_result.uuid, - "action": "failed", - "errorCode": refusal.code, - "error": refusal.detail, + batch.patches_json.push(serde_json::json!({ + "purl": patch.purl, + "uuid": patch.uuid, + "action": "skipped", })); + batch.skipped += 1; continue; } - // org slug is already stored in the client. - match api_client.fetch_patch(None, &search_result.uuid).await { - Ok(Some(patch)) => { - // Record every file the patch touches, added files - // included (empty-beforeHash sentinel); see - // `files_for_manifest`. - let files = files_for_manifest(&patch); - // GUARDRAIL: a patch that yields NO recordable files - // cannot be vendored — recording an empty `files` map and - // reporting the purl as vendored would claim protection - // while writing nothing. Fail loudly instead. - if files.is_empty() { - // Errors are exempt from --silent ("errors only"); - // JSON runs carry them in the envelope instead. - if !params.json { - eprintln!( - " [fail] {} (patch has no applicable files)", - search_result.purl - ); - } - failed += 1; - patch_records_json.push(serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "failed", - "error": "patch has no applicable files", - })); - continue; - } - // Blob failures are errors: only JSON mode suppresses the - // per-file detail line (the envelope carries the error). - let quiet = params.json; - // Vendor flows keep blob content in memory (the vendor - // step re-fetches what it needs); persisting blobs here - // would litter .socket/blobs for no consumer. - if params.persist_blobs - && write_all_patch_blobs(&blobs_dir, &patch, quiet) - .await - .is_err() - { - failed += 1; - patch_records_json.push(serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "failed", - "error": "Blob decode or write failed", - })); - continue; - } - if !params.json && !params.silent { - eprintln!(" [fetch] {}", patch.purl); - } - let mut record_json = serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "downloaded", - }); - merge_metadata(&mut record_json, patch_event_metadata(&patch)); - patch_records_json.push(record_json); - records.insert(patch.purl.clone(), build_patch_record(&patch, files)); - downloaded += 1; - } - Ok(None) => { - if !params.json { - eprintln!(" [fail] {} (could not fetch details)", search_result.purl); - } - failed += 1; - patch_records_json.push(serde_json::json!({ - "purl": search_result.purl, - "uuid": search_result.uuid, - "action": "failed", - "error": "could not fetch details", - })); + // Record every file the patch touches, added files included + // (empty-beforeHash sentinel); see `files_for_manifest`. + let files = files_for_manifest(&patch); + // GUARDRAIL: a patch that yields NO recordable files cannot be + // applied or vendored — recording an empty `files` map and then + // reporting it protected would claim protection while writing + // nothing. Count it as a failure so the status/exit code degrade. + if files.is_empty() { + batch.fail( + params.json, + Some(format!( + "[fail] {} (patch has no applicable files)", + patch.purl + )), + &patch.purl, + &patch.uuid, + "patch has no applicable files", + None, + ); + continue; + } + // Blob failures are errors: only JSON mode suppresses the per-file + // detail line (the envelope carries the error). Vendor flows pass no + // blobs dir — their content stays in memory for the vendor step. + if let Some(blobs_dir) = blobs_dir { + if write_all_patch_blobs(blobs_dir, &patch, params.json) + .await + .is_err() + { + batch.fail( + params.json, + None, + &patch.purl, + &patch.uuid, + "Blob decode or write failed", + None, + ); + continue; } - Err(e) => { - if !params.json { - eprintln!(" [fail] {} ({e})", search_result.purl); - } - failed += 1; - patch_records_json.push(serde_json::json!({ - "purl": search_result.purl, - "uuid": search_result.uuid, - "action": "failed", - "error": e.to_string(), - })); + } + + let (label, tag) = match (store, &action) { + (RecordStore::Ledger(_), _) => ("downloaded", "fetch"), + (RecordStore::Manifest(_), PatchAction::Updated { .. }) => ("updated", "update"), + (RecordStore::Manifest(_), _) => ("added", "add"), + }; + let mut record = serde_json::json!({ + "purl": patch.purl, + "uuid": patch.uuid, + "action": label, + }); + if let PatchAction::Updated { old_uuid } = &action { + if !quiet { + // Defensive: a malformed/short UUID in the store must not + // panic the loop — `short_uuid` never does. + eprintln!( + " [{tag}] {} (replacing {})", + patch.purl, + short_uuid(old_uuid) + ); } + record["oldUuid"] = serde_json::json!(old_uuid); + } else if !quiet { + eprintln!(" [{tag}] {}", patch.purl); } + // Splice description / severity / vulnerability IDs into the record + // so PR-comment bots, dashboards, and CLI consumers can render the + // patch without a second round-trip to the API. + merge_metadata(&mut record, patch_event_metadata(&patch)); + batch.patches_json.push(record); + batch.fetched.push(FetchedPatch { + patch, + files, + action, + }); } + batch +} + +/// Download patches WITHOUT touching the manifest and return the fetched +/// records keyed by purl — the download phase of every vendored run +/// (`scan` / `get --mode vendored`), where the vendor ledger carries the +/// records (`detached`). Honors the same installed-release narrowing as +/// [`download_and_apply_patches`]. A purl already vendored detached at the +/// selected uuid skips the network fetch and reuses the ledger's embedded +/// record, so idempotent re-runs stay cheap. Builds its own client from +/// `params`; callers holding the run's client use +/// [`download_patch_records_with`]. +pub(crate) async fn download_patch_records( + selected: &[PatchSearchResult], + params: &DownloadParams, +) -> (i32, serde_json::Value, HashMap) { + let api_client = api_client_for(params).await; + download_patch_records_with(selected, params, &api_client, HashMap::new()).await +} +/// [`download_patch_records`] over the caller's client. `prefetched` maps +/// uuid → an already-fetched view: the `get ` path resolved its +/// identifier by fetching the view and must not fetch it again (a fresh +/// client could re-hit the 401 the proxy fallback just recovered from). +pub(crate) async fn download_patch_records_with( + selected: &[PatchSearchResult], + params: &DownloadParams, + api_client: &ApiClient, + prefetched: HashMap, +) -> (i32, serde_json::Value, HashMap) { + // The ledger load outcome is handed to the preflight AS a result: an + // unreadable ledger must surface as `vendor_state_unreadable` from the + // one refusal this phase emits (fail closed, nothing exempt), not be + // flattened into an empty ledger that then reports a Bun lock remedy. + // For the classification below it degrades to empty (no detached entry + // to reuse — the vendor step reports the corruption itself). + let vendor_state = load_state(¶ms.cwd).await; + // Bun preflight (see `BunVendorRefusal`): this phase feeds the vendor + // engine, so it must refuse the same projects BEFORE fetching — + // otherwise the view was downloaded for nothing and a package + // resolvable only through the unreadable bun.lockb inventory + // misreported `package_not_installed` instead of the real + // `vendor_bun_*` code. npm-only, so release narrowing (PyPI / RubyGems / + // Maven variants) cannot change its verdict. + let bun_refusal = bun_vendor_preflight_with_ledger( + ¶ms.cwd, + selected, + vendor_state.as_ref().map(|s| &s.entries), + ) + .await; + let vendor_state = vendor_state.unwrap_or_default(); + + let blobs_dir = params.socket_dir().join("blobs"); + let batch = fetch_selected_patches( + selected, + params, + api_client, + RecordStore::Ledger(&vendor_state.entries), + params.persist_blobs.then_some(blobs_dir.as_path()), + bun_refusal.as_ref(), + prefetched, + ) + .await; + + let downloaded = batch.fetched.len(); + let mut records: HashMap = batch.reused.into_iter().collect(); + for FetchedPatch { patch, files, .. } in batch.fetched { + records.insert(patch.purl.clone(), build_patch_record(&patch, files)); + } let mut result_json = serde_json::json!({ - "found": selected.len(), + "found": batch.found, "downloaded": downloaded, - "skipped": skipped, - "failed": failed, + "skipped": batch.skipped, + "failed": batch.failed, "detached": true, - "patches": patch_records_json, + "patches": batch.patches_json, }); - if !narrow_warnings.is_empty() { - result_json["warnings"] = serde_json::json!(narrow_warnings); + if !batch.warnings.is_empty() { + result_json["warnings"] = serde_json::json!(batch.warnings); } - (i32::from(failed > 0), result_json, records) + (i32::from(batch.failed > 0), result_json, records) } /// Emit a warning (stderr `[note]` + `warnings[]`) for every added/updated @@ -1404,7 +1648,7 @@ async fn warn_on_vendored_uuid_drift( downloaded_patches: &[serde_json::Value], warnings: &mut Vec, ) { - let Ok(vendor_state) = socket_patch_core::vendor::load_state(cwd).await else { + let Ok(vendor_state) = load_state(cwd).await else { return; }; if vendor_state.entries.is_empty() { @@ -1417,7 +1661,7 @@ async fn warn_on_vendored_uuid_drift( if !matches!(rec["action"].as_str(), Some("added" | "updated")) { continue; } - let entry = socket_patch_core::vendor::lookup_entry(&vendor_state.entries, purl); + let entry = lookup_entry(&vendor_state.entries, purl); if let Some(entry) = entry.filter(|e| e.uuid != uuid) { let w = format!( "{purl} is vendored at patch {} but the manifest now records {uuid}; \ @@ -1432,63 +1676,70 @@ async fn warn_on_vendored_uuid_drift( } } -/// Run the nested `apply` step over the manifest under `cwd`. Returns -/// whether apply exited 0. Callers print their own "Applying patches..." -/// line (they differ on stdout vs stderr). `get` drives apply internally: -/// the read-only cargo-redirect verifier stays off and embedded VEX is -/// opt-in on the top-level command only, never on this internal -/// invocation. -/// -/// `api` carries the caller's API-client flags and is NOT optional: apply -/// builds its own clients from the `GlobalArgs` handed to it (its telemetry -/// client, and `fetch_stage`'s artifact fetcher), and those only ever see -/// this struct. Leaving the fields at their `GlobalArgs::default()` `None` -/// dropped `--api-url` / `--api-token` / `--org` / `--proxy-url` on the -/// floor, so a token supplied purely as a CLI flag fell through to env → -/// socket-cli config → the token-less public proxy. That breaks the flow -/// for real: a patch view that omits `blobContent` for a file (`Option` on -/// the wire, which is why `--download-mode diff` exists) leaves `get` with -/// no blob to write, and the nested apply must download it — with the wrong -/// client, against the wrong host. -#[allow(clippy::too_many_arguments)] -async fn run_nested_apply( - cwd: &Path, - manifest_path: &Path, - global: bool, - global_prefix: Option, - quiet: bool, - download_mode: String, - strict: bool, - api: socket_patch_core::api::client::ApiClientEnvOverrides, - ecosystems: Option>, -) -> bool { - // Apply re-resolves a relative manifest path against ITS `--cwd` - // (`resolved_manifest_path`), but ours is already cwd-resolved — - // passing it through relative double-joins the cwd (`proj/proj/...`), - // and apply then no-ops on the missing manifest while reporting - // success. Absolutize so it passes through verbatim. +/// The `GlobalArgs` a nested apply runs with: the caller's flags verbatim +/// (`--lock-timeout`, `--verbose`, `--strict`, the API flags, `--ecosystems` +/// … all flow through — apply builds its own clients from these, so a token +/// supplied purely as a flag must reach it), with the fields `get` owns +/// overridden: the already-resolved manifest path (apply re-resolves a +/// relative path against ITS `--cwd`, which double-joins ours — absolutize +/// so it passes through verbatim), `silent` = quiet and `json: false` (the +/// nested apply must never print a second JSON document), and `dry_run: +/// false` — agent-mode `get` ignores `--dry-run` by contract, and the +/// manifest + blobs it just wrote for real must be applied for real too. +fn nested_apply_args(common: &GlobalArgs, manifest_path: &Path, quiet: bool) -> GlobalArgs { let manifest_path = std::path::absolute(manifest_path).unwrap_or_else(|_| manifest_path.to_path_buf()); + GlobalArgs { + manifest_path: manifest_path.display().to_string(), + silent: quiet, + json: false, + dry_run: false, + ..common.clone() + } +} + +/// The caller flags a `DownloadParams` + [`DownloadRun`] pair reconstructs +/// for the nested apply (the engine never sees a `GlobalArgs`). The API +/// fields come from [`resolved_api_overrides`] so the nested apply resolves +/// to the same endpoint/token as the download. +fn nested_apply_args_from_params( + params: &DownloadParams, + run: &DownloadRun<'_>, + manifest_path: &Path, +) -> GlobalArgs { + let api = resolved_api_overrides(params); + let common = GlobalArgs { + cwd: params.cwd.clone(), + global: params.global, + global_prefix: params.global_prefix.clone(), + download_mode: params.download_mode.clone(), + strict: params.strict, + api_url: api.api_url, + api_token: api.api_token, + org: api.org_slug, + proxy_url: api.proxy_url, + // Scope the nested apply like the caller was scoped: leaving this + // at the default `None` made `scan --ecosystems gem --sync` apply + // the WHOLE manifest, mutating other ecosystems' packages the user + // filtered out. + ecosystems: params.ecosystems.clone(), + lock_timeout: run.lock_timeout, + verbose: run.verbose, + ..GlobalArgs::default() + }; + nested_apply_args(&common, manifest_path, params.quiet()) +} + +/// Run the nested `apply` step with `common` (see [`nested_apply_args`]). +/// Returns whether apply exited 0. Callers print their own "Applying +/// patches..." line (they differ on stdout vs stderr). The read-only +/// cargo-redirect verifier stays off and embedded VEX is opt-in on the +/// top-level command only, never on this internal invocation. The caller +/// must have released its own apply lock first: apply acquires its own, +/// and a same-process re-acquire contends. +async fn run_nested_apply(common: GlobalArgs, quiet: bool) -> bool { let apply_args = super::apply::ApplyArgs { - common: crate::args::GlobalArgs { - manifest_path: manifest_path.display().to_string(), - cwd: cwd.to_path_buf(), - global, - global_prefix, - silent: quiet, - download_mode, - strict, - api_url: api.api_url, - api_token: api.api_token, - org: api.org_slug, - proxy_url: api.proxy_url, - // Scope the nested apply like the caller was scoped: leaving - // this at the default `None` made `scan --ecosystems gem --sync` - // apply the WHOLE manifest, mutating other ecosystems' packages - // the user filtered out. - ecosystems, - ..crate::args::GlobalArgs::default() - }, + common, force: false, check: false, vex: Default::default(), @@ -1500,38 +1751,55 @@ async fn run_nested_apply( code == 0 } +/// Download the selected patches into `.socket/` (manifest records + +/// blobs) and, unless `save_only`, apply them in place — the agent-mode +/// engine behind `get` and `scan --apply/--sync`. Returns `(exit_code, +/// json)`. Builds its own client from `params` and takes the manifest lock +/// non-blocking; callers holding the run's client (and `--lock-timeout`) +/// use [`download_and_apply_patches_with`]. pub async fn download_and_apply_patches( selected: &[PatchSearchResult], params: &DownloadParams, ) -> (i32, serde_json::Value) { let api_client = api_client_for(params).await; + let run = DownloadRun { + api_client: &api_client, + lock_timeout: None, + verbose: false, + }; + download_and_apply_patches_with(selected, params, &run).await +} +/// [`download_and_apply_patches`] over the caller's run-level context. +pub async fn download_and_apply_patches_with( + selected: &[PatchSearchResult], + params: &DownloadParams, + run: &DownloadRun<'_>, +) -> (i32, serde_json::Value) { + let quiet = params.quiet(); let manifest_path = params.manifest_path.clone(); - let socket_dir = manifest_path - .parent() - .unwrap_or(Path::new(".")) - .to_path_buf(); - let blobs_dir = socket_dir.join("blobs"); - - if let Err(e) = tokio::fs::create_dir_all(&socket_dir).await { - let err = format!("Failed to create .socket directory: {}", e); - report_error(params.json, &err); - return (1, serde_json::json!({"status": "error", "error": err})); - } - if params.persist_blobs { - if let Err(e) = tokio::fs::create_dir_all(&blobs_dir).await { - let err = format!("Failed to create blobs directory: {}", e); - report_error(params.json, &err); - return (1, serde_json::json!({"status": "error", "error": err})); - } - } + let socket_dir = params.socket_dir(); + let lock_timeout = Duration::from_secs(run.lock_timeout.unwrap_or(0)); + + // The manifest read-modify-write — and the blob writes it records — + // runs under the apply lock: `remove`/`rollback` RMW the same file under + // it, and an unlocked writer here lost their update or had its own + // record clobbered. `acquire` creates `.socket/` itself; the guard's + // drop removes `apply.lock` and prunes an otherwise-empty `.socket/`, so + // a run that records nothing leaves no residue. Released BEFORE the + // nested apply, which takes its own lock (a same-process re-acquire + // would contend). + let guard = match apply_lock::acquire(&socket_dir, lock_timeout) { + Ok(guard) => guard, + Err(e) => return (1, report_lock_failure(params.json, &e, lock_timeout)), + }; let mut manifest = match read_manifest(&manifest_path).await { Ok(Some(m)) => m, Ok(None) => PatchManifest::new(), // Fail closed on a manifest that exists but can't be read/parsed: - // treating it as empty would let the unconditional write below - // replace the file and destroy every tracked patch record. + // treating it as empty would let the write below replace the file + // and destroy every tracked patch record. Err(e) => { let err = format!("Failed to read manifest: {e}"); report_error(params.json, &err); @@ -1539,218 +1807,66 @@ pub async fn download_and_apply_patches( } }; - // Narrow multi-release selections to the installed distribution - // unless --all-releases was passed. `filter_to_installed_releases` - // is a no-op for non-variant ecosystems and single-variant packages. - let (selected, mut narrow_warnings) = - filter_to_installed_releases(selected, params, &api_client).await; - - if !params.json && !params.silent { - eprintln!("\nDownloading {} patch(es)...", selected.len()); - } - - // `patches_added` and `patches_updated` are DISJOINT — one patch lands in - // exactly one of them, matching the per-patch `action` vocabulary - // (CLI_CONTRACT.md: `added` | `updated` | ...) and the single-UUID flow's - // summary in `save_and_apply_patch`. `patches_downloaded` is their sum: - // the JSON `downloaded` / `applied` counts cover both (a replacement was - // fetched and applied just like a new record), and it gates the apply - // step. Counting an update in `patches_added` too made the human summary - // print `Added: 1` AND `Updated: 1` for the one entry it had swapped. - let mut patches_added = 0; - let mut patches_skipped = 0; - let mut patches_failed = 0; - let mut patches_updated = 0; - let mut patches_downloaded = 0; - let mut downloaded_patches: Vec = Vec::new(); - - // Vendored downloads must not claim a patch in the manifest when Bun - // cannot consume its artifact (see `BunVendorRefusal`). Agent/save-only - // flows (`persist_blobs`) retain their record-only intent: the preflight - // is scoped to the `save_only && !persist_blobs` posture the vendored - // flows use, never the agent download. + // Bun preflight for the one non-agent caller left: scan's manifest-mode + // vendored download (`save_only && !persist_blobs`), which feeds the + // vendor engine and must refuse the same projects before fetching. + // Agent/save-only flows keep their record-only intent (no preflight). + // Retire together with that caller once scan's vendored path is + // detached-only. let bun_refusal = if params.save_only && !params.persist_blobs { - bun_vendor_preflight(¶ms.cwd, &selected).await + bun_vendor_preflight(¶ms.cwd, selected).await } else { None }; - for search_result in &selected { - if let Some(refusal) = bun_refusal - .as_ref() - .filter(|r| r.applies_to(&search_result.purl)) - { - patches_failed += 1; - downloaded_patches.push(serde_json::json!({ - "purl": search_result.purl, - "uuid": search_result.uuid, - "action": "failed", - "errorCode": refusal.code, - "error": refusal.detail, - })); - // Errors are exempt from --silent ("errors only", like the - // `[fail]` lines below); JSON runs carry the code + detail in - // the envelope instead. Code-tagged so a `--silent` operator - // can grep the stable code, not just the prose. - if !params.json { - eprintln!( - " [error] {} ({}): {}", - search_result.purl, refusal.code, refusal.detail - ); - } - continue; - } - // org slug is already stored in the client. - match api_client.fetch_patch(None, &search_result.uuid).await { - Ok(Some(patch)) => { - // Classify against the manifest state BEFORE we touch it. - // `Skipped` early-returns; `Updated` is preserved so the - // per-patch JSON record below can include `oldUuid`. - let action = decide_patch_action(&manifest, &patch.purl, &patch.uuid); - if let PatchAction::Skipped = action { - if !params.json && !params.silent { - eprintln!( - " [skip] {} (already in manifest)", - normalize_purl(&patch.purl) - ); - } - downloaded_patches.push(serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "skipped", - })); - patches_skipped += 1; - continue; - } - // Build the manifest `files` map. Retains patch-added new - // files (empty-beforeHash sentinel) so scan/apply/vendor - // record and write them; see `files_for_manifest`. - let files = files_for_manifest(&patch); - - // GUARDRAIL: a patch that yields NO recordable files - // cannot be applied — recording an empty `files` map and - // then reporting `applied` would tell the user we protected - // them while writing nothing. Count it as a failure so the - // status/exit code degrade and it is never auto-applied. - if files.is_empty() { - // Errors are exempt from --silent ("errors only"); - // JSON runs carry them in the envelope instead. - if !params.json { - eprintln!(" [fail] {} (patch has no applicable files)", patch.purl); - } - downloaded_patches.push(serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "failed", - "error": "patch has no applicable files", - })); - patches_failed += 1; - continue; - } - - // Blob failures are errors: only JSON mode suppresses the - // per-file detail line (the envelope carries the error). - let quiet = params.json; - // Vendor flows keep blob content in memory (the vendor - // step re-fetches what it needs); persisting blobs here - // would litter .socket/blobs for no consumer. - if params.persist_blobs - && write_all_patch_blobs(&blobs_dir, &patch, quiet) - .await - .is_err() - { - patches_failed += 1; - downloaded_patches.push(serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "failed", - "error": "Blob decode or write failed", - })); - continue; - } + let blobs_dir = socket_dir.join("blobs"); + let batch = fetch_selected_patches( + selected, + params, + run.api_client, + RecordStore::Manifest(&manifest), + params.persist_blobs.then_some(blobs_dir.as_path()), + bun_refusal.as_ref(), + HashMap::new(), + ) + .await; - manifest - .patches - .insert(patch.purl.clone(), build_patch_record(&patch, files)); - - let mut action_record = match &action { - PatchAction::Updated { old_uuid } => { - patches_updated += 1; - if !params.json && !params.silent { - // Defensive: a malformed/short UUID in the manifest - // must not panic the download loop. `&uuid[..8]` - // would; `short_uuid` falls back to the whole string. - eprintln!( - " [update] {} (replacing {})", - patch.purl, - short_uuid(old_uuid) - ); - } - serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "updated", - "oldUuid": old_uuid, - }) - } - _ => { - patches_added += 1; - if !params.json && !params.silent { - eprintln!(" [add] {}", patch.purl); - } - serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "added", - }) - } - }; - // Splice description / severity / vulnerability IDs into - // the per-patch record so PR-comment bots, dashboards, and - // CLI consumers can render the patch without a second - // round-trip to the API. - merge_metadata(&mut action_record, patch_event_metadata(&patch)); - downloaded_patches.push(action_record); - patches_downloaded += 1; - } - Ok(None) => { - if !params.json { - eprintln!(" [fail] {} (could not fetch details)", search_result.purl); - } - downloaded_patches.push(serde_json::json!({ - "purl": search_result.purl, - "uuid": search_result.uuid, - "action": "failed", - "error": "could not fetch details", - })); - patches_failed += 1; - } - Err(e) => { - if !params.json { - eprintln!(" [fail] {} ({e})", search_result.purl); - } - downloaded_patches.push(serde_json::json!({ - "purl": search_result.purl, - "uuid": search_result.uuid, - "action": "failed", - "error": e.to_string(), - })); - patches_failed += 1; - } + // `added` and `updated` are DISJOINT — one patch lands in exactly one, + // matching the per-patch `action` vocabulary (CLI_CONTRACT.md) and the + // single-uuid flow's summary in `save_and_apply_patch`; `downloaded` is + // their sum (a replacement was fetched and applied just like a new + // record) and gates the apply step. + let downloaded = batch.fetched.len(); + let mut updated = 0usize; + for FetchedPatch { + patch, + files, + action, + } in batch.fetched + { + if matches!(action, PatchAction::Updated { .. }) { + updated += 1; } + manifest + .patches + .insert(patch.purl.clone(), build_patch_record(&patch, files)); } - - // Write manifest - if let Err(e) = write_manifest(&manifest_path, &manifest).await { - let msg = format!("Error writing manifest: {e}"); - let err_json = serde_json::json!({ "status": "error", "error": &msg }); - if params.json { - print_json(&err_json); - } else { - eprintln!("{msg}"); + let added = downloaded - updated; + // Write only when a record changed: an all-skipped or all-failed run + // leaves the manifest bytes (and a fresh project's tree) untouched. + if downloaded > 0 { + if let Err(e) = write_manifest(&manifest_path, &manifest).await { + let msg = format!("Error writing manifest: {e}"); + let err_json = serde_json::json!({ "status": "error", "error": &msg }); + if params.json { + print_json(&err_json); + } else { + eprintln!("{msg}"); + } + return (1, err_json); } - return (1, err_json); } + drop(guard); // Vendored-uuid drift: an explicit `get` is allowed to move the // manifest past the patch uuid the vendor ledger still wires (the user @@ -1759,44 +1875,32 @@ pub async fn download_and_apply_patches( // uuid — tell the operator now instead of letting VEX surprise them // later. (`scan` never hits this: it filters vendored purls before // download.) The nested apply below skips the vendored purl either way. - warn_on_vendored_uuid_drift( - ¶ms.cwd, - params.json || params.silent, - &downloaded_patches, - &mut narrow_warnings, - ) - .await; + let mut warnings = batch.warnings; + warn_on_vendored_uuid_drift(¶ms.cwd, quiet, &batch.patches_json, &mut warnings).await; - if !params.json && !params.silent { + if !quiet { eprintln!("\nPatches saved to {}", manifest_path.display()); - eprintln!(" Added: {patches_added}"); - if patches_skipped > 0 { - eprintln!(" Skipped: {patches_skipped}"); + eprintln!(" Added: {added}"); + if batch.skipped > 0 { + eprintln!(" Skipped: {}", batch.skipped); } - if patches_failed > 0 { - eprintln!(" Failed: {patches_failed}"); + if batch.failed > 0 { + eprintln!(" Failed: {}", batch.failed); } - if patches_updated > 0 { - eprintln!(" Updated: {patches_updated}"); + if updated > 0 { + eprintln!(" Updated: {updated}"); } } // Auto-apply unless --save-only let mut apply_succeeded = false; - if !params.save_only && patches_downloaded > 0 { - if !params.json && !params.silent { + if !params.save_only && downloaded > 0 { + if !quiet { eprintln!("\nApplying patches..."); } apply_succeeded = run_nested_apply( - ¶ms.cwd, - &manifest_path, - params.global, - params.global_prefix.clone(), - params.json || params.silent, - params.download_mode.clone(), - params.strict, - resolved_api_overrides(params), - params.ecosystems.clone(), + nested_apply_args_from_params(params, run, &manifest_path), + quiet, ) .await; } @@ -1807,23 +1911,23 @@ pub async fn download_and_apply_patches( // alongside a non-zero exit code misleads JSON consumers (the scan // wrapper recomputes status from the exit code for exactly this // reason, but `get` surfaces this envelope directly). - let apply_failed = !apply_succeeded && patches_downloaded > 0 && !params.save_only; - let (status, exit_code) = run_outcome(patches_failed > 0, apply_failed); + let apply_failed = !apply_succeeded && downloaded > 0 && !params.save_only; + let (status, exit_code) = run_outcome(batch.failed > 0, apply_failed); let mut result_json = serde_json::json!({ "status": status, - "found": selected.len(), - "downloaded": patches_downloaded, - "skipped": patches_skipped, - "failed": patches_failed, - "applied": if apply_succeeded { patches_downloaded } else { 0 }, - "updated": patches_updated, - "patches": downloaded_patches, + "found": batch.found, + "downloaded": downloaded, + "skipped": batch.skipped, + "failed": batch.failed, + "applied": if apply_succeeded { downloaded } else { 0 }, + "updated": updated, + "patches": batch.patches_json, }); // Surface release-narrowing fallbacks (uninstalled package / no // matching variant) so JSON consumers can see why all variants were // kept. Omitted entirely when narrowing was clean. - if !narrow_warnings.is_empty() { - result_json["warnings"] = serde_json::json!(narrow_warnings); + if !warnings.is_empty() { + result_json["warnings"] = serde_json::json!(warnings); } (exit_code, result_json) @@ -1906,9 +2010,6 @@ pub async fn run(args: GetArgs) -> i32 { // incidence of stale-token fallbacks. let mut fallback_to_proxy = false; - // org slug is already stored in the client - let effective_org_slug: Option<&str> = None; - // Determine identifier type let id_type = if args.id { IdentifierType::Uuid @@ -1935,9 +2036,8 @@ pub async fn run(args: GetArgs) -> i32 { if !quiet { println!("Fetching patch by UUID: {}", args.identifier); } - let mut fetch_result = api_client - .fetch_patch(effective_org_slug, &args.identifier) - .await; + // org slug is already stored in the client. + let mut fetch_result = api_client.fetch_patch(None, &args.identifier).await; // 401/403 from the auth endpoint → swap to the public proxy // and retry once. Free patches still surface; paid patches // come back as the existing "paid_required" branch below. @@ -1951,9 +2051,7 @@ pub async fn run(args: GetArgs) -> i32 { api_client = build_proxy_fallback_client(&overrides); use_public_proxy = true; fallback_to_proxy = true; - fetch_result = api_client - .fetch_patch(effective_org_slug, &args.identifier) - .await; + fetch_result = api_client.fetch_patch(None, &args.identifier).await; } } } @@ -2014,13 +2112,17 @@ pub async fn run(args: GetArgs) -> i32 { super::scan::ScanMode::Agent => save_and_apply_patch(&args, &patch).await, super::scan::ScanMode::Hosted => { let selected = vec![search_result_from_response(&patch)]; - run_get_hosted(&args, &api_client, effective_org_slug, &selected, &[], &[]) - .await + run_get_hosted(&args, &api_client, &selected, &[], &[]).await } super::scan::ScanMode::Vendored => { - run_get_vendored_uuid( + let selected = vec![search_result_from_response(&patch)]; + run_get_vendored( &args, - &patch, + &api_client, + &selected, + Some(&patch), + &[], + &[], telemetry_token.as_deref(), telemetry_org.as_deref(), ) @@ -2064,28 +2166,23 @@ pub async fn run(args: GetArgs) -> i32 { let search_response: SearchResponse = match id_type { IdentifierType::Cve | IdentifierType::Ghsa | IdentifierType::Purl => { if !quiet { - let label = match id_type { - IdentifierType::Cve => "CVE", - IdentifierType::Ghsa => "GHSA", - IdentifierType::Purl => "PURL", - _ => unreachable!(), - }; - println!("Searching patches for {label}: {}", args.identifier); + println!("Searching patches for {id_type}: {}", args.identifier); } + // org slug is already stored in the client. let result = match id_type { IdentifierType::Cve => { api_client - .search_patches_by_cve(effective_org_slug, &args.identifier) + .search_patches_by_cve(None, &args.identifier) .await } IdentifierType::Ghsa => { api_client - .search_patches_by_ghsa(effective_org_slug, &args.identifier) + .search_patches_by_ghsa(None, &args.identifier) .await } IdentifierType::Purl => { api_client - .search_patches_by_package(effective_org_slug, &args.identifier) + .search_patches_by_package(None, &args.identifier) .await } _ => unreachable!(), @@ -2109,12 +2206,7 @@ pub async fn run(args: GetArgs) -> i32 { if !quiet { println!("Enumerating packages..."); } - let crawler_options = CrawlerOptions { - cwd: args.common.cwd.clone(), - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - }; - let (all_packages, _) = crawl_all_ecosystems(&crawler_options).await; + let (all_packages, _) = crawl_all_ecosystems(&crawler_options_for(&args.common)).await; if all_packages.is_empty() { if args.common.json { @@ -2123,13 +2215,9 @@ pub async fn run(args: GetArgs) -> i32 { if args.common.global { println!("No global packages found."); } else { - #[allow(unused_mut)] - let mut install_cmds = String::from("npm/yarn/pnpm/pip"); - install_cmds.push_str("/cargo"); - install_cmds.push_str("/go"); - install_cmds.push_str("/mvn"); - install_cmds.push_str("/composer"); - println!("No packages found. Run {install_cmds} install first."); + println!( + "No packages found. Run npm/yarn/pnpm/pip/cargo/go/mvn/composer install first." + ); } } return 0; @@ -2157,10 +2245,11 @@ pub async fn run(args: GetArgs) -> i32 { ); } - // Search for patches for the best match + // Search for patches for the best match (org slug is already + // stored in the client). let best_match = &matches[0]; match api_client - .search_patches_by_package(effective_org_slug, &best_match.purl) + .search_patches_by_package(None, &best_match.purl) .await { Ok(r) => r, @@ -2306,7 +2395,9 @@ pub async fn run(args: GetArgs) -> i32 { return 0; } - // Smart patch selection: pick one patch per PURL + // Smart patch selection: pick one patch per PURL. `accessible` is + // non-empty here and every entry passes the selector's tier filter, so + // the selection is never empty (one patch per purl group, or `Err`). let selected = match select_patches( &accessible, search_response.can_access_paid_patches, @@ -2316,13 +2407,6 @@ pub async fn run(args: GetArgs) -> i32 { Err(code) => return code, }; - if selected.is_empty() { - if !quiet { - println!("No patches selected."); - } - return 0; - } - // Confirm before acting (default YES), with mode-appropriate wording. // Hosted/vendored dry-runs skip the prompt — nothing mutates (scan's // dry-run posture); agent mode keeps today's behavior. @@ -2354,25 +2438,16 @@ pub async fn run(args: GetArgs) -> i32 { // granted and rewritten, not just the installed distribution. // Same fallbacks as everywhere else: uninstalled/unmatched // bases keep all variants with a warning; --all-releases - // passes through. - let filter_params = DownloadParams { - cwd: args.common.cwd.clone(), - manifest_path: args.common.resolved_manifest_path(), - org: args.common.org.clone(), - save_only: true, - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - json: args.common.json, - silent: args.common.silent, - download_mode: args.common.download_mode.clone(), - api_overrides: args.common.api_client_overrides(), - all_releases: args.all_releases, - strict: args.common.strict, - ecosystems: args.common.ecosystems.clone(), - persist_blobs: false, - }; - let (selected, variant_warnings) = - filter_to_installed_releases(&selected, &filter_params, &api_client).await; + // passes through. (The views it fetched are not needed here: + // hosted never downloads.) + let (selected, variant_warnings, _views) = filter_to_installed_releases( + &selected, + args.all_releases, + &crawler_options_for(&args.common), + quiet, + &api_client, + ) + .await; let mut narrow_warnings = narrow_warnings; narrow_warnings.extend( variant_warnings @@ -2382,7 +2457,6 @@ pub async fn run(args: GetArgs) -> i32 { return run_get_hosted( &args, &api_client, - effective_org_slug, &selected, &narrow_skips, &narrow_warnings, @@ -2390,9 +2464,11 @@ pub async fn run(args: GetArgs) -> i32 { .await; } super::scan::ScanMode::Vendored => { - return run_get_vendored_search( + return run_get_vendored( &args, + &api_client, &selected, + None, &narrow_skips, &narrow_warnings, telemetry_token.as_deref(), @@ -2403,30 +2479,18 @@ pub async fn run(args: GetArgs) -> i32 { super::scan::ScanMode::Agent => {} } - // Download and apply (agent mode) - let params = DownloadParams { - cwd: args.common.cwd.clone(), - manifest_path: args.common.resolved_manifest_path(), - org: args.common.org.clone(), - save_only: args.save_only, - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - json: args.common.json, - silent: args.common.silent, - download_mode: args.common.download_mode.clone(), - api_overrides: args.common.api_client_overrides(), - all_releases: args.all_releases, - strict: args.common.strict, - ecosystems: args.common.ecosystems.clone(), - persist_blobs: true, + // Download and apply (agent mode), with the run's client and flags. + let params = get_download_params(&args, args.save_only, /*persist_blobs=*/ true); + let run = DownloadRun { + api_client: &api_client, + lock_timeout: args.common.lock_timeout, + verbose: args.common.verbose, }; - - let (code, mut result_json) = download_and_apply_patches(&selected, ¶ms).await; - // A download-phase HARD error (unreadable manifest, unwritable - // .socket, failed manifest write) is an `error`-status envelope the - // engine has ALREADY printed — printing below would put a second JSON - // document on stdout (get's `--json` contract is exactly one per - // run; `run_get_vendored_search` has the same guard). Per-patch + let (code, mut result_json) = download_and_apply_patches_with(&selected, ¶ms, &run).await; + // A download-phase HARD error (lock refused, unreadable manifest, + // failed manifest write) is an `error`-status envelope the engine has + // ALREADY printed — printing below would put a second JSON document on + // stdout (get's `--json` contract is exactly one per run). Per-patch // failures are NOT this case: they ride a success-shaped // (`partial_failure`) envelope the engine leaves for us to print. if result_json["status"] == "error" { @@ -2495,55 +2559,31 @@ fn display_search_results(patches: &[PatchSearchResult], can_access_paid: bool) } } -/// Save an already-fetched patch to the manifest and (unless -/// `--save-only`) apply it. Takes the `PatchResponse` the caller fetched -/// rather than re-fetching by UUID: the caller's client may have fallen -/// back to the public proxy after a 401/403, and a fresh client built -/// here would hit the same auth failure again, breaking the fallback -/// end to end. -/// The manifest-record half of the single-uuid save — blobs dir + blob -/// writes (when `persist_blobs`), fail-closed manifest read, the -/// no-applicable-files guardrail, action classification, and the manifest -/// write — WITHOUT the nested apply, drift warning, or terminal JSON -/// envelope. Shared by the agent-mode [`save_and_apply_patch`] terminal -/// (`persist_blobs: true`, `insert_when_skipped: true` — today's exact -/// behavior, a same-uuid re-get still rewrites the record bytes) and the -/// `--mode vendored` uuid path (`false`/`false`: the vendor step stages -/// patch content in memory so nothing lands in `.socket/blobs`, and an -/// idempotent re-get leaves the manifest bytes untouched, matching the -/// multi-patch download loop's Skipped `continue`). +/// The manifest-record half of the agent single-uuid save, under the apply +/// lock: fail-closed manifest read, the no-applicable-files guardrail, +/// action classification against the manifest, and — unless the same uuid +/// is already recorded — the blob writes and the manifest write. Takes the +/// `PatchResponse` the caller fetched rather than re-fetching by UUID: the +/// caller's client may have fallen back to the public proxy after a +/// 401/403, and a fresh client would hit the same auth failure again. A +/// same-uuid re-get writes nothing (matching the multi-patch engine's +/// `skipped`); the lock is released on return, before the nested apply +/// takes its own. /// -/// Errors are reported here exactly as before the extraction and surface -/// as `Err(exit_code)`. -async fn save_patch_record( - args: &GetArgs, - patch: &PatchResponse, - persist_blobs: bool, - insert_when_skipped: bool, -) -> Result { +/// Errors are reported here and surface as `Err(exit_code)`. +async fn save_patch_record(args: &GetArgs, patch: &PatchResponse) -> Result { let manifest_path = args.common.resolved_manifest_path(); let socket_dir = manifest_path .parent() .unwrap_or(Path::new(".")) .to_path_buf(); - - if persist_blobs { - if let Err(e) = tokio::fs::create_dir_all(socket_dir.join("blobs")).await { - report_error( - args.common.json, - format!("Failed to create blobs directory: {e}"), - ); - return Err(1); - } - } else if let Err(e) = tokio::fs::create_dir_all(&socket_dir).await { - // No blobs dir in vendored mode, but the manifest write below (and - // the vendor step's apply lock) still need `.socket/` itself. - report_error( - args.common.json, - format!("Failed to create .socket directory: {e}"), - ); - return Err(1); - } + let lock_timeout = Duration::from_secs(args.common.lock_timeout.unwrap_or(0)); + // See `download_and_apply_patches_with`: the RMW runs under the lock, + // which also creates `.socket/` and prunes it again when nothing lands. + let _guard = apply_lock::acquire(&socket_dir, lock_timeout).map_err(|e| { + report_lock_failure(args.common.json, &e, lock_timeout); + 1 + })?; let mut manifest = match read_manifest(&manifest_path).await { Ok(Some(m)) => m, @@ -2577,10 +2617,18 @@ async fn save_patch_record( return Err(1); } - if persist_blobs - && write_all_patch_blobs(&socket_dir.join("blobs"), patch, args.common.json) - .await - .is_err() + // Classify against the manifest state BEFORE the insert, with the same + // vocabulary `download_and_apply_patches` emits (CLI_CONTRACT.md): a + // different uuid already recorded at this purl is `updated` (+`oldUuid`), + // not `added` — consumers diff manifest replacements on that action. + let action = decide_patch_action(&manifest, &patch.purl, &patch.uuid); + if action == PatchAction::Skipped { + return Ok(action); + } + + if write_all_patch_blobs(&socket_dir.join("blobs"), patch, args.common.json) + .await + .is_err() { if args.common.json { print_json(&serde_json::json!({ @@ -2605,21 +2653,12 @@ async fn save_patch_record( return Err(1); } - // Classify against the manifest state BEFORE the insert, with the same - // vocabulary `download_and_apply_patches` emits (CLI_CONTRACT.md): a - // different uuid already recorded at this purl is `updated` (+`oldUuid`), - // not `added` — consumers diff manifest replacements on that action. - let action = decide_patch_action(&manifest, &patch.purl, &patch.uuid); - - if insert_when_skipped || action != PatchAction::Skipped { - manifest - .patches - .insert(patch.purl.clone(), build_patch_record(patch, files)); - - if let Err(e) = write_manifest(&manifest_path, &manifest).await { - report_error(args.common.json, format!("Error writing manifest: {e}")); - return Err(1); - } + manifest + .patches + .insert(patch.purl.clone(), build_patch_record(patch, files)); + if let Err(e) = write_manifest(&manifest_path, &manifest).await { + report_error(args.common.json, format!("Error writing manifest: {e}")); + return Err(1); } Ok(action) } @@ -2630,7 +2669,7 @@ async fn save_and_apply_patch(args: &GetArgs, patch: &PatchResponse) -> i32 { let quiet = args.common.json || args.common.silent; let manifest_path = args.common.resolved_manifest_path(); - let action = match save_patch_record(args, patch, true, true).await { + let action = match save_patch_record(args, patch).await { Ok(action) => action, Err(code) => return code, }; @@ -2677,15 +2716,8 @@ async fn save_and_apply_patch(args: &GetArgs, patch: &PatchResponse) -> i32 { println!("\nApplying patches..."); } apply_succeeded = run_nested_apply( - &args.common.cwd, - &manifest_path, - args.common.global, - args.common.global_prefix.clone(), + nested_apply_args(&args.common, &manifest_path, quiet), quiet, - args.common.download_mode.clone(), - args.common.strict, - args.common.api_client_overrides(), - args.common.ecosystems.clone(), ) .await; } @@ -2724,11 +2756,7 @@ async fn save_and_apply_patch(args: &GetArgs, patch: &PatchResponse) -> i32 { if !warnings.is_empty() { result_json["warnings"] = serde_json::json!(warnings); } - println!( - "{}", - serde_json::to_string_pretty(&result_json) - .expect("serializing an in-memory JSON value cannot fail") - ); + print_json(&result_json); } exit_code @@ -2748,46 +2776,26 @@ fn search_result_from_response(patch: &PatchResponse) -> PatchSearchResult { } } -/// Transient-frame boxed constructor for the vendored-mode download phase — -/// `download_and_apply_patches`' future embeds the in-process apply engine, -/// and `run_get_vendored_search`'s poll frame must not carry it inline -/// (Windows 1 MiB main-thread stack; scan's vendor flow boxes the same call). -fn boxed_download_and_apply<'a>( - selected: &'a [PatchSearchResult], - params: &'a DownloadParams, -) -> std::pin::Pin + 'a>> { - Box::pin(download_and_apply_patches(selected, params)) -} - -/// Print the whole-manifest blast-radius note for `--mode vendored`: the -/// vendor step is scan's — it reconciles and (re)vendors EVERY manifest -/// record, not just the one(s) this get selected. -async fn note_vendored_whole_manifest_scope( - manifest_path: &Path, - selected_purls: &[&str], - quiet: bool, -) { - if quiet { - return; - } - let Ok(Some(manifest)) = read_manifest(manifest_path).await else { - return; - }; - let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); - let selected_canon: std::collections::HashSet = - selected_purls.iter().map(|p| canon(p)).collect(); - let others = manifest - .patches - .keys() - .filter(|k| !selected_canon.contains(&canon(k))) - .count(); - if others > 0 { - eprintln!( - " [note] --mode vendored runs the vendor engine over the whole manifest: \ - {others} existing record(s) will also be verified/re-vendored, and records \ - whose packages left the manifest may have their vendored state reverted \ - (same behavior as `scan --mode vendored`)." - ); +/// The `DownloadParams` a `get` run hands its download engine. Only the +/// posture differs per mode: agent persists blobs and applies unless +/// `--save-only`; vendored holds content in memory (`save_only`, no blobs) +/// because the vendor step is the persistence. +fn get_download_params(args: &GetArgs, save_only: bool, persist_blobs: bool) -> DownloadParams { + DownloadParams { + cwd: args.common.cwd.clone(), + manifest_path: args.common.resolved_manifest_path(), + org: args.common.org.clone(), + save_only, + global: args.common.global, + global_prefix: args.common.global_prefix.clone(), + json: args.common.json, + silent: args.common.silent, + download_mode: args.common.download_mode.clone(), + api_overrides: args.common.api_client_overrides(), + all_releases: args.all_releases, + strict: args.common.strict, + ecosystems: args.common.ecosystems.clone(), + persist_blobs, } } @@ -2799,8 +2807,7 @@ async fn note_vendored_whole_manifest_scope( /// `redirect` block into the get base envelope passed as `scan_result`. async fn run_get_hosted( args: &GetArgs, - api_client: &socket_patch_core::api::client::ApiClient, - effective_org_slug: Option<&str>, + api_client: &ApiClient, selected: &[PatchSearchResult], narrow_skips: &[serde_json::Value], narrow_warnings: &[(String, String)], @@ -2821,308 +2828,174 @@ async fn run_get_hosted( fold_narrowing_into_result(&mut result, &[], narrow_warnings); result }); - // Embedded VEX stays a scan/vendor feature (get has no --vex): a - // default-off VexEmbedArgs — deliberately NOT env-bound here, so an - // ambient SOCKET_VEX only affects commands that declare the flag. - let vex = crate::commands::vex::VexEmbedArgs::default(); - super::scan::boxed_run_redirect_selected( - &args.common, - &vex, - /*prune_requested=*/ false, - api_client, - effective_org_slug, - &pairs, - scan_result, - ) - .await -} - -/// `get … --mode vendored` (search path): scan's vendored posture end to -/// end — download phase writing ONLY the manifest (blobs in memory), then -/// scan's whole-manifest vendor step, telemetry included — so the result -/// matches `scan --mode vendored` selecting the same patches. -async fn run_get_vendored_search( - args: &GetArgs, - selected: &[PatchSearchResult], - narrow_skips: &[serde_json::Value], - narrow_warnings: &[(String, String)], - telemetry_token: Option<&str>, - telemetry_org: Option<&str>, -) -> i32 { - let quiet = args.common.json || args.common.silent; - let manifest_path = args.common.resolved_manifest_path(); - let socket_dir = manifest_path - .parent() - .unwrap_or(Path::new(".")) - .to_path_buf(); - - // Dry run: ledger-classification preview only (scan's posture) — no - // download, no vendor step, no writes. - if args.common.dry_run { - let preview = super::scan::preview_vendor_json(&args.common.cwd, selected).await; - if args.common.json { - let mut result = serde_json::json!({ - "status": "success", - "found": selected.len() + narrow_skips.len(), - "patches": narrow_skips, - }); - fold_narrowing_into_result(&mut result, &[], narrow_warnings); - result["vendor"] = preview; - print_json(&result); - } else if !args.common.silent { - println!( - "[dry-run] Would download and vendor {} patch(es).", - selected.len() - ); - super::scan::print_dry_run_refusals(&preview); - } - return 0; - } - - let selected_purls: Vec<&str> = selected.iter().map(|s| s.purl.as_str()).collect(); - note_vendored_whole_manifest_scope(&manifest_path, &selected_purls, quiet).await; - - // Download phase — scan's vendored posture: manifest-only writes, blobs - // held in memory, the nested apply never runs (save_only). - let params = DownloadParams { - cwd: args.common.cwd.clone(), - manifest_path: manifest_path.clone(), - org: args.common.org.clone(), - save_only: true, - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - json: args.common.json, - silent: args.common.silent, - download_mode: args.common.download_mode.clone(), - api_overrides: args.common.api_client_overrides(), - all_releases: args.all_releases, - strict: args.common.strict, - ecosystems: args.common.ecosystems.clone(), - persist_blobs: false, - }; - let (dl_code, mut result) = boxed_download_and_apply(selected, ¶ms).await; - // A download-phase HARD error (unreadable manifest, unwritable - // .socket, failed manifest write — an `error`-status envelope the - // engine has ALREADY printed) aborts before the vendor step: get's - // `--json` contract is exactly one JSON document per run, and the - // vendor step would only re-fail on the same broken state and print a - // second, different document. Per-patch failures are NOT this case — - // they ride a success-shaped envelope and the vendor step still runs - // (scan parity: previously-recorded patches still (re)vendor). - if result["status"] == "error" { - return dl_code; - } - let mut has_errors = dl_code != 0; - fold_narrowing_into_result(&mut result, narrow_skips, narrow_warnings); - if let Some(obj) = result.as_object_mut() { - // save_only: the nested apply structurally never ran, so `applied` - // would misleadingly report 0 — drop it (scan's vendored download - // sub-object gets the same surgery). - obj.remove("applied"); - } - - // The vendor step (scan's, verbatim): apply lock, whole-manifest - // reconcile + staging + engine. A per-patch download failure does not - // skip it — previously-recorded patches still (re)vendor, like scan. - match super::scan::boxed_scan_vendor_step(&args.common, &manifest_path, &socket_dir, None).await - { - Ok((vendor_errors, venv)) => { - has_errors |= vendor_errors; - // Telemetry follows the RUN outcome, not the vendor step alone: - // a download-phase refusal/failure exits 1 and must not report - // a successful vendoring of zero patches (scan's arms agree). - crate::commands::vendor::track_outcomes_for_vendor( - has_errors, - &venv, - args.common.dry_run, - telemetry_token, - telemetry_org, - ) - .await; - if args.common.json { - result["status"] = serde_json::json!(if has_errors { - "partial_failure" - } else { - "success" - }); - result["vendor"] = - serde_json::to_value(&venv).unwrap_or_else(|_| serde_json::json!({})); - print_json(&result); - } - i32::from(has_errors) - } - Err((code, message, venv)) => { - socket_patch_core::telemetry::track_patch_vendor_failed( - &message, - args.common.dry_run, - telemetry_token, - telemetry_org, - ) - .await; - if args.common.json { - // A pre-failure reconcile already mutated the vendor ledger - // on disk; its envelope (events included) must reach the - // JSON consumer even though the run aborts here. - if let Some(venv) = venv { - result["vendor"] = - serde_json::to_value(&*venv).unwrap_or_else(|_| serde_json::json!({})); - } - result["status"] = serde_json::json!("error"); - result["error"] = serde_json::json!({ "code": code, "message": message }); - print_json(&result); - } else { - eprintln!("Error ({code}): {message}"); - } - 1 - } - } + // Embedded VEX stays a scan/vendor feature (get has no --vex): a + // default-off VexEmbedArgs — deliberately NOT env-bound here, so an + // ambient SOCKET_VEX only affects commands that declare the flag. + let vex = crate::commands::vex::VexEmbedArgs::default(); + // org slug is already stored in the client. + super::scan::boxed_run_redirect_selected( + &args.common, + &vex, + /*prune_requested=*/ false, + api_client, + None, + &pairs, + scan_result, + ) + .await } -/// `get --mode vendored`: record the ALREADY-FETCHED patch in the -/// manifest (no blobs, no nested apply — the vendor step stages content in -/// memory), then run scan's whole-manifest vendor step. Reuses the fetched -/// `PatchResponse` so the uuid path's proxy-fallback survives the record -/// save; the vendor step builds its own client from the flags, exactly as -/// scan's does. -async fn run_get_vendored_uuid( +/// `get … --mode vendored`, both identifier paths: scan's vendored posture +/// end to end — the detached download phase ([`download_patch_records_with`]: +/// records fetched into memory, no manifest, no blobs) feeding scan's +/// detached vendor step (apply lock, in-memory staging, the vendor engine; +/// the ledger carries every record `detached: true`), telemetry included — +/// so the result matches `scan --mode vendored` selecting the same patches. +/// `.socket/manifest.json` is never read or written here. +/// +/// `prefetched` is the `get ` path's already-fetched view: it resolved +/// the identifier by fetching it (with the possibly-proxy-fallback client) +/// and the engine serves the record from it instead of fetching again. That +/// path also refuses a Bun project BEFORE the engine, with the contract's +/// exact pre-record envelope, so a refused run writes nothing at all; the +/// search path lets the engine record the refusal per patch and still runs +/// the vendor step (scan parity). +#[allow(clippy::too_many_arguments)] +async fn run_get_vendored( args: &GetArgs, - patch: &PatchResponse, + api_client: &ApiClient, + selected: &[PatchSearchResult], + prefetched: Option<&PatchResponse>, + narrow_skips: &[serde_json::Value], + narrow_warnings: &[(String, String)], telemetry_token: Option<&str>, telemetry_org: Option<&str>, ) -> i32 { - let quiet = args.common.json || args.common.silent; let manifest_path = args.common.resolved_manifest_path(); let socket_dir = manifest_path .parent() .unwrap_or(Path::new(".")) .to_path_buf(); + // Dry run: ledger-classification preview only (scan's posture) — no + // download, no vendor step, no writes. if args.common.dry_run { - let selected = vec![search_result_from_response(patch)]; - let preview = super::scan::preview_vendor_json(&args.common.cwd, &selected).await; + let preview = super::scan::preview_vendor_json(&args.common.cwd, selected).await; if args.common.json { let mut result = serde_json::json!({ "status": "success", - "found": 1, - "patches": [], + "found": selected.len() + narrow_skips.len(), + "patches": narrow_skips, }); + fold_narrowing_into_result(&mut result, &[], narrow_warnings); result["vendor"] = preview; print_json(&result); } else if !args.common.silent { - println!("[dry-run] Would download and vendor 1 patch."); + println!( + "[dry-run] Would download and vendor {} patch(es).", + selected.len() + ); super::scan::print_dry_run_refusals(&preview); } return 0; } - // Bun preflight (see `BunVendorRefusal`): refuse BEFORE the manifest - // record is saved and before the vendor step, so the tree stays exactly - // as it was (no `.socket/` is created on a fresh project). The - // already-fetched patch is the only network traffic of a refused run. - // - // JSON shape (contract: `get --mode vendored` pre-record refusal; - // the record carries BOTH `errorCode` and `error` like the search path's - // failed records, and the envelope carries `skipped` like this path's - // success shape): - // - // { - // "status": "error", - // "found": 1, "downloaded": 0, "skipped": 0, "failed": 1, - // "error": { "code": "", "message": "" }, - // "patches": [{ "purl": "…", "uuid": "…", "action": "failed", - // "errorCode": "", "error": "" }] - // } - // - // Human: `Error (): ` on stderr — an error, so it is - // exempt from `--silent` like every other `Error (…)` line here. - let selected = vec![search_result_from_response(patch)]; - if let Some(refusal) = bun_vendor_preflight(&args.common.cwd, &selected) - .await - .filter(|r| r.applies_to(&patch.purl)) - { - let BunVendorRefusal { code, detail, .. } = refusal; - // Same failure telemetry as the vendor-step Err arm below: this run - // exits 1 without vendoring anything. - socket_patch_core::telemetry::track_patch_vendor_failed( - &detail, - args.common.dry_run, - telemetry_token, - telemetry_org, - ) - .await; - if args.common.json { - print_json(&serde_json::json!({ - "status": "error", - "found": 1, - "downloaded": 0, - "skipped": 0, - "failed": 1, - "error": { "code": code, "message": detail }, - "patches": [{ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "failed", - "errorCode": code, - "error": detail, - }], - })); - } else { - eprintln!("Error ({code}): {detail}"); - } - return 1; - } - - note_vendored_whole_manifest_scope(&manifest_path, &[patch.purl.as_str()], quiet).await; - - let action = match save_patch_record(args, patch, false, false).await { - Ok(action) => action, - Err(code) => return code, - }; - let changed = action != PatchAction::Skipped; - let action_label = match &action { - PatchAction::Added => "added", - PatchAction::Updated { .. } => "updated", - PatchAction::Skipped => "skipped", - }; - if !quiet { - println!("\nPatch record saved to {}", manifest_path.display()); - match &action { - PatchAction::Added => println!(" Added: 1"), - PatchAction::Updated { old_uuid } => { - println!(" Updated: 1 (replacing {})", short_uuid(old_uuid)); + if let Some(patch) = prefetched { + // Bun preflight (see `BunVendorRefusal`): refuse BEFORE the engine + // and the vendor step, so the tree stays exactly as it was (no + // `.socket/` is created on a fresh project). The already-fetched + // patch is the only network traffic of a refused run. + // + // JSON shape (contract: `get --mode vendored` pre-record + // refusal; the record carries BOTH `errorCode` and `error` like the + // search path's failed records, and the envelope carries `skipped` + // like this path's success shape): + // + // { + // "status": "error", + // "found": 1, "downloaded": 0, "skipped": 0, "failed": 1, + // "error": { "code": "", "message": "" }, + // "patches": [{ "purl": "…", "uuid": "…", "action": "failed", + // "errorCode": "", "error": "" }] + // } + // + // Human: `Error (): ` on stderr — an error, so it is + // exempt from `--silent` like every other `Error (…)` line here. + if let Some(refusal) = bun_vendor_preflight(&args.common.cwd, selected) + .await + .filter(|r| r.applies_to(&patch.purl)) + { + let BunVendorRefusal { code, detail, .. } = refusal; + // Same failure telemetry as the vendor-step Err arm below: this + // run exits 1 without vendoring anything. + socket_patch_core::telemetry::track_patch_vendor_failed( + &detail, + args.common.dry_run, + telemetry_token, + telemetry_org, + ) + .await; + if args.common.json { + print_json(&serde_json::json!({ + "status": "error", + "found": 1, + "downloaded": 0, + "skipped": 0, + "failed": 1, + "error": { "code": code, "message": detail }, + "patches": [{ + "purl": patch.purl, + "uuid": patch.uuid, + "action": "failed", + "errorCode": code, + "error": detail, + }], + })); + } else { + eprintln!("Error ({code}): {detail}"); } - PatchAction::Skipped => println!(" Skipped: 1 (already exists)"), + return 1; } } - let mut result = if args.common.json { - let mut patch_record = serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": action_label, - }); - if let PatchAction::Updated { old_uuid } = &action { - patch_record["oldUuid"] = serde_json::json!(old_uuid); - } - if changed { - merge_metadata(&mut patch_record, patch_event_metadata(patch)); - } - serde_json::json!({ - "status": "success", - "found": 1, - "downloaded": if changed { 1 } else { 0 }, - "skipped": if changed { 0 } else { 1 }, - "patches": [patch_record], - }) - } else { - serde_json::Value::Null - }; + // Download phase — records in memory, blobs never persisted, the nested + // apply structurally never runs (save_only): the vendor step IS the + // persistence. Boxed: the future embeds the narrowing + fetch loop, and + // `run`'s poll frame must fit Windows' 1 MiB main-thread stack. + let params = get_download_params( + args, /*save_only=*/ true, /*persist_blobs=*/ false, + ); + let prefetched_views: HashMap = prefetched + .map(|p| HashMap::from([(p.uuid.clone(), p.clone())])) + .unwrap_or_default(); + let (dl_code, mut result, records) = Box::pin(download_patch_records_with( + selected, + ¶ms, + api_client, + prefetched_views, + )) + .await; + let mut has_errors = dl_code != 0; + fold_narrowing_into_result(&mut result, narrow_skips, narrow_warnings); - match super::scan::boxed_scan_vendor_step(&args.common, &manifest_path, &socket_dir, None).await + // The vendor step (scan's, verbatim): apply lock, in-memory staging, the + // engine over exactly the records fetched above. A per-patch download + // failure does not skip it (scan parity). + match super::scan::boxed_scan_vendor_step( + &args.common, + &manifest_path, + &socket_dir, + Some(&records), + ) + .await { Ok((vendor_errors, venv)) => { + has_errors |= vendor_errors; + // Telemetry follows the RUN outcome, not the vendor step alone: + // a download-phase refusal/failure exits 1 and must not report + // a successful vendoring of zero patches (scan's arms agree). crate::commands::vendor::track_outcomes_for_vendor( - vendor_errors, + has_errors, &venv, args.common.dry_run, telemetry_token, @@ -3130,7 +3003,7 @@ async fn run_get_vendored_uuid( ) .await; if args.common.json { - result["status"] = serde_json::json!(if vendor_errors { + result["status"] = serde_json::json!(if has_errors { "partial_failure" } else { "success" @@ -3139,7 +3012,7 @@ async fn run_get_vendored_uuid( serde_json::to_value(&venv).unwrap_or_else(|_| serde_json::json!({})); print_json(&result); } - i32::from(vendor_errors) + i32::from(has_errors) } Err((code, message, venv)) => { socket_patch_core::telemetry::track_patch_vendor_failed( @@ -3150,6 +3023,9 @@ async fn run_get_vendored_uuid( ) .await; if args.common.json { + // A vendor envelope built before the failure (events + // included) must reach the JSON consumer even though the + // run aborts here. if let Some(venv) = venv { result["vendor"] = serde_json::to_value(&*venv).unwrap_or_else(|_| serde_json::json!({})); @@ -3165,6 +3041,13 @@ async fn run_get_vendored_uuid( } } +/// Decode a patch view's `blobContent` (canonical, padded base64 as the API +/// produces it). Hand-rolled only because `base64` is a dev-dependency of +/// this crate today — once it is a plain dependency (it already is one of +/// `socket-patch-core`, pinned workspace-wide), this body should become +/// `base64::engine::general_purpose::STANDARD.decode(input)` with +/// `DecodeError::InvalidByte(_, b)` mapped to the +/// `Invalid base64 character: ` message below (pinned by a unit test). pub(crate) fn base64_decode(input: &str) -> Result, String> { let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut table = [255u8; 256]; @@ -4903,46 +4786,51 @@ mod tests { ); } - /// `download_patch_records` with `persist_blobs`: an uncreatable blobs - /// dir (`.socket` squatted by a regular file) is a hard `error` envelope - /// BEFORE any fetch, with no records handed to the caller. + /// `download_patch_records` with `persist_blobs` on a tree whose + /// `.socket` path is squatted by a regular file: the blobs dir is created + /// lazily, at the first blob actually persisted, so the failure surfaces + /// as the per-patch `Blob decode or write failed` after the view fetch — + /// no record handed to the caller, the squatting file left untouched. #[tokio::test] #[serial_test::serial] - async fn download_patch_records_blobs_dir_create_failure_errors_before_any_fetch() { - use wiremock::MockServer; + async fn download_patch_records_persist_blobs_unwritable_blobs_dir_is_failed_and_unrecorded() { + use wiremock::matchers::{method, path as wm_path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); let server = MockServer::start().await; + let uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let purl = "pkg:npm/covgap-blobfail@1.0.0"; + Mock::given(method("GET")) + .and(wm_path(format!("/v0/orgs/test-org/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": uuid, "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": "0".repeat(64), "afterHash": "1".repeat(64), + "blobContent": "cGF0Y2hlZAo=", + }}, + "vulnerabilities": {}, "description": "d", "license": "MIT", "tier": "free", + }))) + .mount(&server) + .await; let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join(".socket"), b"not a dir").unwrap(); - let selected = vec![mk_patch( - "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "pkg:npm/covgap-blobfail@1.0.0", - "free", - "2024-01-01", - )]; + let selected = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; let mut params = detached_params(tmp.path(), server.uri()); params.persist_blobs = true; let (code, json, records) = download_patch_records(&selected, ¶ms).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["status"], "error", "json={json}"); - assert!( - json["error"] - .as_str() - .unwrap_or_default() - .contains("blobs directory"), - "the error must name the blobs dir; json={json}" + assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!( + json["patches"][0]["error"], "Blob decode or write failed", + "json={json}" ); - assert!(records.is_empty()); assert!( - server - .received_requests() - .await - .unwrap_or_default() - .is_empty(), - "the failure must precede any fetch" + records.is_empty(), + "a blob failure must not hand back a record" ); assert_eq!( std::fs::read(tmp.path().join(".socket")).unwrap(), @@ -4997,12 +4885,10 @@ mod tests { records.is_empty(), "a blob failure must not hand back a record" ); - let blobs = tmp.path().join(".socket/blobs"); - assert!(blobs.is_dir(), "the blobs dir itself was created"); - assert_eq!( - std::fs::read_dir(&blobs).unwrap().count(), - 0, - "no blob may materialize from undecodable content" + assert!( + !tmp.path().join(".socket").exists(), + "the blobs dir is created only once a blob decodes, so undecodable \ + content must leave no `.socket/` behind at all" ); } @@ -5470,6 +5356,198 @@ mod tests { ); } + /// The nested apply inherits the caller's flags verbatim (`--lock-timeout` + /// and `--verbose` were dropped when its args were rebuilt from Default), + /// with `json`/`dry_run` forced off — one JSON document per run, and + /// agent-mode `get` ignores `--dry-run` — `silent` following the caller's + /// quiet gate, and the manifest path absolutized so apply does not + /// re-resolve it against its own `--cwd`. + #[test] + fn nested_apply_args_flow_caller_flags_and_force_a_real_quiet_apply() { + let common = GlobalArgs { + lock_timeout: Some(30), + verbose: true, + strict: true, + json: true, + dry_run: true, + api_token: Some("flag-token".into()), + ..GlobalArgs::default() + }; + let nested = nested_apply_args(&common, Path::new("proj/.socket/manifest.json"), true); + assert_eq!( + nested.lock_timeout, + Some(30), + "--lock-timeout must reach the nested apply" + ); + assert!( + nested.verbose && nested.strict, + "--verbose / --strict must flow through" + ); + assert_eq!(nested.api_token.as_deref(), Some("flag-token")); + assert!( + !nested.json && !nested.dry_run, + "the nested apply is always a real, non-JSON run" + ); + assert!(nested.silent, "silent follows the caller's quiet gate"); + assert!( + Path::new(&nested.manifest_path).is_absolute(), + "got {}", + nested.manifest_path + ); + } + + /// The engine's variant rebuilds the same shape from `DownloadParams` + + /// `DownloadRun`: the API flags via `resolved_api_overrides` (so `--org` + /// fills a missing override org), the run's lock/verbosity flags, and + /// quiet = json || silent. + #[test] + fn nested_apply_args_from_params_carry_run_flags_and_resolved_api_overrides() { + let client = ApiClient::new(socket_patch_core::api::client::ApiClientOptions { + api_url: "http://127.0.0.1:1".into(), + api_token: None, + use_public_proxy: false, + org_slug: None, + }); + let run = DownloadRun { + api_client: &client, + lock_timeout: Some(7), + verbose: true, + }; + let params = dl_params_for_org(Some("from-org".into()), None); + let nested = + nested_apply_args_from_params(¶ms, &run, Path::new(".socket/manifest.json")); + assert_eq!(nested.lock_timeout, Some(7)); + assert!(nested.verbose); + assert_eq!( + nested.org.as_deref(), + Some("from-org"), + "a missing override org must fall back to --org" + ); + assert_eq!(nested.download_mode, "diff"); + assert!(nested.silent, "json || silent params run a quiet apply"); + assert!(!nested.json && !nested.dry_run); + } + + /// The uuid path hands the engine the view it already fetched: the + /// record is served from `prefetched` with ZERO network traffic (a + /// fresh fetch could re-hit the 401 the proxy fallback recovered from), + /// and the ledger-free classification reports it `downloaded`. + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_with_prefetched_view_never_fetches() { + use wiremock::MockServer; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let server = MockServer::start().await; // trap: no mounts + let tmp = tempfile::tempdir().unwrap(); + let mut patch = patch_with_files(HashMap::from([( + "package/index.js".to_string(), + file_resp(Some(&"0".repeat(64)), Some(&"1".repeat(64))), + )])); + patch.uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".into(); + patch.purl = "pkg:npm/covgap-prefetched@1.0.0".into(); + let selected = vec![mk_patch(&patch.uuid, &patch.purl, "free", "2024-01-01")]; + let params = detached_params(tmp.path(), server.uri()); + let client = api_client_for(¶ms).await; + let prefetched = HashMap::from([(patch.uuid.clone(), patch.clone())]); + + let (code, json, records) = + download_patch_records_with(&selected, ¶ms, &client, prefetched).await; + + assert_eq!(code, 0, "json={json}"); + assert_eq!(json["downloaded"], 1, "json={json}"); + assert_eq!(json["detached"], true, "json={json}"); + assert_eq!(json["patches"][0]["action"], "downloaded", "json={json}"); + assert!( + json["patches"][0].get("oldUuid").is_none(), + "no ledger entry, no oldUuid; json={json}" + ); + assert_eq!( + records.get(&patch.purl).map(|r| r.uuid.as_str()), + Some(patch.uuid.as_str()), + "the record must be built from the prefetched view" + ); + assert!( + server + .received_requests() + .await + .unwrap_or_default() + .is_empty(), + "a prefetched view must never be fetched again" + ); + assert!( + !tmp.path().join(".socket").exists(), + "the detached download phase writes nothing" + ); + } + + /// A ledger entry at an OLDER uuid: the fetched record is `downloaded` + /// and carries `oldUuid` — the re-vendor the vendor step will perform — + /// derived from the ledger, since the vendored flows have no manifest. + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_superseding_uuid_carries_old_uuid_from_ledger() { + use wiremock::matchers::{method, path as wm_path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let server = MockServer::start().await; + let purl = "pkg:npm/covgap-supersede@1.0.0"; + let old_uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let new_uuid = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + Mock::given(method("GET")) + .and(wm_path(format!( + "/v0/orgs/test-org/patches/view/{new_uuid}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": new_uuid, "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": "0".repeat(64), "afterHash": "1".repeat(64), + "blobContent": "cGF0Y2hlZAo=", + }}, + "vulnerabilities": {}, "description": "d", "license": "MIT", "tier": "free", + }))) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + let vendor = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + std::fs::write( + vendor.join("state.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "entries": { purl: { + "ecosystem": "npm", + "basePurl": purl, + "uuid": old_uuid, + "artifact": { + "path": format!(".socket/vendor/npm/{old_uuid}/covgap-supersede-1.0.0.tgz"), + }, + "wiring": [] + }} + })) + .unwrap(), + ) + .unwrap(); + + let selected = vec![mk_patch(new_uuid, purl, "free", "2024-01-01")]; + let (code, json, records) = + download_patch_records(&selected, &detached_params(tmp.path(), server.uri())).await; + + assert_eq!(code, 0, "json={json}"); + assert_eq!(json["downloaded"], 1, "json={json}"); + assert_eq!(json["skipped"], 0, "json={json}"); + assert_eq!(json["patches"][0]["action"], "downloaded", "json={json}"); + assert_eq!(json["patches"][0]["oldUuid"], old_uuid, "json={json}"); + assert_eq!( + records.get(purl).map(|r| r.uuid.as_str()), + Some(new_uuid), + "the superseding record is what the vendor step receives" + ); + } + /// The env guard must RESTORE a variable that was set before the scrub — /// the suite depends on it not leaking scrubbed state across tests. #[test] diff --git a/crates/socket-patch-cli/tests/covgap_commands_get.rs b/crates/socket-patch-cli/tests/covgap_commands_get.rs index 3fbde91f..81d2d9fb 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_get.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_get.rs @@ -80,20 +80,37 @@ fn default_args(identifier: &str, cwd: &Path) -> GetArgs { } } +/// A `view/{uuid}` body with an arbitrary `files` map. +fn view_json(uuid: &str, purl: &str, files: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "uuid": uuid, + "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "files": files, + "vulnerabilities": {}, + "description": "covgap fixture", + "license": "MIT", + "tier": "free", + }) +} + /// `view/{uuid}` with an arbitrary `files` map. async fn mount_view_files(server: &MockServer, uuid: &str, purl: &str, files: serde_json::Value) { Mock::given(method("GET")) .and(path(format!("/v0/orgs/{ORG}/patches/view/{uuid}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "uuid": uuid, - "purl": purl, - "publishedAt": "2024-01-01T00:00:00Z", - "files": files, - "vulnerabilities": {}, - "description": "covgap fixture", - "license": "MIT", - "tier": "free", - }))) + .respond_with(ResponseTemplate::new(200).set_body_json(view_json(uuid, purl, files))) + .mount(server) + .await; +} + +/// `view/{uuid}` served exactly ONCE: the get's own fetch succeeds, and the +/// vendor step's in-memory staging — which fetches the view again — then +/// 404s, tripping the `no_local_source` staging refusal. +async fn mount_view_once(server: &MockServer, uuid: &str, purl: &str) { + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view_json(uuid, purl, good_files()))) + .up_to_n_times(1) .mount(server) .await; } @@ -121,6 +138,43 @@ fn manifest_json(cwd: &Path) -> serde_json::Value { serde_json::from_str(&body).unwrap() } +/// The vendor ledger — the ONLY record a `--mode vendored` run writes. +fn vendor_state(cwd: &Path) -> serde_json::Value { + let body = std::fs::read_to_string(cwd.join(".socket/vendor/state.json")) + .expect("the vendor ledger must be written"); + serde_json::from_str(&body).unwrap() +} + +/// A vendored get records `purl` as a DETACHED ledger entry at `uuid` with +/// the patch record embedded (vendored mode is manifest-free), and never +/// writes `.socket/manifest.json` or `.socket/blobs`. +fn assert_vendored_detached(cwd: &Path, purl: &str, uuid: &str) { + let state = vendor_state(cwd); + let entry = &state["entries"][purl]; + assert_eq!(entry["uuid"], uuid, "ledger entry for {purl}: {state}"); + assert_eq!( + entry["detached"], true, + "every get --mode vendored entry is detached: {state}" + ); + assert_eq!( + entry["record"]["uuid"], uuid, + "the embedded record is the verification source: {state}" + ); + assert_no_manifest(cwd); + assert!( + !cwd.join(".socket/blobs").exists(), + "the vendored download phase must not persist blobs" + ); +} + +/// Pre-stage an `apply.lock` so the lock can be acquired inside a directory +/// the test is about to make read-only: `acquire` opens an EXISTING file +/// without needing to create one, so the run gets past the lock and fails +/// at the write under test (the manifest) instead. +fn prestage_lock_file(socket: &Path) { + std::fs::write(socket.join("apply.lock"), b"").unwrap(); +} + async fn received_paths(server: &MockServer) -> Vec { server .received_requests() @@ -417,11 +471,8 @@ async fn get_uuid_view_without_after_hashes_fails_no_applicable_files() { .await; let tmp = tempfile::tempdir().unwrap(); - let (code, stdout, _stderr) = run_get_bin( - tmp.path(), - &server.uri(), - &[UUID, "--save-only", "--json"], - ); + let (code, stdout, _stderr) = + run_get_bin(tmp.path(), &server.uri(), &[UUID, "--save-only", "--json"]); assert_eq!(code, 1, "guardrail must exit 1; stdout={stdout}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "error", "stdout={stdout}"); @@ -453,11 +504,8 @@ async fn get_uuid_traversal_after_hash_fails_blob_write_both_modes() { let server = MockServer::start().await; mount_view_files(&server, UUID, PURL, traversal_files.clone()).await; let tmp = tempfile::tempdir().unwrap(); - let (code, stdout, _stderr) = run_get_bin( - tmp.path(), - &server.uri(), - &[UUID, "--save-only", "--json"], - ); + let (code, stdout, _stderr) = + run_get_bin(tmp.path(), &server.uri(), &[UUID, "--save-only", "--json"]); assert_eq!(code, 1, "blob failure must exit 1; stdout={stdout}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "error", "stdout={stdout}"); @@ -487,9 +535,11 @@ async fn get_uuid_traversal_after_hash_fails_blob_write_both_modes() { } } -/// A regular FILE squatting on the `.socket` path must fail the save -/// fail-closed in BOTH uuid modes (agent: blobs dir create; vendored: -/// `.socket` create) without destroying the file. +/// A regular FILE squatting on the `.socket` path must fail the run +/// fail-closed in BOTH uuid modes without destroying the file: the agent +/// save's apply-lock acquire refuses (`.socket` cannot be created), and the +/// vendored run's vendor step refuses the same way (its download phase +/// writes nothing). #[tokio::test] #[serial] async fn get_uuid_socket_path_occupied_by_file_fails_closed() { @@ -497,7 +547,7 @@ async fn get_uuid_socket_path_occupied_by_file_fails_closed() { mount_view_files(&server, UUID, PURL, good_files()).await; let uri = server.uri(); - // Agent mode (persist_blobs=true → blobs dir create fails). + // Agent mode: the lock acquire inside the save refuses. { let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join(".socket"), b"not a dir").unwrap(); @@ -512,7 +562,7 @@ async fn get_uuid_socket_path_occupied_by_file_fails_closed() { ); } - // Vendored mode (persist_blobs=false → `.socket` itself create fails). + // Vendored mode: the vendor step's `.socket` create / lock refuses. { let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join(".socket"), b"not a dir").unwrap(); @@ -522,7 +572,7 @@ async fn get_uuid_socket_path_occupied_by_file_fails_closed() { args.mode = Some(ScanMode::Vendored); args.common.vendor_source = "build".to_string(); let code = run(args).await; - assert_eq!(code, 1, "vendored save must fail when .socket is a file"); + assert_eq!(code, 1, "vendored run must fail when .socket is a file"); assert_eq!( std::fs::read(tmp.path().join(".socket")).unwrap(), b"not a dir", @@ -532,8 +582,9 @@ async fn get_uuid_socket_path_occupied_by_file_fails_closed() { } /// Manifest-write failure on the uuid save path: a read-only `.socket` dir -/// (blobs dir kept writable) must fail the run with exit 1 and leave the -/// pre-existing manifest byte-identical. +/// (blobs dir kept writable, lock file pre-staged so the acquire succeeds) +/// must fail the run with exit 1 and leave the pre-existing manifest +/// byte-identical. #[cfg(unix)] #[tokio::test] #[serial] @@ -549,6 +600,7 @@ async fn get_uuid_readonly_socket_dir_fails_manifest_write_preserving_manifest() seed_manifest_with(tmp.path(), PURL, UUID_B); let socket = tmp.path().join(".socket"); std::fs::create_dir_all(socket.join("blobs")).unwrap(); + prestage_lock_file(&socket); let before = std::fs::read_to_string(socket.join("manifest.json")).unwrap(); std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o555)).unwrap(); @@ -755,9 +807,10 @@ async fn human_package_search_api_error_reports_fetch_failure() { // (3) download_and_apply_patches engine failure branches // =========================================================================== -/// The agent download loop's no-applicable-files guardrail (never executed -/// before): a fetched view with NO recordable files is a per-patch failure — -/// exit 1, `partial_failure`, and the purl absent from the manifest. +/// The agent download loop's no-applicable-files guardrail: a fetched view +/// with NO recordable files is a per-patch failure — exit 1, +/// `partial_failure` — and, since nothing was recorded, no manifest is +/// written and the `.socket/` the lock created is pruned again. #[tokio::test] #[serial] async fn engine_no_applicable_files_is_failed_and_unrecorded() { @@ -786,15 +839,16 @@ async fn engine_no_applicable_files_is_failed_and_unrecorded() { json["patches"][0]["error"], "patch has no applicable files", "json={json}" ); + assert_no_manifest(tmp.path()); assert!( - manifest_json(tmp.path())["patches"][PURL].is_null(), - "a guardrail failure must not record the purl" + !tmp.path().join(".socket").exists(), + "a run that records nothing leaves no .socket/ behind" ); } /// The agent download loop's blob-failure branch: an invalid (traversal) /// afterHash fails the blob write — `Blob decode or write failed`, purl -/// unrecorded, and nothing written outside `.socket/blobs`. +/// unrecorded (no manifest), and nothing written outside `.socket/blobs`. #[tokio::test] #[serial] async fn engine_invalid_blob_hash_is_failed_and_unrecorded() { @@ -824,7 +878,7 @@ async fn engine_invalid_blob_hash_is_failed_and_unrecorded() { json["patches"][0]["error"], "Blob decode or write failed", "json={json}" ); - assert!(manifest_json(tmp.path())["patches"][PURL].is_null()); + assert_no_manifest(tmp.path()); assert!( !tmp.path().join("covgap-escaped").exists() && !tmp.path().join(".socket/covgap-escaped").exists(), @@ -849,11 +903,13 @@ async fn engine_view_404_is_could_not_fetch_details() { json["patches"][0]["error"], "could not fetch details", "json={json}" ); - assert!(manifest_json(tmp.path())["patches"][PURL].is_null()); + assert_no_manifest(tmp.path()); } -/// `.socket` occupied by a regular file: the engine must fail before ANY -/// fetch with an `error`-status envelope. +/// `.socket` occupied by a regular file: the engine's apply-lock acquire +/// (the first thing it does — the manifest RMW runs under the lock) must +/// fail before ANY fetch with an `error`-status envelope carrying the +/// stable `lock_io` code. #[tokio::test] #[serial] async fn engine_socket_path_occupied_fails_before_any_fetch() { @@ -869,6 +925,7 @@ async fn engine_socket_path_occupied_fails_before_any_fetch() { assert_eq!(code, 1, "json={json}"); assert_eq!(json["status"], "error", "json={json}"); + assert_eq!(json["errorCode"], "lock_io", "json={json}"); assert!( json["error"] .as_str() @@ -881,14 +938,20 @@ async fn engine_socket_path_occupied_fails_before_any_fetch() { 0, "the engine must fail before any fetch" ); + assert_eq!( + std::fs::read(tmp.path().join(".socket")).unwrap(), + b"not a dir", + "the squatting file must be left untouched" + ); } -/// Read-only `.socket` + `persist_blobs`: the blobs-dir create fails with -/// its own `error` envelope, before any fetch. +/// Read-only `.socket`: the engine cannot create its lock file, so it fails +/// closed with the `lock_io` error envelope before any fetch — nothing is +/// downloaded into a directory it could not record in. #[cfg(unix)] #[tokio::test] #[serial] -async fn engine_readonly_socket_fails_blobs_dir_create() { +async fn engine_readonly_socket_fails_closed_before_any_fetch() { use std::os::unix::fs::PermissionsExt; let server = MockServer::start().await; @@ -907,23 +970,33 @@ async fn engine_readonly_socket_fails_blobs_dir_create() { let (code, json) = download_and_apply_patches(&selected, &engine_params(tmp.path(), server.uri())).await; - std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o755)).unwrap(); + // A refused acquire prunes the EMPTY `.socket/` it could not lock (no + // residue), so there is normally nothing left to restore. + if socket.exists() { + std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o755)).unwrap(); + } assert_eq!(code, 1, "json={json}"); assert_eq!(json["status"], "error", "json={json}"); + assert_eq!(json["errorCode"], "lock_io", "json={json}"); assert!( json["error"] .as_str() .unwrap_or_default() - .contains("blobs"), - "the error must name the blobs dir; json={json}" + .contains(".socket"), + "the error must name the lock path; json={json}" ); assert_eq!(requests_containing(&server, "/patches/view/").await, 0); + assert!( + !socket.exists(), + "a refused lock leaves no empty .socket/ behind (D1: the failed acquire prunes it)" + ); } -/// Read-only `.socket` without blobs (`persist_blobs: false`) and an empty -/// selection: the loop no-ops but the manifest write still runs — and its -/// failure must surface as the `Error writing manifest` envelope. +/// Read-only `.socket` with the lock file pre-staged (so the acquire +/// succeeds) and no blobs to write (`persist_blobs: false`): the fetched +/// record's manifest write is the first write — its failure must surface as +/// the `Error writing manifest` envelope, with no manifest materializing. #[cfg(unix)] #[tokio::test] #[serial] @@ -931,18 +1004,21 @@ async fn engine_readonly_socket_fails_manifest_write() { use std::os::unix::fs::PermissionsExt; let server = MockServer::start().await; + mount_view_files(&server, UUID, PURL, good_files()).await; let tmp = tempfile::tempdir().unwrap(); let socket = tmp.path().join(".socket"); std::fs::create_dir_all(&socket).unwrap(); + prestage_lock_file(&socket); std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o555)).unwrap(); if !readonly_dir_enforced(&socket) { std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o755)).unwrap(); return; } + let selected = vec![search_result(UUID, PURL)]; let mut params = engine_params(tmp.path(), server.uri()); params.persist_blobs = false; - let (code, json) = download_and_apply_patches(&[], ¶ms).await; + let (code, json) = download_and_apply_patches(&selected, ¶ms).await; std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o755)).unwrap(); @@ -985,9 +1061,10 @@ async fn engine_uninstalled_variant_base_keeps_all_with_warning() { .as_array() .unwrap_or_else(|| panic!("keep-all fallback must surface warnings; json={json}")); assert!( - warnings - .iter() - .any(|w| w.as_str().unwrap_or_default().contains("not installed locally")), + warnings.iter().any(|w| w + .as_str() + .unwrap_or_default() + .contains("not installed locally")), "json={json}" ); } @@ -1013,7 +1090,10 @@ async fn engine_human_mode_skip_and_failed_summary() { assert_eq!(code, 1, "json={json}"); assert_eq!(json["status"], "partial_failure", "json={json}"); - assert_eq!(json["skipped"], 1, "same-uuid entry is skipped; json={json}"); + assert_eq!( + json["skipped"], 1, + "same-uuid entry is skipped; json={json}" + ); assert_eq!(json["failed"], 1, "json={json}"); assert_eq!(json["downloaded"], 0, "json={json}"); // The skipped purl's record is untouched. @@ -1094,16 +1174,20 @@ async fn engine_variant_no_hash_match_keeps_all_variants_with_note() { params.silent = false; let (code, json) = download_and_apply_patches(&selected, ¶ms).await; - assert_eq!(code, 0, "keep-all downloads must still succeed; json={json}"); + assert_eq!( + code, 0, + "keep-all downloads must still succeed; json={json}" + ); assert_eq!(json["found"], 2, "json={json}"); assert_eq!(json["downloaded"], 2, "json={json}"); let warnings = json["warnings"] .as_array() .unwrap_or_else(|| panic!("no-match fallback must warn; json={json}")); assert!( - warnings - .iter() - .any(|w| w.as_str().unwrap_or_default().contains("No release variant")), + warnings.iter().any(|w| w + .as_str() + .unwrap_or_default() + .contains("No release variant")), "json={json}" ); // Keep-all is observable in the manifest: BOTH qualified purls recorded. @@ -1158,7 +1242,10 @@ async fn engine_variant_view_fetch_error_keeps_errored_variant() { params.all_releases = false; let (code, json) = download_and_apply_patches(&selected, ¶ms).await; - assert_eq!(code, 1, "the kept variant's failure must surface; json={json}"); + assert_eq!( + code, 1, + "the kept variant's failure must surface; json={json}" + ); assert_eq!( json["found"], 1, "only the fetch-error variant may be kept (vacuous match); json={json}" @@ -1177,9 +1264,9 @@ async fn engine_variant_view_fetch_error_keeps_errored_variant() { .any(|p| p["purl"] == purl_mismatch.as_str()), "json={json}" ); - let manifest = manifest_json(tmp.path()); - assert!(manifest["patches"][&purl_erroring].is_null()); - assert!(manifest["patches"][&purl_mismatch].is_null()); + // Neither variant was recorded: the only kept one failed, so no + // manifest is written at all. + assert_no_manifest(tmp.path()); } // =========================================================================== @@ -1195,10 +1282,11 @@ fn vendored_args(identifier: &str, cwd: &Path, api_url: String) -> GetArgs { args } -/// `get --mode vendored` (search path — previously ZERO coverage): -/// scan's vendored posture end to end. The narrowed fan-out's installed -/// version is recorded in the manifest, the artifact committed, the lock -/// rewired — and NO blobs (content stays in memory). +/// `get --mode vendored` (search path): scan's vendored posture end +/// to end. The narrowed fan-out's installed version is recorded as a +/// detached ledger entry (vendored mode is manifest-free), the artifact +/// committed, the lock rewired — and NO manifest, NO blobs (content stays in +/// memory). #[tokio::test] #[serial] async fn get_ghsa_vendored_search_commits_artifact_and_wires_lock() { @@ -1212,14 +1300,11 @@ async fn get_ghsa_vendored_search_commits_artifact_and_wires_lock() { let code = run(vendored_args(GHSA, tmp.path(), server.uri())).await; assert_eq!(code, 0, "search-path vendored get should succeed"); - let manifest = manifest_json(tmp.path()); + assert_vendored_detached(tmp.path(), PURL, UUID); + let state = vendor_state(tmp.path()); assert!( - manifest["patches"][PURL].is_object(), - "the installed version must be recorded; manifest={manifest}" - ); - assert!( - manifest["patches"][PURL_V2].is_null(), - "the uninstalled version must be narrowed out; manifest={manifest}" + state["entries"][PURL_V2].is_null(), + "the uninstalled version must be narrowed out; state={state}" ); let artifact = tmp .path() @@ -1231,16 +1316,11 @@ async fn get_ghsa_vendored_search_commits_artifact_and_wires_lock() { "the patched artifact must be committed at {}", artifact.display() ); - assert!(tmp.path().join(".socket/vendor/state.json").is_file()); let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); assert!( lock.contains(".socket/vendor/npm/"), "the lock must be rewired to the vendored artifact; got:\n{lock}" ); - assert!( - !tmp.path().join(".socket/blobs").exists(), - "the vendored download phase must not persist blobs" - ); } /// The search-path vendored dry-run: classification preview only — exit 0, @@ -1274,43 +1354,53 @@ async fn get_ghsa_vendored_search_dry_run_writes_nothing() { ); } -/// `get --mode vendored --json` over a manifest already holding the -/// purl at a DIFFERENT uuid: the envelope's record is `updated` with -/// `oldUuid`, and the manifest converges on the new uuid (subprocess: the -/// envelope is the contract). +/// `get --mode vendored --json` flags for `uuid`. +fn vendored_json_args(uuid: &str) -> [&str; 6] { + [ + uuid, + "--mode", + "vendored", + "--vendor-source", + "build", + "--json", + ] +} + +/// `get --mode vendored --json` over a purl the ledger already +/// vendors at a DIFFERENT uuid (an earlier vendored get): the envelope's +/// record is `downloaded` with `oldUuid` naming the superseded entry — +/// derived from the ledger, vendored mode having no manifest — and the +/// ledger converges on the new uuid (subprocess: the envelope is the +/// contract). #[tokio::test] -async fn get_uuid_vendored_updated_action_carries_old_uuid() { +async fn get_uuid_vendored_superseding_action_carries_old_uuid() { let server = MockServer::start().await; + mount_real_view(&server, UUID_B, PURL).await; mount_real_view(&server, UUID, PURL).await; let tmp = tempfile::tempdir().unwrap(); write_project(tmp.path()); - seed_manifest_with(tmp.path(), PURL, UUID_B); - let (code, stdout, stderr) = run_get_bin( - tmp.path(), - &server.uri(), - &[ - UUID, - "--mode", - "vendored", - "--vendor-source", - "build", - "--json", - ], - ); + let (code, stdout, stderr) = + run_get_bin(tmp.path(), &server.uri(), &vendored_json_args(UUID_B)); + assert_eq!(code, 0, "first vendoring: stdout={stdout}\nstderr={stderr}"); + assert_vendored_detached(tmp.path(), PURL, UUID_B); + + let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &vendored_json_args(UUID)); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "stdout={stdout}"); assert_eq!(v["downloaded"], 1, "stdout={stdout}"); assert_eq!(v["skipped"], 0, "stdout={stdout}"); - assert_eq!(v["patches"][0]["action"], "updated", "stdout={stdout}"); + assert_eq!(v["detached"], true, "stdout={stdout}"); + assert_eq!(v["patches"][0]["action"], "downloaded", "stdout={stdout}"); assert_eq!(v["patches"][0]["oldUuid"], UUID_B, "stdout={stdout}"); assert!(v["vendor"].is_object(), "stdout={stdout}"); - assert_eq!(manifest_json(tmp.path())["patches"][PURL]["uuid"], UUID); + assert_vendored_detached(tmp.path(), PURL, UUID); } -/// Human vendored-uuid dry-run line. +/// Human vendored-uuid dry-run line (the same count flavor as the search +/// path — both identifier kinds share one preview). #[tokio::test] async fn human_vendored_uuid_dry_run_prints_line() { let server = MockServer::start().await; @@ -1326,7 +1416,7 @@ async fn human_vendored_uuid_dry_run_prints_line() { ); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); assert!( - stdout.contains("[dry-run] Would download and vendor 1 patch."), + stdout.contains("[dry-run] Would download and vendor 1 patch(es)."), "stdout={stdout}" ); assert!(!tmp.path().join(".socket").exists()); @@ -1359,21 +1449,23 @@ async fn human_vendored_search_dry_run_prints_count() { // (6) vendor-step error arms (uuid + search paths) // =========================================================================== -/// The refused-staging fixture (scan_vendor_step_error_e2e's recipe): a -/// manifest record whose view the API never serves, plus a dropped ledger -/// entry so the pre-failure reconcile produces events the error envelope -/// must carry. -const REFUSED_UUID: &str = "44444444-4444-4444-8444-444444444444"; -const REFUSED_PURL: &str = "pkg:npm/left-pad@1.3.0"; -const DROPPED_UUID: &str = "55555555-5555-4555-8555-555555555555"; -const DROPPED_PURL: &str = "pkg:npm/gone@9.9.9"; - -fn seed_vendor_error_fixture(root: &Path) { - // The unstageable manifest record (its view is never mounted). +/// Legacy on-disk state a vendored get must leave ALONE: an agent-mode +/// manifest record and a non-detached ledger entry the selection never +/// names. Vendored mode is manifest-free — its vendor step verifies exactly +/// the records the download phase fetched and never reconciles the ledger +/// against a manifest — so neither may change, even when the run fails. +const OTHER_UUID: &str = "44444444-4444-4444-8444-444444444444"; +const OTHER_PURL: &str = "pkg:npm/left-pad@1.3.0"; +const UNSELECTED_UUID: &str = "55555555-5555-4555-8555-555555555555"; +const UNSELECTED_PURL: &str = "pkg:npm/gone@9.9.9"; + +/// Seed the legacy state; returns the exact manifest and ledger bytes for +/// the byte-identical checks after the run. +fn seed_legacy_state(root: &Path) -> (String, String) { seed_manifest_with_files( root, - REFUSED_PURL, - REFUSED_UUID, + OTHER_PURL, + OTHER_UUID, serde_json::json!({ "package/index.js": { "beforeHash": git_hash(b"lp before\n"), @@ -1381,19 +1473,18 @@ fn seed_vendor_error_fixture(root: &Path) { } }), ); - // The dropped ledger entry the reconcile reverts before staging. let vendor = root.join(".socket/vendor"); std::fs::create_dir_all(&vendor).unwrap(); std::fs::write( vendor.join("state.json"), serde_json::to_vec_pretty(&serde_json::json!({ "version": 1, - "entries": { DROPPED_PURL: { + "entries": { UNSELECTED_PURL: { "ecosystem": "npm", - "basePurl": DROPPED_PURL, - "uuid": DROPPED_UUID, + "basePurl": UNSELECTED_PURL, + "uuid": UNSELECTED_UUID, "artifact": { - "path": format!(".socket/vendor/npm/{DROPPED_UUID}/gone-9.9.9.tgz"), + "path": format!(".socket/vendor/npm/{UNSELECTED_UUID}/gone-9.9.9.tgz"), }, "wiring": [] }} @@ -1401,6 +1492,23 @@ fn seed_vendor_error_fixture(root: &Path) { .unwrap(), ) .unwrap(); + ( + std::fs::read_to_string(root.join(".socket/manifest.json")).unwrap(), + std::fs::read_to_string(vendor.join("state.json")).unwrap(), + ) +} + +fn assert_legacy_state_untouched(root: &Path, manifest_before: &str, state_before: &str) { + assert_eq!( + std::fs::read_to_string(root.join(".socket/manifest.json")).unwrap(), + manifest_before, + "vendored mode never reads or writes the manifest" + ); + assert_eq!( + std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(), + state_before, + "the detached vendor step never reconciles unselected ledger entries" + ); } fn assert_vendor_error_envelope(v: &serde_json::Value) { @@ -1413,75 +1521,49 @@ fn assert_vendor_error_envelope(v: &serde_json::Value) { v["vendor"]["status"], "partialFailure", "the carried envelope's status must be demoted; envelope={v}" ); - let events = v["vendor"]["events"].as_array().unwrap_or_else(|| { - panic!("the pre-failure reconcile's events must survive the error; envelope={v}") - }); + let events = v["vendor"]["events"] + .as_array() + .unwrap_or_else(|| panic!("the carried envelope must have events[]; envelope={v}")); assert!( - events.iter().any(|e| e["purl"] == DROPPED_PURL), - "the reconcile's revert of {DROPPED_PURL} must be reported; envelope={v}" + !events.iter().any(|e| e["purl"] == UNSELECTED_PURL), + "the detached vendor step must not reconcile (revert) unselected ledger entries; envelope={v}" ); + // The download half reports the record it fetched before the abort. + assert_eq!(v["patches"][0]["action"], "downloaded", "envelope={v}"); + assert_eq!(v["detached"], true, "envelope={v}"); } /// `get --mode vendored --json` whose vendor step dies at staging: -/// exit 1, ONE JSON document with `status: error`, `error.code`, and the -/// pre-failure reconcile's vendor events carried in `result.vendor`. +/// exit 1, ONE JSON document with `status: error`, `error.code`, the +/// demoted vendor envelope carried in `result.vendor` — and the legacy +/// manifest record + unselected ledger entry byte-identical. #[tokio::test] -async fn get_uuid_vendored_vendor_step_error_carries_reconcile_events() { +async fn get_uuid_vendored_vendor_step_error_leaves_legacy_state_alone() { let server = MockServer::start().await; - mount_view_files(&server, UUID, PURL, good_files()).await; + mount_view_once(&server, UUID, PURL).await; let tmp = tempfile::tempdir().unwrap(); - seed_vendor_error_fixture(tmp.path()); + let (manifest_before, state_before) = seed_legacy_state(tmp.path()); - let (code, stdout, stderr) = run_get_bin( - tmp.path(), - &server.uri(), - &[ - UUID, - "--mode", - "vendored", - "--vendor-source", - "build", - "--json", - ], - ); + let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &vendored_json_args(UUID)); assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_vendor_error_envelope(&v); - // The download half still reports the record it saved before the abort. - assert_eq!(v["patches"][0]["action"], "added", "stdout={stdout}"); - // Non-vacuous: the reconcile really persisted (the ledger's only entry - // is gone, so save_state deleted state.json). - assert!( - !tmp.path().join(".socket/vendor/state.json").exists(), - "the reconcile must have reverted the dropped entry" - ); + assert_legacy_state_untouched(tmp.path(), &manifest_before, &state_before); } -/// The SEARCH-path flavor of the same error arm (`run_get_vendored_search`'s -/// `Err` match): a GHSA-selected vendored run must emit the identical -/// single-document error envelope. `--all-releases` keeps the uninstalled -/// fixture package out of the narrowing's way. +/// The SEARCH-path flavor of the same error arm: a GHSA-selected vendored +/// run must emit the identical single-document error envelope and leave the +/// legacy state alone. `--all-releases` keeps the uninstalled fixture +/// package out of the narrowing's way. #[tokio::test] -async fn get_search_vendored_vendor_step_error_carries_reconcile_events() { +async fn get_search_vendored_vendor_step_error_leaves_legacy_state_alone() { let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG}/patches/by-ghsa/{GHSA}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "patches": [{ - "uuid": UUID, "purl": PURL, - "publishedAt": "2024-01-01T00:00:00Z", - "description": "x", "license": "MIT", "tier": "free", - "vulnerabilities": {} - }], - "canAccessPaidPatches": false, - }))) - .mount(&server) - .await; - mount_view_files(&server, UUID, PURL, good_files()).await; + mount_ghsa_single(&server).await; + mount_view_once(&server, UUID, PURL).await; let tmp = tempfile::tempdir().unwrap(); - seed_vendor_error_fixture(tmp.path()); + let (manifest_before, state_before) = seed_legacy_state(tmp.path()); let (code, stdout, stderr) = run_get_bin( tmp.path(), @@ -1499,23 +1581,20 @@ async fn get_search_vendored_vendor_step_error_carries_reconcile_events() { assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_vendor_error_envelope(&v); - assert!( - !tmp.path().join(".socket/vendor/state.json").exists(), - "the reconcile must have reverted the dropped entry" - ); + assert_legacy_state_untouched(tmp.path(), &manifest_before, &state_before); } -/// Human flavor of the vendored-uuid run over a manifest holding OTHER -/// records: stdout carries the `Patch record saved to` block, stderr the -/// whole-manifest blast-radius `[note]` and — the vendor step failing at -/// staging — the `Error (no_local_source)` line. +/// Human flavor of the vendored-uuid run over legacy state: stderr carries +/// the engine's `[fetch]` line and — the vendor step failing at staging — +/// the `Error (no_local_source)` line; nothing claims a manifest save and no +/// whole-manifest note prints (vendored mode has no manifest scope). #[tokio::test] -async fn human_vendored_uuid_prints_record_saved_note_and_vendor_error() { +async fn human_vendored_uuid_prints_fetch_and_vendor_error_without_manifest_note() { let server = MockServer::start().await; - mount_view_files(&server, UUID, PURL, good_files()).await; + mount_view_once(&server, UUID, PURL).await; let tmp = tempfile::tempdir().unwrap(); - seed_vendor_error_fixture(tmp.path()); + let (manifest_before, state_before) = seed_legacy_state(tmp.path()); let (code, stdout, stderr) = run_get_bin( tmp.path(), @@ -1524,17 +1603,22 @@ async fn human_vendored_uuid_prints_record_saved_note_and_vendor_error() { ); assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); assert!( - stdout.contains("Patch record saved to") && stdout.contains("Added: 1"), - "the record-saved block must print; stdout={stdout}" + stderr.contains(&format!("[fetch] {PURL}")), + "the detached download phase reports the fetched record; stderr={stderr}" ); assert!( - stderr.contains("--mode vendored runs the vendor engine over the whole manifest"), - "the blast-radius note must warn about the other manifest record; stderr={stderr}" + !stdout.contains("Patch record saved to") && !stdout.contains("Added: 1"), + "nothing is saved to a manifest in vendored mode; stdout={stdout}" + ); + assert!( + !stderr.contains("whole manifest"), + "there is no whole-manifest scope to warn about; stderr={stderr}" ); assert!( stderr.contains("Error (no_local_source):"), "the vendor-step error must print with its code; stderr={stderr}" ); + assert_legacy_state_untouched(tmp.path(), &manifest_before, &state_before); } // =========================================================================== @@ -1572,7 +1656,10 @@ async fn human_uuid_paid_via_proxy_prints_upgrade_message() { ("SOCKET_TELEMETRY_DISABLED", "1"), ], ); - assert_eq!(code, 0, "paid_required is exit 0; stdout={stdout}\nstderr={stderr}"); + assert_eq!( + code, 0, + "paid_required is exit 0; stdout={stdout}\nstderr={stderr}" + ); assert!( stdout.contains("requires a paid subscription"), "stdout={stdout}" @@ -1596,7 +1683,10 @@ async fn human_uuid_not_found_prints_message() { let tmp = tempfile::tempdir().unwrap(); let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &[UUID, "--save-only"]); - assert_eq!(code, 0, "not-found is exit 0; stdout={stdout}\nstderr={stderr}"); + assert_eq!( + code, 0, + "not-found is exit 0; stdout={stdout}\nstderr={stderr}" + ); assert!( stdout.contains(&format!("No patch found with UUID: {UUID}")), "stdout={stdout}" @@ -1611,9 +1701,7 @@ async fn human_uuid_not_found_prints_message() { async fn human_cve_search_empty_prints_search_label_and_not_found() { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path_regex(format!( - r"^/v0/orgs/{ORG}/patches/by-cve/.+$" - ))) + .and(path_regex(format!(r"^/v0/orgs/{ORG}/patches/by-cve/.+$"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [], "canAccessPaidPatches": false, @@ -1757,11 +1845,8 @@ async fn human_vendored_drift_note_prints_on_stderr() { ) .unwrap(); - let (code, stdout, stderr) = run_get_bin( - tmp.path(), - &server.uri(), - &[UUID_B, "--id", "--save-only"], - ); + let (code, stdout, stderr) = + run_get_bin(tmp.path(), &server.uri(), &[UUID_B, "--id", "--save-only"]); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); assert!( stderr.contains("[note]") && stderr.contains("is vendored at patch"), @@ -1797,9 +1882,10 @@ async fn human_ghsa_all_uninstalled_advises_all_releases() { } // =========================================================================== -// (8) final coverage mop-up (2026-09): human/silent twins, vendored search -// hard-error and partial-failure arms, lock-held vendor-step errors, -// and the reconcile-failure Ok(vendor_errors=true) demotion. +// (8) final coverage mop-up (2026-09): human/silent twins, the vendored +// search path's manifest independence and partial-failure arm, +// lock-held vendor-step errors, and the detached vendor step's +// hands-off posture toward unselected ledger entries. // =========================================================================== /// `by-ghsa/{GHSA}` returning exactly the one project-fixture patch. @@ -1819,18 +1905,6 @@ async fn mount_ghsa_single(server: &MockServer) { .await; } -/// The manifest `files` map `save_patch_record` writes for the project -/// fixture's real bytes — used to seed a manifest that classifies a re-get -/// of `UUID` as `Skipped` while staying vendorable. -fn real_files_manifest_json() -> serde_json::Value { - serde_json::json!({ - "package/index.js": { - "beforeHash": git_hash(BEFORE_BYTES), - "afterHash": git_hash(AFTER_BYTES), - } - }) -} - /// Human-mode (json=false) engine run with `--silent`: the /// no-applicable-files failure is an ERROR, exempt from --silent — the /// envelope still degrades to partial_failure and the purl stays @@ -1859,10 +1933,7 @@ async fn engine_human_silent_no_applicable_files_still_fails() { assert_eq!(code, 1, "json={json}"); assert_eq!(json["status"], "partial_failure", "json={json}"); assert_eq!(json["failed"], 1, "json={json}"); - assert!( - manifest_json(tmp.path())["patches"][PURL].is_null(), - "a guardrail failure must not record the purl" - ); + assert_no_manifest(tmp.path()); } /// Human-mode (json=false) twin of the manifest-write-failure envelope: the @@ -1875,20 +1946,23 @@ async fn engine_human_readonly_socket_manifest_write_failure_still_errors() { use std::os::unix::fs::PermissionsExt; let server = MockServer::start().await; + mount_view_files(&server, UUID, PURL, good_files()).await; let tmp = tempfile::tempdir().unwrap(); let socket = tmp.path().join(".socket"); std::fs::create_dir_all(&socket).unwrap(); + prestage_lock_file(&socket); std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o555)).unwrap(); if !readonly_dir_enforced(&socket) { std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o755)).unwrap(); return; } + let selected = vec![search_result(UUID, PURL)]; let mut params = engine_params(tmp.path(), server.uri()); params.persist_blobs = false; params.json = false; params.silent = true; - let (code, json) = download_and_apply_patches(&[], ¶ms).await; + let (code, json) = download_and_apply_patches(&selected, ¶ms).await; std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o755)).unwrap(); @@ -1963,9 +2037,9 @@ async fn human_search_fixes_line_falls_back_to_advisory_id_without_cves() { } /// Human search-path vendored SUCCESS: the vendored flow commits the -/// artifact and rewires the lock in human mode too — and with no -/// pre-existing manifest the whole-manifest blast-radius note must NOT -/// print (there is nothing else the vendor step could touch). +/// artifact and rewires the lock in human mode too — and no whole-manifest +/// blast-radius note prints (the detached vendor step touches exactly the +/// selected records; there is no manifest scope). #[tokio::test] async fn human_vendored_search_success_commits_artifact_without_blast_radius_note() { let server = MockServer::start().await; @@ -1991,23 +2065,29 @@ async fn human_vendored_search_success_commits_artifact_without_blast_radius_not "the artifact must be committed; stdout={stdout}\nstderr={stderr}" ); let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); - assert!(lock.contains(".socket/vendor/npm/"), "lock must be rewired:\n{lock}"); + assert!( + lock.contains(".socket/vendor/npm/"), + "lock must be rewired:\n{lock}" + ); assert!( !stderr.contains("whole manifest"), "no blast-radius note without a pre-existing manifest; stderr={stderr}" ); } -/// Search-path vendored download hard error: a corrupt manifest fails the -/// download phase closed with ONE `status: error` JSON document, before any -/// patch view is fetched and before the vendor step could print a second -/// document. +/// A corrupt committed `.socket/manifest.json` does not block a vendored +/// get: vendored mode never reads the manifest (the download phase records +/// in memory, the vendor step verifies from the ledger), so the run +/// succeeds — ONE JSON document — and the corrupt bytes are preserved, +/// never clobbered. #[tokio::test] -async fn vendored_search_json_corrupt_manifest_is_single_error_document() { +async fn vendored_search_ignores_corrupt_manifest_and_vendors() { let server = MockServer::start().await; mount_ghsa_single(&server).await; + mount_real_view(&server, UUID, PURL).await; let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); std::fs::create_dir_all(tmp.path().join(".socket")).unwrap(); std::fs::write(tmp.path().join(".socket/manifest.json"), b"{ corrupt").unwrap(); @@ -2024,26 +2104,19 @@ async fn vendored_search_json_corrupt_manifest_is_single_error_document() { "--json", ], ); - assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); - assert_eq!(v["status"], "error", "stdout={stdout}"); - assert!( - v["error"] - .as_str() - .unwrap_or_default() - .contains("manifest"), - "the error must name the manifest read; stdout={stdout}" - ); - assert_eq!( - requests_containing(&server, "/patches/view/").await, - 0, - "the fail-closed read must precede any fetch" - ); + assert_eq!(v["status"], "success", "stdout={stdout}"); + assert_eq!(v["patches"][0]["action"], "downloaded", "stdout={stdout}"); + assert_eq!(v["vendor"]["summary"]["applied"], 1, "stdout={stdout}"); assert_eq!( std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(), b"{ corrupt", "the corrupt manifest must be preserved, never clobbered" ); + let state = vendor_state(tmp.path()); + assert_eq!(state["entries"][PURL]["uuid"], UUID, "state={state}"); + assert_eq!(state["entries"][PURL]["detached"], true, "state={state}"); } /// Search-path vendored run where one patch's download FAILS but the vendor @@ -2099,22 +2172,19 @@ async fn vendored_search_json_download_failure_with_clean_vendor_is_partial_fail async fn vendored_lock_held_vendor_step_errors_without_vendor_envelope() { use std::time::Duration; - // (a) uuid path, --json: the record is saved, then the vendor step - // refuses on the held lock. + // (a) uuid path, --json: the record is fetched into memory (the + // lock-free download phase), then the vendor step refuses on the held + // lock. { let server = MockServer::start().await; mount_view_files(&server, UUID, PURL, good_files()).await; let tmp = tempfile::tempdir().unwrap(); let socket = tmp.path().join(".socket"); std::fs::create_dir_all(&socket).unwrap(); - let _lock = - socket_patch_core::patch::apply_lock::acquire(&socket, Duration::ZERO).unwrap(); + let _lock = socket_patch_core::patch::apply_lock::acquire(&socket, Duration::ZERO).unwrap(); - let (code, stdout, stderr) = run_get_bin( - tmp.path(), - &server.uri(), - &[UUID, "--mode", "vendored", "--vendor-source", "build", "--json"], - ); + let (code, stdout, stderr) = + run_get_bin(tmp.path(), &server.uri(), &vendored_json_args(UUID)); assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "error", "stdout={stdout}"); @@ -2124,9 +2194,10 @@ async fn vendored_lock_held_vendor_step_errors_without_vendor_envelope() { "no pre-failure vendor envelope exists to carry; stdout={stdout}" ); assert_eq!( - v["patches"][0]["action"], "added", - "the record save preceded the refusal; stdout={stdout}" + v["patches"][0]["action"], "downloaded", + "the download phase preceded the refusal; stdout={stdout}" ); + assert_no_manifest(tmp.path()); } // (b) search path, --json: same refusal after the download phase. @@ -2137,8 +2208,7 @@ async fn vendored_lock_held_vendor_step_errors_without_vendor_envelope() { let tmp = tempfile::tempdir().unwrap(); let socket = tmp.path().join(".socket"); std::fs::create_dir_all(&socket).unwrap(); - let _lock = - socket_patch_core::patch::apply_lock::acquire(&socket, Duration::ZERO).unwrap(); + let _lock = socket_patch_core::patch::apply_lock::acquire(&socket, Duration::ZERO).unwrap(); let (code, stdout, stderr) = run_get_bin( tmp.path(), @@ -2168,8 +2238,7 @@ async fn vendored_lock_held_vendor_step_errors_without_vendor_envelope() { let tmp = tempfile::tempdir().unwrap(); let socket = tmp.path().join(".socket"); std::fs::create_dir_all(&socket).unwrap(); - let _lock = - socket_patch_core::patch::apply_lock::acquire(&socket, Duration::ZERO).unwrap(); + let _lock = socket_patch_core::patch::apply_lock::acquire(&socket, Duration::ZERO).unwrap(); let (code, stdout, stderr) = run_get_bin( tmp.path(), @@ -2191,17 +2260,21 @@ async fn vendored_lock_held_vendor_step_errors_without_vendor_envelope() { } } -/// Human vendored-uuid over a manifest holding the SAME purl at a DIFFERENT -/// uuid: the `Updated: 1 (replacing …)` print, then a clean vendor step — -/// exit 0 with the artifact committed. +/// Human vendored-uuid over a purl the ledger already vendors at a +/// DIFFERENT uuid: the engine's `[fetch] … (replacing …)` line names the +/// superseded short uuid (ledger-derived — there is no manifest), then a +/// clean vendor step — exit 0 with the artifact committed at the new uuid. #[tokio::test] -async fn human_vendored_uuid_update_prints_replacing_and_vendors() { +async fn human_vendored_uuid_supersede_prints_replacing_and_vendors() { let server = MockServer::start().await; + mount_real_view(&server, UUID_B, PURL).await; mount_real_view(&server, UUID, PURL).await; let tmp = tempfile::tempdir().unwrap(); write_project(tmp.path()); - seed_manifest_with(tmp.path(), PURL, UUID_B); + let (code, stdout, stderr) = + run_get_bin(tmp.path(), &server.uri(), &vendored_json_args(UUID_B)); + assert_eq!(code, 0, "first vendoring: stdout={stdout}\nstderr={stderr}"); let (code, stdout, stderr) = run_get_bin( tmp.path(), @@ -2210,10 +2283,14 @@ async fn human_vendored_uuid_update_prints_replacing_and_vendors() { ); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); assert!( - stdout.contains("Updated: 1 (replacing 22222222)"), - "the update print must carry the short old uuid; stdout={stdout}" + stderr.contains(&format!("[fetch] {PURL} (replacing 22222222)")), + "the fetch line must carry the short superseded uuid; stderr={stderr}" ); - assert_eq!(manifest_json(tmp.path())["patches"][PURL]["uuid"], UUID); + assert!( + !stdout.contains("Patch record saved to"), + "nothing is saved to a manifest in vendored mode; stdout={stdout}" + ); + assert_vendored_detached(tmp.path(), PURL, UUID); let artifact = tmp .path() .join(".socket/vendor/npm") @@ -2222,18 +2299,19 @@ async fn human_vendored_uuid_update_prints_replacing_and_vendors() { assert!(artifact.is_file(), "stdout={stdout}\nstderr={stderr}"); } -/// Human vendored-uuid re-get of an ALREADY-RECORDED uuid: the -/// `Skipped: 1 (already exists)` print, the manifest untouched, and the -/// vendor step still runs (and succeeds) over the existing record. +/// Human vendored-uuid re-get of an ALREADY-VENDORED uuid: the download +/// phase reuses the ledger's detached record (`[skip] … (already vendored)`, +/// no manifest anywhere) and the vendor step still runs (and succeeds) over +/// it — the artifact survives. #[tokio::test] -async fn human_vendored_uuid_same_uuid_skip_prints_already_exists() { +async fn human_vendored_uuid_rerun_prints_already_vendored_skip() { let server = MockServer::start().await; mount_real_view(&server, UUID, PURL).await; let tmp = tempfile::tempdir().unwrap(); write_project(tmp.path()); - seed_manifest_with_files(tmp.path(), PURL, UUID, real_files_manifest_json()); - let manifest_before = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); + let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &vendored_json_args(UUID)); + assert_eq!(code, 0, "first vendoring: stdout={stdout}\nstderr={stderr}"); let (code, stdout, stderr) = run_get_bin( tmp.path(), @@ -2242,14 +2320,14 @@ async fn human_vendored_uuid_same_uuid_skip_prints_already_exists() { ); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); assert!( - stdout.contains("Skipped: 1 (already exists)"), - "stdout={stdout}" + stderr.contains(&format!("[skip] {PURL} (already vendored)")), + "stderr={stderr}" ); - assert_eq!( - manifest_before, - std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), - "an idempotent re-get must leave the manifest bytes untouched" + assert!( + !stdout.contains("Skipped: 1 (already exists)"), + "the manifest-vocabulary summary is gone with the manifest; stdout={stdout}" ); + assert_vendored_detached(tmp.path(), PURL, UUID); let artifact = tmp .path() .join(".socket/vendor/npm") @@ -2258,33 +2336,31 @@ async fn human_vendored_uuid_same_uuid_skip_prints_already_exists() { assert!(artifact.is_file(), "stdout={stdout}\nstderr={stderr}"); } -/// Vendor step returning `Ok(vendor_errors = true)`: a dropped ledger entry -/// whose ecosystem this build cannot revert is a RECORDED-and-continued -/// reconcile failure — the run demotes to `partial_failure` (exit 1) while -/// the selected patch still vendors successfully. +/// A ledger entry the vendored get did not select — here one from an +/// ecosystem this build cannot even revert — is left alone: the detached +/// vendor step vendors exactly the selected records and never reconciles +/// the ledger against a manifest, so the run is a clean `success` and the +/// foreign entry survives untouched beside the new one. #[tokio::test] -async fn vendored_uuid_json_reconcile_revert_failure_demotes_to_partial_failure() { +async fn vendored_uuid_json_leaves_unselected_ledger_entries_alone() { let server = MockServer::start().await; mount_real_view(&server, UUID, PURL).await; let tmp = tempfile::tempdir().unwrap(); write_project(tmp.path()); - // A non-detached ledger entry the (about-to-be-written) manifest does - // not contain -> reconcile_dropped tries to revert it -> the unknown - // ecosystem fails the revert, which is recorded and continued. - let dropped_purl = "pkg:covgapeco/gone@1.0.0"; + let foreign_purl = "pkg:covgapeco/gone@1.0.0"; let vendor = tmp.path().join(".socket/vendor"); std::fs::create_dir_all(&vendor).unwrap(); std::fs::write( vendor.join("state.json"), serde_json::to_vec_pretty(&serde_json::json!({ "version": 1, - "entries": { dropped_purl: { + "entries": { foreign_purl: { "ecosystem": "covgapeco", - "basePurl": dropped_purl, - "uuid": DROPPED_UUID, + "basePurl": foreign_purl, + "uuid": UNSELECTED_UUID, "artifact": { - "path": format!(".socket/vendor/covgapeco/{DROPPED_UUID}/gone-1.0.0.tgz"), + "path": format!(".socket/vendor/covgapeco/{UNSELECTED_UUID}/gone-1.0.0.tgz"), }, "wiring": [] }} @@ -2293,25 +2369,24 @@ async fn vendored_uuid_json_reconcile_revert_failure_demotes_to_partial_failure( ) .unwrap(); - let (code, stdout, stderr) = run_get_bin( - tmp.path(), - &server.uri(), - &[UUID, "--mode", "vendored", "--vendor-source", "build", "--json"], - ); - assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); + let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &vendored_json_args(UUID)); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); - assert_eq!(v["status"], "partial_failure", "stdout={stdout}"); - assert_eq!(v["vendor"]["status"], "partialFailure", "stdout={stdout}"); + assert_eq!(v["status"], "success", "stdout={stdout}"); + assert_eq!(v["vendor"]["status"], "success", "stdout={stdout}"); let events = v["vendor"]["events"] .as_array() .unwrap_or_else(|| panic!("vendor events must be carried; stdout={stdout}")); assert!( - events - .iter() - .any(|e| e["purl"] == dropped_purl && e["errorCode"] == "revert_failed"), - "the reconcile failure must be reported; stdout={stdout}" + !events.iter().any(|e| e["purl"] == foreign_purl), + "an unselected ledger entry must not be touched or reported; stdout={stdout}" + ); + let state = vendor_state(tmp.path()); + assert_eq!( + state["entries"][foreign_purl]["uuid"], UNSELECTED_UUID, + "the foreign entry must survive: {state}" ); - // The selected patch still vendored despite the reconcile failure. + assert_vendored_detached(tmp.path(), PURL, UUID); let artifact = tmp .path() .join(".socket/vendor/npm") diff --git a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs index e49f2e88..c0d884b2 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs @@ -1318,14 +1318,24 @@ async fn bun_get_uuid_vendored_fresh_checkout_frozen_install() { "the view endpoint must have served the patch record" ); - // Manifest yes, blobs no (scan-vendored parity: content stays in memory). - let manifest: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(proj.join(".socket").join("manifest.json")).unwrap(), + // Ledger yes (a detached entry carrying the record), manifest no, blobs + // no — vendored mode is manifest-free (scan-vendored parity). + assert!( + !proj.join(".socket").join("manifest.json").exists(), + "get --mode vendored must NOT write the manifest (the ledger is the record)" + ); + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(proj.join(".socket").join("vendor").join("state.json")) + .expect("vendor ledger missing"), ) .unwrap(); assert_eq!( - manifest["patches"][purl]["uuid"], UUID, - "the manifest must record the vendored patch: {manifest}" + state["entries"][purl]["uuid"], UUID, + "the ledger must record the vendored patch: {state}" + ); + assert_eq!( + state["entries"][purl]["detached"], true, + "a get --mode vendored entry is detached: {state}" ); assert!( !proj.join(".socket").join("blobs").exists(), diff --git a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs index ee276549..673130b8 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs @@ -665,14 +665,27 @@ async fn cargo_get_uuid_vendored_fresh_checkout_locked_build() { "nested vendor envelope must report no failures: {env}" ); - // The download phase writes ONLY the manifest — no blobs on disk. - let manifest: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(proj.join(".socket/manifest.json")).unwrap()) - .unwrap(); + // The download phase writes NOTHING under .socket/ — no manifest, no + // blobs; the ledger's detached entry (written by the vendor step) is the + // record. + assert!( + !proj.join(".socket/manifest.json").exists(), + "get --mode vendored must NOT write the manifest (the ledger is the record)" + ); + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(proj.join(".socket/vendor/state.json")) + .expect("vendor ledger missing"), + ) + .unwrap(); assert_eq!( - manifest["patches"][purl.as_str()]["uuid"], + state["entries"][purl.as_str()]["uuid"], UUID, - "manifest must record the vendored patch: {manifest}" + "the ledger must record the vendored patch: {state}" + ); + assert_eq!( + state["entries"][purl.as_str()]["detached"], + true, + "a get --mode vendored entry is detached: {state}" ); assert!( !proj.join(".socket/blobs").exists(), diff --git a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs index e2fb7a81..5132ee48 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs @@ -670,15 +670,25 @@ async fn composer_get_uuid_vendored_fresh_checkout_install() { "expected an applied vendor event for {purl}: {env}" ); - // get wrote the manifest itself, keyed by the bare composer purl — and - // persisted NO blobs. - let manifest: serde_json::Value = - serde_json::from_slice(&std::fs::read(proj.join(".socket/manifest.json")).unwrap()) - .unwrap(); + // get wrote NO manifest and NO blobs: the ledger's detached entry, keyed + // by the bare composer purl, is the record. + assert!( + !proj.join(".socket/manifest.json").exists(), + "get --mode vendored must NOT write the manifest (the ledger is the record)" + ); + let state: serde_json::Value = serde_json::from_slice( + &std::fs::read(proj.join(".socket/vendor/state.json")).expect("vendor ledger missing"), + ) + .unwrap(); assert_eq!( - manifest["patches"][purl.as_str()]["uuid"], + state["entries"][purl.as_str()]["uuid"], UUID, - "manifest must record the vendored patch under the bare purl: {manifest}" + "the ledger must record the vendored patch under the bare purl: {state}" + ); + assert_eq!( + state["entries"][purl.as_str()]["detached"], + true, + "a get --mode vendored entry is detached: {state}" ); assert!( !proj.join(".socket/blobs").exists(), diff --git a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs index 1de7d1cc..b3e5bd2d 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs @@ -1064,16 +1064,27 @@ async fn gem_get_uuid_vendored_fresh_checkout_bundle_install() { "clean vendor event: {applied}" ); - // Persistence: the manifest records the patch; NO blobs land on disk - // (the committed artifact IS the patch — parity with scan --mode - // vendored). - let manifest: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(proj.join(".socket/manifest.json")).unwrap()) - .unwrap(); + // Persistence: the ledger's detached entry records the patch; NO + // manifest and NO blobs land on disk (the committed artifact IS the + // patch — parity with scan --mode vendored). + assert!( + !proj.join(".socket/manifest.json").exists(), + "get --mode vendored must NOT write the manifest (the ledger is the record)" + ); + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(proj.join(".socket/vendor/state.json")) + .expect("vendor ledger missing"), + ) + .unwrap(); assert_eq!( - manifest["patches"][purl.as_str()]["uuid"], + state["entries"][purl.as_str()]["uuid"], UUID, - "manifest must record the vendored patch: {manifest}" + "the ledger must record the vendored patch: {state}" + ); + assert_eq!( + state["entries"][purl.as_str()]["detached"], + true, + "a get --mode vendored entry is detached: {state}" ); assert!( !proj.join(".socket/blobs").exists(), diff --git a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs index 4d85f2fb..1912ca97 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs @@ -589,16 +589,25 @@ async fn go_get_uuid_vendored_fresh_checkout_offline_build() { "the patch record must be fetched via the API" ); - // Download-phase persistence: the manifest records the patch, but NO - // blobs are written (scan --mode vendored parity: content in memory). - let manifest: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(consumer.join(".socket/manifest.json")) - .expect("the manifest must be written"), + // Persistence: the ledger's detached entry records the patch; NO + // manifest and NO blobs are written (scan --mode vendored parity: + // content in memory, vendored mode is manifest-free). + assert!( + !consumer.join(".socket/manifest.json").exists(), + "get --mode vendored must NOT write the manifest (the ledger is the record)" + ); + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(consumer.join(".socket/vendor/state.json")) + .expect("vendor ledger missing"), ) .unwrap(); assert_eq!( - manifest["patches"][UPURL]["uuid"], UUID, - "manifest must record the vendored patch: {manifest}" + state["entries"][UPURL]["uuid"], UUID, + "the ledger must record the vendored patch: {state}" + ); + assert_eq!( + state["entries"][UPURL]["detached"], true, + "a get --mode vendored entry is detached: {state}" ); assert!( !consumer.join(".socket/blobs").exists(), diff --git a/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs index 60d82f47..e96ac299 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs @@ -667,23 +667,30 @@ async fn npm_get_uuid_vendored_fresh_checkout_npm_ci() { ); assert_eq!(env["vendor"]["summary"]["failed"], 0, "no failures: {env}"); - // Committed state: manifest record + artifact + ledger, NO blobs. - let manifest: serde_json::Value = - serde_json::from_slice(&std::fs::read(proj.join(".socket/manifest.json")).unwrap()) - .unwrap(); - assert_eq!( - manifest["patches"][purl.as_str()]["uuid"], - UUID, - "the manifest must record the vendored patch: {manifest}" + // Committed state: artifact + ledger (a detached entry carrying the + // record), NO manifest, NO blobs — vendored mode is manifest-free. + assert!( + !proj.join(".socket/manifest.json").exists(), + "get --mode vendored must NOT write the manifest (the ledger is the record)" ); let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); assert!( proj.join(&tgz_rel).is_file(), "vendored tarball missing at {tgz_rel}" ); - assert!( - proj.join(".socket/vendor/state.json").is_file(), - "vendor ledger missing" + let state: serde_json::Value = serde_json::from_slice( + &std::fs::read(proj.join(".socket/vendor/state.json")).expect("vendor ledger missing"), + ) + .unwrap(); + assert_eq!( + state["entries"][purl.as_str()]["uuid"], + UUID, + "the ledger must record the vendored patch: {state}" + ); + assert_eq!( + state["entries"][purl.as_str()]["detached"], + true, + "a get --mode vendored entry is detached: {state}" ); assert!( !proj.join(".socket/blobs").exists(), diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs index 7d7b9b16..431b0f48 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs @@ -453,8 +453,17 @@ async fn run_pnpm_capstone(pm: &str, driver: VendorDriver) { "get must fetch the patch record from the mocked view endpoint" ); assert!( - proj.join(".socket/manifest.json").is_file(), - "get --mode vendored must write the manifest" + !proj.join(".socket/manifest.json").exists(), + "get --mode vendored must NOT write the manifest (the ledger is the record)" + ); + let state: serde_json::Value = serde_json::from_slice( + &std::fs::read(proj.join(".socket/vendor/state.json")).expect("vendor ledger missing"), + ) + .unwrap(); + assert_eq!( + state["entries"][purl.as_str()]["detached"], + true, + "a get --mode vendored entry is detached: {state}" ); assert!( !proj.join(".socket/blobs").exists(), diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs index 28febfb4..1be328d9 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs @@ -656,14 +656,23 @@ async fn uv_get_uuid_vendored_fresh_checkout_frozen_offline() { ); assert_vendored_applied(&env["vendor"]); - // get wrote the manifest itself, keyed by the suite's bare pypi purl — - // and persisted NO blobs. - let manifest: serde_json::Value = - serde_json::from_slice(&std::fs::read(proj.join(".socket/manifest.json")).unwrap()) - .unwrap(); + // get wrote NO manifest and NO blobs: the ledger's detached entry, keyed + // by the suite's bare pypi purl, is the record. + assert!( + !proj.join(".socket/manifest.json").exists(), + "get --mode vendored must NOT write the manifest (the ledger is the record)" + ); + let state: serde_json::Value = serde_json::from_slice( + &std::fs::read(proj.join(".socket/vendor/state.json")).expect("vendor ledger missing"), + ) + .unwrap(); + assert_eq!( + state["entries"][PURL]["uuid"], UUID, + "the ledger must record the vendored patch under the bare purl: {state}" + ); assert_eq!( - manifest["patches"][PURL]["uuid"], UUID, - "manifest must record the vendored patch under the bare purl: {manifest}" + state["entries"][PURL]["detached"], true, + "a get --mode vendored entry is detached: {state}" ); assert!( !proj.join(".socket/blobs").exists(), diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs index 0f7d5da2..e449af16 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs @@ -415,8 +415,17 @@ async fn run_berry_capstone(driver: VendorDriver) { "get must fetch the patch record from the mocked view endpoint" ); assert!( - proj.join(".socket/manifest.json").is_file(), - "get --mode vendored must write the manifest" + !proj.join(".socket/manifest.json").exists(), + "get --mode vendored must NOT write the manifest (the ledger is the record)" + ); + let state: serde_json::Value = serde_json::from_slice( + &std::fs::read(proj.join(".socket/vendor/state.json")).expect("vendor ledger missing"), + ) + .unwrap(); + assert_eq!( + state["entries"][purl.as_str()]["detached"], + true, + "a get --mode vendored entry is detached: {state}" ); assert!( !proj.join(".socket/blobs").exists(), diff --git a/crates/socket-patch-cli/tests/get_modes_e2e.rs b/crates/socket-patch-cli/tests/get_modes_e2e.rs index 87387960..166b66d3 100644 --- a/crates/socket-patch-cli/tests/get_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/get_modes_e2e.rs @@ -283,9 +283,11 @@ async fn get_uuid_hosted_json_envelope_nests_redirect() { // --------------------------------------------------------------------------- /// `get --mode vendored --json` (local `--vendor-source build`, so no -/// vendoring-service mocks): get's record envelope with `applied` DROPPED -/// (save-only posture — the nested apply structurally never ran) and scan's -/// full vendor `Envelope` nested under `vendor` (camelCase keys/statuses). +/// vendoring-service mocks): the detached download envelope — the same +/// vocabulary scan's `download` block uses (`downloaded`, `patches[].action: +/// "downloaded"`, `detached: true`), no `applied` (nothing is applied in +/// place) — with scan's full vendor `Envelope` nested under `vendor` +/// (camelCase keys/statuses). #[tokio::test] async fn get_uuid_vendored_json_envelope_nests_vendor() { let server = MockServer::start().await; @@ -315,13 +317,21 @@ async fn get_uuid_vendored_json_envelope_nests_vendor() { assert_eq!(v["status"], "success", "envelope={v}"); assert_eq!(v["found"], 1, "envelope={v}"); assert_eq!(v["downloaded"], 1, "envelope={v}"); + assert_eq!(v["skipped"], 0, "envelope={v}"); + assert_eq!(v["failed"], 0, "envelope={v}"); + assert_eq!( + v["detached"], true, + "vendored get is the detached download phase; got {v}" + ); assert_eq!(v["patches"][0]["purl"], PURL1, "envelope={v}"); assert_eq!(v["patches"][0]["uuid"], UUID1, "envelope={v}"); - assert_eq!(v["patches"][0]["action"], "added", "envelope={v}"); + assert_eq!( + v["patches"][0]["action"], "downloaded", + "the detached vocabulary: the record was fetched into memory, not added to a manifest; got {v}" + ); assert!( v.get("applied").is_none(), - "vendored mode must DROP the top-level `applied` key (the nested \ - apply never runs under the save-only download posture); got {v}" + "vendored mode has no top-level `applied` key (nothing is applied in place); got {v}" ); // The nested vendor Envelope: the unified `--json` shape the standalone @@ -348,8 +358,8 @@ async fn get_uuid_vendored_json_envelope_nests_vendor() { ); // Anti-vacuity: the envelope reflects the real vendored result — - // committed artifact + rewired lock, and NO blobs (scan parity: - // patch content stays in memory). + // committed artifact + rewired lock, NO blobs and NO manifest (vendored + // mode is manifest-free: the ledger's detached entry is the record). let artifact = tmp .path() .join(".socket/vendor/npm") @@ -359,6 +369,10 @@ async fn get_uuid_vendored_json_envelope_nests_vendor() { let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); assert!(lock.contains(".socket/vendor/npm/"), "lock:\n{lock}"); assert!(!tmp.path().join(".socket/blobs").exists()); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "get --mode vendored must not write .socket/manifest.json" + ); } // --------------------------------------------------------------------------- @@ -693,6 +707,10 @@ async fn get_vendored_then_hosted_takes_over_cleanly() { lock.contains(".socket/vendor/npm/"), "precondition: lock vendored-wired; got:\n{lock}" ); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "precondition: vendored get wrote no manifest — the takeover unwinds a detached entry" + ); // Step 2: hosted via get — the takeover. let (code, stdout, stderr) = run_get( diff --git a/crates/socket-patch-cli/tests/in_process_get_modes.rs b/crates/socket-patch-cli/tests/in_process_get_modes.rs index b1d81e3c..57919cc6 100644 --- a/crates/socket-patch-cli/tests/in_process_get_modes.rs +++ b/crates/socket-patch-cli/tests/in_process_get_modes.rs @@ -368,9 +368,11 @@ async fn get_uuid_hosted_dry_run_writes_nothing() { // --------------------------------------------------------------------------- /// `get --mode vendored` must produce scan's vendored result: the -/// manifest record, the committed artifact under `.socket/vendor/npm//`, -/// the vendor ledger, the lock rewired to the `file:` artifact — and NO -/// `.socket/blobs` (the download phase holds content in memory). +/// committed artifact under `.socket/vendor/npm//`, the vendor ledger +/// carrying the record as a detached entry, the lock rewired to the `file:` +/// artifact — and NO `.socket/manifest.json` and NO `.socket/blobs` +/// (vendored mode is manifest-free; the download phase holds content in +/// memory and the ledger is the only record). #[tokio::test] #[serial] async fn get_uuid_vendored_commits_artifact_and_wires_lock() { @@ -385,10 +387,13 @@ async fn get_uuid_vendored_commits_artifact_and_wires_lock() { let code = socket_patch_cli::commands::get::run(args).await; assert_eq!(code, 0, "get --mode vendored should succeed"); - assert_eq!( - read_manifest_purls(tmp.path()), - vec![PURL1.to_string()], - "the manifest must record the vendored patch" + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "vendored mode must NOT write the manifest (the ledger is the record)" + ); + assert!( + read_manifest_purls(tmp.path()).is_empty(), + "no manifest record may exist for the vendored patch" ); let artifact = tmp .path() @@ -400,9 +405,20 @@ async fn get_uuid_vendored_commits_artifact_and_wires_lock() { "the patched artifact must be committed at {}", artifact.display() ); - assert!( - tmp.path().join(".socket/vendor/state.json").is_file(), - "the vendor ledger must be written" + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")) + .expect("the vendor ledger must be written"), + ) + .unwrap(); + let entry = &state["entries"][PURL1]; + assert_eq!(entry["uuid"], UUID1, "ledger entry: {state}"); + assert_eq!( + entry["detached"], true, + "every get --mode vendored entry is detached: {state}" + ); + assert_eq!( + entry["record"]["uuid"], UUID1, + "the embedded record is the verification source: {state}" ); let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); assert!( @@ -415,9 +431,10 @@ async fn get_uuid_vendored_commits_artifact_and_wires_lock() { ); } -/// Re-running the same vendored get is an idempotent no-op: the manifest -/// insert is gated on `changed` and the vendor engine lands on its benign -/// `already_vendored` skip. The artifact survives. +/// Re-running the same vendored get is an idempotent no-op: the download +/// phase reuses the ledger's detached record (`skipped`, no fetch) and the +/// vendor engine lands on its benign `already_vendored` skip. The artifact +/// survives and no manifest appears. #[tokio::test] #[serial] async fn get_uuid_vendored_rerun_is_idempotent() { @@ -451,6 +468,10 @@ async fn get_uuid_vendored_rerun_is_idempotent() { lock_after_first, lock_after_second, "the re-run must leave the lock byte-identical" ); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "an idempotent vendored re-run still writes no manifest" + ); } /// `--dry-run` on vendored mode is a classification preview: no download, no From 201e66ce8610f4c7c6ac24e7409f12691fc33a9e Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 19:52:25 -0400 Subject: [PATCH 16/44] refactor(cli/rollback): lock-guard cleanup, one vendored-revert loop, ledger-only remove fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit repair: drop the post-drop apply.lock unlink and its pre-lock mkdir (the lock guard now creates .socket/ on acquire and removes apply.lock plus an emptied .socket/ on drop, dry-run included); scan the lockfile vendor references once per run and reuse the manifest-missing gate's result; pass run()'s API client into repair_inner (one token notice, not two); print the blob-cleanup status through core's format_cleanup_result. rollback: thread the manifest and the vendor-ownership key set (loaded once under the lock) into rollback_patches_inner instead of re-reading both; the "No patches found in manifest" line prints only for an unscoped run with no work in ANY leg; never mkdir .socket/blobs (the download creates it on demand; a file squatting on the path is still refused); precompile the path-scope globs once; collapse is_local_redirect / exclude_local_redirects into before_blob_gate_manifest; one manifest clone and an inlined `vendored: []`; surface the engine's ownership-not-restored advisory as a run warning; reword the vendored prompt clause and the pre-lock comment for the new lock semantics. remove: existence probes before the lock (no pre-lock ledger parses); the manifest-less path is no longer gated on entry.detached — any ledger entry without a manifest record is removable through the ledger; the ledger-only path honors --preserve-state and drift-keeps exactly like the manifest path (both now share one revert loop); the vendor ledger is loaded once and the nested rollback receives it; the manifest removal is computed once (no third read, dead post-write not_found arm gone); the two hosted-unwind copies share unwind_hosted; the "(not installed)" line prints only when something was not installed. A zero-patch manifest is never deleted (D4). Shared helpers (rollback.rs): revert_vendor_entry + VendorRevertStep (the silent classifier both commands map to their own vocabulary), sweep_unused_artifacts (the blobs/diffs/packages GC pass), and vendored_purl_keys_of; remove.rs: vendor_entry_matches / vendor_entry_covers_purl (the ledger matching triple). Tests: apply.lock gone after every command, no .socket/ residue after a full hosted/vendored reversal, blobs/ removed when emptied, repair's lock warning retired, repair --dry-run also removes a leftover lock; new ledger-only remove tests for --preserve-state, drift-keeps and entries without the detached flag; the detached-drift silent test now pins exit 1. Co-Authored-By: Claude Fable 5.1 --- .../socket-patch-cli/src/commands/remove.rs | 1689 ++++++++--------- .../socket-patch-cli/src/commands/repair.rs | 213 +-- .../socket-patch-cli/src/commands/rollback.rs | 586 +++--- .../tests/cli_remove_silent.rs | 35 +- ...ge_fix_rollback_ecosystem_scoped_replay.rs | 4 + .../tests/covgap_commands_remove.rs | 216 ++- .../tests/covgap_commands_repair.rs | 35 +- .../tests/covgap_commands_rollback.rs | 38 +- .../in_process_remove_repair_lifecycle.rs | 49 +- .../tests/in_process_rollback_hosted.rs | 19 +- .../tests/repair_invariants.rs | 32 +- 11 files changed, 1655 insertions(+), 1261 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index e6c4916b..abaa7c1c 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -1,21 +1,27 @@ use clap::Args; use socket_patch_core::api::client::get_api_client_with_overrides; -use socket_patch_core::manifest::cleanup_blobs::{ - cleanup_unused_archives, cleanup_unused_blobs, format_cleanup_result, -}; +use socket_patch_core::manifest::cleanup_blobs::format_cleanup_result; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::patch::redirect::{ + load_redirect_state, persist_redirect_state, RedirectState, REDIRECT_STATE_REL, +}; use socket_patch_core::telemetry::{track_patch_remove_failed, track_patch_removed}; use socket_patch_core::utils::purl::{purl_matches_identifier, strip_purl_qualifiers}; -use socket_patch_core::vendor::{load_state, save_state, VendorEntry, VendorState}; +use socket_patch_core::vendor::{ + load_state, RevertOpts, VendorEntry, VendorState, VENDOR_STATE_REL, +}; +use std::collections::HashSet; use std::path::Path; use std::time::Duration; use super::get::short_uuid; -use super::rollback::{all_files_already_original, pin_before_hash_blobs, rollback_patches}; -use super::vendor::{dispatch_revert_one, dispatch_revert_one_opts}; +use super::rollback::{ + all_files_already_original, pin_before_hash_blobs, revert_vendor_entry, + rollback_patches_inner, run_hosted_leg, sweep_unused_artifacts, vendored_purl_keys_of, + HostedLegOutcome, InnerSelection, VendorRevertStep, +}; use crate::args::{apply_env_toggles, GlobalArgs}; -use socket_patch_core::vendor::RevertOpts; use crate::commands::lock_cli::acquire_or_emit; use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status}; use crate::output::confirm; @@ -32,28 +38,76 @@ pub(crate) fn patch_matches(purl: &str, uuid: &str, identifier: &str) -> bool { } } -/// Vendor-ledger entries matching a remove identifier: by ledger key or -/// base purl (mirroring the manifest matching). Sorted by key for +/// A vendor-ledger entry matches a remove/rollback identifier by its +/// ledger key or by its base purl (mirroring the manifest matching; a +/// golang key is case-encoded while `base_purl` holds the decoded spelling +/// users type). +pub(crate) fn vendor_entry_matches(key: &str, entry: &VendorEntry, identifier: &str) -> bool { + patch_matches(key, &entry.uuid, identifier) + || patch_matches(&entry.base_purl, &entry.uuid, identifier) +} + +/// Does the ledger entry under `key` own the manifest purl `purl`? The +/// ledger-key / qualifier-stripped-key / base-purl triple — the per-entry +/// form of the set core's `vendored_purl_keys` flattens. +pub(crate) fn vendor_entry_covers_purl(key: &str, entry: &VendorEntry, purl: &str) -> bool { + key == purl + || strip_purl_qualifiers(key) == strip_purl_qualifiers(purl) + || entry.base_purl == strip_purl_qualifiers(purl) +} + +/// Vendor-ledger entries matching a remove identifier, sorted by key for /// deterministic event order. fn vendor_entries_matching(state: &VendorState, identifier: &str) -> Vec<(String, VendorEntry)> { let mut matches: Vec<(String, VendorEntry)> = state .entries .iter() - .filter(|(key, entry)| { - patch_matches(key, &entry.uuid, identifier) - || patch_matches(&entry.base_purl, &entry.uuid, identifier) - }) + .filter(|(key, entry)| vendor_entry_matches(key, entry, identifier)) .map(|(k, e)| (k.clone(), e.clone())) .collect(); matches.sort_by(|a, b| a.0.cmp(&b.0)); matches } +/// Hosted redirect records matching a remove identifier, sorted. +fn hosted_records_matching(state: &RedirectState, identifier: &str) -> Vec { + let mut matches: Vec = state + .records + .iter() + .filter(|(purl, rec)| patch_matches(purl, &rec.uuid, identifier)) + .map(|(purl, _)| purl.clone()) + .collect(); + matches.sort(); + matches +} + +/// Drop every manifest entry matching `identifier` except `exclusions` +/// (drift-kept vendored purls, whose record must survive with their +/// vendored state). Returns the removed purls, sorted. +fn remove_matching( + manifest: &mut PatchManifest, + identifier: &str, + exclusions: &HashSet, +) -> Vec { + let mut removed: Vec = manifest + .patches + .iter() + .filter(|(purl, patch)| { + patch_matches(purl, &patch.uuid, identifier) && !exclusions.contains(*purl) + }) + .map(|(purl, _)| purl.clone()) + .collect(); + removed.sort(); + for purl in &removed { + manifest.patches.remove(purl); + } + removed +} + /// Emit the `not_found` envelope (or stderr line) for an identifier that -/// matched nothing, tracking the failure. Both the pre-flight match and -/// the post-rollback manifest mutation share this exit path. `dry_run` -/// rides the envelope so a preview's failures still report `dryRun: true` -/// (matching apply's error envelopes and remove's own success envelope). +/// matched nothing in any store, tracking the failure. `dry_run` rides the +/// envelope so a preview's failures still report `dryRun: true` (matching +/// apply's error envelopes and remove's own success envelope). async fn emit_not_found( json: bool, dry_run: bool, @@ -146,40 +200,28 @@ pub async fn run(args: RemoveArgs) -> i32 { get_api_client_with_overrides(args.common.api_client_overrides()).await; let api_token = telemetry_client.api_token().cloned(); let org_slug = telemetry_client.org_slug().cloned(); + let loud = !args.common.json && !args.common.silent; let manifest_path = args.common.resolved_manifest_path(); - + let cwd = &args.common.cwd; + + // ── state discovery ───────────────────────────────────────────────── + // A ledger-only project (vendored mode keeps its records in the vendor + // ledger, hosted mode in the redirect ledger — neither writes a + // manifest) proceeds manifest-less: `remove` is the per-purl exit path + // for those entries. Only cheap EXISTENCE probes run before the lock — + // they decide the truly-empty error path, which never locks (a bare + // project must not see `.socket/` created and pruned again). The + // stores themselves are loaded under the lock below. let manifest_missing = tokio::fs::metadata(&manifest_path).await.is_err(); if manifest_missing { - // A pure-detached project (`scan --vendor --detached`) has a - // vendor ledger but deliberately no manifest, and `remove` is the - // per-purl exit path for its entries — so a missing manifest is - // only fatal when the ledger has no detached match either. An - // unreadable ledger falls through to the error: nothing is - // mutated on that path. - let has_detached_match = load_state(&args.common.cwd) + let vendor_ledger_exists = tokio::fs::metadata(cwd.join(VENDOR_STATE_REL)) .await - .map(|s| { - vendor_entries_matching(&s, &args.identifier) - .iter() - .any(|(_, e)| e.detached) - }) - .unwrap_or(false); - // Hosted redirects likewise live outside the manifest (the - // redirect ledger is the only persistence), so a hosted-only - // project's `remove` proceeds manifest-less too. - let has_hosted_match = socket_patch_core::patch::redirect::load_redirect_state( - &args.common.cwd, - ) - .await - .ok() - .flatten() - .is_some_and(|st| { - st.records - .iter() - .any(|(purl, rec)| patch_matches(purl, &rec.uuid, &args.identifier)) - }); - if !has_detached_match && !has_hosted_match { + .is_ok(); + let redirect_ledger_exists = tokio::fs::metadata(cwd.join(REDIRECT_STATE_REL)) + .await + .is_ok(); + if !vendor_ledger_exists && !redirect_ledger_exists { emit_error_envelope( args.common.json, args.common.dry_run, @@ -191,10 +233,11 @@ pub async fn run(args: RemoveArgs) -> i32 { } // Serialize against concurrent socket-patch runs targeting the - // same `.socket/` directory. Note: `rollback_patches` (which - // `remove` calls into) does NOT acquire the lock — that would - // self-deadlock — so the outer remove invocation holds it for - // both the rollback and the manifest mutation. + // same `.socket/` directory. The nested in-place rollback does NOT + // acquire the lock (that would self-deadlock): this one guard covers + // the rollback, the ledger reverts and the manifest mutation, and its + // drop removes `apply.lock` (and an emptied `.socket/`) on every exit + // path. let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); let _lock = match acquire_or_emit( socket_dir, @@ -207,9 +250,9 @@ pub async fn run(args: RemoveArgs) -> i32 { Err(code) => return code, }; - // Read manifest to show what will be removed and confirm. On the - // pure-detached path there is no manifest to read or mutate; an empty - // view routes the flow to the detached-only removal below. + // Read the manifest to show what will be removed and confirm. On the + // ledger-only path there is no manifest to read or mutate; an empty + // view routes the flow to the ledger-only removals below. let manifest = if manifest_missing { PatchManifest::new() } else { @@ -248,26 +291,30 @@ pub async fn run(args: RemoveArgs) -> i32 { .filter(|(purl, patch)| patch_matches(purl, &patch.uuid, &args.identifier)) .collect(); + // The vendor ledger, loaded ONCE under the lock: it scopes the nested + // rollback (vendor-owned purls are not restored in place) and drives + // the vendored leg. An unreadable ledger degrades to "nothing vendored" + // for the rollback and fails closed at the vendored leg — exactly where + // the run is about to mutate vendored state. + let vendor_state_result = load_state(cwd).await; + if matching.is_empty() { - // Detached vendored patches (`scan --vendor --detached`) have no - // manifest entry — `remove` is their per-purl exit path (alongside - // `vendor --revert`'s all-at-once). An unreadable ledger falls - // through to `not_found`: nothing is mutated on that path. - let detached_state = load_state(&args.common.cwd).await.unwrap_or_default(); - let detached: Vec<(String, VendorEntry)> = - vendor_entries_matching(&detached_state, &args.identifier) - .into_iter() - .filter(|(_, e)| e.detached) - .collect(); - if !detached.is_empty() { - return remove_detached_only( - &args, - detached, - detached_state, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + // Ledger-only entries (vendored mode keeps no manifest record) — + // `remove` is their per-purl exit path (alongside `vendor + // --revert`'s all-at-once). An unreadable ledger falls through to + // `not_found`: nothing is mutated on that path. + if let Ok(state) = vendor_state_result { + let ledger_matches = vendor_entries_matching(&state, &args.identifier); + if !ledger_matches.is_empty() { + return remove_ledger_only( + &args, + ledger_matches, + state, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; + } } // Hosted-only patches likewise have no manifest entry — the @@ -275,16 +322,8 @@ pub async fn run(args: RemoveArgs) -> i32 { // their per-purl exit path (the unwind IS the removal). An // unreadable ledger falls through to `not_found`: nothing is // mutated on that path. - if let Ok(Some(redirect_state)) = - socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd).await - { - let mut hosted_matches: Vec = redirect_state - .records - .iter() - .filter(|(purl, rec)| patch_matches(purl, &rec.uuid, &args.identifier)) - .map(|(purl, _)| purl.clone()) - .collect(); - hosted_matches.sort(); + if let Ok(Some(redirect_state)) = load_redirect_state(cwd).await { + let hosted_matches = hosted_records_matching(&redirect_state, &args.identifier); if !hosted_matches.is_empty() { return remove_hosted_only( &args, @@ -312,7 +351,7 @@ pub async fn run(args: RemoveArgs) -> i32 { // to multiple manifest entries (PyPI release variants), make the // blast radius explicit so the user understands why a single // `remove pkg:pypi/foo@1.0` is removing several variants. - if !args.common.json && !args.common.silent { + if loud { if args.identifier.starts_with("pkg:") && !args.identifier.contains('?') && matching.len() > 1 @@ -348,13 +387,20 @@ pub async fn run(args: RemoveArgs) -> i32 { format!("Remove {} patch(es) and rollback files?", matching.len()) }; if !args.common.dry_run && !confirm(&prompt, true, args.common.yes, args.common.json) { - if !args.common.json && !args.common.silent { + if loud { println!("Removal cancelled."); } return 0; } - // First, rollback the patch if not skipped + // ── nested in-place rollback ──────────────────────────────────────── + // Vendor-owned purls are excluded from the in-place restore (the + // vendored leg below reverts them); an unreadable ledger degrades to + // "nothing vendored" here and fails closed at that leg. + let vendored_keys: HashSet = vendor_state_result + .as_ref() + .map(vendored_purl_keys_of) + .unwrap_or_default(); let mut rollback_count = 0; // In-scope manifest entries the nested rollback SKIPPED because the // crawler found no installed package (`RollbackOutcome::not_installed`, @@ -367,22 +413,30 @@ pub async fn run(args: RemoveArgs) -> i32 { // (no rollback ran, so nothing is known — semantics unchanged). let mut rollback_not_installed: Vec = Vec::new(); if !args.skip_rollback { - if !args.common.json && !args.common.silent { + if loud { println!("Rolling back patch before removal..."); } - match rollback_patches( - &args.common, - &manifest_path, - Some(&args.identifier), - args.common.dry_run, - args.common.json || args.common.silent, - None, + // The delegation runs muted under --json/--silent (the envelope, + // or the silence, is ours) and unscoped by --ecosystems (the + // identifier IS the scope). + let delegated = GlobalArgs { + silent: args.common.json || args.common.silent, + ecosystems: None, + ..args.common.clone() + }; + match rollback_patches_inner( + &delegated, + socket_dir, + &manifest, + &vendored_keys, + InnerSelection::Identifier(Some(&args.identifier)), + Some(&telemetry_client), ) .await { - Ok((success, results, _vendored_skipped, not_installed)) => { - rollback_not_installed = not_installed; - if !success { + Ok(outcome) => { + rollback_not_installed = outcome.not_installed; + if !outcome.success { track_patch_remove_failed( "Rollback failed during patch removal", api_token.as_deref(), @@ -390,15 +444,16 @@ pub async fn run(args: RemoveArgs) -> i32 { ) .await; emit_error_envelope( - args.common.json, - args.common.dry_run, + args.common.json, + args.common.dry_run, "rollback_failed", "Rollback failed during patch removal. Use --skip-rollback to remove from manifest without restoring files.".to_string(), ); return 1; } - rollback_count = results + rollback_count = outcome + .results .iter() .filter(|r| r.success && !r.files_rolled_back.is_empty()) .count(); @@ -408,19 +463,22 @@ pub async fn run(args: RemoveArgs) -> i32 { // `Iterator::all` over an empty slice is vacuously `true`, // so a zero-file (or not-installed) result would otherwise // be miscounted as "already in original state". - let already_original = results + let already_original = outcome + .results .iter() .filter(|r| r.success && all_files_already_original(r)) .count(); - if !args.common.json && !args.common.silent { + if loud { if rollback_count > 0 { println!("Rolled back {rollback_count} package(s)"); } if already_original > 0 { println!("{already_original} package(s) already in original state"); } - if results.is_empty() { + // Vendor-owned targets say nothing here: the vendored + // leg below reports each key's own disposition. + if !rollback_not_installed.is_empty() { println!("No packages found to rollback (not installed)"); } println!(); @@ -429,8 +487,8 @@ pub async fn run(args: RemoveArgs) -> i32 { Err(e) => { track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; emit_error_envelope( - args.common.json, - args.common.dry_run, + args.common.json, + args.common.dry_run, "rollback_failed", format!("Error during rollback: {e}. Use --skip-rollback to remove from manifest without restoring files."), ); @@ -439,6 +497,7 @@ pub async fn run(args: RemoveArgs) -> i32 { } } + // ── vendored leg ──────────────────────────────────────────────────── // Vendor-owned purls: removing the patch means reverting the vendoring // (restore the recorded lockfile fragments, delete the artifact, drop // the ledger entry) — otherwise the lockfile keeps consuming the @@ -452,7 +511,7 @@ pub async fn run(args: RemoveArgs) -> i32 { // would leave wired. `--skip-rollback` ("don't touch my tree") skips // the revert too — the wiring stays until the next `vendor` run // reconciles the then-dropped entry. - let mut vendor_state = match load_state(&args.common.cwd).await { + let mut vendor_state = match vendor_state_result { Ok(s) => s, Err(e) => { emit_error_envelope( @@ -465,28 +524,17 @@ pub async fn run(args: RemoveArgs) -> i32 { } }; let vendored_matches = vendor_entries_matching(&vendor_state, &args.identifier); - // Reverted entries ride the final envelope as Removed/vendor_reverted - // events WITHOUT bumping summary.removed (that count stays "manifest - // entries deleted", same as the blob-sweep carrier). Retained/warning - // events are Skipped and bump normally. - let mut vendor_reverted_events: Vec = Vec::new(); - let mut vendor_skipped_events: Vec = Vec::new(); - // Ledger keys whose revert drift-kept: their manifest entries are - // EXCLUDED from the removal below (dropping a record whose vendored - // state survives would hand `vendor`'s reconcile a revert with no - // backing record). - let mut vendor_kept_purls: std::collections::HashSet = - std::collections::HashSet::new(); + let mut vendor_leg = RemoveVendorLeg::default(); if !vendored_matches.is_empty() { if args.skip_rollback { for (key, _) in &vendored_matches { - if !args.common.json && !args.common.silent { + if loud { eprintln!( "Note: {key} is vendored; --skip-rollback leaves the vendor wiring and \ artifact in place (the next `vendor` run will reconcile-revert it)." ); } - vendor_skipped_events.push( + vendor_leg.skipped.push( PatchEvent::new(PatchAction::Skipped, key.clone()).with_reason( "vendor_state_retained", "vendor wiring and artifact left in place (--skip-rollback)", @@ -494,134 +542,38 @@ pub async fn run(args: RemoveArgs) -> i32 { ); } } else { - for (key, entry) in &vendored_matches { - let outcome = dispatch_revert_one_opts( - entry, - &args.common.cwd, - RevertOpts { - dry_run: args.common.dry_run, - keep_artifact: args.preserve_state, - }, - ) - .await; - for w in &outcome.warnings { - if !args.common.json && !args.common.silent { - eprintln!("Warning ({}): {}", w.code, w.detail); - } - vendor_skipped_events.push( - PatchEvent::new(PatchAction::Skipped, key.clone()) - .with_reason(w.code, w.detail.clone()), - ); - } - if !outcome.success { - track_patch_remove_failed( - "vendor revert failed during patch removal", - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; - emit_error_envelope( - args.common.json, - args.common.dry_run, - "vendor_revert_failed", - format!( - "could not revert vendoring for {key}: {}. The manifest was not \ - modified.", - outcome.error.as_deref().unwrap_or("unknown error") - ), - ); - return 1; - } - if outcome.kept_artifact { - // Drift-keep: the lock changed under us and the backend - // left both the wiring and the artifact alone. Per the - // RevertOutcome contract the ledger entry stays — and so - // must the manifest entry, or `vendor`'s reconcile would - // re-revert an entry whose backing record is gone. - if !args.common.json && !args.common.silent { - eprintln!( - "Kept vendored state for {key}: lockfile wiring drifted; \ - its manifest entry was kept too" - ); - } - vendor_kept_purls.insert(key.clone()); - vendor_skipped_events.push( - PatchEvent::new(PatchAction::Skipped, key.clone()).with_reason( - "vendor_revert_kept", - "lockfile wiring drifted; vendored state and manifest entry kept", - ), - ); - continue; - } - if args.common.dry_run { - if !args.common.json && !args.common.silent { - if args.preserve_state { - println!("Would unwire vendoring for {key} (artifact preserved)"); - } else { - println!("Would revert vendoring for {key}"); - } - } - // Dry-run flips the would-be Removed to a Verified - // preview, same convention as apply/vendor/repair. - vendor_reverted_events.push( - PatchEvent::new(PatchAction::Verified, key.clone()).with_reason( - "vendor_would_revert", - "vendoring would be reverted on remove", - ), - ); - continue; - } - if args.preserve_state { - // Entry kept byte-identical: its already-reverted wiring - // records replay as silent no-ops later (the liveness - // contract) and a re-vendor re-wires from the live lock. - if !args.common.json && !args.common.silent { - println!("Unwired vendoring for {key} (artifact preserved)"); - } - vendor_skipped_events.push( - PatchEvent::new(PatchAction::Skipped, key.clone()).with_reason( - "vendor_state_preserved", - "lockfile unwired; artifact and ledger entry preserved \ - (--preserve-state)", - ), - ); - continue; - } - vendor_state.entries.remove(key); - if let Err(e) = save_state(&args.common.cwd, &vendor_state).await { - emit_error_envelope( - args.common.json, - args.common.dry_run, - "vendor_state_write_failed", - e.to_string(), - ); - return 1; - } - if !args.common.json && !args.common.silent { - println!("Reverted vendoring for {key}"); - } - vendor_reverted_events.push( - PatchEvent::new(PatchAction::Removed, key.clone()) - .with_reason("vendor_reverted", "vendoring reverted on remove"), - ); - } + let keys: Vec = vendored_matches.iter().map(|(k, _)| k.clone()).collect(); + vendor_leg = match revert_vendored_matches( + &args, + &keys, + &mut vendor_state, + api_token.as_deref(), + org_slug.as_deref(), + true, + ) + .await + { + Ok(leg) => leg, + Err(code) => return code, + }; } } - // Hosted-redirect leg: an identifier can also (or only) match hosted - // records in the redirect ledger. Supported ecosystems (cargo, - // npm-family) unwind per-purl; when the identifier covers EVERY record - // the whole-ledger replay serves the rest; otherwise unsupported - // targets fail closed BEFORE the manifest mutation. A corrupt ledger - // skips the leg with a warning (the identifier may still match other - // stores). `--skip-rollback` leaves hosted wiring untouched, like the - // vendor wiring above; `--preserve-state` still unwinds — hosted has - // no preservable local state. + // ── hosted leg ────────────────────────────────────────────────────── + // An identifier can also (or only) match hosted records in the + // redirect ledger. Supported ecosystems (cargo, npm-family) unwind + // per-purl; when the identifier covers EVERY record the whole-ledger + // replay serves the rest; otherwise unsupported targets fail closed + // BEFORE the manifest mutation. A corrupt ledger skips the leg with a + // warning (the identifier may still match other stores). + // `--skip-rollback` leaves hosted wiring untouched, like the vendor + // wiring above; `--preserve-state` still unwinds — hosted has no + // preservable local state. let mut hosted_reverted_events: Vec = Vec::new(); if !args.skip_rollback { - match socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd).await { + match load_redirect_state(cwd).await { Err(e) => { - if !args.common.silent && !args.common.json { + if loud { eprintln!( "Warning: cannot read the hosted redirect ledger ({e}); hosted \ redirects were not examined" @@ -630,83 +582,20 @@ pub async fn run(args: RemoveArgs) -> i32 { } Ok(None) => {} Ok(Some(mut redirect_state)) => { - let mut hosted_matches: Vec = redirect_state - .records - .iter() - .filter(|(purl, rec)| patch_matches(purl, &rec.uuid, &args.identifier)) - .map(|(purl, _)| purl.clone()) - .collect(); - hosted_matches.sort(); + let hosted_matches = hosted_records_matching(&redirect_state, &args.identifier); if !hosted_matches.is_empty() { - let replay_eligible = redirect_state - .records - .keys() - .all(|p| hosted_matches.contains(p)); - let before = - (redirect_state.edits.len(), redirect_state.records.len()); - let leg = super::rollback::run_hosted_leg( - &args.common, - &hosted_matches, - &mut redirect_state, - replay_eligible, - ) - .await; - // Persist FIRST, failure or not: per-purl reverts flush - // lockfile writes as they go, so an early error return - // without persisting would strand already-reverted - // purls' records in the on-disk ledger (lockfiles and - // ledger desynced; `list`/VEX attest dead wiring). - if !args.common.dry_run - && (redirect_state.edits.len(), redirect_state.records.len()) != before - { - if let Err(e) = - socket_patch_core::patch::redirect::persist_redirect_state( - &args.common.cwd, - &redirect_state, - ) + let leg = + match unwind_hosted(&args.common, &hosted_matches, &mut redirect_state) .await { - emit_error_envelope( - args.common.json, - args.common.dry_run, - "hosted_revert_failed", - format!("failed to persist the hosted redirect ledger: {e}"), - ); - return 1; - } - } - if !leg.unsupported.is_empty() { - emit_error_envelope( - args.common.json, - args.common.dry_run, - "hosted_revert_unsupported", - format!( - "no per-purl hosted-redirect revert exists for: {}. Run an \ - unscoped `socket-patch rollback` to unwind ALL hosted \ - redirects, or re-run `scan --mode hosted` to normalize. \ - The manifest was not modified.", - leg.unsupported.join(", ") - ), - ); - return 1; - } - if let Some((what, why)) = leg.failed.first() { - emit_error_envelope( - args.common.json, - args.common.dry_run, - "hosted_revert_failed", - format!( - "could not unwind hosted redirect for {what}: {why}. The \ - manifest was not modified." - ), - ); - return 1; - } - if args.preserve_state - && !leg.reverted.is_empty() - && !args.common.silent - && !args.common.json - { + Ok(leg) => leg, + Err(err) => { + let (code, msg) = hosted_unwind_error(err, true); + emit_error_envelope(args.common.json, args.common.dry_run, code, msg); + return 1; + } + }; + if args.preserve_state && !leg.reverted.is_empty() && loud { eprintln!( "Note: hosted redirects have no preservable local state; \ their ledger records were dropped with the unwound wiring." @@ -730,327 +619,513 @@ pub async fn run(args: RemoveArgs) -> i32 { } } - // Manifest entries excluded from the removal: drift-kept vendored - // purls (kept ledger key / base-purl / qualifier-stripped matching). - let excluded_kept: std::collections::HashSet = matching + // ── manifest mutation ─────────────────────────────────────────────── + // Drift-kept vendored purls are EXCLUDED from the removal (dropping a + // record whose vendored state survives would hand `vendor`'s reconcile + // a revert with no backing record); the matching mirrors the + // ledger-key / base-purl / qualifier-stripped triple. + let excluded_kept: HashSet = matching .iter() .map(|(purl, _)| (*purl).clone()) .filter(|purl| { - vendor_kept_purls.iter().any(|key| { - key == purl - || strip_purl_qualifiers(key) == strip_purl_qualifiers(purl) - || vendored_matches - .iter() - .find(|(k, _)| k == key) - .is_some_and(|(_, e)| e.base_purl == strip_purl_qualifiers(purl)) + vendor_leg.kept.iter().any(|key| { + vendored_matches + .iter() + .find(|(k, _)| k == key) + .is_some_and(|(k, e)| vendor_entry_covers_purl(k, e, purl)) }) }) .collect(); - // Now remove from manifest. On --dry-run the removal is simulated in - // memory (manifest untouched) so the blob sweep below can still - // preview against the post-removal reference set. `--preserve-state` - // deliberately touches neither the manifest nor the blobs. - let removal = if args.preserve_state { - Ok((Vec::new(), manifest.clone())) - } else if args.common.dry_run { - let removed: Vec = matching - .iter() - .map(|(purl, _)| (*purl).clone()) - .filter(|p| !excluded_kept.contains(p)) - .collect(); - let mut simulated = manifest.clone(); - simulated.patches.retain(|purl, _| !removed.contains(purl)); - Ok((removed, simulated)) + // The removal is computed ONCE, from the manifest read under the lock + // (nothing rewrites it in between); on --dry-run it stays in memory so + // the blob sweep below can still preview against the post-removal + // reference set. `--preserve-state` deliberately touches neither the + // manifest nor the blobs. An emptied manifest stays on disk as + // `{"patches": {}}` — it carries the setup block and the + // empty-vs-missing exit codes of `list`/`apply`/`repair`. + let mut updated_manifest = manifest.clone(); + let removed = if args.preserve_state { + Vec::new() } else { - remove_patch_from_manifest(&args.identifier, &manifest_path, &excluded_kept).await + remove_matching(&mut updated_manifest, &args.identifier, &excluded_kept) }; - match removal { - Ok((removed, updated_manifest)) => { - if removed.is_empty() && !args.preserve_state { - if !excluded_kept.is_empty() { - // Every matching entry was drift-kept: the remove did - // not happen. NOT not_found — the identifier matched; - // partialFailure keeps `summary.removed` honest at 0. - let msg = format!( - "{}: every matching entry's vendored state drift-kept; nothing was \ - removed (re-run `scan --mode vendored` to normalize, then remove)", - args.identifier - ); - track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()) - .await; - if args.common.json { - let mut env = Envelope::new(Command::Remove); - env.dry_run = args.common.dry_run; - for ev in vendor_skipped_events { - env.record(ev); - } - env.status = Status::PartialFailure; - env.error = Some(EnvelopeError::new("vendor_revert_kept", msg)); - println!("{}", env.to_pretty_json()); - } else { - eprintln!("Error: {msg}"); - } - return 1; - } - emit_not_found( - args.common.json, - args.common.dry_run, - &args.identifier, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; - return 1; + if removed.is_empty() && !args.preserve_state { + // Every matching entry was drift-kept (the identifier matched, so + // this is the only way the removal can be empty): the remove did + // not happen. NOT not_found; partialFailure keeps `summary.removed` + // honest at 0. + let msg = format!( + "{}: every matching entry's vendored state drift-kept; nothing was \ + removed (re-run `scan --mode vendored` to normalize, then remove)", + args.identifier + ); + track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; + if args.common.json { + let mut env = Envelope::new(Command::Remove); + env.dry_run = args.common.dry_run; + for ev in vendor_leg.skipped { + env.record(ev); } + env.status = Status::PartialFailure; + env.error = Some(EnvelopeError::new("vendor_revert_kept", msg)); + println!("{}", env.to_pretty_json()); + } else { + eprintln!("Error: {msg}"); + } + return 1; + } + if !args.common.dry_run && !removed.is_empty() { + if let Err(e) = write_manifest(&manifest_path, &updated_manifest).await { + let msg = e.to_string(); + track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; + emit_error_envelope(args.common.json, args.common.dry_run, "remove_failed", msg); + return 1; + } + } - if !args.common.json && !args.common.silent { - if args.preserve_state { - println!( - "Manifest entries and vendored artifacts preserved \ - (--preserve-state); re-apply with `socket-patch apply` or \ - `socket-patch vendor`." - ); - } else if args.common.dry_run { - println!("Would remove {} patch(es) from manifest:", removed.len()); - } else { - println!("Removed {} patch(es) from manifest:", removed.len()); - } - for purl in &removed { - println!(" - {purl}"); - } - if args.common.dry_run { - println!("\nDry run — nothing was changed."); - } else if !args.preserve_state { - println!("\nManifest updated at {}", manifest_path.display()); + if loud { + if args.preserve_state { + println!( + "Manifest entries and vendored artifacts preserved \ + (--preserve-state); re-apply with `socket-patch apply` or \ + `socket-patch vendor`." + ); + } else if args.common.dry_run { + println!("Would remove {} patch(es) from manifest:", removed.len()); + } else { + println!("Removed {} patch(es) from manifest:", removed.len()); + } + for purl in &removed { + println!(" - {purl}"); + } + if args.common.dry_run { + println!("\nDry run — nothing was changed."); + } else if !args.preserve_state { + println!("\nManifest updated at {}", manifest_path.display()); + } + } + + // FAIL-CLOSED (crawler-miss guard): dropped entries whose nested + // rollback was skipped as not-installed were never actually reverted, + // and the miss may be a crawler layout gap with the patched bytes + // still on disk. Sweeping their beforeHash blobs would permanently + // destroy the only local revert data, so they are pinned into the + // sweep's keep set; a warning event + stderr line surface each one. + // Entries genuinely rolled back (or already original) appear in the + // rollback's results, never here. + let retained_not_installed: Vec<&str> = rollback_not_installed + .iter() + .map(String::as_str) + .filter(|p| removed.iter().any(|r| r == p)) + .collect(); + if loud && !retained_not_installed.is_empty() { + eprintln!( + "\nWarning: {} removed patch(es) had no matching installed package, so \ + their rollback was skipped (a crawler miss would look the same); their \ + revert data (beforeHash blobs) was kept in .socket/blobs:", + retained_not_installed.len() + ); + for purl in &retained_not_installed { + eprintln!(" - {purl}"); + } + } + + // ── GC ────────────────────────────────────────────────────────────── + // Clean up unused blobs (previewed, not deleted, on --dry-run). The + // reference manifest is the post-removal manifest PLUS one synthetic + // keep record per retained entry above: `cleanup_unused_blobs` keeps + // only afterHash blobs (beforeHash blobs are normally re-downloadable + // on demand), so each pinned before-hash is listed in an afterHash + // slot. Scoped to REVERT data only — the retained entries' real + // afterHash blobs stay sweepable like any other orphan. + let mut cleanup_reference = updated_manifest; + let pinned_purls: Vec = retained_not_installed + .iter() + .map(|p| (*p).to_string()) + .collect(); + pin_before_hash_blobs(&mut cleanup_reference, &manifest, pinned_purls.iter()); + let mut blobs_removed = 0; + let mut archives_removed = 0; + if !args.preserve_state { + let sweep = sweep_unused_artifacts(&cleanup_reference, socket_dir, args.common.dry_run).await; + match sweep.blobs { + Ok(r) => { + blobs_removed = r.blobs_removed; + if loud && r.blobs_removed > 0 { + println!("\n{}", format_cleanup_result(&r, args.common.dry_run)); } } - - // FAIL-CLOSED (crawler-miss guard): dropped entries whose nested - // rollback was skipped as not-installed were never actually - // reverted, and the miss may be a crawler layout gap with the - // patched bytes still on disk. Sweeping their beforeHash blobs - // would permanently destroy the only local revert data, so they - // are pinned into the sweep's keep set; a warning event + stderr - // line surface each one. Entries genuinely rolled back (or - // already original) appear in `results`, never here. - let retained_not_installed: Vec<&str> = rollback_not_installed - .iter() - .map(String::as_str) - .filter(|p| removed.iter().any(|r| r == p)) - .collect(); - if !args.common.json && !args.common.silent && !retained_not_installed.is_empty() { - eprintln!( - "\nWarning: {} removed patch(es) had no matching installed package, so \ - their rollback was skipped (a crawler miss would look the same); their \ - revert data (beforeHash blobs) was kept in .socket/blobs:", - retained_not_installed.len() - ); - for purl in &retained_not_installed { - eprintln!(" - {purl}"); + Err(e) => { + // repair's posture: warn and continue, never fatal. + if loud { + eprintln!("Warning: blob cleanup failed: {e}"); } } - - // Clean up unused blobs (previewed, not deleted, on --dry-run). - // The reference manifest is the post-removal manifest PLUS one - // synthetic keep record per retained entry above: - // `cleanup_unused_blobs` keeps only afterHash blobs (beforeHash - // blobs are normally re-downloadable on demand), so each pinned - // before-hash is listed in an afterHash slot. Scoped to REVERT - // data only — the retained entries' real afterHash blobs stay - // sweepable like any other orphan. - let mut cleanup_reference = updated_manifest.clone(); - let pinned_purls: Vec = retained_not_installed - .iter() - .map(|p| (*p).to_string()) - .collect(); - pin_before_hash_blobs(&mut cleanup_reference, &manifest, pinned_purls.iter()); - let blobs_path = socket_dir.join("blobs"); - let mut blobs_removed = 0; - let mut archives_removed = 0; - if !args.preserve_state { - match cleanup_unused_blobs(&cleanup_reference, &blobs_path, args.common.dry_run) - .await - { - Ok(cleanup_result) => { - blobs_removed = cleanup_result.blobs_removed; - if !args.common.json - && !args.common.silent - && cleanup_result.blobs_removed > 0 - { - println!( - "\n{}", - format_cleanup_result(&cleanup_result, args.common.dry_run) - ); - } - } - Err(e) => { - // repair's posture: warn and continue, never fatal. - if !args.common.silent && !args.common.json { - eprintln!("Warning: blob cleanup failed: {e}"); - } + } + // Diff/package archives use the same manifest-uuid keep rule + // (parity with repair and scan --prune). + for (dir, result) in [("diffs", sweep.diffs), ("packages", sweep.packages)] { + match result { + Ok(r) => archives_removed += r.blobs_removed, + Err(e) => { + if loud { + eprintln!("Warning: {dir} cleanup failed: {e}"); } } - // Diff/package archives use the same manifest-uuid keep rule - // (parity with repair and scan --prune). - for dir in ["diffs", "packages"] { - match cleanup_unused_archives( - &cleanup_reference, - &socket_dir.join(dir), - args.common.dry_run, + } + } + } + + if args.common.json { + let mut env = Envelope::new(Command::Remove); + env.dry_run = args.common.dry_run; + // Dry-run flips would-be Removed events to Verified previews (the + // apply/vendor/repair convention), so `summary.removed` stays + // "manifest entries actually deleted" — zero on a preview. + let removal_action = if args.common.dry_run { + PatchAction::Verified + } else { + PatchAction::Removed + }; + // The crawler-miss warnings first (the rollback skip is the + // earliest outcome chronologically). Recorded — they bump + // `summary.skipped` like the vendor retained/warning events — and + // additive: runs with every target genuinely rolled back (or + // already original) emit none, leaving existing consumers + // byte-identical output. + for purl in &retained_not_installed { + let mut kept: Vec = manifest + .patches + .get(*purl) + .map(|record| { + record + .files + .values() + .filter(|info| !info.before_hash.is_empty()) + .map(|info| info.before_hash.clone()) + .collect() + }) + .unwrap_or_default(); + kept.sort(); + kept.dedup(); + env.record( + PatchEvent::new(PatchAction::Skipped, (*purl).to_string()) + .with_reason( + "rollback_not_installed", + "rollback skipped: no installed package found (a crawler \ + miss would look the same); beforeHash blobs kept in \ + .socket/blobs so a later rollback/repair can still restore", ) - .await - { - Ok(r) => archives_removed += r.blobs_removed, - Err(e) => { - if !args.common.silent && !args.common.json { - eprintln!("Warning: {dir} cleanup failed: {e}"); - } + .with_details(serde_json::json!({ "beforeBlobsRetained": kept })), + ); + } + // Chronological: the vendor revert ran before the manifest + // mutation. Reverted events bypass `record` so `summary.removed` + // stays equal to the number of manifest entries deleted (same rule + // as the blob-sweep carrier below); retained/warning Skipped + // events bump `summary.skipped` normally. + for ev in vendor_leg.reverted { + env.events.push(ev); + } + // Hosted unwinds likewise bypass `record` — summary.removed stays + // "manifest entries deleted". + for ev in hosted_reverted_events { + env.events.push(ev); + } + for ev in vendor_leg.skipped { + env.record(ev); + } + // One Removed event per purl whose manifest entry was deleted + // (Verified on --dry-run). + for purl in &removed { + env.record(PatchEvent::new(removal_action, purl.clone())); + } + // One artifact-level Removed event carrying the blob-sweep and + // rollback counts. Emitted whenever either is non-zero so the + // `rolledBack` count is still reported even when no blobs happened + // to be swept (e.g. the removed patch's afterHash blobs are still + // referenced elsewhere). + // + // Pushed directly rather than via `env.record`: this is a + // purl-less metadata carrier, not a removed manifest entry. The + // per-purl events above are the authoritative patch-removal + // count, so `summary.removed` must equal the number of entries + // deleted (`removed.len()`) — letting this carrier bump `removed` + // too would double-count, reporting e.g. `removed: 2` for a + // single-patch removal that happened to sweep an orphan blob. + // Consumers read the blob/rollback totals from `details`, never + // from `summary.removed`. + if blobs_removed > 0 || rollback_count > 0 || archives_removed > 0 { + env.events + .push(PatchEvent::artifact(removal_action).with_details( + serde_json::json!({ + "blobsRemoved": blobs_removed, + "rolledBack": rollback_count, + "archivesRemoved": archives_removed, + }), + )); + } + // Any drift-kept entry means part of the requested removal did + // NOT happen: the run is a partialFailure (exit 1) even when + // sibling entries were removed. + if !vendor_leg.kept.is_empty() { + env.status = Status::PartialFailure; + } + println!("{}", env.to_pretty_json()); + } + + if !args.common.dry_run { + track_patch_removed(removed.len(), api_token.as_deref(), org_slug.as_deref()).await; + } + if vendor_leg.kept.is_empty() { + 0 + } else { + // Errors print even under --silent; the per-key drift-keep lines + // above are gated, so name the outcome once here. + if !args.common.json { + eprintln!( + "Error: {} matching entr{} drift-kept (vendored state and manifest \ + record retained); re-run `scan --mode vendored` to normalize, then \ + remove again", + vendor_leg.kept.len(), + if vendor_leg.kept.len() == 1 { "y was" } else { "ies were" } + ); + } + 1 + } +} + +/// The vendored leg's envelope material, collected by +/// [`revert_vendored_matches`]. +#[derive(Default)] +struct RemoveVendorLeg { + /// `Removed`/`vendor_reverted` events (`Verified`/`vendor_would_revert` + /// on --dry-run), one per reverted key. + reverted: Vec, + /// Backend warnings, drift-keeps and preserved entries — `Skipped` + /// events. + skipped: Vec, + /// Ledger keys whose revert drift-kept: entry, artifact and any + /// manifest record stay. + kept: Vec, + /// Entries actually reverted and dropped from the ledger (wet runs). + reverted_count: usize, +} + +/// The vendored-revert loop shared by the manifest-backed and ledger-only +/// remove paths: revert each key (see `revert_vendor_entry` for the +/// drift-keep / `--preserve-state` / dry-run classification), print the +/// human lines, collect the envelope events. The first hard failure — a +/// backend refusal or a ledger write failure — emits its error envelope +/// and returns `Err(1)`; `manifest_backed` callers' messages add that the +/// manifest was not touched. +async fn revert_vendored_matches( + args: &RemoveArgs, + keys: &[String], + state: &mut VendorState, + api_token: Option<&str>, + org_slug: Option<&str>, + manifest_backed: bool, +) -> Result { + let loud = !args.common.json && !args.common.silent; + let opts = RevertOpts { + dry_run: args.common.dry_run, + keep_artifact: args.preserve_state, + }; + let mut leg = RemoveVendorLeg::default(); + for key in keys { + let result = revert_vendor_entry(&args.common.cwd, key, state, opts).await; + for w in &result.warnings { + if loud { + eprintln!("Warning ({}): {}", w.code, w.detail); + } + leg.skipped.push( + PatchEvent::new(PatchAction::Skipped, key.clone()) + .with_reason(w.code, w.detail.clone()), + ); + } + match result.step { + VendorRevertStep::Missing => {} + VendorRevertStep::Failed(why) => { + track_patch_remove_failed( + "vendor revert failed during patch removal", + api_token, + org_slug, + ) + .await; + emit_error_envelope( + args.common.json, + args.common.dry_run, + "vendor_revert_failed", + format!( + "could not revert vendoring for {key}: {why}{}", + if manifest_backed { + ". The manifest was not modified." + } else { + "" } - } - } + ), + ); + return Err(1); } - - if args.common.json { - let mut env = Envelope::new(Command::Remove); - env.dry_run = args.common.dry_run; - // Dry-run flips would-be Removed events to Verified - // previews (the apply/vendor/repair convention), so - // `summary.removed` stays "manifest entries actually - // deleted" — zero on a preview. - let removal_action = if args.common.dry_run { - PatchAction::Verified + VendorRevertStep::Kept => { + // Drift-keep: the lock changed under us and the backend + // left both the wiring and the artifact alone. Per the + // RevertOutcome contract the ledger entry stays — and so + // must any manifest entry, or `vendor`'s reconcile would + // re-revert an entry whose backing record is gone. + let (note, detail) = if manifest_backed { + ( + "; its manifest entry was kept too", + "lockfile wiring drifted; vendored state and manifest entry kept", + ) } else { - PatchAction::Removed + ( + "", + "lockfile wiring drifted; vendored state and ledger entry kept", + ) }; - // The crawler-miss warnings first (the rollback skip is the - // earliest outcome chronologically). Recorded — they bump - // `summary.skipped` like the vendor retained/warning events - // — and additive: runs with every target genuinely rolled - // back (or already original) emit none, leaving existing - // consumers byte-identical output. - for purl in &retained_not_installed { - let mut kept: Vec = manifest - .patches - .get(*purl) - .map(|record| { - record - .files - .values() - .filter(|info| !info.before_hash.is_empty()) - .map(|info| info.before_hash.clone()) - .collect() - }) - .unwrap_or_default(); - kept.sort(); - kept.dedup(); - env.record( - PatchEvent::new(PatchAction::Skipped, (*purl).to_string()) - .with_reason( - "rollback_not_installed", - "rollback skipped: no installed package found (a crawler \ - miss would look the same); beforeHash blobs kept in \ - .socket/blobs so a later rollback/repair can still restore", - ) - .with_details(serde_json::json!({ "beforeBlobsRetained": kept })), - ); - } - // Chronological: the vendor revert ran before the manifest - // mutation. Reverted events bypass - // `record` so `summary.removed` stays equal to the number - // of manifest entries deleted (same rule as the blob-sweep - // carrier below); retained/warning Skipped events bump - // `summary.skipped` normally. - for ev in vendor_reverted_events { - env.events.push(ev); - } - // Hosted unwinds likewise bypass `record` — summary.removed - // stays "manifest entries deleted". - for ev in hosted_reverted_events { - env.events.push(ev); + if loud { + eprintln!("Kept vendored state for {key}: lockfile wiring drifted{note}"); } - for ev in vendor_skipped_events { - env.record(ev); - } - // One Removed event per purl whose manifest entry was - // deleted (Verified on --dry-run). - for purl in &removed { - env.record(PatchEvent::new(removal_action, purl.clone())); - } - // One artifact-level Removed event carrying the - // blob-sweep and rollback counts. Emitted whenever either - // is non-zero so the `rolledBack` count is still reported - // even when no blobs happened to be swept (e.g. the removed - // patch's afterHash blobs are still referenced elsewhere). - // - // Pushed directly rather than via `env.record`: this is a - // purl-less metadata carrier, not a removed manifest entry. - // The per-purl events above are the authoritative - // patch-removal count, so `summary.removed` must equal the - // number of entries deleted (`removed.len()`) — letting this - // carrier bump `removed` too would double-count, reporting - // e.g. `removed: 2` for a single-patch removal that happened - // to sweep an orphan blob. Consumers read the blob/rollback - // totals from `details`, never from `summary.removed`. - if blobs_removed > 0 || rollback_count > 0 || archives_removed > 0 { - env.events - .push(PatchEvent::artifact(removal_action).with_details( - serde_json::json!({ - "blobsRemoved": blobs_removed, - "rolledBack": rollback_count, - "archivesRemoved": archives_removed, - }), - )); - } - // Any drift-kept entry means part of the requested removal - // did NOT happen: the run is a partialFailure (exit 1) even - // when sibling entries were removed. - if !vendor_kept_purls.is_empty() { - env.status = Status::PartialFailure; + leg.kept.push(key.clone()); + leg.skipped.push( + PatchEvent::new(PatchAction::Skipped, key.clone()) + .with_reason("vendor_revert_kept", detail), + ); + } + VendorRevertStep::WouldRevert => { + if loud { + if args.preserve_state { + println!("Would unwire vendoring for {key} (artifact preserved)"); + } else { + println!("Would revert vendoring for {key}"); + } } - println!("{}", env.to_pretty_json()); + // Dry-run flips the would-be Removed to a Verified preview, + // same convention as apply/vendor/repair. + leg.reverted.push( + PatchEvent::new(PatchAction::Verified, key.clone()).with_reason( + "vendor_would_revert", + "vendoring would be reverted on remove", + ), + ); } - - if !args.common.dry_run { - track_patch_removed(removed.len(), api_token.as_deref(), org_slug.as_deref()).await; + VendorRevertStep::Preserved => { + if loud { + println!("Unwired vendoring for {key} (artifact preserved)"); + } + leg.skipped.push( + PatchEvent::new(PatchAction::Skipped, key.clone()).with_reason( + "vendor_state_preserved", + "lockfile unwired; artifact and ledger entry preserved \ + (--preserve-state)", + ), + ); } - if vendor_kept_purls.is_empty() { - 0 - } else { - // Errors print even under --silent; the per-key drift-keep - // lines above are gated, so name the outcome once here. - if !args.common.json { - eprintln!( - "Error: {} matching entr{} drift-kept (vendored state and manifest \ - record retained); re-run `scan --mode vendored` to normalize, then \ - remove again", - vendor_kept_purls.len(), - if vendor_kept_purls.len() == 1 { "y was" } else { "ies were" } - ); + VendorRevertStep::Reverted => { + if loud { + println!("Reverted vendoring for {key}"); } - 1 + leg.reverted_count += 1; + leg.reverted.push( + PatchEvent::new(PatchAction::Removed, key.clone()) + .with_reason("vendor_reverted", "vendoring reverted on remove"), + ); + } + VendorRevertStep::LedgerWriteFailed(e) => { + emit_error_envelope( + args.common.json, + args.common.dry_run, + "vendor_state_write_failed", + e, + ); + return Err(1); } } - Err(e) => { - track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; - emit_error_envelope(args.common.json, args.common.dry_run, "remove_failed", e); - 1 + } + Ok(leg) +} + +/// Why a hosted unwind stopped. Each caller renders its own message (the +/// manifest-backed path adds that the manifest was not touched). +enum HostedUnwindError { + /// The ledger could not be persisted after the reverts flushed. + Persist(String), + /// Scoped targets whose ecosystem has no per-purl hosted revert. + Unsupported(Vec), + /// A per-purl revert (or the whole-ledger replay) refused. + Failed { what: String, why: String }, +} + +/// Unwind the hosted redirect records in `hosted_matches` and persist the +/// ledger — FIRST, failure or not: the per-purl reverts flush lockfile +/// writes as they go, so an early error return without persisting would +/// strand already-reverted purls' records in the on-disk ledger (lockfiles +/// and ledger desynced; `list`/VEX attest dead wiring). When the matches +/// cover EVERY record the whole-ledger replay serves the ecosystems without +/// a per-purl revert. Shared by the manifest-backed and hosted-only remove +/// paths. +async fn unwind_hosted( + common: &GlobalArgs, + hosted_matches: &[String], + state: &mut RedirectState, +) -> Result { + let replay_eligible = state.records.keys().all(|p| hosted_matches.contains(p)); + let before = (state.edits.len(), state.records.len()); + let leg = run_hosted_leg(common, hosted_matches, state, replay_eligible).await; + if !common.dry_run && (state.edits.len(), state.records.len()) != before { + if let Err(e) = persist_redirect_state(&common.cwd, state).await { + return Err(HostedUnwindError::Persist(e.to_string())); } } + if !leg.unsupported.is_empty() { + return Err(HostedUnwindError::Unsupported(leg.unsupported)); + } + if let Some((what, why)) = leg.failed.first().cloned() { + return Err(HostedUnwindError::Failed { what, why }); + } + Ok(leg) +} + +/// Error code + message for a stopped hosted unwind. +fn hosted_unwind_error(err: HostedUnwindError, manifest_backed: bool) -> (&'static str, String) { + let note = if manifest_backed { + " The manifest was not modified." + } else { + "" + }; + match err { + HostedUnwindError::Persist(e) => ( + "hosted_revert_failed", + format!("failed to persist the hosted redirect ledger: {e}"), + ), + HostedUnwindError::Unsupported(purls) => ( + "hosted_revert_unsupported", + format!( + "no per-purl hosted-redirect revert exists for: {}. Run an unscoped \ + `socket-patch rollback` to unwind ALL hosted redirects, or re-run \ + `scan --mode hosted` to normalize.{note}", + purls.join(", ") + ), + ), + HostedUnwindError::Failed { what, why } => ( + "hosted_revert_failed", + if manifest_backed { + format!("could not unwind hosted redirect for {what}: {why}.{note}") + } else { + format!("could not unwind hosted redirect for {what}: {why}") + }, + ), + } } -/// Remove path for identifiers that match ONLY detached vendored entries -/// (no manifest record): confirm, revert each entry's wiring + artifact, -/// drop it from the ledger, and report `Removed`/`vendor_reverted` events. -/// Unlike the manifest path, the reverts here ARE the removal, so they go -/// through `env.record` and bump `summary.removed`. `--skip-rollback` is -/// refused: with no manifest entry to delete, removing a detached patch -/// can only mean reverting its vendoring. /// Remove path for identifiers that match ONLY hosted redirect records -/// (no manifest entry, no detached vendor entry): confirm, unwind each +/// (no manifest entry, no vendor-ledger entry): confirm, unwind each /// record's lockfile wiring, drop it from the redirect ledger, and report -/// `Removed`/`hosted_reverted` events. Like the detached path, the unwind -/// IS the removal, so events go through `env.record` and bump +/// `Removed`/`hosted_reverted` events. Like the ledger-only vendored path, +/// the unwind IS the removal, so events go through `env.record` and bump /// `summary.removed`. `--skip-rollback` is refused (with no manifest /// entry to delete, removing a hosted patch can only mean unwinding its /// redirect); `--preserve-state` still unwinds — hosted has no @@ -1058,10 +1133,11 @@ pub async fn run(args: RemoveArgs) -> i32 { async fn remove_hosted_only( args: &RemoveArgs, hosted_matches: Vec, - mut redirect_state: socket_patch_core::patch::redirect::RedirectState, + mut redirect_state: RedirectState, api_token: Option<&str>, org_slug: Option<&str>, ) -> i32 { + let loud = !args.common.json && !args.common.silent; if args.skip_rollback { emit_error_envelope( args.common.json, @@ -1076,7 +1152,7 @@ async fn remove_hosted_only( return 1; } - if !args.common.json && !args.common.silent { + if loud { eprintln!("The following hosted redirect(s) will be unwound and removed:"); for purl in &hosted_matches { eprintln!(" - {purl}"); @@ -1089,75 +1165,35 @@ async fn remove_hosted_only( hosted_matches.len() ); if !args.common.dry_run && !confirm(&prompt, true, args.common.yes, args.common.json) { - if !args.common.json && !args.common.silent { + if loud { println!("Removal cancelled."); } return 0; } - let replay_eligible = redirect_state - .records - .keys() - .all(|p| hosted_matches.contains(p)); - let before = (redirect_state.edits.len(), redirect_state.records.len()); - let leg = super::rollback::run_hosted_leg( - &args.common, - &hosted_matches, - &mut redirect_state, - replay_eligible, - ) - .await; - // Persist FIRST, failure or not (see the main-flow hosted leg): the - // per-purl reverts already flushed lockfile writes, so the on-disk - // ledger must reflect them even when a later match failed. - if !args.common.dry_run - && (redirect_state.edits.len(), redirect_state.records.len()) != before - { - if let Err(e) = socket_patch_core::patch::redirect::persist_redirect_state( - &args.common.cwd, - &redirect_state, - ) - .await - { - emit_error_envelope( - args.common.json, - args.common.dry_run, - "hosted_revert_failed", - format!("failed to persist the hosted redirect ledger: {e}"), - ); + let leg = match unwind_hosted(&args.common, &hosted_matches, &mut redirect_state).await { + Ok(leg) => leg, + Err(err) => { + match &err { + HostedUnwindError::Unsupported(_) => { + track_patch_remove_failed( + "hosted redirect revert unsupported", + api_token, + org_slug, + ) + .await; + } + HostedUnwindError::Failed { .. } => { + track_patch_remove_failed("hosted redirect revert failed", api_token, org_slug) + .await; + } + HostedUnwindError::Persist(_) => {} + } + let (code, msg) = hosted_unwind_error(err, false); + emit_error_envelope(args.common.json, args.common.dry_run, code, msg); return 1; } - } - if !leg.unsupported.is_empty() { - track_patch_remove_failed( - "hosted redirect revert unsupported", - api_token, - org_slug, - ) - .await; - emit_error_envelope( - args.common.json, - args.common.dry_run, - "hosted_revert_unsupported", - format!( - "no per-purl hosted-redirect revert exists for: {}. Run an unscoped \ - `socket-patch rollback` to unwind ALL hosted redirects, or re-run \ - `scan --mode hosted` to normalize.", - leg.unsupported.join(", ") - ), - ); - return 1; - } - if let Some((what, why)) = leg.failed.first() { - track_patch_remove_failed("hosted redirect revert failed", api_token, org_slug).await; - emit_error_envelope( - args.common.json, - args.common.dry_run, - "hosted_revert_failed", - format!("could not unwind hosted redirect for {what}: {why}"), - ); - return 1; - } + }; let mut env = Envelope::new(Command::Remove); env.dry_run = args.common.dry_run; let action = if args.common.dry_run { @@ -1183,13 +1219,25 @@ async fn remove_hosted_only( 0 } -async fn remove_detached_only( +/// Remove path for identifiers that match ONLY vendor-ledger entries (no +/// manifest record — the shape every `scan/get --mode vendored` entry +/// has): confirm, revert each entry's wiring + artifact, drop it from the +/// ledger, and report `Removed`/`vendor_reverted` events. Unlike the +/// manifest path, the reverts here ARE the removal, so they go through +/// `env.record` and bump `summary.removed`. Drift-keeps and +/// `--preserve-state` follow the manifest path's rules exactly (the loop is +/// shared): a kept entry stays in the ledger and fails the run, +/// `--preserve-state` unwires and keeps everything. `--skip-rollback` is +/// refused: with no manifest entry to delete, removing a ledger-only +/// patch can only mean reverting its vendoring. +async fn remove_ledger_only( args: &RemoveArgs, - detached: Vec<(String, VendorEntry)>, + matches: Vec<(String, VendorEntry)>, mut state: VendorState, api_token: Option<&str>, org_slug: Option<&str>, ) -> i32 { + let loud = !args.common.json && !args.common.silent; if args.skip_rollback { emit_error_envelope( args.common.json, @@ -1204,129 +1252,95 @@ async fn remove_detached_only( return 1; } - if !args.common.json && !args.common.silent { - eprintln!("The following detached vendored patch(es) will be reverted and removed:"); - for (key, entry) in &detached { + if loud { + if args.preserve_state { + eprintln!( + "The following detached vendored patch(es) will be unwired (artifacts and \ + ledger entries preserved):" + ); + } else { + eprintln!("The following detached vendored patch(es) will be reverted and removed:"); + } + for (key, entry) in &matches { eprintln!(" - {key} (UUID: {})", short_uuid(&entry.uuid)); } eprintln!(); } // `--dry-run` previews without mutating — nothing to confirm. - let prompt = format!( - "Remove {} vendored patch(es) and revert their vendoring?", - detached.len() - ); + let prompt = if args.preserve_state { + format!( + "Unwire vendoring for {} vendored patch(es)? (artifacts and ledger entries will \ + be preserved)", + matches.len() + ) + } else { + format!( + "Remove {} vendored patch(es) and revert their vendoring?", + matches.len() + ) + }; if !args.common.dry_run && !confirm(&prompt, true, args.common.yes, args.common.json) { - if !args.common.json && !args.common.silent { + if loud { println!("Removal cancelled."); } return 0; } + let keys: Vec = matches.iter().map(|(k, _)| k.clone()).collect(); + let leg = match revert_vendored_matches(args, &keys, &mut state, api_token, org_slug, false) + .await + { + Ok(leg) => leg, + Err(code) => return code, + }; + let mut env = Envelope::new(Command::Remove); env.dry_run = args.common.dry_run; - for (key, entry) in &detached { - let outcome = dispatch_revert_one(entry, &args.common.cwd, args.common.dry_run).await; - for w in &outcome.warnings { - if !args.common.json && !args.common.silent { - eprintln!("Warning ({}): {}", w.code, w.detail); - } - env.record( - PatchEvent::new(PatchAction::Skipped, key.clone()) - .with_reason(w.code, w.detail.clone()), - ); - } - if !outcome.success { - track_patch_remove_failed( - "vendor revert failed during patch removal", - api_token, - org_slug, - ) - .await; - emit_error_envelope( - args.common.json, - args.common.dry_run, - "vendor_revert_failed", - format!( - "could not revert vendoring for {key}: {}", - outcome.error.as_deref().unwrap_or("unknown error") - ), - ); - return 1; - } - if args.common.dry_run { - if !args.common.json && !args.common.silent { - println!("Would revert vendoring for {key}"); - } - // Verified preview (the dry-run convention); still recorded - // so `summary.verified` counts the would-be removals. - env.record( - PatchEvent::new(PatchAction::Verified, key.clone()).with_reason( - "vendor_would_revert", - "vendoring would be reverted on remove", - ), - ); - continue; - } - state.entries.remove(key); - if let Err(e) = save_state(&args.common.cwd, &state).await { - emit_error_envelope( - args.common.json, - args.common.dry_run, - "vendor_state_write_failed", - e.to_string(), + // The reverts ARE the removal: every event is recorded, so + // `summary.removed` counts the reverted entries (`summary.verified` + // the would-be removals on --dry-run). + for ev in leg.reverted { + env.record(ev); + } + for ev in leg.skipped { + env.record(ev); + } + if !leg.kept.is_empty() { + // Any drift-kept entry means part of the requested removal did + // NOT happen: partialFailure (exit 1). When EVERY match kept, the + // top-level error names the outcome — nothing was removed. + env.mark_partial_failure(); + if leg.kept.len() == keys.len() { + let msg = format!( + "{}: every matching entry's vendored state drift-kept; nothing was \ + removed (re-run `scan --mode vendored` to normalize, then remove)", + args.identifier ); - return 1; + track_patch_remove_failed(&msg, api_token, org_slug).await; + env.error = Some(EnvelopeError::new("vendor_revert_kept", msg)); } - if !args.common.json && !args.common.silent { - println!("Reverted vendoring for {key}"); - } - env.record( - PatchEvent::new(PatchAction::Removed, key.clone()) - .with_reason("vendor_reverted", "vendoring reverted on remove"), - ); } if args.common.json { println!("{}", env.to_pretty_json()); } if !args.common.dry_run { - track_patch_removed(detached.len(), api_token, org_slug).await; - } - 0 -} - -async fn remove_patch_from_manifest( - identifier: &str, - manifest_path: &Path, - // Matching entries to KEEP anyway — drift-kept vendored purls whose - // vendored state survived the revert (the record must survive with it). - exclusions: &std::collections::HashSet, -) -> Result<(Vec, PatchManifest), String> { - let mut manifest = read_manifest(manifest_path) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "Invalid manifest".to_string())?; - - let removed: Vec = manifest - .patches - .iter() - .filter(|(purl, patch)| { - patch_matches(purl, &patch.uuid, identifier) && !exclusions.contains(*purl) - }) - .map(|(purl, _)| purl.clone()) - .collect(); - - for purl in &removed { - manifest.patches.remove(purl); + track_patch_removed(leg.reverted_count, api_token, org_slug).await; } - - if !removed.is_empty() { - write_manifest(manifest_path, &manifest) - .await - .map_err(|e| e.to_string())?; + if leg.kept.is_empty() { + 0 + } else { + // Errors print even under --silent; the per-key drift-keep lines + // are gated, so name the outcome once here. + if !args.common.json { + eprintln!( + "Error: {} matching entr{} drift-kept (vendored state and ledger record \ + retained); re-run `scan --mode vendored` to normalize, then remove again", + leg.kept.len(), + if leg.kept.len() == 1 { "y was" } else { "ies were" } + ); + } + 1 } - - Ok((removed, manifest)) } #[cfg(test)] @@ -1347,10 +1361,9 @@ mod tests { } } - /// Write a manifest with three PyPI release variants of one - /// package@version plus an unrelated npm package, returning the - /// temp dir (kept alive) and the manifest path. - async fn write_multi_variant(dir: &Path) { + /// A manifest with three PyPI release variants of one package@version + /// plus an unrelated npm package. + fn multi_variant_manifest() -> PatchManifest { let mut patches = HashMap::new(); patches.insert( "pkg:pypi/six@1.16.0?artifact_id=wheel-cp311".to_string(), @@ -1365,42 +1378,35 @@ mod tests { make_record("uuid-cp312"), ); patches.insert("pkg:npm/foo@1.0".to_string(), make_record("uuid-foo")); - let manifest = PatchManifest { + PatchManifest { patches, setup: None, - }; - write_manifest(&dir.join("manifest.json"), &manifest) - .await - .expect("write manifest"); + } } - #[tokio::test] - async fn remove_base_purl_removes_all_variants() { - let tmp = tempfile::tempdir().expect("tempdir"); - write_multi_variant(tmp.path()).await; - let manifest_path = tmp.path().join("manifest.json"); + #[test] + fn remove_base_purl_removes_all_variants() { + let mut manifest = multi_variant_manifest(); - let (removed, manifest) = remove_patch_from_manifest("pkg:pypi/six@1.16.0", &manifest_path, &Default::default()) - .await - .expect("remove ok"); + let removed = remove_matching(&mut manifest, "pkg:pypi/six@1.16.0", &Default::default()); - // All three release variants removed; the npm package untouched. + // All three release variants removed (sorted); the npm package untouched. assert_eq!(removed.len(), 3); assert!(removed.iter().all(|p| p.contains("six@1.16.0"))); + assert!(removed.windows(2).all(|w| w[0] < w[1]), "sorted: {removed:?}"); assert_eq!(manifest.patches.len(), 1); assert!(manifest.patches.contains_key("pkg:npm/foo@1.0")); } - #[tokio::test] - async fn remove_qualified_purl_removes_single_variant() { - let tmp = tempfile::tempdir().expect("tempdir"); - write_multi_variant(tmp.path()).await; - let manifest_path = tmp.path().join("manifest.json"); + #[test] + fn remove_qualified_purl_removes_single_variant() { + let mut manifest = multi_variant_manifest(); - let (removed, manifest) = - remove_patch_from_manifest("pkg:pypi/six@1.16.0?artifact_id=sdist", &manifest_path, &Default::default()) - .await - .expect("remove ok"); + let removed = remove_matching( + &mut manifest, + "pkg:pypi/six@1.16.0?artifact_id=sdist", + &Default::default(), + ); // Only the sdist variant removed; the two wheels + npm remain. assert_eq!(removed, vec!["pkg:pypi/six@1.16.0?artifact_id=sdist"]); @@ -1410,15 +1416,11 @@ mod tests { .contains_key("pkg:pypi/six@1.16.0?artifact_id=sdist")); } - #[tokio::test] - async fn remove_by_uuid_removes_single_variant() { - let tmp = tempfile::tempdir().expect("tempdir"); - write_multi_variant(tmp.path()).await; - let manifest_path = tmp.path().join("manifest.json"); + #[test] + fn remove_by_uuid_removes_single_variant() { + let mut manifest = multi_variant_manifest(); - let (removed, manifest) = remove_patch_from_manifest("uuid-cp312", &manifest_path, &Default::default()) - .await - .expect("remove ok"); + let removed = remove_matching(&mut manifest, "uuid-cp312", &Default::default()); assert_eq!(removed, vec!["pkg:pypi/six@1.16.0?artifact_id=wheel-cp312"]); assert_eq!(manifest.patches.len(), 3); @@ -1428,60 +1430,43 @@ mod tests { /// must not accidentally match same-prefix neighbours like /// `foobar@1.0`. Guards the `strip_purl_qualifiers == identifier` /// exact-equality path for non-PyPI keys. - #[tokio::test] - async fn remove_npm_purl_is_exact_and_does_not_prefix_match() { - let tmp = tempfile::tempdir().expect("tempdir"); + #[test] + fn remove_npm_purl_is_exact_and_does_not_prefix_match() { let mut patches = HashMap::new(); patches.insert("pkg:npm/foo@1.0".to_string(), make_record("uuid-foo")); patches.insert("pkg:npm/foobar@1.0".to_string(), make_record("uuid-foobar")); - let manifest = PatchManifest { + let mut manifest = PatchManifest { patches, setup: None, }; - let manifest_path = tmp.path().join("manifest.json"); - write_manifest(&manifest_path, &manifest) - .await - .expect("write manifest"); - let (removed, manifest) = remove_patch_from_manifest("pkg:npm/foo@1.0", &manifest_path, &Default::default()) - .await - .expect("remove ok"); + let removed = remove_matching(&mut manifest, "pkg:npm/foo@1.0", &Default::default()); assert_eq!(removed, vec!["pkg:npm/foo@1.0"]); assert_eq!(manifest.patches.len(), 1); assert!(manifest.patches.contains_key("pkg:npm/foobar@1.0")); } - /// An identifier that matches nothing removes nothing and — crucially - /// — must NOT rewrite the manifest file. We assert byte-identity of - /// the on-disk manifest before/after so a future change that always - /// re-serializes (churning mtime / formatting) is caught. - #[tokio::test] - async fn remove_no_match_leaves_manifest_file_untouched() { - let tmp = tempfile::tempdir().expect("tempdir"); - write_multi_variant(tmp.path()).await; - let manifest_path = tmp.path().join("manifest.json"); - let before_bytes = tokio::fs::read(&manifest_path).await.expect("read before"); - - let (removed, manifest) = - remove_patch_from_manifest("pkg:npm/not-here@9.9.9", &manifest_path, &Default::default()) - .await - .expect("remove ok"); + /// An identifier that matches nothing removes nothing and leaves the + /// manifest intact. `run` gates the manifest write on a non-empty + /// removal, so a no-op remove never rewrites the file (the on-disk + /// byte-identity is pinned end-to-end by `cli_parse_remove`'s no-match + /// test). + #[test] + fn remove_no_match_leaves_manifest_untouched() { + let mut manifest = multi_variant_manifest(); + let before = manifest.clone(); + + let removed = remove_matching(&mut manifest, "pkg:npm/not-here@9.9.9", &Default::default()); assert!(removed.is_empty(), "nothing should match"); - assert_eq!(manifest.patches.len(), 4, "manifest left intact"); - let after_bytes = tokio::fs::read(&manifest_path).await.expect("read after"); - assert_eq!( - before_bytes, after_bytes, - "a no-op remove must not rewrite the manifest file" - ); + assert_eq!(manifest, before, "manifest left intact"); } /// A base PURL must not bleed across versions: removing `six@1.16.0` /// leaves `six@1.17.0` (and its variants) in place. - #[tokio::test] - async fn remove_base_purl_does_not_touch_other_versions() { - let tmp = tempfile::tempdir().expect("tempdir"); + #[test] + fn remove_base_purl_does_not_touch_other_versions() { let mut patches = HashMap::new(); patches.insert( "pkg:pypi/six@1.16.0?artifact_id=sdist".to_string(), @@ -1491,18 +1476,12 @@ mod tests { "pkg:pypi/six@1.17.0?artifact_id=sdist".to_string(), make_record("uuid-17-sdist"), ); - let manifest = PatchManifest { + let mut manifest = PatchManifest { patches, setup: None, }; - let manifest_path = tmp.path().join("manifest.json"); - write_manifest(&manifest_path, &manifest) - .await - .expect("write manifest"); - let (removed, manifest) = remove_patch_from_manifest("pkg:pypi/six@1.16.0", &manifest_path, &Default::default()) - .await - .expect("remove ok"); + let removed = remove_matching(&mut manifest, "pkg:pypi/six@1.16.0", &Default::default()); assert_eq!(removed, vec!["pkg:pypi/six@1.16.0?artifact_id=sdist"]); assert_eq!(manifest.patches.len(), 1); @@ -1510,4 +1489,22 @@ mod tests { .patches .contains_key("pkg:pypi/six@1.17.0?artifact_id=sdist")); } + + /// Drift-kept exclusions survive the removal of their matching + /// siblings: the record whose vendored state was kept stays. + #[test] + fn remove_matching_honors_exclusions() { + let mut manifest = multi_variant_manifest(); + let exclusions: HashSet = + ["pkg:pypi/six@1.16.0?artifact_id=sdist".to_string()].into(); + + let removed = remove_matching(&mut manifest, "pkg:pypi/six@1.16.0", &exclusions); + + assert_eq!(removed.len(), 2, "the two wheels go, the excluded sdist stays"); + assert!(manifest + .patches + .contains_key("pkg:pypi/six@1.16.0?artifact_id=sdist")); + assert!(manifest.patches.contains_key("pkg:npm/foo@1.0")); + assert_eq!(manifest.patches.len(), 2); + } } diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 4448677c..4a7d828b 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -3,10 +3,8 @@ use socket_patch_core::api::blob_fetcher::{ fetch_missing_sources, format_fetch_result, get_missing_archives, get_missing_blobs, DownloadMode, FetchMissingBlobsResult, }; -use socket_patch_core::api::client::get_api_client_with_overrides; -use socket_patch_core::manifest::cleanup_blobs::{ - cleanup_unused_archives, cleanup_unused_blobs, format_cleanup_result, -}; +use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; +use socket_patch_core::manifest::cleanup_blobs::format_cleanup_result; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::patch::apply::PatchSources; use socket_patch_core::telemetry::{track_patch_repair_failed, track_patch_repaired}; @@ -15,6 +13,7 @@ use std::time::Duration; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; use crate::commands::lock_cli::{acquire_or_emit, error_envelope}; +use crate::commands::rollback::sweep_unused_artifacts; use crate::json_envelope::{Command, Envelope, PatchAction, PatchEvent, Status}; #[derive(Args)] @@ -69,13 +68,20 @@ pub async fn run(args: RepairArgs) -> i32 { let manifest_path = args.common.resolved_manifest_path(); + // The lockfile scan (`scan_vendor_references` opens every wiring file) + // runs at most once per repair: the existence gate below needs it only + // for a ledger-less project, and that result is reused under the lock. + let mut vendor_references: Option> = None; + if tokio::fs::metadata(&manifest_path).await.is_err() { // Hosted (redirect) mode leaves no local artifacts to repair: the // lockfiles point at patch.socket.dev URLs, not `.socket/vendor/...`, // and there is no manifest or vendor ledger. A project whose only // trace is `redirect-state.json` is therefore a no-op for repair — // exit success with an informational skip rather than the - // `manifest_not_found` error a bare directory would get. + // `manifest_not_found` error a bare directory would get. Only cheap + // existence probes (and the read-only lockfile scan) run before the + // lock, so a project with nothing to repair never grows `.socket/`. let redirect_state = args .common .cwd @@ -84,10 +90,13 @@ pub async fn run(args: RepairArgs) -> i32 { .common .cwd .join(socket_patch_core::vendor::VENDOR_STATE_REL); - let has_vendor_traces = tokio::fs::metadata(&state_file).await.is_ok() - || !crate::commands::repair_vendor::scan_vendor_references(&args.common.cwd) - .await - .is_empty(); + let mut has_vendor_traces = tokio::fs::metadata(&state_file).await.is_ok(); + if !has_vendor_traces { + let refs = crate::commands::repair_vendor::scan_vendor_references(&args.common.cwd) + .await; + has_vendor_traces = !refs.is_empty(); + vendor_references = Some(refs); + } if !has_vendor_traces { if tokio::fs::metadata(&redirect_state).await.is_ok() { let msg = "hosted redirects need no local repair; re-run \ @@ -120,18 +129,17 @@ pub async fn run(args: RepairArgs) -> i32 { } return 1; } - // The vendor-only repair still serializes on the .socket lock; the - // lock layer deliberately refuses to mkdir. - if let Some(dir) = manifest_path.parent() { - let _ = tokio::fs::create_dir_all(dir).await; - } } // Serialize against concurrent socket-patch runs targeting the - // same `.socket/` directory. See `apply_lock`. A live holder makes - // repair refuse with `lock_held` — it never steals the lock. + // same `.socket/` directory. See `apply_lock`: acquire creates the + // directory when needed (the vendor-only repair of a ledger-less + // project), and the guard's drop removes `apply.lock` — and an + // otherwise-empty `.socket/` — on every exit path, dry-run included. + // A live holder makes repair refuse with `lock_held`; it never steals + // the lock. let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); - let lock = match acquire_or_emit( + let _lock = match acquire_or_emit( socket_dir, Command::Repair, args.common.json, @@ -142,7 +150,22 @@ pub async fn run(args: RepairArgs) -> i32 { Err(code) => return code, }; - let exit_code = match repair_inner(&args, &manifest_path).await { + // Lockfile references are read under the lock (a concurrent vendor run + // rewrites them under the same lock) unless the gate above already + // scanned this ledger-less project. + let vendor_references = match vendor_references { + Some(refs) => refs, + None => crate::commands::repair_vendor::scan_vendor_references(&args.common.cwd).await, + }; + + match repair_inner( + &args, + &manifest_path, + Some(&telemetry_client), + vendor_references, + ) + .await + { Ok((env, counts)) => { // A repair where some artifacts failed to download is marked a // partial failure inside `repair_inner` (a `Failed` event plus @@ -186,36 +209,7 @@ pub async fn run(args: RepairArgs) -> i32 { } 1 } - }; - - // Clean slate: repair owns the lock-file cleanup (the mutating - // commands deliberately leave `apply.lock` behind between runs). - // Drop our guard FIRST so the unlink races nothing we hold, then - // best-effort delete. A live holder never reaches here — contention - // already returned above. The residual window (a competitor that - // acquires between the drop and the unlink gets its file orphaned) - // is microseconds at the tail of a finished repair and worth the - // trade; see `apply_lock`'s module doc. - drop(lock); - if !args.common.dry_run { - let lock_file = socket_dir.join("apply.lock"); - match std::fs::remove_file(&lock_file) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { - // Housekeeping only: a leftover lock file is harmless, so - // a failed delete warns (human mode) without flipping the - // exit code of an otherwise-finished repair. - if !args.common.silent && !args.common.json { - eprintln!( - "Warning: could not remove lock file {}: {e}", - lock_file.display() - ); - } - } - } } - exit_code } /// Aggregate counts surfaced by `repair_inner` for telemetry use. @@ -228,6 +222,13 @@ struct RepairCounts { async fn repair_inner( args: &RepairArgs, manifest_path: &Path, + // The client `run()` already built: constructing another one for the + // download printed the core client's "No SOCKET_API_TOKEN set" notice + // twice per repair. `None` (unit tests) builds one on demand, only when + // the download below actually fires. + api_client: Option<&ApiClient>, + // `(eco, uuid, rel)` lockfile vendor references, scanned once by `run`. + vendor_references: Vec<(String, String, String)>, ) -> Result<(Envelope, RepairCounts), String> { // `Ok(None)` = no manifest (vendor-only repair); present-but-invalid // stays a hard error. @@ -281,12 +282,10 @@ async fn repair_inner( // Lockfile vendor references count as vendored even before the ledger // is reconstructed, so a no-ledger repair doesn't download sources for // entries the vendored phase is about to own. - let referenced_uuids: std::collections::HashSet = - crate::commands::repair_vendor::scan_vendor_references(&args.common.cwd) - .await - .into_iter() - .map(|(_, uuid, _)| uuid) - .collect(); + let referenced_uuids: std::collections::HashSet = vendor_references + .into_iter() + .map(|(_, uuid, _)| uuid) + .collect(); let scoped_manifest = manifest.as_ref().map(|m| { let patches = m .patches @@ -366,8 +365,16 @@ async fn repair_inner( if !quiet { println!("\nDownloading missing {}s...", download_mode.as_tag()); } - let (client, _) = - get_api_client_with_overrides(args.common.api_client_overrides()).await; + let built_client; + let client = match api_client { + Some(c) => c, + None => { + built_client = get_api_client_with_overrides(args.common.api_client_overrides()) + .await + .0; + &built_client + } + }; let sources = PatchSources { blobs_path: &blobs_path, packages_path: Some(&packages_path), @@ -380,7 +387,7 @@ async fn repair_inner( .as_ref() .expect("step 1 requires a manifest"); let fetch_result = - fetch_missing_sources(m, &sources, download_mode, &client, None).await; + fetch_missing_sources(m, &sources, download_mode, client, None).await; downloaded_count = fetch_result.downloaded; download_failed_count = fetch_result.failed; if !quiet { @@ -420,62 +427,42 @@ async fn repair_inner( if !quiet { println!(); } - match cleanup_unused_blobs(manifest, &blobs_path, args.common.dry_run).await { - Ok(cleanup_result) => { - blobs_checked += cleanup_result.blobs_checked; - blobs_cleaned += cleanup_result.blobs_removed; - bytes_freed += cleanup_result.bytes_freed; - if !quiet { - if cleanup_result.blobs_checked == 0 { - println!("No blobs directory found, nothing to clean up."); - } else if cleanup_result.blobs_removed == 0 { - println!( - "Checked {} blob(s), all are in use.", - cleanup_result.blobs_checked - ); - } else { - println!( - "{}", - format_cleanup_result(&cleanup_result, args.common.dry_run) - ); - } - } - } - Err(e) => { - // A failed cleanup is error output: `--silent` (suppress - // NON-error output) must not mute it, and the JSON envelope - // must carry it — a bare `status: success` with no events is - // indistinguishable from "nothing to clean". Recorded as an - // informational skip (not `Failed`) to preserve the human - // path's warn-and-continue contract: status stays success, - // exit stays 0. - if !args.common.json { - eprintln!("Warning: blob cleanup failed: {e}"); - } - env.record( - PatchEvent::artifact(PatchAction::Skipped) - .with_reason("cleanup_failed", format!("blob cleanup failed: {e}")), - ); - } - } - - // Diff and package archives. - for (path, label) in [(&diffs_path, "diff"), (&packages_path, "package")] { - match cleanup_unused_archives(manifest, path, args.common.dry_run).await { + let sweep = sweep_unused_artifacts(manifest, socket_dir, args.common.dry_run).await; + // The blob pass prints its status unconditionally ("all are in + // use" included — the core helper owns that wording); the archive + // passes print only when they removed something, relabeled. + let passes = [ + ("blob", None, sweep.blobs), + ("diff", Some("diff archive(s)"), sweep.diffs), + ("package", Some("package archive(s)"), sweep.packages), + ]; + for (label, relabel, result) in passes { + match result { Ok(cleanup_result) => { blobs_checked += cleanup_result.blobs_checked; blobs_cleaned += cleanup_result.blobs_removed; bytes_freed += cleanup_result.bytes_freed; - if !quiet && cleanup_result.blobs_removed > 0 { - println!( - "{}", - format_cleanup_result(&cleanup_result, args.common.dry_run) - .replace("blob(s)", &format!("{label} archive(s)")) - ); + if quiet { + continue; + } + let text = format_cleanup_result(&cleanup_result, args.common.dry_run); + match relabel { + None => println!("{text}"), + Some(relabel) if cleanup_result.blobs_removed > 0 => { + println!("{}", text.replace("blob(s)", relabel)); + } + Some(_) => {} } } Err(e) => { - // Same contract as the blob-cleanup arm above. + // A failed cleanup is error output: `--silent` (suppress + // NON-error output) must not mute it, and the JSON + // envelope must carry it — a bare `status: success` with + // no events is indistinguishable from "nothing to + // clean". Recorded as an informational skip (not + // `Failed`) to preserve the human path's + // warn-and-continue contract: status stays success, exit + // stays 0, and the loop goes on to the next directory. if !args.common.json { eprintln!("Warning: {label} cleanup failed: {e}"); } @@ -634,7 +621,7 @@ mod tests { let mut args = offline_args(tmp.path()); args.common.dry_run = true; - let (env, counts) = repair_inner(&args, &socket.join("manifest.json")) + let (env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); @@ -659,7 +646,7 @@ mod tests { args.common.offline = false; args.common.dry_run = true; - let (env, _counts) = repair_inner(&args, &socket.join("manifest.json")) + let (env, _counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); @@ -683,7 +670,7 @@ mod tests { write_blob(&socket, &orphan_hash, orphan_bytes); let args = offline_args(tmp.path()); - let (env, counts) = repair_inner(&args, &socket.join("manifest.json")) + let (env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); @@ -716,7 +703,7 @@ mod tests { args.common.offline = false; args.download_only = true; - let (_env, counts) = repair_inner(&args, &socket.join("manifest.json")) + let (_env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); @@ -758,7 +745,7 @@ mod tests { ); let args = offline_args(tmp.path()); - let (env, counts) = repair_inner(&args, &socket.join("manifest.json")) + let (env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); @@ -838,7 +825,7 @@ mod tests { let mut args = offline_args(tmp.path()); args.common.json = false; - let (env, counts) = repair_inner(&args, &socket.join("manifest.json")) + let (env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); @@ -857,7 +844,7 @@ mod tests { args.common.dry_run = true; args.common.json = false; - let (env, _counts) = repair_inner(&args, &socket.join("manifest.json")) + let (env, _counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); @@ -879,7 +866,7 @@ mod tests { // No blob on disk → manifest afterHash is "missing". Not dry-run. let args = offline_args(tmp.path()); - let (env, counts) = repair_inner(&args, &socket.join("manifest.json")) + let (env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 12986e52..68ac11c0 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -2,7 +2,9 @@ use clap::Args; use socket_patch_core::api::blob_fetcher::{fetch_blobs_by_hash, format_fetch_result}; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; -use socket_patch_core::manifest::cleanup_blobs::{cleanup_unused_archives, cleanup_unused_blobs}; +use socket_patch_core::manifest::cleanup_blobs::{ + cleanup_unused_archives, cleanup_unused_blobs, CleanupResult, +}; use socket_patch_core::manifest::operations::{ get_before_hash_blobs, read_manifest, write_manifest, }; @@ -14,6 +16,7 @@ use socket_patch_core::patch::rollback::{ }; use socket_patch_core::telemetry::{track_patch_rollback_failed, track_patch_rolled_back}; use socket_patch_core::utils::purl::strip_purl_qualifiers; +use socket_patch_core::vendor::{save_state, RevertOpts, VendorState, VendorWarning}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -21,7 +24,8 @@ use std::time::Duration; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; use crate::commands::apply::is_local_go; use crate::commands::lock_cli::acquire_or_emit; -use crate::commands::remove::patch_matches; +use crate::commands::remove::{patch_matches, vendor_entry_covers_purl, vendor_entry_matches}; +use crate::commands::vendor::dispatch_revert_one_opts; use crate::ecosystem_dispatch::{find_all_packages_for_rollback, partition_purls}; use crate::json_envelope::Command as EnvelopeCommand; use crate::looks_like_uuid; @@ -174,31 +178,31 @@ struct PatchToRollback { /// out not-installed exits 0 / `success`. Do not "fix" this into symmetry: /// `remove` also rides on it (it drops long-uninstalled entries from the /// manifest via its "No packages found to rollback" path). -struct RollbackOutcome { +pub(crate) struct RollbackOutcome { /// No attempted rollback failed (per-package; see above). - success: bool, - results: Vec, + pub(crate) success: bool, + pub(crate) results: Vec, /// Vendor-owned purls excluded from in-place rollback (benign). - vendored_skipped: Vec, + pub(crate) vendored_skipped: Vec, /// In-scope manifest entries with no installed package on disk — /// apply's `unmatched` twin (`package_not_installed`). Never in the /// before-blob plan, never a failed result. Sorted for determinism. - not_installed: Vec, + pub(crate) not_installed: Vec, /// Release-variant manifest entries narrowed away by /// `select_installed_variants` (their distribution is not on disk; /// an attempted sibling covered the group). The manifest-cleanup /// default drops them with their group. Empty on early returns. - narrowed_out: Vec, + pub(crate) narrowed_out: Vec, /// The run aborted at the before-blob gate BEFORE any restore ran /// (offline with missing blobs, or a failed download). The CLI /// boundary's manifest-cleanup default must skip entirely: nothing /// was restored, so nothing is removable and the GC must not sweep /// the revert data the retry needs. - aborted: bool, + pub(crate) aborted: bool, } /// How `rollback_patches_inner` selects manifest entries. -enum InnerSelection<'a> { +pub(crate) enum InnerSelection<'a> { /// The legacy single-identifier filter (`remove`'s delegation): a /// no-match identifier is an error, a missing manifest is an error, /// and `None` selects the whole manifest. @@ -206,8 +210,10 @@ enum InnerSelection<'a> { /// A pre-resolved purl set from the CLI boundary's target resolver /// (identifiers ∪ path globs ∪ everything). No-match and /// missing-manifest handling already happened upstream, so an empty - /// selection is a quiet success; `announce_empty` keeps the unscoped - /// run's "No patches found in manifest" line. + /// selection is a quiet success; `announce_empty` keeps the "No + /// patches found in manifest" line for an unscoped run with no work + /// in ANY leg (a hosted-/vendored-only project has work, so it is + /// not "no patches"). Scope { purls: &'a HashSet, announce_empty: bool, @@ -218,33 +224,27 @@ enum InnerSelection<'a> { // Local go rolls back by dropping the project-local redirect (go's `replace` // directive) + the patched copy — no in-place restore, no before-blob. Cargo // patches in place (vendored or registry cache), so it rolls back in place from -// before-blobs like npm/pypi. The helper is an inert stub without `golang`. -// `is_local_go` is shared with `apply`, which creates the same redirects. - -/// True when `purl` rolls back by dropping a project-local redirect (local-mode -/// go) rather than restoring bytes from a before-blob. The before-blob gate uses -/// this to skip those PURLs — they read no blobs, so a missing before-blob must -/// not block (or trigger a needless download for) an offline redirect rollback. -fn is_local_redirect(purl: &str, common: &GlobalArgs) -> bool { - if is_local_go(purl, common) { - return true; - } - let _ = (purl, common); - false -} - -/// Copy of `manifest` with local-redirect PURLs (local-mode go) removed — used -/// for the before-blob gate, which those PURLs never need. Avoids blocking an -/// offline redirect rollback on absent blobs. -fn exclude_local_redirects(manifest: &PatchManifest, common: &GlobalArgs) -> PatchManifest { +// before-blobs like npm/pypi. `is_local_go` is shared with `apply`, which +// creates the same redirects. + +/// The before-blob gate's manifest: the ATTEMPTED (crawler-discovered) +/// entries of `scoped`, minus local-redirect PURLs (local-mode go). Those +/// roll back by dropping a project-local redirect and read no blobs, so a +/// missing before-blob must not block (or trigger a needless download for) +/// an offline redirect rollback. +fn before_blob_gate_manifest( + scoped: &PatchManifest, + attempted: &HashSet<&str>, + common: &GlobalArgs, +) -> PatchManifest { PatchManifest { - patches: manifest + patches: scoped .patches .iter() - .filter(|(purl, _)| !is_local_redirect(purl, common)) + .filter(|(purl, _)| attempted.contains(purl.as_str()) && !is_local_go(purl, common)) .map(|(k, v)| (k.clone(), v.clone())) .collect(), - setup: manifest.setup.clone(), + setup: None, } } @@ -534,96 +534,165 @@ pub(crate) struct HostedLegOutcome { pub(crate) edited_files: std::collections::BTreeSet, } +/// What one vendored ledger entry's revert did. Silent by design — the +/// caller owns the print and envelope vocabulary. Shared by `rollback`'s +/// vendored leg and both of `remove`'s vendored paths, so the drift-keep +/// and `--preserve-state` rules are identical by construction. +pub(crate) enum VendorRevertStep { + /// `key` has no ledger entry (a divergent ledger, or an earlier leg + /// already reverted it): a silent no-op. + Missing, + /// The backend refused; nothing changed for this entry. + Failed(String), + /// Drift-keep: the lock changed under us and the backend left both the + /// wiring and the artifact alone. Per `RevertOutcome`'s contract the + /// ledger entry — and any manifest record — must survive. + Kept, + /// Dry run: the revert (or, with `keep_artifact`, the unwire) would + /// succeed. Nothing changed. + WouldRevert, + /// `keep_artifact`: wiring restored; artifact and ledger entry kept + /// byte-identical. Its wiring records now describe already-reverted + /// fragments, which later reverts replay as silent no-ops (the + /// liveness contract), and a re-vendor re-wires from the live lock. + Preserved, + /// Reverted on disk, dropped from the ledger, ledger saved (per entry, + /// so the run is crash-consistent like `vendor --revert`). + Reverted, + /// Reverted on disk and dropped from the in-memory ledger, but the + /// ledger write failed. + LedgerWriteFailed(String), +} + +pub(crate) struct VendorRevertResult { + pub(crate) warnings: Vec, + pub(crate) step: VendorRevertStep, +} + +/// Revert the vendored ledger entry `key` (see [`VendorRevertStep`]). +pub(crate) async fn revert_vendor_entry( + cwd: &Path, + key: &str, + state: &mut VendorState, + opts: RevertOpts, +) -> VendorRevertResult { + let Some(entry) = state.entries.get(key).cloned() else { + return VendorRevertResult { + warnings: Vec::new(), + step: VendorRevertStep::Missing, + }; + }; + let outcome = dispatch_revert_one_opts(&entry, cwd, opts).await; + let step = if !outcome.success { + VendorRevertStep::Failed(outcome.error.unwrap_or_else(|| "unknown error".into())) + } else if outcome.kept_artifact { + VendorRevertStep::Kept + } else if opts.dry_run { + VendorRevertStep::WouldRevert + } else if opts.keep_artifact { + VendorRevertStep::Preserved + } else { + state.entries.remove(key); + match save_state(cwd, state).await { + Ok(()) => VendorRevertStep::Reverted, + Err(e) => VendorRevertStep::LedgerWriteFailed(e.to_string()), + } + }; + VendorRevertResult { + warnings: outcome.warnings, + step, + } +} + +/// One GC pass over `.socket/blobs`, `diffs` and `packages` against +/// `reference` (the post-removal manifest with the revert blobs a later +/// rollback needs pinned in). Each directory reports separately: callers +/// own the warn-and-continue posture and the wording. The core sweep +/// removes an emptied directory, so a fully reverted project keeps none +/// of the three. Shared by rollback's GC, remove's post-removal sweep and +/// repair's cleanup phase. +pub(crate) struct ArtifactSweep { + pub(crate) blobs: std::io::Result, + pub(crate) diffs: std::io::Result, + pub(crate) packages: std::io::Result, +} + +pub(crate) async fn sweep_unused_artifacts( + reference: &PatchManifest, + socket_dir: &Path, + dry_run: bool, +) -> ArtifactSweep { + ArtifactSweep { + blobs: cleanup_unused_blobs(reference, &socket_dir.join("blobs"), dry_run).await, + diffs: cleanup_unused_archives(reference, &socket_dir.join("diffs"), dry_run).await, + packages: cleanup_unused_archives(reference, &socket_dir.join("packages"), dry_run).await, + } +} + /// Unwire the in-scope vendored entries. `preserve` keeps artifacts and /// ledger entries (only the lockfile wiring is restored); otherwise a -/// clean revert drops the entry and saves the ledger per purl -/// (crash-consistent, like `vendor --revert`). +/// clean revert drops the entry and saves the ledger per purl. async fn run_vendored_leg( common: &GlobalArgs, keys: &[String], - state: &mut socket_patch_core::vendor::VendorState, + state: &mut VendorState, preserve: bool, ) -> VendoredLegOutcome { - use crate::commands::vendor::dispatch_revert_one_opts; - use socket_patch_core::vendor::{save_state, RevertOpts}; - let mut out = VendoredLegOutcome::default(); + let opts = RevertOpts { + dry_run: common.dry_run, + keep_artifact: preserve, + }; + let loud = !common.json && !common.silent; for key in keys { - let Some(entry) = state.entries.get(key).cloned() else { - continue; - }; - let outcome = dispatch_revert_one_opts( - &entry, - &common.cwd, - RevertOpts { - dry_run: common.dry_run, - keep_artifact: preserve, - }, - ) - .await; - for w in &outcome.warnings { - if !common.json && !common.silent { + let result = revert_vendor_entry(&common.cwd, key, state, opts).await; + for w in &result.warnings { + if loud { eprintln!("Warning ({}): {}", w.code, w.detail); } out.warnings.push((w.code.to_string(), w.detail.clone())); } - if !outcome.success { - let why = outcome - .error - .as_deref() - .unwrap_or("unknown error") - .to_string(); - // Errors print even under --silent. - if !common.json { - eprintln!("Failed to revert vendoring for {key}: {why}"); + match result.step { + VendorRevertStep::Missing => {} + VendorRevertStep::Failed(why) => { + // Errors print even under --silent. + if !common.json { + eprintln!("Failed to revert vendoring for {key}: {why}"); + } + out.failed.push((key.clone(), why)); } - out.failed.push((key.clone(), why)); - continue; - } - if outcome.kept_artifact { - // Drift-keep: the lock changed under us; the backend left both - // the wiring and the artifact alone. The entry (and the - // manifest record) must survive — see RevertOutcome's contract. - out.kept.push(( + VendorRevertStep::Kept => out.kept.push(( key.clone(), "lockfile wiring drifted; vendored state left untouched".to_string(), - )); - continue; - } - if common.dry_run { - if !common.json && !common.silent { - if preserve { + )), + VendorRevertStep::WouldRevert if preserve => { + if loud { println!("Would unwire vendoring for {key} (artifact preserved)"); - } else { - println!("Would revert vendoring for {key}"); } - } - if preserve { out.preserved.push(key.clone()); - } else { + } + VendorRevertStep::WouldRevert => { + if loud { + println!("Would revert vendoring for {key}"); + } out.reverted.push(key.clone()); } - continue; - } - if preserve { - // Ledger entry kept byte-identical: its wiring records now - // describe already-reverted fragments, which later reverts - // replay as silent no-ops (the liveness contract), and a - // re-vendor re-wires from the live lock probe. - if !common.json && !common.silent { - println!("Unwired vendoring for {key} (artifact preserved)"); + VendorRevertStep::Preserved => { + if loud { + println!("Unwired vendoring for {key} (artifact preserved)"); + } + out.preserved.push(key.clone()); } - out.preserved.push(key.clone()); - } else { - state.entries.remove(key); - if let Err(e) = save_state(&common.cwd, state).await { - out.failed.push((key.clone(), format!("vendor ledger write failed: {e}"))); - continue; + VendorRevertStep::Reverted => { + if loud { + println!("Reverted vendoring for {key}"); + } + out.reverted.push(key.clone()); } - if !common.json && !common.silent { - println!("Reverted vendoring for {key}"); + VendorRevertStep::LedgerWriteFailed(e) => { + out.failed + .push((key.clone(), format!("vendor ledger write failed: {e}"))); } - out.reverted.push(key.clone()); } } out @@ -792,8 +861,9 @@ pub async fn run(args: RollbackArgs) -> i32 { // missing manifest is no longer fatal when a ledger holds work. // // Only cheap EXISTENCE probes happen before the lock (they decide the - // truly-empty error path, which never locks — the lock file would - // materialize `.socket/` in a project that has none). The stores + // truly-empty error path, which never locks: acquiring would create + // `.socket/` only for the guard's drop to prune it again, and a bare + // project must never see the directory flicker). The stores // themselves are LOADED UNDER the apply lock below: this run persists // mutated clones of the ledgers, so a pre-lock snapshot could clobber // a concurrent run's writes with stale state. @@ -856,12 +926,20 @@ pub async fn run(args: RollbackArgs) -> i32 { Err(code) => return code, }; - // Load the state stores UNDER the lock (see the discovery note above). + // Load the state stores UNDER the lock (see the discovery note above), + // each exactly once: the agent leg below receives the manifest and the + // vendor-ownership key set instead of re-reading them. let vendor_state_result = socket_patch_core::vendor::load_state(&cwd).await; let redirect_state_result = socket_patch_core::patch::redirect::load_redirect_state(&cwd).await; let vendor_corrupt = vendor_state_result.is_err(); let redirect_corrupt = redirect_state_result.is_err(); + // An unreadable ledger degrades to "nothing vendored" for the in-place + // leg (its own containment is the `vendor_state_unreadable` exit below). + let vendored_keys: HashSet = vendor_state_result + .as_ref() + .map(vendored_purl_keys_of) + .unwrap_or_default(); // ── scope resolution ──────────────────────────────────────────────── let manifest = if manifest_missing { @@ -931,9 +1009,7 @@ pub async fn run(args: RollbackArgs) -> i32 { } } for (key, entry) in &vendor_entries { - if patch_matches(key, &entry.uuid, id) - || patch_matches(&entry.base_purl, &entry.uuid, id) - { + if vendor_entry_matches(key, entry, id) { vendor_scope.insert(key.clone()); matched = true; } @@ -996,13 +1072,21 @@ pub async fn run(args: RollbackArgs) -> i32 { args.common.silent || args.common.json, ) .await; + // One single-pattern scope per raw pattern, compiled once (not per + // discovered copy), so each pattern can be checked for a match. + let singles: Vec = path_scope + .raw() + .iter() + .map(|raw| { + crate::path_scope::PathScope::parse(std::slice::from_ref(raw)) + .expect("already parsed above") + }) + .collect(); let mut matched_patterns: HashSet = HashSet::new(); let mut path_selected: HashSet = HashSet::new(); for (purl, paths) in &discovered { for path in paths { - for (idx, raw) in path_scope.raw().iter().enumerate() { - let single = crate::path_scope::PathScope::parse(std::slice::from_ref(raw)) - .expect("already parsed above"); + for (idx, single) in singles.iter().enumerate() { if single.matches(&cwd, path) { matched_patterns.insert(idx); path_selected.insert(purl.clone()); @@ -1126,10 +1210,6 @@ pub async fn run(args: RollbackArgs) -> i32 { || !hosted_scope.is_empty() || hosted_leftover_edits > 0; if has_work && !args.common.dry_run && !args.preserve_state { - let detached_count = vendor_entries - .iter() - .filter(|(k, e)| vendor_scope.contains(k) && e.detached) - .count(); // Compose only the clauses that apply, so a hosted-only run never // claims manifest entries it does not have. let mut clauses: Vec = Vec::new(); @@ -1140,14 +1220,13 @@ pub async fn run(args: RollbackArgs) -> i32 { )); } if !vendor_scope.is_empty() { - let mut clause = format!("delete {} vendored artifact(s)", vendor_scope.len()); - if detached_count > 0 { - clause.push_str(&format!( - " ({detached_count} detached — their embedded patch records are the \ - only local copy)" - )); - } - clauses.push(clause); + // Vendored-mode entries live only in the ledger (their embedded + // patch record is the local copy), so name the ledger records + // as what goes, the way the manifest clause names its entries. + clauses.push(format!( + "delete {} vendored artifact(s) and their ledger records", + vendor_scope.len() + )); } if !hosted_scope.is_empty() { clauses.push(format!( @@ -1174,13 +1253,18 @@ pub async fn run(args: RollbackArgs) -> i32 { } // ── agent leg (in-place restore) ──────────────────────────────────── + // The "No patches found in manifest" line is for an unscoped run with + // nothing to do anywhere: a hosted-/vendored-only project has work in + // the other legs and is not "no patches". let selection = InnerSelection::Scope { purls: &manifest_scope, - announce_empty: !scoped, + announce_empty: !scoped && !has_work, }; match rollback_patches_inner( &args.common, - &manifest_path, + &socket_dir, + &manifest, + &vendored_keys, selection, Some(&telemetry_client), ) @@ -1211,14 +1295,6 @@ pub async fn run(args: RollbackArgs) -> i32 { vendored_leg = run_vendored_leg(&args.common, &keys, &mut vs, args.preserve_state).await; } - // `vendored` (the legacy "benign, untouched" array) is - // reserved-empty in v5.0: acted-on entries land in the - // vendoredReverted/vendoredPreserved/vendoredKept arrays, and - // the corrupt-ledger skip cannot name vendor-owned purls (the - // detection itself needs the ledger) — it surfaces via the - // `vendor_state_unreadable` warning and exit 1 instead. - let vendored: Vec = Vec::new(); - let _ = &vendored_excluded; // ── hosted leg ─────────────────────────────────────────────── let mut hosted_leg = HostedLegOutcome::default(); @@ -1270,12 +1346,10 @@ pub async fn run(args: RollbackArgs) -> i32 { // ledger-key / base-purl / qualifier-stripped triple). let vendored_reverted_ok = |purl: &str| { vendored_leg.reverted.iter().any(|key| { - key == purl - || strip_purl_qualifiers(key) == strip_purl_qualifiers(purl) - || vendor_entries - .iter() - .find(|(k, _)| k == key) - .is_some_and(|(_, e)| e.base_purl == strip_purl_qualifiers(purl)) + vendor_entries + .iter() + .find(|(k, _)| k == key) + .is_some_and(|(k, e)| vendor_entry_covers_purl(k, e, purl)) }) }; let succeeded_purls: HashSet = results @@ -1307,6 +1381,10 @@ pub async fn run(args: RollbackArgs) -> i32 { .collect(); removable.sort(); + // The manifest is rewritten only when an entry actually leaves + // it; an emptied manifest stays on disk as `{"patches": {}}` + // (it carries the setup block and `list`/`apply`/`repair`'s + // empty-vs-missing exit codes) — never deleted. let mut removed: Vec = Vec::new(); let mut updated_manifest = manifest.clone(); let mut manifest_write_failed: Option = None; @@ -1314,7 +1392,7 @@ pub async fn run(args: RollbackArgs) -> i32 { updated_manifest .patches .retain(|purl, _| !removable.contains(purl)); - removed = removable.clone(); + removed = removable; if !args.common.dry_run { if let Err(e) = write_manifest(&manifest_path, &updated_manifest).await { manifest_write_failed = Some(e.to_string()); @@ -1335,60 +1413,46 @@ pub async fn run(args: RollbackArgs) -> i32 { let mut gc_json: serde_json::Value = serde_json::json!({ "skipped": true }); let mut gc_bytes_freed: u64 = 0; if cleanup_allowed { - let mut cleanup_reference = updated_manifest.clone(); // Pin the beforeHash blobs of EVERY entry remaining in the // manifest (still-active patches keep their revert data — // an eco-scoped or failed run must never destroy the blobs // a later rollback needs) plus removed-but-not-installed // entries (remove's crawler-miss guard). Blobs referenced // only by genuinely-removed entries are what gets swept. - let pinned_purls: Vec<&String> = removed + let pinned_purls: Vec = removed .iter() .filter(|p| not_installed.contains(p)) .chain(updated_manifest.patches.keys()) + .cloned() .collect(); - pin_before_hash_blobs(&mut cleanup_reference, &manifest, pinned_purls); - let blobs_dir = socket_dir.join("blobs"); - let mut removed_blobs = 0usize; - let mut removed_diffs = 0usize; - let mut removed_packages = 0usize; - match cleanup_unused_blobs(&cleanup_reference, &blobs_dir, args.common.dry_run) - .await - { - Ok(r) => { - removed_blobs = r.blobs_removed; - gc_bytes_freed += r.bytes_freed; - } - Err(e) => run_warnings.push(( - "cleanup_failed".into(), - format!("blob cleanup failed: {e}"), - )), - } - for (dir, slot) in [ - ("diffs", &mut removed_diffs), - ("packages", &mut removed_packages), - ] { - match cleanup_unused_archives( - &cleanup_reference, - &socket_dir.join(dir), - args.common.dry_run, - ) - .await - { + // The post-removal manifest is not needed past the sweep: + // it becomes the (pin-augmented) reference in place. + let mut cleanup_reference = updated_manifest; + pin_before_hash_blobs(&mut cleanup_reference, &manifest, pinned_purls.iter()); + let sweep = + sweep_unused_artifacts(&cleanup_reference, &socket_dir, args.common.dry_run) + .await; + let mut removed_counts = [0usize; 3]; + for (slot, (label, result)) in removed_counts.iter_mut().zip([ + ("blob", sweep.blobs), + ("diffs", sweep.diffs), + ("packages", sweep.packages), + ]) { + match result { Ok(r) => { *slot = r.blobs_removed; gc_bytes_freed += r.bytes_freed; } Err(e) => run_warnings.push(( "cleanup_failed".into(), - format!("{dir} cleanup failed: {e}"), + format!("{label} cleanup failed: {e}"), )), } } gc_json = serde_json::json!({ - "removedBlobs": removed_blobs, - "removedDiffArchives": removed_diffs, - "removedPackageArchives": removed_packages, + "removedBlobs": removed_counts[0], + "removedDiffArchives": removed_counts[1], + "removedPackageArchives": removed_counts[2], "bytesFreed": gc_bytes_freed, }); } @@ -1441,6 +1505,17 @@ pub async fn run(args: RollbackArgs) -> i32 { .iter() .chain(hosted_leg.warnings.iter()) .for_each(|(code, detail)| run_warnings.push((code.clone(), detail.clone()))); + // A restored package whose ownership could not be put back + // (the engine reports it on `error` with `success: true`) is + // restored but worth a note; `results[].error` carries it too. + for r in results.iter().filter(|r| r.success) { + if let Some(note) = &r.error { + run_warnings.push(( + "ownership_not_restored".into(), + format!("{}: {note}", r.package_key), + )); + } + } // ── status / exit ──────────────────────────────────────────── // Not-installed entries never flip the exit code (see @@ -1493,10 +1568,13 @@ pub async fn run(args: RollbackArgs) -> i32 { "code": code, "detail": detail, })) .collect::>(), - // Vendor-owned purls the run did NOT act on (the - // corrupt-ledger skip); acted-on entries are in the - // vendored* arrays below. - "vendored": vendored, + // The legacy "benign, untouched" array is + // reserved-empty in v5.0: acted-on entries land in + // the vendored* arrays below, and the corrupt-ledger + // skip cannot name vendor-owned purls (the detection + // itself needs the ledger) — it surfaces via the + // `vendor_state_unreadable` warning and exit 1. + "vendored": [], "vendoredReverted": vendored_leg.reverted, "vendoredPreserved": vendored_leg.preserved, "vendoredKept": vendored_leg.kept @@ -1628,6 +1706,8 @@ pub async fn run(args: RollbackArgs) -> i32 { for (code, detail) in &run_warnings { if code == "vendor_state_unreadable" || code == "redirect_state_unreadable" { eprintln!("Error ({code}): {detail}"); + } else if code == "ownership_not_restored" && !args.common.silent { + eprintln!("Warning ({code}): {detail}"); } } } @@ -1732,36 +1812,46 @@ pub async fn run(args: RollbackArgs) -> i32 { } } -async fn rollback_patches_inner( +/// Every purl spelling under which `state`'s entries are addressable — +/// each entry's ledger key, its base purl and the qualifier-stripped key +/// (the same triple as core's `vendored_purl_keys`, computed from an +/// already-loaded ledger instead of re-reading it). +pub(crate) fn vendored_purl_keys_of(state: &VendorState) -> HashSet { + state + .entries + .iter() + .flat_map(|(key, entry)| { + [ + key.clone(), + entry.base_purl.clone(), + strip_purl_qualifiers(key).to_string(), + ] + }) + .collect() +} + +/// The in-place (agent) rollback engine over an already-loaded `manifest`. +/// `vendored_keys` is the ledger's ownership set (see +/// [`vendored_purl_keys_of`]): vendor-owned purls are excluded from the +/// in-place restore. Both `run()` and `remove`'s delegation load each +/// store once under the lock and thread it in here. +pub(crate) async fn rollback_patches_inner( common: &GlobalArgs, - manifest_path: &Path, + socket_dir: &Path, + manifest: &PatchManifest, + vendored_keys: &HashSet, selection: InnerSelection<'_>, - // The client `run()` already built. Constructing one per phase printed - // the core client's "No SOCKET_API_TOKEN set" notice once per - // construction — twice in a single rollback. `None` (the `remove` - // delegation path) builds one on demand, only when the blob download - // below actually fires. + // The client the caller already built. Constructing one per phase + // printed the core client's "No SOCKET_API_TOKEN set" notice once per + // construction — twice in a single rollback. `None` builds one on + // demand, only when the blob download below actually fires. api_client: Option<&ApiClient>, ) -> Result { - // The Scope selection tolerates a missing manifest (ledger-only - // projects reach here with hosted/vendored work and no manifest); - // the Identifier selection keeps the legacy hard requirement. - let manifest = match read_manifest(manifest_path).await.map_err(|e| e.to_string())? { - Some(m) => m, - None => match &selection { - InnerSelection::Identifier(_) => return Err("Invalid manifest".to_string()), - InnerSelection::Scope { .. } => PatchManifest::new(), - }, - }; - - let socket_dir = manifest_path - .parent() - .expect("manifest path names a file, so it has a parent"); let mut blobs_path = socket_dir.join("blobs"); let patches_to_rollback = match &selection { InnerSelection::Identifier(identifier) => { - find_patches_to_rollback(&manifest, identifier.as_deref()) + find_patches_to_rollback(manifest, identifier.as_deref()) } InnerSelection::Scope { purls, .. } => manifest .patches @@ -1807,9 +1897,8 @@ async fn rollback_patches_inner( // in the installed tree, so before-blob restoration is meaningless // there (and would only hash-mismatch). `remove` reverts vendoring; // `vendor --revert` undoes it wholesale. Matching mirrors apply's - // ledger-key / base-purl / qualifier-stripped triple; unreadable state - // degrades to "nothing vendored". - let vendored_keys = socket_patch_core::vendor::vendored_purl_keys(&common.cwd).await; + // ledger-key / base-purl / qualifier-stripped triple; the caller + // degrades unreadable state to "nothing vendored". let is_vendored = |p: &str| vendored_keys.contains(p) || vendored_keys.contains(strip_purl_qualifiers(p)); let (vendored_targets, patches_to_rollback): (Vec<_>, Vec<_>) = patches_to_rollback @@ -1830,14 +1919,19 @@ async fn rollback_patches_inner( }); } - // `--dry-run` must not mutate `.socket/` ("Preview, no mutations"): - // don't create the blobs dir; a throwaway stage replaces it below. - // Created only now that in-place work is known to exist, so a - // hosted-/vendored-only rollback leaves no empty blobs dir behind. + // Nothing here creates `.socket/blobs`: the engine only READS blobs, + // and the missing-blob download creates the directory itself when (and + // only when) it has something to write — so a rollback whose blobs are + // cached or whose files are already original leaves no empty blobs + // dir behind. A regular FILE squatting on the path is corrupt state, + // though: refuse up front rather than misreport it as N "Before blob + // not found" failures. if !common.dry_run { - tokio::fs::create_dir_all(&blobs_path) - .await - .map_err(|e| e.to_string())?; + if let Ok(meta) = tokio::fs::metadata(&blobs_path).await { + if !meta.is_dir() { + return Err(format!("{} is not a directory", blobs_path.display())); + } + } } // Create filtered manifest (a synthetic rollback-target subset, never @@ -1911,7 +2005,7 @@ async fn rollback_patches_inner( let undiscovered_redirects: Vec = scoped_manifest .patches .keys() - .filter(|purl| is_local_redirect(purl, common) && !all_packages.contains_key(*purl)) + .filter(|purl| is_local_go(purl, common) && !all_packages.contains_key(*purl)) .cloned() .collect(); @@ -2021,18 +2115,7 @@ async fn rollback_patches_inner( // reads no blobs, so a missing before-blob must not block an // offline redirect rollback. let attempted_purls: HashSet<&str> = rollback_targets.iter().map(|(p, _)| p.as_str()).collect(); - let gate_manifest = exclude_local_redirects( - &PatchManifest { - patches: scoped_manifest - .patches - .iter() - .filter(|(purl, _)| attempted_purls.contains(purl.as_str())) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - setup: None, - }, - common, - ); + let gate_manifest = before_blob_gate_manifest(&scoped_manifest, &attempted_purls, common); // Apply's `unmatched` twin: in-scope manifest entries the crawler found // no installed package for. Undiscovered local redirects are NOT @@ -2344,10 +2427,12 @@ async fn rollback_patches_inner( }) } -// Export for use by remove command. The third tuple element lists -// vendor-owned purls that were excluded from in-place rollback (benign); -// the fourth is `RollbackOutcome::not_installed` — in-scope manifest -// entries the crawler found no installed package for. +// The legacy path-taking delegation shape, kept for the unit tests below +// (`remove` now threads its already-loaded manifest and ledger straight +// into `rollback_patches_inner`; this wrapper reads them from disk). The +// third tuple element lists vendor-owned purls that were excluded from +// in-place rollback (benign); the fourth is `RollbackOutcome::not_installed` +// — in-scope manifest entries the crawler found no installed package for. // // The returned `bool` is `RollbackOutcome::success` — per-package semantics // only. Manifest entries whose package is not installed are NOT failures @@ -2371,6 +2456,7 @@ async fn rollback_patches_inner( // passed as flags the nested client was unauthenticated and pointed at the // public proxy, so the download failed and the whole `remove` aborted with // `rollback_failed` (see tests/remove_rollback_api_overrides.rs). +#[cfg(test)] pub(crate) async fn rollback_patches( common: &crate::args::GlobalArgs, manifest_path: &Path, @@ -2379,8 +2465,17 @@ pub(crate) async fn rollback_patches( silent: bool, ecosystems: Option>, ) -> Result<(bool, Vec, Vec, Vec), String> { + // The Identifier selection keeps the legacy hard requirement: a + // missing manifest is the (historical) "Invalid manifest" error. + let manifest = read_manifest(manifest_path) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Invalid manifest".to_string())?; + let socket_dir = manifest_path + .parent() + .expect("manifest path names a file, so it has a parent"); + let vendored_keys = socket_patch_core::vendor::vendored_purl_keys(&common.cwd).await; let delegated_common = crate::args::GlobalArgs { - manifest_path: manifest_path.display().to_string(), ecosystems, silent, dry_run, @@ -2388,7 +2483,9 @@ pub(crate) async fn rollback_patches( }; let outcome = rollback_patches_inner( &delegated_common, - manifest_path, + socket_dir, + &manifest, + &vendored_keys, InnerSelection::Identifier(identifier), None, ) @@ -2818,14 +2915,15 @@ mod tests { // The gate must STILL report the cargo before-blob as missing — cargo // is an in-place rollback that genuinely needs it. - let gate = exclude_local_redirects(&manifest, &common); + let attempted: HashSet<&str> = manifest.patches.keys().map(String::as_str).collect(); + let gate = before_blob_gate_manifest(&manifest, &attempted, &common); let gate_missing = get_missing_before_blobs(&gate, blobs).await; assert!( gate_missing.contains("cargo_before"), "gate must keep cargo before-blobs (in-place rollback), got {gate_missing:?}" ); // And the cargo PURL must not be classified as a redirect. - assert!(!is_local_redirect("pkg:cargo/serde@1.0.0", &common)); + assert!(!is_local_go("pkg:cargo/serde@1.0.0", &common)); } /// Regression: local-GO redirects must be excluded from the before-blob @@ -2869,17 +2967,29 @@ mod tests { // Gate manifest: the local-go PURL is excluded, so its before-blob is // not counted as missing. With the npm blob present, the gate reports - // nothing missing. - let gate = exclude_local_redirects(&manifest, &common); + // nothing missing. (Only ATTEMPTED purls enter the gate; every + // entry is attempted here.) + let attempted: HashSet<&str> = manifest.patches.keys().map(String::as_str).collect(); + let gate = before_blob_gate_manifest(&manifest, &attempted, &common); let gate_missing = get_missing_before_blobs(&gate, blobs).await; assert!( gate_missing.is_empty(), "gate must exclude local-go before-blobs, got {gate_missing:?}" ); - // And `is_local_redirect` must classify the go PURL as a redirect in + // A purl the crawler did not discover is not attempted, so it never + // gates either — even an in-place npm one. + let only_go: HashSet<&str> = ["pkg:golang/github.com%2Fpkg%2Ferrors@0.9.1"].into(); + assert!( + before_blob_gate_manifest(&manifest, &only_go, &common) + .patches + .is_empty(), + "an undiscovered in-place purl and a local-go purl both stay out of the gate" + ); + + // And `is_local_go` must classify the go PURL as a redirect in // local mode but a global PURL as in-place (gate must keep the latter). - assert!(is_local_redirect( + assert!(is_local_go( "pkg:golang/github.com%2Fpkg%2Ferrors@0.9.1", &common )); @@ -2887,10 +2997,18 @@ mod tests { global: true, ..crate::args::GlobalArgs::default() }; - assert!(!is_local_redirect( + assert!(!is_local_go( "pkg:golang/github.com%2Fpkg%2Ferrors@0.9.1", &global )); + let global_attempted: HashSet<&str> = + ["pkg:golang/github.com%2Fpkg%2Ferrors@0.9.1"].into(); + assert!( + before_blob_gate_manifest(&manifest, &global_attempted, &global) + .patches + .contains_key("pkg:golang/github.com%2Fpkg%2Ferrors@0.9.1"), + "a global go purl rolls back in place, so its before-blob gates" + ); } /// Regression: rolling back a local-GO patch must DROP the project-local diff --git a/crates/socket-patch-cli/tests/cli_remove_silent.rs b/crates/socket-patch-cli/tests/cli_remove_silent.rs index 1b08e7cd..7531dc3e 100644 --- a/crates/socket-patch-cli/tests/cli_remove_silent.rs +++ b/crates/socket-patch-cli/tests/cli_remove_silent.rs @@ -155,6 +155,10 @@ fn remove_silent_reclaims_stale_lock_without_output() { stderr_rest.is_empty(), "--silent must produce no stderr chatter; got {stderr_rest:?}" ); + assert!( + !socket.join("apply.lock").exists(), + "the reclaimed lock file is removed when the run's guard drops" + ); } /// Write a vendor ledger with one npm entry (empty wiring, so the revert @@ -294,8 +298,8 @@ fn remove_silent_suppresses_vendored_skip_rollback_note() { ); } -/// The detached-only remove path (`scan --vendor --detached` entries with -/// no manifest record) printed its pre-removal listing (stderr) and +/// The ledger-only remove path (vendored entries with no manifest record — +/// the shape `scan --mode vendored` writes) printed its pre-removal listing (stderr) and /// "Reverted vendoring for ..." (stdout) even under `--silent`: the whole /// function gated on `!json` alone. #[test] @@ -497,7 +501,10 @@ fn remove_silent_suppresses_detached_dry_run_preview() { } /// Detached-path twin of the backend-warning gate: warnings printed under -/// `--silent` because the whole function gated on `!json` alone. +/// `--silent` because the whole function gated on `!json` alone. The +/// drifted wiring is a genuine drift-keep, so — exactly like the manifest +/// path — the ledger entry survives, the run exits 1, and the drift-keep +/// ERROR line still prints under `--silent` (errors only, never nothing). #[test] fn remove_silent_suppresses_detached_revert_warnings() { let purl = "pkg:npm/__remove_silent_detached__@1.0.0"; @@ -508,13 +515,27 @@ fn remove_silent_suppresses_detached_revert_warnings() { std::fs::create_dir_all(&socket).expect("create .socket"); std::fs::write(socket.join("manifest.json"), r#"{ "patches": {} }"#).expect("write manifest"); write_vendor_state_wired(tmp.path(), purl, uuid, true, DRIFTED_WIRING); + let ledger_before = + std::fs::read(tmp.path().join(".socket/vendor/state.json")).expect("read ledger"); let (code, _stdout, stderr) = run_remove(tmp.path(), &[purl, "--silent", "--yes"]); - assert_eq!(code, 0, "detached remove must succeed; stderr={stderr:?}"); + assert_eq!( + code, 1, + "an all-kept detached remove is a partial failure; stderr={stderr:?}" + ); assert!( !stderr.contains("Warning ("), "--silent must suppress detached revert warnings; got {stderr:?}" ); + assert!( + stderr.contains("drift-kept"), + "the drift-keep error line must print even under --silent; got {stderr:?}" + ); + assert_eq!( + std::fs::read(tmp.path().join(".socket/vendor/state.json")).expect("read ledger"), + ledger_before, + "a drift-kept entry must survive in the ledger" + ); // Control run: without --silent the warning must print. let tmp2 = tempfile::tempdir().expect("tempdir"); @@ -523,11 +544,15 @@ fn remove_silent_suppresses_detached_revert_warnings() { std::fs::write(socket2.join("manifest.json"), r#"{ "patches": {} }"#).expect("write manifest"); write_vendor_state_wired(tmp2.path(), purl, uuid, true, DRIFTED_WIRING); let (loud_code, _loud_stdout, loud_stderr) = run_remove(tmp2.path(), &[purl, "--yes"]); - assert_eq!(loud_code, 0); + assert_eq!(loud_code, 1); assert!( loud_stderr.contains("Warning (vendor_lock_entry_drifted)"), "non-silent detached run must print the backend warning; got {loud_stderr:?}" ); + assert!( + loud_stderr.contains("Kept vendored state for"), + "non-silent detached run must name the kept entry; got {loud_stderr:?}" + ); } /// Errors must still print under `--silent` ("errors only", not "nothing"): diff --git a/crates/socket-patch-cli/tests/coverage_fix_rollback_ecosystem_scoped_replay.rs b/crates/socket-patch-cli/tests/coverage_fix_rollback_ecosystem_scoped_replay.rs index f1906d54..c8781a6b 100644 --- a/crates/socket-patch-cli/tests/coverage_fix_rollback_ecosystem_scoped_replay.rs +++ b/crates/socket-patch-cli/tests/coverage_fix_rollback_ecosystem_scoped_replay.rs @@ -136,4 +136,8 @@ async fn unscoped_rollback_still_replays_leftover_edits() { !ledger_path(tmp.path()).exists(), "the emptied ledger must be deleted" ); + assert!( + !tmp.path().join(".socket").exists(), + "the replayed-out project keeps no .socket/ residue" + ); } diff --git a/crates/socket-patch-cli/tests/covgap_commands_remove.rs b/crates/socket-patch-cli/tests/covgap_commands_remove.rs index 78ec5b58..1ff2c1fa 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_remove.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_remove.rs @@ -1574,12 +1574,12 @@ fn remove_cleanup_failures_warn_not_fatal() { // from the covered Ok(success=false) gate abort. // --------------------------------------------------------------------------- -/// `.socket/blobs` planted as a regular FILE makes the wet rollback's -/// `create_dir_all(blobs_path)` fail (an infrastructure `Err`, not the -/// before-blob gate's Ok(success=false)): remove must surface it as -/// `rollback_failed` with the "Error during rollback:" prefix and leave -/// the manifest untouched. The package must be installed off its original -/// bytes so the rollback has in-place work (the dir is created lazily). +/// `.socket/blobs` planted as a regular FILE is refused by the wet +/// rollback's shape probe (an infrastructure `Err`, not the before-blob +/// gate's Ok(success=false)): remove must surface it as `rollback_failed` +/// with the "Error during rollback:" prefix and leave the manifest +/// untouched. The package must be installed off its original bytes so the +/// rollback has in-place work (the probe runs only then). #[test] fn remove_rollback_infrastructure_error_surfaces_rollback_failed() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -1767,3 +1767,207 @@ mod pty { assert_eq!(read_bytes(&lock_path), lock_before, "lock untouched"); } } + +// --------------------------------------------------------------------------- +// 14. Ledger-only path: --preserve-state, drift-keeps, and the missing +// `detached` flag — one revert loop shared with the manifest path. +// --------------------------------------------------------------------------- + +/// `remove --preserve-state` on a ledger-only entry unwires the lockfile +/// but KEEPS the artifact and the ledger entry — the documented +/// `--preserve-state` promise, which the ledger-only path used to ignore +/// (deleting the very state it promised to preserve). The empty wiring +/// makes the unwire an offline no-op, so the keep-everything half is what +/// shows: exit 0, a `skipped`/`vendor_state_preserved` event, no `removed` +/// event, ledger byte-identical, artifact on disk. Dry-run twin previews +/// the unwire and mutates nothing. +#[test] +fn remove_detached_preserve_state_keeps_artifact_and_ledger_entry() { + let tmp = tempfile::tempdir().expect("tempdir"); + let purl = "pkg:npm/__covgap_detpreserve__@1.0.0"; + let uuid = "77777777-7777-4777-8777-777777777777"; + let artifact_dir = + write_vendor_ledger_entry(tmp.path(), purl, purl, uuid, "[]", "\"detached\": true,\n "); + let ledger_path = tmp.path().join(".socket/vendor/state.json"); + let ledger_before = read_bytes(&ledger_path); + + let (code, stdout, stderr) = run_remove( + tmp.path(), + &[purl, "--json", "--yes", "--offline", "--preserve-state"], + &[], + ); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + let v = parse_envelope(&stdout); + assert_eq!(v["status"], "success", "envelope={v}"); + assert_eq!( + v["summary"]["removed"], 0, + "nothing is removed under --preserve-state; envelope={v}" + ); + assert!(event_purls(&v, "removed").is_empty(), "envelope={v}"); + let events = v["events"].as_array().expect("events array"); + assert!( + events.iter().any(|e| e["action"] == "skipped" + && e["errorCode"] == "vendor_state_preserved" + && e["purl"] == purl), + "expected a skipped/vendor_state_preserved event: {events:?}" + ); + assert_eq!( + read_bytes(&ledger_path), + ledger_before, + "the ledger entry must be preserved byte-for-byte" + ); + assert!( + artifact_dir.join("package.tgz").exists(), + "the artifact must be preserved" + ); + + // Dry-run twin: the listing and the preview line say what --preserve-state + // will do, and nothing moves. + let tmp2 = tempfile::tempdir().expect("tempdir"); + let artifact_dir2 = + write_vendor_ledger_entry(tmp2.path(), purl, purl, uuid, "[]", "\"detached\": true,\n "); + let ledger_path2 = tmp2.path().join(".socket/vendor/state.json"); + let ledger_before2 = read_bytes(&ledger_path2); + let (code, stdout, stderr) = run_remove( + tmp2.path(), + &[purl, "--offline", "--preserve-state", "--dry-run"], + &[], + ); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + stdout.contains(&format!("Would unwire vendoring for {purl} (artifact preserved)")), + "the preserve preview line must print; stdout=\n{stdout}" + ); + assert!( + stderr.contains("will be unwired (artifacts and ledger entries preserved)"), + "the listing must be honest about --preserve-state; stderr=\n{stderr}" + ); + assert_eq!(read_bytes(&ledger_path2), ledger_before2, "dry run: ledger untouched"); + assert!(artifact_dir2.join("package.tgz").exists(), "dry run: artifact untouched"); +} + +/// A drifted lock on a ledger-only entry: the backend DRIFT-KEEPS +/// (`kept_artifact`), and the ledger-only path must honor it exactly like +/// the manifest path — keep the ledger entry and the artifact, report +/// `skipped`/`vendor_revert_kept`, and fail the run (`partialFailure`, +/// top-level `vendor_revert_kept` since every match kept, exit 1). Before +/// the fix the entry was dropped from the ledger while its wiring and +/// artifact stayed behind — the "wired but ledgerless" recovery state +/// `repair` exists for — and the run reported a clean removal. +#[test] +fn remove_detached_drift_keep_holds_ledger_entry_and_exits_one() { + let tmp = tempfile::tempdir().expect("tempdir"); + let purl = "pkg:npm/__covgap_detdrift__@1.0.0"; + let uuid = "88888888-8888-4888-8888-888888888888"; + let artifact_dir = write_vendor_ledger_entry( + tmp.path(), + purl, + purl, + uuid, + DRIFTED_WIRING, + "\"detached\": true,\n ", + ); + let ledger_path = tmp.path().join(".socket/vendor/state.json"); + let ledger_before = read_bytes(&ledger_path); + + let (code, stdout, stderr) = + run_remove(tmp.path(), &[purl, "--json", "--yes", "--offline"], &[]); + assert_eq!( + code, 1, + "an all-kept remove is a partial failure; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v = parse_envelope(&stdout); + assert_eq!(v["status"], "partialFailure", "envelope={v}"); + assert_eq!( + v["error"]["code"], "vendor_revert_kept", + "every match kept → top-level error; envelope={v}" + ); + assert_eq!(v["summary"]["removed"], 0, "envelope={v}"); + let events = v["events"].as_array().expect("events array"); + assert!( + events.iter().any(|e| e["action"] == "skipped" + && e["errorCode"] == "vendor_revert_kept" + && e["purl"] == purl), + "expected a skipped/vendor_revert_kept event: {events:?}" + ); + assert!( + event_purls(&v, "removed").is_empty(), + "nothing may be reported removed; envelope={v}" + ); + assert_eq!( + read_bytes(&ledger_path), + ledger_before, + "the drift-kept ledger entry must survive byte-for-byte" + ); + assert!( + artifact_dir.join("package.tgz").exists(), + "the drift-kept artifact must survive" + ); + + // Human twin: the per-key keep line and the errors-only summary line. + let tmp2 = tempfile::tempdir().expect("tempdir"); + write_vendor_ledger_entry( + tmp2.path(), + purl, + purl, + uuid, + DRIFTED_WIRING, + "\"detached\": true,\n ", + ); + let (code, stdout, stderr) = run_remove(tmp2.path(), &[purl, "--yes", "--offline"], &[]); + assert_eq!(code, 1, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + stderr.contains(&format!("Kept vendored state for {purl}: lockfile wiring drifted")), + "the per-key keep line must print; stderr=\n{stderr}" + ); + assert!( + stderr.contains("1 matching entry was drift-kept (vendored state and ledger record retained)"), + "the summary error line must print; stderr=\n{stderr}" + ); + assert!( + !stdout.contains("Reverted vendoring for"), + "nothing was reverted; stdout=\n{stdout}" + ); +} + +/// The ledger-only path is not gated on the entry's `detached` flag: ANY +/// ledger entry with no manifest record — the shape `remove --skip-rollback` +/// leaves behind, and every `scan/get --mode vendored` entry — is removable +/// through the ledger. Before, a non-detached ledger-only match fell through +/// to `not_found`, wired forever. +#[test] +fn remove_ledger_only_entry_without_detached_flag_reverts() { + let tmp = tempfile::tempdir().expect("tempdir"); + let purl = "pkg:npm/__covgap_ledgeronly__@1.0.0"; + let uuid = "99999999-9999-4999-8999-999999999999"; + // Empty manifest: the identifier matches only the ledger entry, which + // carries no `detached` flag at all. + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write(socket.join("manifest.json"), r#"{ "patches": {} }"#).unwrap(); + let artifact_dir = write_vendor_ledger_entry(tmp.path(), purl, purl, uuid, "[]", ""); + + let (code, stdout, stderr) = + run_remove(tmp.path(), &[purl, "--json", "--yes", "--offline"], &[]); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + let v = parse_envelope(&stdout); + assert_eq!(v["status"], "success", "envelope={v}"); + assert_eq!( + v["summary"]["removed"], 1, + "the revert IS the removal; envelope={v}" + ); + assert_eq!(event_purls(&v, "removed"), vec![purl], "envelope={v}"); + assert!( + !tmp.path().join(".socket/vendor").exists(), + "the emptied ledger and its vendor/ dir are pruned" + ); + assert!(!artifact_dir.exists(), "the artifact is deleted"); + assert!( + socket.join("manifest.json").exists(), + "the (empty) manifest is project state and stays" + ); + assert!( + !socket.join("apply.lock").exists(), + "the lock file never outlives the run" + ); +} diff --git a/crates/socket-patch-cli/tests/covgap_commands_repair.rs b/crates/socket-patch-cli/tests/covgap_commands_repair.rs index 4bf66cef..2e7df6ae 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_repair.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_repair.rs @@ -12,7 +12,8 @@ //! `format_cleanup_result`'s exact wording, //! * the archive-cleanup failure arm (stderr warning + `cleanup_failed` //! skip event, exit stays 0, loop continues to the packages pass), -//! * the lock-file unlink-failure warning (exit stays 0), +//! * an unremovable `apply.lock` (read-only `.socket`) stays non-fatal +//! and silent (exit stays 0), //! * the loud "Rebuilt N vendored artifact(s)." summary after the //! vendored-repair phase. //! @@ -433,8 +434,9 @@ fn repair_removes_orphan_archives_human_mode_prints_relabeled_summary() { /// pass still sweeps its orphan after the diffs pass failed. /// /// Deterministic cross-platform fixture: `.socket/diffs` is a regular FILE, -/// so `cleanup_dir`'s metadata() succeeds (no early return) and read_dir() -/// fails with ENOTDIR. +/// so `cleanup_dir`'s read_dir() fails with ENOTDIR/NotADirectory (not +/// NotFound, which is the silent "nothing to sweep" case) and that error +/// propagates as `cleanup_failed`. #[test] fn repair_archive_cleanup_failure_warns_and_continues() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -517,20 +519,23 @@ fn stdout_reports_package_sweep(stdout: &str) -> bool { } // --------------------------------------------------------------------------- -// Lock-file unlink failure (housekeeping stays non-fatal) +// Lock-file unlink failure (the guard's cleanup stays non-fatal) // --------------------------------------------------------------------------- -/// A failed `apply.lock` unlink at the tail of a finished repair is -/// housekeeping: human mode warns on stderr WITHOUT flipping the exit code. -/// Unix-only: unlink needs write on the parent dir, so a 0o555 `.socket` -/// makes the delete fail deterministically while opening the pre-created -/// lock file (no dir write needed) and reading the manifest still work. -/// Same chmod choreography as `repair_cleanup_failure_is_reported_in_json_ -/// and_silent_modes` in `repair_invariants.rs` (running as root would let -/// the unlink through and fail this test loudly, not vacuously). +/// A failed `apply.lock` unlink when the lock guard drops at the tail of a +/// finished repair is best-effort housekeeping: it must neither panic (a +/// panicking drop would abort the process with exit 101) nor flip the exit +/// code, and it is silent — the core guard cannot see `--silent`/`--json`, +/// so it never prints. Unix-only: unlink needs write on the parent dir, so +/// a 0o555 `.socket` makes the delete fail deterministically while opening +/// the pre-created lock file (no dir write needed) and reading the manifest +/// still work. Same chmod choreography as +/// `repair_cleanup_failure_is_reported_in_json_and_silent_modes` in +/// `repair_invariants.rs` (running as root would let the unlink through and +/// fail this test loudly, not vacuously). #[cfg(unix)] #[test] -fn repair_warns_but_exits_zero_when_lock_file_unremovable() { +fn repair_exits_zero_and_stays_quiet_when_lock_file_unremovable() { use std::os::unix::fs::PermissionsExt; let tmp = tempfile::tempdir().expect("tempdir"); @@ -559,8 +564,8 @@ fn repair_warns_but_exits_zero_when_lock_file_unremovable() { finished repair; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stderr.contains("Warning: could not remove lock file"), - "human mode must warn about the undeletable lock file; stderr=\n{stderr}" + !stderr.contains("lock file"), + "the guard's best-effort unlink is silent; stderr=\n{stderr}" ); assert!( socket.join("apply.lock").exists(), diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index cf2bf2c7..44600c91 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -509,10 +509,11 @@ fn corrupt_manifest_json_errors_in_both_modes() { ); } -/// `.socket/blobs` existing as a regular FILE makes the inner pipeline's -/// `create_dir_all` fail — the boundary maps that to the legacy -/// `{status: "error", rolledBack: 0, vendored: [], results: []}` envelope -/// (and a bare `Error:` stderr line in human mode), exit 1. +/// `.socket/blobs` existing as a regular FILE is corrupt state the inner +/// pipeline refuses up front (its wet-run shape probe; the directory itself +/// is only ever created by the blob download) — the boundary maps that to +/// the legacy `{status: "error", rolledBack: 0, vendored: [], results: []}` +/// envelope (and a bare `Error:` stderr line in human mode), exit 1. #[test] fn blobs_path_as_file_yields_legacy_error_envelope() { let build = || { @@ -528,7 +529,7 @@ fn blobs_path_as_file_yields_legacy_error_envelope() { &after_hash, )], ); - // The blobs path is a regular FILE, so create_dir_all must fail. + // The blobs path is a regular FILE, so the wet run's shape probe refuses. std::fs::write(socket.join("blobs"), b"not a directory").expect("write blobs file"); tmp }; @@ -1442,10 +1443,12 @@ fn write_two_record_fixture(root: &Path) { ); } -/// Human wet run over a hosted-only (manifest-less) project: the unscoped -/// "No patches found in manifest" announce, the wet "Unwound hosted -/// redirect for {purl}" line, and the reinstall note — with the wiring -/// actually unwound and the emptied ledger deleted. +/// Human wet run over a hosted-only (manifest-less) project: the wet +/// "Unwound hosted redirect for {purl}" line and the reinstall note — with +/// the wiring actually unwound, the emptied ledger deleted and no +/// `.socket/` residue. The unscoped "No patches found in manifest" line is +/// reserved for a run with no work in ANY leg: a project whose patches are +/// all hosted has work, so the line must NOT print alongside the unwind. #[test] fn hosted_human_wet_announces_and_unwinds() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -1457,8 +1460,9 @@ fn hosted_human_wet_announces_and_unwinds() { "the hosted-only rollback succeeds; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains("No patches found in manifest"), - "the unscoped empty-manifest announce must print; stdout=\n{stdout}" + !stdout.contains("No patches found in manifest"), + "the empty-manifest announce must not print when the hosted leg has work; \ + stdout=\n{stdout}" ); assert!( stdout.contains(&format!("Unwound hosted redirect for {LP_PURL}")), @@ -1477,6 +1481,11 @@ fn hosted_human_wet_announces_and_unwinds() { !ledger_path(tmp.path()).exists(), "the emptied ledger must be deleted" ); + assert!( + !tmp.path().join(".socket").exists(), + "a fully unwound hosted project keeps no .socket/ residue (vendor/ pruned \ + with the ledger, apply.lock removed by the lock guard)" + ); } /// Human dry-run twin: "Would unwind hosted redirect for {purl}", nothing @@ -1686,6 +1695,10 @@ fn leftover_edits_only_ledger_replays_unscoped() { !ledger_path(tmp.path()).exists(), "the emptied ledger must be deleted" ); + assert!( + !tmp.path().join(".socket").exists(), + "the replayed-out project keeps no .socket/ residue" + ); } /// A corrupt redirect ledger skips ONLY the hosted leg: exit 1 with the @@ -1784,6 +1797,7 @@ fn ecosystems_filter_narrows_hosted_scope() { "the wiring must be unwound" ); assert!(!ledger_path(tmp.path()).exists(), "ledger deleted"); + assert!(!tmp.path().join(".socket").exists(), "no .socket/ residue"); } /// A path-shaped target selects a HOSTED record through its installed @@ -1822,6 +1836,7 @@ fn path_glob_selects_hosted_record() { "the wiring must be unwound" ); assert!(!ledger_path(tmp.path()).exists(), "ledger deleted"); + assert!(!tmp.path().join(".socket").exists(), "no .socket/ residue"); } /// `persist_redirect_state` FAILURE after the hosted leg mutated the @@ -1923,6 +1938,7 @@ fn bun_deferred_purl_unwinds_via_replay() { !ledger_path(tmp.path()).exists(), "record and edit both unwound: the ledger must be deleted" ); + assert!(!tmp.path().join(".socket").exists(), "no .socket/ residue"); } // ═══════════════ 4. GC-failure warnings (unix permissions) ═════════════════ diff --git a/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs b/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs index 0f6dd1c3..cb4afb12 100644 --- a/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs +++ b/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs @@ -117,13 +117,22 @@ async fn remove_with_rollback_full_chain() { serde_json::from_str(&std::fs::read_to_string(socket.join("manifest.json")).unwrap()) .unwrap(); assert_eq!(m["patches"].as_object().unwrap().len(), 0); - // 3. Blobs no longer referenced — cleanup should have removed them. - let blobs_remaining: Vec<_> = std::fs::read_dir(&blobs).unwrap().flatten().collect(); + // 3. Blobs no longer referenced — cleanup removed them, and the + // emptied store directory with them. assert!( - blobs_remaining.is_empty(), - "blob cleanup must remove orphaned blobs after remove; still present: {:?}", - blobs_remaining + !blobs.exists(), + "blob cleanup must remove the orphaned blobs and the emptied .socket/blobs/; left: {:?}", + std::fs::read_dir(&blobs) + .map(|rd| rd.flatten().map(|e| e.file_name()).collect::>()) + .unwrap_or_default() ); + // 4. The lock file never outlives the run; the (now empty) manifest is + // project state and keeps `.socket/` alive. + assert!( + !socket.join("apply.lock").exists(), + "apply.lock must be removed when the command's lock guard drops" + ); + assert!(socket.join("manifest.json").exists()); } #[tokio::test] @@ -710,13 +719,13 @@ async fn repair_offline_with_present_blobs_succeeds() { ); } -/// Regression: `remove` is the documented per-purl exit path for detached -/// vendored patches (`scan --vendor --detached`), and detached mode writes -/// NO manifest (scan_vendor_e2e pins "detached mode must not create a -/// manifest"). But `remove`'s pre-flight manifest-existence gate returned -/// `manifest_not_found` (exit 1) before the detached branch could run, so -/// on a pure-detached project — the primary detached scenario — the exit -/// path was unreachable. The ledger stayed wired forever. +/// Regression: `remove` is the documented per-purl exit path for vendored +/// (ledger-only) patches — `scan --mode vendored` keeps its records in the +/// vendor ledger and writes NO manifest (scan_vendor_e2e pins that). But +/// `remove`'s pre-flight manifest-existence gate returned +/// `manifest_not_found` (exit 1) before the ledger branch could run, so on +/// a vendored project — the primary scenario — the exit path was +/// unreachable. The ledger stayed wired forever. #[tokio::test] #[serial] async fn remove_detached_vendored_without_manifest_reverts() { @@ -724,9 +733,9 @@ async fn remove_detached_vendored_without_manifest_reverts() { let purl = "pkg:npm/detached-only@1.0.0"; let uuid = "55555555-5555-4555-8555-555555555555"; - // What `scan --vendor --detached` leaves behind: ledger + artifact, - // no `.socket/manifest.json`. Empty wiring makes the npm revert a - // pure offline artifact-dir delete. + // What `scan --mode vendored` leaves behind: ledger + artifact, no + // `.socket/manifest.json`. Empty wiring makes the npm revert a pure + // offline artifact-dir delete. let vendor = tmp.path().join(".socket/vendor"); let artifact_dir = vendor.join("npm").join(uuid); std::fs::create_dir_all(&artifact_dir).unwrap(); @@ -780,10 +789,13 @@ async fn remove_detached_vendored_without_manifest_reverts() { !artifact_dir.exists(), "the vendored artifact must be deleted on remove" ); - // And no manifest was conjured into being along the way. + // And no manifest was conjured into being along the way — in fact the + // full reversal leaves NO `.socket/` at all: the emptied ledger and + // its `vendor/` levels are pruned, and the lock guard removes + // `apply.lock` and the then-empty directory. assert!( - !tmp.path().join(".socket/manifest.json").exists(), - "remove must not create a manifest on a pure-detached project" + !tmp.path().join(".socket").exists(), + "a fully reverted vendored project keeps no .socket/ residue" ); } @@ -838,7 +850,6 @@ async fn repair_telemetry_attributed_to_env_credentials() { std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); std::env::remove_var("SOCKET_OFFLINE"); - std::env::remove_var("VITEST"); let code = repair_run(make_repair_args(tmp.path(), "file")).await; std::env::remove_var("SOCKET_API_URL"); std::env::remove_var("SOCKET_API_TOKEN"); diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index 3885537b..902ac40f 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -499,6 +499,12 @@ async fn npm_hosted_round_trip() { !tmp.path().join(".socket/manifest.json").exists(), "a hosted-only rollback must not materialize a manifest" ); + assert!( + !tmp.path().join(".socket").exists(), + "a fully unwound hosted project keeps no .socket/ residue: the ledger's \ + vendor/ dir is pruned with it and the lock guard removes apply.lock and \ + the emptied directory" + ); } /// Dry-run twin of the round trip — the review-caught regression: the @@ -719,6 +725,10 @@ async fn pypi_requirements_hosted_round_trip() { !tmp.path().join(".socket/manifest.json").exists(), "hosted mode never touches the manifest" ); + assert!( + !tmp.path().join(".socket").exists(), + "a fully unwound hosted project keeps no .socket/ residue" + ); } // --------------------------------------------------------------------------- @@ -834,8 +844,9 @@ async fn hosted_only_project_without_manifest() { ); assert!(!ledger_path(tmp.path()).exists(), "ledger must be deleted"); assert!( - !tmp.path().join(".socket/manifest.json").exists(), - "no manifest may be materialized" + !tmp.path().join(".socket").exists(), + "no manifest may be materialized, and the emptied .socket/ (ledger and \ + vendor/ pruned, apply.lock removed) must be gone" ); // Truly empty: all three stores absent keeps the legacy error. @@ -896,4 +907,8 @@ async fn preserve_state_still_unwinds_hosted() { !ledger_path(tmp.path()).exists(), "hosted ledger records are dropped with the wiring — no preservable state" ); + assert!( + !tmp.path().join(".socket").exists(), + "with nothing preservable, the emptied .socket/ is gone too" + ); } diff --git a/crates/socket-patch-cli/tests/repair_invariants.rs b/crates/socket-patch-cli/tests/repair_invariants.rs index 3fa917e4..0e658852 100644 --- a/crates/socket-patch-cli/tests/repair_invariants.rs +++ b/crates/socket-patch-cli/tests/repair_invariants.rs @@ -563,7 +563,12 @@ fn repair_cleanup_failure_is_reported_in_json_and_silent_modes() { } // --------------------------------------------------------------------------- -// Advisory-lock cleanup — repair owns the old `unlock --release` behavior +// Advisory lock — the lock file never outlives the run +// +// Every mutating command's lock guard reclaims a leftover `apply.lock` in +// place and removes it (plus an otherwise-empty `.socket/`) when it drops; +// repair — the historical home of the old `unlock --release` fold-in — is +// where that contract is pinned. // --------------------------------------------------------------------------- /// Take an exclusive flock on the binary's lock file path (the same @@ -584,8 +589,8 @@ fn take_external_lock(socket_dir: &Path) -> std::fs::File { file } -/// A leftover `apply.lock` from an earlier (or crashed) run is removed -/// by a successful repair — the fold-in of the old `unlock --release`. +/// A leftover `apply.lock` from an earlier (or crashed) run is reclaimed +/// in place and gone once a successful repair's lock guard drops. #[test] fn repair_deletes_leftover_lock_file_on_success() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -619,9 +624,12 @@ fn repair_deletes_probe_created_lock_file() { ); } -/// `--dry-run` mutates nothing — including the lock file. +/// The lock file is runtime state, not project state: `--dry-run` mutates +/// nothing that belongs to the project, but its guard still reclaims a +/// leftover `apply.lock` and removes it on the way out — no `if !dry_run` +/// gate may creep back around the release. #[test] -fn repair_dry_run_preserves_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"); @@ -630,8 +638,12 @@ fn repair_dry_run_preserves_lock_file() { let (code, stdout) = run_repair(tmp.path(), &["--dry-run"]); assert_eq!(code, 0, "expected exit 0; stdout=\n{stdout}"); assert!( - socket.join("apply.lock").exists(), - "--dry-run must not delete apply.lock" + !socket.join("apply.lock").exists(), + "a dry run leaves no apply.lock behind either" + ); + assert!( + socket.join("manifest.json").exists() && socket.join("blobs").join(REFERENCED_HASH).exists(), + "project state is untouched by the dry run" ); } @@ -656,9 +668,9 @@ fn repair_refuses_and_keeps_lock_when_live_holder() { ); } -/// The lock-file cleanup is housekeeping that runs on every completion -/// path, not a success reward: a repair that fails past the lock (here: -/// an unparseable manifest → `repair_failed`) still deletes the file. +/// The lock-file cleanup runs on every completion path, not as a success +/// reward: a repair that fails past the lock (here: an unparseable +/// manifest → `repair_failed`) still drops its guard and the file with it. #[test] fn repair_deletes_lock_file_even_when_repair_fails() { let tmp = tempfile::tempdir().expect("tempdir"); From dd806483614fad2c3a3c129890952d41219a3d41 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 19:54:50 -0400 Subject: [PATCH 17/44] refactor(cli-misc): ledger-aware list/setup --check, apply hook hygiene, one socket-dir derivation apply: build the API client only after the no-manifest and --check exits (and before the lock), thread that one client into staging and the mismatch blob top-up; parse the manifest once (PnP gate + apply loop) and narrow it in place instead of cloning; decide the empty-scope no-op before the ledger read and crawl; mismatch_blob_gaps hashes only files whose afterHash blob is actually missing; the all-unmatched warning prints under --silent (it flips the exit code); "No patch manifest found; nothing to apply." replaces the false "No .socket folder found"; surface core's ownership-not-restored advisory as an ownership_not_restored run warning; release the lock before output/telemetry; merge go/npm local-scope predicates (dead `go` alias gone). list: fold the vendor ledger's detached records in as a third provenance (`vendored`, .socket/vendor/state.json) so vendored-only projects list and exit 0; ledger root = the new GlobalArgs::project_root(). setup: --exclude persistence moves behind the mutation gate (after discovery and confirm; also on the already-configured path for an explicit flag), takes apply.lock for its read-modify-write and reports write/lock failures instead of swallowing them; the manifest is read once per run; --check folds detached ledger records into the property-4 consistency pass; gem/composer discovered once and bundler probed once per run (add_plugin_directive_with); patch_setup telemetry fires only for a successful non-dry run; dead --ecosystems alias tables removed. vex: one vendor-ledger read per run (fold + no-verify + VendorContext via vendor_context_from); json_requires_output usage error no longer fires telemetry. fetch_stage: stage_patch_sources takes the caller's client; offline remedy per stager (vendored: re-run online, never `repair`); is_valid_blob_hash from core. bun_preflight: one preflight core behind the three entry points. path_scope: bind(cwd) absolutizes once. ecosystem_dispatch: "Using at:" banner goes to stderr. update: dead dry_run assignment. update_notifier: core env_truthy. args: project_root()/socket_dir()/socket_dir_of(), --lock-timeout doc, VITEST scrub gone. Tests updated/added for every changed behavior (cli_apply_silent, cli_parse_list vendored twins, covgap apply/setup, setup_contract_gaps detached property-4). Co-Authored-By: Claude Fable 5.1 --- crates/socket-patch-cli/src/args.rs | 126 +++++- crates/socket-patch-cli/src/commands/apply.rs | 333 +++++++++------ .../src/commands/bun_preflight.rs | 43 +- .../src/commands/fetch_stage.rs | 133 ++++-- crates/socket-patch-cli/src/commands/list.rs | 275 ++++++++---- crates/socket-patch-cli/src/commands/mod.rs | 55 +++ crates/socket-patch-cli/src/commands/setup.rs | 392 ++++++++++++------ .../socket-patch-cli/src/commands/update.rs | 3 +- crates/socket-patch-cli/src/commands/vex.rs | 77 ++-- .../src/ecosystem_dispatch.rs | 7 +- crates/socket-patch-cli/src/path_scope.rs | 71 +++- .../socket-patch-cli/src/update_notifier.rs | 16 +- .../tests/cli_apply_silent.rs | 109 ++++- .../socket-patch-cli/tests/cli_parse_list.rs | 240 +++++++++++ .../tests/covgap_commands_apply.rs | 59 +++ .../tests/covgap_commands_setup.rs | 104 +++++ .../tests/setup_contract_gaps.rs | 114 ++++- 17 files changed, 1666 insertions(+), 491 deletions(-) diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 3953225b..3e553cf2 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -258,8 +258,10 @@ pub struct GlobalArgs { /// positive value retries with a 100 ms backoff until the lock /// frees or the budget elapses. Only meaningful for the lock- /// contending subcommands (`apply`, `rollback`, `repair`, `remove`, - /// `vendor`, and the vendored modes of `scan`/`get`); other - /// commands accept it silently. + /// `vendor`, `setup --exclude`'s manifest write, and the hosted / + /// vendored modes of `scan`/`get`); other commands accept it + /// silently. Every holder removes the lock file on exit, so a + /// leftover from a crashed run never contends. #[arg(long = "lock-timeout", env = "SOCKET_LOCK_TIMEOUT")] pub lock_timeout: Option, @@ -308,6 +310,43 @@ impl GlobalArgs { } } + /// The project root whose `.socket/` state stores — manifest, vendor + /// ledger, redirect ledger — belong together: the RESOLVED manifest's + /// directory, stepping out of a standard `.socket/` layout when the + /// manifest lives in one. For the default `/.socket/manifest.json` + /// this is exactly `cwd`; for a `--manifest-path` into another project + /// it is that project's root (its `.socket` parent's parent); for a + /// bare file like `--manifest-path /tmp/x/abs.json` it is the file's + /// own directory. Every command that reads more than one store must + /// derive them from THIS root, so `--manifest-path` can never + /// interleave two projects' state (CLI_CONTRACT.md: both stores always + /// come from the SAME project). + pub(crate) fn project_root(&self) -> PathBuf { + let manifest_path = self.resolved_manifest_path(); + match manifest_path.parent() { + Some(dir) + if dir.file_name() + == Some(std::ffi::OsStr::new( + socket_patch_core::constants::SOCKET_DIR, + )) => + { + dir.parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| self.cwd.clone()) + } + Some(dir) => dir.to_path_buf(), + None => self.cwd.clone(), + } + } + + /// The directory the manifest lives in — where `apply.lock`, `blobs/`, + /// `diffs/` and `packages/` sit (`/.socket` by default). The one + /// derivation every lock acquire and artifact probe uses; see + /// [`socket_dir_of`] for callers holding a raw manifest path. + pub(crate) fn socket_dir(&self) -> PathBuf { + socket_dir_of(&self.resolved_manifest_path(), &self.cwd) + } + /// Build [`ApiClientEnvOverrides`] from the CLI flags. /// /// Every field is forwarded as `Some(_)` only when set and non-empty. @@ -326,6 +365,19 @@ impl GlobalArgs { } } +/// The `.socket/`-role directory for `manifest_path`: its parent, falling +/// back to `cwd` for a bare relative file name — never `"."`, which is +/// wrong under a non-default `--cwd`. [`GlobalArgs::resolved_manifest_path`] +/// always joins a relative path onto `cwd`, so the fallback is reachable +/// only for callers handed an unresolved path. +pub(crate) fn socket_dir_of(manifest_path: &Path, cwd: &Path) -> PathBuf { + manifest_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(Path::to_path_buf) + .unwrap_or_else(|| cwd.to_path_buf()) +} + /// Apply CLI-flag toggles for env-driven knobs by mirroring them into env /// vars. This is how `--offline` / `--debug` / `--no-telemetry` reach core /// code that reads `SOCKET_OFFLINE` / `SOCKET_DEBUG` / @@ -530,11 +582,11 @@ mod tests { } /// Clear the extra env the core telemetry gate reads beyond the - /// `SOCKET_*` set (`is_telemetry_disabled` also consults `VITEST` and the - /// legacy `SOCKET_PATCH_TELEMETRY_DISABLED` name), so the airgap tests - /// below can't pass or fail vacuously. Restores afterwards. + /// `SOCKET_*` set (`is_telemetry_disabled` also consults the legacy + /// `SOCKET_PATCH_TELEMETRY_DISABLED` name), so the airgap tests below + /// can't pass or fail vacuously. Restores afterwards. fn with_clean_telemetry_env(f: impl FnOnce()) { - with_env_cleared(&["VITEST", "SOCKET_PATCH_TELEMETRY_DISABLED"], f); + with_env_cleared(&["SOCKET_PATCH_TELEMETRY_DISABLED"], f); } /// `--offline` promises "never contact the network", but the telemetry @@ -1003,6 +1055,68 @@ mod tests { ); } + /// The default layout: `/.socket/manifest.json` → the project + /// root is `cwd` and the socket dir is `/.socket`. + #[test] + fn project_root_and_socket_dir_for_default_layout() { + let args = GlobalArgs { + cwd: PathBuf::from("/work/project"), + ..GlobalArgs::default() + }; + assert_eq!(args.project_root(), PathBuf::from("/work/project")); + assert_eq!( + args.socket_dir(), + PathBuf::from("/work/project").join(".socket") + ); + } + + /// `--manifest-path` into ANOTHER project's `.socket/`: every store + /// (manifest, vendor ledger, redirect ledger, lock) resolves against + /// that project — its `.socket` parent's parent — never the cwd. + #[test] + fn project_root_steps_out_of_a_foreign_socket_dir() { + let args = GlobalArgs { + cwd: PathBuf::from("/work/project"), + manifest_path: "../other/.socket/manifest.json".to_string(), + ..GlobalArgs::default() + }; + let other = PathBuf::from("/work/project").join("../other"); + assert_eq!(args.project_root(), other); + assert_eq!(args.socket_dir(), other.join(".socket")); + } + + /// A bare manifest file outside any `.socket/` layout: the file's own + /// directory plays both roles. + #[test] + fn project_root_of_a_bare_manifest_file_is_its_directory() { + let args = GlobalArgs { + cwd: PathBuf::from("/work/project"), + manifest_path: "custom/mp.json".to_string(), + ..GlobalArgs::default() + }; + let custom = PathBuf::from("/work/project").join("custom"); + assert_eq!(args.project_root(), custom); + assert_eq!(args.socket_dir(), custom); + } + + /// `socket_dir_of` on a raw relative file name falls back to `cwd`, + /// never to `"."` (wrong under a non-default `--cwd`); a resolved path + /// yields its parent. + #[test] + fn socket_dir_of_bare_relative_name_falls_back_to_cwd() { + assert_eq!( + socket_dir_of(Path::new("manifest.json"), Path::new("/work/project")), + PathBuf::from("/work/project"), + ); + let resolved = PathBuf::from("/work/project") + .join(".socket") + .join("manifest.json"); + assert_eq!( + socket_dir_of(&resolved, Path::new("/elsewhere")), + PathBuf::from("/work/project").join(".socket"), + ); + } + /// `parse_supported_ecosystem` accepts every supported ecosystem name /// and returns it verbatim. #[test] diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 4e07cdea..bae40dea 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -1,5 +1,6 @@ use clap::Args; -use socket_patch_core::api::client::get_api_client_with_overrides; +use socket_patch_core::api::blob_fetcher::get_missing_blobs; +use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning; use socket_patch_core::crawlers::{ detect_npm_pkg_manager, CrawlerOptions, Ecosystem, NpmPkgManager, RubyCrawler, @@ -72,6 +73,7 @@ async fn ensure_blobs_for_mismatches( all_packages: &HashMap>, vendored_purls: &HashSet, staged: &mut StagedSources, + client: &ApiClient, ) { if args.common.strict && !args.force { return; // strict fails on mismatch — nothing to fetch @@ -116,9 +118,8 @@ async fn ensure_blobs_for_mismatches( } return; }; - let (client, _) = get_api_client_with_overrides(args.common.api_client_overrides()).await; let _ = socket_patch_core::api::blob_fetcher::fetch_blobs_by_hash( - &needed, blobs_path, &client, None, + &needed, blobs_path, client, None, ) .await; } @@ -150,6 +151,11 @@ async fn ensure_blobs_for_mismatches( /// by contrast, mirrors the apply loop's representative check against the /// FIRST copy (release-variant ecosystems install one directory per /// `package@version`). +/// +/// Only a mismatched file whose afterHash blob is NOT staged can queue a +/// fetch, so the probe first decides that with metadata probes alone and +/// hashes only the files that can still matter: the common fully-cached +/// run hashes nothing here (the apply loop re-verifies everything anyway). async fn mismatch_blob_gaps( manifest: &PatchManifest, all_packages: &HashMap>, @@ -158,6 +164,18 @@ async fn mismatch_blob_gaps( force: bool, ) -> HashSet { let mut needed: HashSet = HashSet::new(); + let missing = get_missing_blobs(manifest, blobs_path).await; + if missing.is_empty() { + return needed; + } + // A record can queue a fetch only through a content-modifying file + // (non-empty beforeHash) whose afterHash blob is missing. + let can_queue = |record: &PatchRecord| { + record + .files + .values() + .any(|f| !f.before_hash.is_empty() && missing.contains(&f.after_hash)) + }; for (purl, pkg_paths) in all_packages { let Some(first_path) = pkg_paths.first() else { continue; @@ -177,6 +195,9 @@ async fn mismatch_blob_gaps( { continue; } + if !records.iter().any(|(_, record)| can_queue(record)) { + continue; + } let gated = variant_eco && !force && (records.len() > 1 @@ -184,6 +205,9 @@ async fn mismatch_blob_gaps( .first() .is_some_and(|(key, _)| key.as_str() != stripped)); for (_, record) in records { + if !can_queue(record) { + continue; + } if gated { if let Some((file_name, file_info)) = representative_file(&record.files) { let status = verify_file_patch(first_path, file_name, file_info) @@ -195,16 +219,12 @@ async fn mismatch_blob_gaps( } } for (file_name, info) in &record.files { - if info.before_hash.is_empty() { + if info.before_hash.is_empty() || !missing.contains(&info.after_hash) { continue; } for pkg_path in pkg_paths { let verify = verify_file_patch(pkg_path, file_name, info).await; - if verify.status == VerifyStatus::HashMismatch - && tokio::fs::metadata(blobs_path.join(&info.after_hash)) - .await - .is_err() - { + if verify.status == VerifyStatus::HashMismatch { needed.insert(info.after_hash.clone()); break; // the fetch is per-hash; one drifted copy queues it } @@ -273,17 +293,23 @@ pub(crate) fn is_local_go(purl: &str, common: &GlobalArgs) -> bool { && Ecosystem::from_purl(purl) == Some(Ecosystem::Golang) } -/// Whether local-go redirects are in scope (local mode + golang not filtered out -/// by `--ecosystems`). Gates reconcile / `--check`. -fn go_in_local_scope(common: &GlobalArgs) -> bool { +/// Whether this run can touch `eco`'s LOCAL install tree at all: local mode +/// (a `--global` / `--global-prefix` run crawls a different tree, so the +/// checkout says nothing about what it will patch) with the ecosystem not +/// filtered out by `--ecosystems`. The filter check is the exact `cli_name` +/// match `partition_purls` applies — clap admits no alias or case variant — +/// so a scope decided here can never diverge from the crawl scope. Gates +/// the local-go reconcile / `--check` (golang) and the yarn-PnP refusal +/// (npm: the refusal is about THIS run's packages living inside +/// `.yarn/cache/*.zip`, so a run that never crawls the checkout's +/// `node_modules` must not be refused by its layout). +fn eco_in_local_scope(common: &GlobalArgs, eco: Ecosystem) -> bool { if common.global || common.global_prefix.is_some() { return false; } match &common.ecosystems { None => true, - Some(list) => list - .iter() - .any(|e| e.eq_ignore_ascii_case("golang") || e.eq_ignore_ascii_case("go")), + Some(list) => list.iter().any(|e| e == eco.cli_name()), } } @@ -329,7 +355,7 @@ async fn try_local_go_apply( /// After the apply loop: prune local-go redirects whose patches were dropped /// from the manifest. No-op unless local go is in scope. async fn reconcile_local_go(common: &GlobalArgs, target_manifest_purls: &HashSet) { - if !go_in_local_scope(common) { + if !eco_in_local_scope(common, Ecosystem::Golang) { return; } let desired: HashSet = target_manifest_purls @@ -387,7 +413,7 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { { use socket_patch_core::patch::redirect::golang_local::Drift as GoDrift; - if go_in_local_scope(&args.common) { + if eco_in_local_scope(&args.common, Ecosystem::Golang) { // Vendored modules are excluded: their replace directives point at // `.socket/vendor/golang/` (the verify engine skips Vendor-owned // entries) and their state is audited by `vendor`, not `--check`. @@ -605,38 +631,14 @@ pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent PatchEvent::new(PatchAction::Applied, purl).with_files(files) } -/// Whether this run can touch `--cwd`'s npm `node_modules` at all: local -/// mode (a `--global` / `--global-prefix` run crawls a different tree, so -/// the checkout's layout says nothing about what it will patch) with npm -/// not filtered out by `--ecosystems`. Gates the yarn-PnP refusal — the -/// refusal is about THIS run's packages living inside `.yarn/cache/*.zip`, -/// so a run that never crawls the checkout's `node_modules` must not be -/// refused by its layout. The filter check is the exact `cli_name` match -/// `partition_purls` applies, so the refusal scope can never diverge from -/// the crawl scope. -fn npm_in_local_scope(common: &GlobalArgs) -> bool { - if common.global || common.global_prefix.is_some() { - return false; - } - match &common.ecosystems { - None => true, - Some(list) => list.iter().any(|e| e == Ecosystem::Npm.cli_name()), - } -} - /// True when the manifest records at least one npm patch — the only kind a /// PnP layout can block (a polyglot repo's pypi/gem/go patches live outside -/// `node_modules` and apply fine). An unreadable or vanished manifest -/// returns false so the ordinary manifest error paths surface instead of a -/// misdirected layout refusal. -async fn manifest_targets_npm(manifest_path: &Path) -> bool { - match read_manifest(manifest_path).await { - Ok(Some(m)) => m - .patches - .keys() - .any(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Npm)), - _ => false, - } +/// `node_modules` and apply fine). +fn manifest_targets_npm(manifest: &PatchManifest) -> bool { + manifest + .patches + .keys() + .any(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Npm)) } /// Print the yarn-PnP refusal (JSON envelope or human stderr) and return @@ -668,14 +670,12 @@ fn refuse_yarn_pnp(args: &ApplyArgs) -> i32 { pub async fn run(args: ApplyArgs) -> i32 { apply_env_toggles(&args.common); - let (telemetry_client, _) = - get_api_client_with_overrides(args.common.api_client_overrides()).await; - let api_token = telemetry_client.api_token().cloned(); - let org_slug = telemetry_client.org_slug().cloned(); - let manifest_path = args.common.resolved_manifest_path(); - // Check if manifest exists - exit successfully if no .socket folder is set up + // No manifest → nothing to apply: a clean exit-0 no-op (load-bearing + // for the install hooks, which run `apply --silent` on every install). + // Nothing below this gate is touched — no API client (its config read, + // stderr advisory and org-slug round-trip), no lock, no `.socket/`. if tokio::fs::metadata(&manifest_path).await.is_err() { // A yarn-PnP layout refuses loudly even with no manifest: scan // cannot discover PnP packages (they live inside .yarn/cache zips), @@ -686,7 +686,7 @@ pub async fn run(args: ApplyArgs) -> i32 { // Scoped to runs that would actually crawl this checkout's // node_modules: a --global/--global-prefix run or an --ecosystems // filter excluding npm never touches it. - if npm_in_local_scope(&args.common) + if eco_in_local_scope(&args.common, Ecosystem::Npm) && matches!( detect_npm_pkg_manager(&args.common.cwd), NpmPkgManager::YarnBerryPnP @@ -700,7 +700,10 @@ pub async fn run(args: ApplyArgs) -> i32 { env.dry_run = args.common.dry_run; println!("{}", env.to_pretty_json()); } else if !args.common.silent { - println!("No .socket folder found, skipping patch application."); + // Names the manifest, not the folder: hosted- and vendored-mode + // projects have a `.socket/` (their ledgers live under + // `.socket/vendor/`) and still nothing for `apply` to do. + println!("No patch manifest found; nothing to apply."); } return 0; } @@ -713,12 +716,21 @@ pub async fn run(args: ApplyArgs) -> i32 { return run_check(&args, &manifest_path).await; } + // The run's ONE API client — built past both read-only exits above (a + // hook on a manifest-less project or a CI `--check` never pays its + // config read, stderr advisory or org-slug round-trip) and BEFORE the + // lock, so none of that lengthens the lock hold. It serves the staging + // fetch, the mismatch blob top-up and telemetry. + let (client, _) = get_api_client_with_overrides(args.common.api_client_overrides()).await; + let api_token = client.api_token().cloned(); + let org_slug = client.org_slug().cloned(); + // Serialize against concurrent socket-patch runs targeting the same - // `.socket/` directory. The guard releases on function return; see - // `socket_patch_core::patch::apply_lock`. - let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); - let _lock = match acquire_or_emit( - socket_dir, + // `.socket/` directory. Released explicitly once every mutation is done + // (output and a possibly slow telemetry POST must not keep a sibling + // waiting), otherwise on return; see `socket_patch_core::patch::apply_lock`. + let lock = match acquire_or_emit( + &args.common.socket_dir(), Command::Apply, args.common.json, args.common.dry_run, @@ -728,6 +740,23 @@ pub async fn run(args: ApplyArgs) -> i32 { Err(code) => return code, }; + // ONE parse of the manifest for the whole run — the PnP gate and the + // apply loop (embedded VEX re-reads it by design, after the writes). + // Apply never modifies it, so a read under the lock is final. `Ok(None)` + // (vanished since the existence probe above) and a read/parse error take + // the same exit as every other apply failure. + let manifest = match read_manifest(&manifest_path).await { + Ok(Some(m)) => m, + Ok(None) => { + lock.release(); + return report_apply_failure(&args, "Invalid manifest", &api_token, &org_slug).await; + } + Err(e) => { + lock.release(); + return report_apply_failure(&args, &e.to_string(), &api_token, &org_slug).await; + } + }; + // Package-manager layout detection. yarn-berry PnP keeps packages // inside `.yarn/cache/*.zip` and resolves them via `.pnp.cjs` — // the npm crawler can't reach them and rewriting zips is a @@ -735,12 +764,14 @@ pub async fn run(args: ApplyArgs) -> i32 { // `yarn patch` — but only when an npm patch is actually in scope: // a polyglot repo's pypi/gem/go patches apply fine under PnP, and a // global-tree or non-npm `--ecosystems` run never crawls this - // checkout's node_modules at all. pnpm gets an informational event; - // the CoW guard in `apply_file_patch` does the substantive safety - // work. + // checkout's node_modules at all. pnpm gets an informational note; + // the substantive safety is core's rename-over write + // (`utils::fs::atomic_write_bytes` never touches the store's shared + // inode). match detect_npm_pkg_manager(&args.common.cwd) { NpmPkgManager::YarnBerryPnP => { - if npm_in_local_scope(&args.common) && manifest_targets_npm(&manifest_path).await { + if eco_in_local_scope(&args.common, Ecosystem::Npm) && manifest_targets_npm(&manifest) + { return refuse_yarn_pnp(&args); } } @@ -750,9 +781,9 @@ pub async fn run(args: ApplyArgs) -> i32 { "Note: pnpm layout detected. Copy-on-write will keep the global store untouched." ); } - // Non-fatal — CoW handles the safety. JSON consumers see - // the layout-detected info in the apply envelope's - // existing events (no separate event added here yet). + // Non-fatal — the rename-over write handles the safety. JSON + // consumers see the layout-detected info in the apply + // envelope's existing events (no separate event added here yet). } NpmPkgManager::Bun => { if !args.common.json && !args.common.silent { @@ -761,7 +792,7 @@ pub async fn run(args: ApplyArgs) -> i32 { ); } // Same shape as pnpm: bun hard-links from its global - // install cache by default. The CoW guard handles the + // install cache by default. The rename-over write handles the // safety; this is informational only. } // Exhaustive on purpose (no `_`): a new package-manager layout must @@ -770,7 +801,7 @@ pub async fn run(args: ApplyArgs) -> i32 { NpmPkgManager::Npm | NpmPkgManager::YarnClassic | NpmPkgManager::Unknown => {} } - match apply_patches_inner(&args, &manifest_path).await { + match apply_patches_inner(&args, manifest, &client).await { Ok(ApplyOutcome { success, results, @@ -783,6 +814,21 @@ pub async fn run(args: ApplyArgs) -> i32 { .filter(|r| r.success && !r.files_patched.is_empty()) .count(); + // Applied-with-advisory results: the bytes ARE patched, but a + // post-write ownership restore was not permitted (core carries + // it as `error` on a SUCCESSFUL result, where the event mapper + // rightly ignores it). It rides the run-warning channel so it + // is never silent. + let mut run_warnings = run_warnings; + run_warnings.extend(results.iter().filter_map(|r| { + let note = r.error.as_deref().filter(|_| r.success)?; + note.contains("ownership could not be restored") + .then(|| RunWarning { + code: "ownership_not_restored".to_string(), + detail: format!("{}: {note}", normalize_purl(&r.package_key)), + }) + })); + // Run-level advisories + best-effort fallback-home skips on the // human path: one gated stderr line each. `--silent` is // errors-only, and under `--json` the envelope copies below are @@ -815,6 +861,9 @@ pub async fn run(args: ApplyArgs) -> i32 { None }; let vex_failed = matches!(vex_result, Some(Err(_))); + // Every mutation — the patches and the VEX attestation — is + // done: release the lock before output and telemetry. + lock.release(); if args.common.json { let mut env = Envelope::new(Command::Apply); @@ -1036,28 +1085,41 @@ pub async fn run(args: ApplyArgs) -> i32 { } } Err(e) => { - track_patch_apply_failed( - &e, - args.common.dry_run, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; - if args.common.json { - let mut env = Envelope::new(Command::Apply); - env.dry_run = args.common.dry_run; - env.mark_error(EnvelopeError::new("apply_failed", e.clone())); - println!("{}", env.to_pretty_json()); - } else { - // Errors print even under --silent ("errors only", never - // "nothing"): exit 1 with no message would be undiagnosable. - eprintln!("Error: {e}"); - } - 1 + lock.release(); + report_apply_failure(&args, &e, &api_token, &org_slug).await } } } +/// The one apply-failure exit: `apply_failed` telemetry, then the error +/// envelope (`--json`) or an `Error:` line that prints even under +/// `--silent` ("errors only", never "nothing" — exit 1 with no message +/// would be undiagnosable), exit 1. Shared by the manifest read in `run` +/// and `apply_patches_inner`'s `Err` arm. +async fn report_apply_failure( + args: &ApplyArgs, + error: &str, + api_token: &Option, + org_slug: &Option, +) -> i32 { + track_patch_apply_failed( + error, + args.common.dry_run, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; + if args.common.json { + let mut env = Envelope::new(Command::Apply); + env.dry_run = args.common.dry_run; + env.mark_error(EnvelopeError::new("apply_failed", error.to_string())); + println!("{}", env.to_pretty_json()); + } else { + eprintln!("Error: {error}"); + } + 1 +} + /// Synthesize one vendor-owned `Skipped`/`vendored` result per in-scope /// vendored purl, BEFORE the crawl-driven matching (and its empty-crawl /// early returns): a vendored package must surface as vendored — never as @@ -1166,18 +1228,12 @@ impl FallbackHomeSkip { async fn apply_patches_inner( args: &ApplyArgs, - manifest_path: &Path, + mut manifest: PatchManifest, + client: &ApiClient, ) -> Result { - let manifest = read_manifest(manifest_path) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "Invalid manifest".to_string())?; - // Resolve patch sources (read `.socket/` directly, or stage an overlay // tempdir + download the gap). Shared with `vendor` via fetch_stage. - let socket_dir = manifest_path - .parent() - .expect("manifest path names a file, so it has a parent"); + let socket_dir = args.common.socket_dir(); // Partition manifest PURLs by ecosystem up front. The source probes, // the offline guard, and the download planner in `fetch_stage` must only // consider patches this run can actually apply — the `--ecosystems` @@ -1192,15 +1248,17 @@ async fn apply_patches_inner( .flat_map(|purls| purls.iter().cloned()) .collect(); - // In-scope view of the manifest for source probing and fetching. The - // apply loop keeps using the full `manifest` for per-PURL lookups — - // those are already scoped by `partitioned`. - let mut scoped_manifest = manifest.clone(); - scoped_manifest + // Narrow the manifest to the `--ecosystems` scope IN PLACE: every later + // lookup key comes from `partitioned` / `all_packages`, which are + // already in scope, so nothing downstream needs the full map (and the + // source probes, the offline guard and the download planner must only + // ever see in-scope patches). + manifest .patches .retain(|purl, _| target_manifest_purls.contains(purl)); - let mut staged = match stage_patch_sources(&args.common, &scoped_manifest, socket_dir).await? { + let mut staged = + match stage_patch_sources(&args.common, &manifest, &socket_dir, client).await? { StageOutcome::Ready(s) => s, StageOutcome::Unavailable => { return Ok(ApplyOutcome { @@ -1213,6 +1271,35 @@ async fn apply_patches_inner( } }; + // Local go: prune `replace`-redirects whose patches were dropped from the + // manifest (orphans). Done here — before the crawl + the "no packages + // found" early returns — so orphans are reconciled even when the manifest + // now lists zero in-scope go patches (the all-removed case). No-op unless + // local go is in scope. + reconcile_local_go(&args.common, &target_manifest_purls).await; + + if partitioned.is_empty() { + // Nothing in scope: the manifest lists no patches (or every patch was + // filtered out by `--ecosystems`). There is genuinely no work to do, + // so this is a clean no-op SUCCESS — not a failure. Returning `false` + // here used to exit 1 / `partialFailure`, which broke the npm + // `postinstall` hook (it runs `apply` on every install, including + // fresh projects whose manifest has no matching patches yet). Decided + // BEFORE the ledger read, gem discovery and the crawl — none of which + // can add work to an empty scope — but AFTER the staging above, which + // is where `--download-mode` is validated at runtime. + if !args.common.silent && !args.common.json { + println!("No patches to apply."); + } + return Ok(ApplyOutcome { + success: true, + results: Vec::new(), + unmatched: Vec::new(), + run_warnings: Vec::new(), + fallback_skips: Vec::new(), + }); + } + // Vendor ownership wins for EVERY ecosystem: a purl recorded in // `.socket/vendor/state.json` is managed by the explicit `vendor` // action — apply must not re-patch its installed tree (or repoint a @@ -1226,13 +1313,6 @@ async fn apply_patches_inner( let (mut results, mut matched_manifest_purls, vendored_bases) = synthesize_vendor_owned_results(&target_manifest_purls, &vendored_purls); - // Local go: prune `replace`-redirects whose patches were dropped from the - // manifest (orphans). Done here — before the crawl + the "no packages - // found" early returns — so orphans are reconciled even when the manifest - // now lists zero in-scope go patches (the all-removed case). No-op unless - // local go is in scope. - reconcile_local_go(&args.common, &target_manifest_purls).await; - let crawler_options = CrawlerOptions { cwd: args.common.cwd.clone(), global: args.common.global, @@ -1290,25 +1370,6 @@ async fn apply_patches_inner( ) .await; - if all_packages.is_empty() && partitioned.is_empty() { - // Nothing in scope: the manifest lists no patches (or every patch was - // filtered out by `--ecosystems`). There is genuinely no work to do, - // so this is a clean no-op SUCCESS — not a failure. Returning `false` - // here used to exit 1 / `partialFailure`, which broke the npm - // `postinstall` hook (it runs `apply` on every install, including - // fresh projects whose manifest has no matching patches yet). - if !args.common.silent && !args.common.json { - println!("No patches to apply."); - } - return Ok(ApplyOutcome { - success: true, - results: Vec::new(), - unmatched: Vec::new(), - run_warnings, - fallback_skips, - }); - } - if all_packages.is_empty() { // Vendored purls are already accounted for (synthesized Skipped/ // vendored results above); only the remainder is genuinely @@ -1319,7 +1380,11 @@ async fn apply_patches_inner( &matched_manifest_purls, &vendored_bases, ); - if !unmatched.is_empty() && !args.common.silent && !args.common.json { + // This diagnostic flips the exit code, so it prints even under + // --silent ("errors only", never nothing — the hooked `apply + // --silent` used to exit 1 mutely here); `--json` mutes stderr and + // the envelope's `package_not_installed` events are the channel. + if !unmatched.is_empty() && !args.common.json { eprintln!("Warning: No packages found that match available patches"); eprintln!( " {} targeted manifest patch(es) were in scope, but no matching packages were found on disk.", @@ -1339,7 +1404,15 @@ async fn apply_patches_inner( } // Apply patches - ensure_blobs_for_mismatches(args, &manifest, &all_packages, &vendored_purls, &mut staged).await; + ensure_blobs_for_mismatches( + args, + &manifest, + &all_packages, + &vendored_purls, + &mut staged, + client, + ) + .await; let sources = staged.as_patch_sources(); let policy = mismatch_policy(args.force, args.common.strict); let mut has_errors = false; diff --git a/crates/socket-patch-cli/src/commands/bun_preflight.rs b/crates/socket-patch-cli/src/commands/bun_preflight.rs index cba818aa..a11b641e 100644 --- a/crates/socket-patch-cli/src/commands/bun_preflight.rs +++ b/crates/socket-patch-cli/src/commands/bun_preflight.rs @@ -78,26 +78,7 @@ pub(crate) async fn bun_vendor_preflight( cwd: &Path, selected: &[PatchSearchResult], ) -> Option { - let pairs = selection_pairs(selected); - if !pairs.iter().any(|(purl, _)| purl.starts_with("pkg:npm/")) { - return None; - } - let (code, detail) = socket_patch_core::vendor::bun_lock::preflight_vendor(cwd) - .await - .err()?; - // Loaded only once the project is known to refuse: an accepted project - // never touches the ledger here (the vendor step owns it). - let ledger = load_state(cwd).await; - Some( - refusal_with_exemptions( - cwd, - code, - detail, - &pairs, - ledger.as_ref().map(|s| &s.entries), - ) - .await, - ) + preflight_pairs(cwd, &selection_pairs(selected), None).await } /// [`bun_vendor_preflight`] for callers that already loaded the ledger (the @@ -108,7 +89,7 @@ pub(crate) async fn bun_vendor_preflight_with_ledger( selected: &[PatchSearchResult], ledger: LedgerLoad<'_>, ) -> Option { - bun_vendor_preflight_pairs(cwd, &selection_pairs(selected), ledger).await + preflight_pairs(cwd, &selection_pairs(selected), Some(ledger)).await } /// The preflight over bare `(purl, uuid)` pairs — the `vendor` command's @@ -119,6 +100,18 @@ pub(crate) async fn bun_vendor_preflight_pairs( cwd: &Path, pairs: &[(&str, &str)], ledger: LedgerLoad<'_>, +) -> Option { + preflight_pairs(cwd, pairs, Some(ledger)).await +} + +/// The one preflight every entry point above funnels into. `ledger` is the +/// caller's own load when it has one; `None` loads the ledger here — and +/// only once the project is known to refuse, so an accepted project never +/// touches `state.json` (the vendor step owns it). +async fn preflight_pairs( + cwd: &Path, + pairs: &[(&str, &str)], + ledger: Option>, ) -> Option { if !pairs.iter().any(|(purl, _)| purl.starts_with("pkg:npm/")) { return None; @@ -126,6 +119,14 @@ pub(crate) async fn bun_vendor_preflight_pairs( let (code, detail) = socket_patch_core::vendor::bun_lock::preflight_vendor(cwd) .await .err()?; + let loaded; + let ledger = match ledger { + Some(ledger) => ledger, + None => { + loaded = load_state(cwd).await; + loaded.as_ref().map(|s| &s.entries) + } + }; Some(refusal_with_exemptions(cwd, code, detail, pairs, ledger).await) } diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index 2c1aa004..c7c58651 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -14,12 +14,12 @@ use socket_patch_core::api::blob_fetcher::{ fetch_missing_blobs, fetch_missing_sources, format_fetch_result, get_missing_archives, get_missing_blobs, DownloadMode, }; -use socket_patch_core::api::client::get_api_client_with_overrides; +use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::manifest::schema::PatchManifest; -use socket_patch_core::patch::apply::PatchSources; +use socket_patch_core::patch::apply::{is_valid_blob_hash, PatchSources}; use tempfile::TempDir; -use super::get::{base64_decode, is_valid_blob_hash}; +use super::get::base64_decode; use crate::args::GlobalArgs; /// Resolved artifact locations for the patch pipeline. Holds the overlay @@ -72,12 +72,25 @@ pub(crate) enum StageOutcome { Unavailable, } +/// The disk stager's remedy: `repair` fills the persistent `.socket/` +/// cache `apply` reads from. +const APPLY_OFFLINE_REMEDY: &str = + "Run \"socket-patch repair\" to download missing artifacts."; + +/// The memory stager's remedy. Vendored content is fetched into memory and +/// never lands under `.socket/`; sending a vendored project to `repair` +/// instead would populate `.socket/blobs/` — exactly the residue vendored +/// mode promises not to leave (and from inside `repair --offline` the hint +/// was self-referential). +const VENDOR_OFFLINE_REMEDY: &str = "Re-run without --offline to fetch the missing patch \ + content (kept in memory; nothing is written under .socket/)."; + /// Shared offline diagnostic: patches with no usable local source while -/// `--offline` is set (first five PURLs, then the `repair` hint). +/// `--offline` is set (first five PURLs, then the caller's `remedy` line). /// Prints even under `--silent` (errors only, NEVER nothing — an exit-1 /// run with zero output is undiagnosable); `--json` mutes stderr and the /// caller's envelope is the machine channel instead. -fn report_offline_missing(common: &GlobalArgs, purls: &[&str]) { +fn report_offline_missing(common: &GlobalArgs, purls: &[&str], remedy: &str) { if common.json { return; } @@ -91,7 +104,7 @@ fn report_offline_missing(common: &GlobalArgs, purls: &[&str]) { if purls.len() > 5 { eprintln!(" ... and {} more", purls.len() - 5); } - eprintln!("Run \"socket-patch repair\" to download missing artifacts."); + eprintln!("{remedy}"); } /// The manifest PURLs with no usable local source. A patch is "locally @@ -156,13 +169,16 @@ async fn overlay_dir(src: &Path, dst: &Path) { /// Resolve patch sources for `manifest`: read straight from `.socket/` when /// everything needed is cached (or `--offline`), else stage an overlay -/// tempdir and fetch the gap. `Err` is a hard setup failure (bad -/// `--download-mode`, tempdir creation); `Ok(Unavailable)` is the soft -/// "cannot proceed" path with diagnostics already printed. +/// tempdir and fetch the gap through `client` (the run's one API client — +/// building another here repeated its advisory and org-slug resolution). +/// `Err` is a hard setup failure (bad `--download-mode`, tempdir creation); +/// `Ok(Unavailable)` is the soft "cannot proceed" path with diagnostics +/// already printed. pub(crate) async fn stage_patch_sources( common: &GlobalArgs, manifest: &PatchManifest, socket_dir: &Path, + client: &ApiClient, ) -> Result { let quiet = common.silent || common.json; let socket_blobs_path = socket_dir.join("blobs"); @@ -191,7 +207,7 @@ pub(crate) async fn stage_patch_sources( // verification on its own; we still surface the no-source // diagnosis so the user runs `repair` before retrying. if !no_source_purls.is_empty() { - report_offline_missing(common, &no_source_purls); + report_offline_missing(common, &no_source_purls, APPLY_OFFLINE_REMEDY); return Ok(StageOutcome::Unavailable); } } @@ -247,10 +263,9 @@ pub(crate) async fn stage_patch_sources( ); } - let (client, _) = get_api_client_with_overrides(common.api_client_overrides()).await; let sources = staged.as_patch_sources(); let fetch_result = - fetch_missing_sources(manifest, &sources, download_mode, &client, None).await; + fetch_missing_sources(manifest, &sources, download_mode, client, None).await; if !quiet { println!("{}", format_fetch_result(&fetch_result)); @@ -269,7 +284,7 @@ pub(crate) async fn stage_patch_sources( still_missing_blobs.len() ); } - let blob_result = fetch_missing_blobs(manifest, &staged.blobs, &client, None).await; + let blob_result = fetch_missing_blobs(manifest, &staged.blobs, client, None).await; if !quiet { println!("{}", format_fetch_result(&blob_result)); } @@ -409,7 +424,7 @@ pub(crate) async fn stage_vendor_sources_in_memory( if !to_fetch.is_empty() { if common.offline { let purls: Vec<&str> = to_fetch.iter().map(|(purl, _)| *purl).collect(); - report_offline_missing(common, &purls); + report_offline_missing(common, &purls, VENDOR_OFFLINE_REMEDY); return MemStageOutcome::Unavailable; } @@ -529,6 +544,25 @@ mod tests { } } + /// A network-free client for the offline arms (never used: they return + /// before any fetch), built directly so no ambient token or socket-cli + /// config can leak into a unit test. + fn offline_client() -> ApiClient { + ApiClient::new(socket_patch_core::api::client::ApiClientOptions { + api_url: "http://127.0.0.1:1".to_string(), + api_token: None, + use_public_proxy: false, + org_slug: None, + }) + } + + /// The client `dead_endpoint_args` describes (see there). + async fn dead_endpoint_client(args: &GlobalArgs) -> ApiClient { + get_api_client_with_overrides(args.api_client_overrides()) + .await + .0 + } + /// Everything cached → read `.socket/` in place: no overlay tempdir, and /// the returned paths are the persistent cache dirs themselves. #[tokio::test] @@ -538,9 +572,14 @@ mod tests { std::fs::create_dir_all(socket_dir.join("blobs")).unwrap(); std::fs::write(socket_dir.join("blobs").join(HASH), b"patched").unwrap(); - let outcome = stage_patch_sources(&offline_args(), &manifest_with_one_patch(), &socket_dir) - .await - .expect("no hard failure"); + let outcome = stage_patch_sources( + &offline_args(), + &manifest_with_one_patch(), + &socket_dir, + &offline_client(), + ) + .await + .expect("no hard failure"); let StageOutcome::Ready(staged) = outcome else { panic!("fully-cached staging must be Ready"); }; @@ -555,9 +594,14 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let socket_dir = tmp.path().join(".socket"); - let outcome = stage_patch_sources(&offline_args(), &manifest_with_one_patch(), &socket_dir) - .await - .expect("no hard failure"); + let outcome = stage_patch_sources( + &offline_args(), + &manifest_with_one_patch(), + &socket_dir, + &offline_client(), + ) + .await + .expect("no hard failure"); assert!( matches!(outcome, StageOutcome::Unavailable), "offline + no local source must be Unavailable" @@ -581,9 +625,14 @@ mod tests { ) .unwrap(); - let outcome = stage_patch_sources(&offline_args(), &manifest_with_one_patch(), &socket_dir) - .await - .expect("no hard failure"); + let outcome = stage_patch_sources( + &offline_args(), + &manifest_with_one_patch(), + &socket_dir, + &offline_client(), + ) + .await + .expect("no hard failure"); assert!( matches!(outcome, StageOutcome::Ready(_)), "a present diff archive is a usable source for the disk stager" @@ -656,10 +705,12 @@ mod tests { ) .unwrap(); + let args = dead_endpoint_args(); let outcome = stage_patch_sources( - &dead_endpoint_args(), + &args, &manifest_with_one_patch(), &socket_dir, + &dead_endpoint_client(&args).await, ) .await .expect("no hard failure"); @@ -687,9 +738,14 @@ mod tests { download_mode: "file".to_string(), ..dead_endpoint_args() }; - let outcome = stage_patch_sources(&args, &manifest_with_one_patch(), &socket_dir) - .await - .expect("no hard failure"); + let outcome = stage_patch_sources( + &args, + &manifest_with_one_patch(), + &socket_dir, + &dead_endpoint_client(&args).await, + ) + .await + .expect("no hard failure"); assert!( matches!(outcome, StageOutcome::Ready(_)), "a local diff archive covers the patch even when the blob download fails" @@ -703,10 +759,12 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let socket_dir = tmp.path().join(".socket"); + let args = dead_endpoint_args(); let outcome = stage_patch_sources( - &dead_endpoint_args(), + &args, &manifest_with_one_patch(), &socket_dir, + &dead_endpoint_client(&args).await, ) .await .expect("no hard failure"); @@ -726,7 +784,13 @@ mod tests { silent: true, ..GlobalArgs::default() }; - let Err(err) = stage_patch_sources(&args, &manifest_with_one_patch(), tmp.path()).await + let Err(err) = stage_patch_sources( + &args, + &manifest_with_one_patch(), + tmp.path(), + &offline_client(), + ) + .await else { panic!("an unparseable download mode is a hard failure"); }; @@ -747,9 +811,14 @@ mod tests { std::fs::create_dir_all(socket_dir.join("blobs")).unwrap(); std::fs::write(socket_dir.join("blobs").join(HASH), b"cached").unwrap(); - let outcome = stage_patch_sources(&offline_args(), &manifest_with_one_patch(), &socket_dir) - .await - .expect("no hard failure"); + let outcome = stage_patch_sources( + &offline_args(), + &manifest_with_one_patch(), + &socket_dir, + &offline_client(), + ) + .await + .expect("no hard failure"); let StageOutcome::Ready(mut staged) = outcome else { panic!("fully-cached staging must be Ready"); }; diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 151b1ed0..7db9c54b 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -1,11 +1,10 @@ -use std::path::{Path, PathBuf}; - use clap::Args; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::redirect::{RedirectState, REDIRECT_STATE_REL}; use socket_patch_core::telemetry::track_patch_listed; use socket_patch_core::utils::socket_cli_config; +use socket_patch_core::vendor::state::{VendorEntry, VENDOR_STATE_REL}; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::json_envelope::{ @@ -18,55 +17,96 @@ pub struct ListArgs { pub common: GlobalArgs, } -/// One listable patch record with its provenance: a `.socket/manifest.json` -/// entry (agent/vendored modes) or a hosted redirect-ledger record -/// (`scan --mode hosted` records its patches ONLY in -/// `.socket/vendor/redirect-state.json` and never writes the manifest — -/// without the ledger records, a purely hosted-wired project listed as -/// `manifest_not_found` while its patches were demonstrably live). +/// Where a listed record lives. Declaration order is the tie-break order +/// when one purl appears in several stores: coexistence is real state (e.g. +/// an agent-applied patch alongside live hosted wiring), so every copy is +/// shown, labeled apart. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Source { + /// A `.socket/manifest.json` entry (agent mode). + Manifest, + /// A hosted redirect-ledger record: `scan --mode hosted` records its + /// patches ONLY in `.socket/vendor/redirect-state.json` and never + /// writes the manifest — without these, a purely hosted-wired project + /// listed as `manifest_not_found` while its patches were demonstrably + /// live. + Hosted, + /// A vendor-ledger record: vendored mode is manifest-free, so every + /// `scan`/`get --mode vendored` patch lives ONLY in + /// `.socket/vendor/state.json`, as a `detached` entry's embedded record + /// (the hosted rule again — a vendored-only project lists and exits 0). + Vendored, +} + +/// The `(mode, ledger)` label pair for a ledger-sourced record — the shared +/// constant labels, never a ledger's own opaque `mode` string (see +/// `HOSTED_MODE_LABEL`'s docs) — or `None` for a manifest entry. Shared by +/// the JSON `details` and the human `Mode:` line. +fn ledger_label(source: Source) -> Option<(&'static str, &'static str)> { + match source { + Source::Manifest => None, + Source::Hosted => Some((crate::commands::HOSTED_MODE_LABEL, REDIRECT_STATE_REL)), + Source::Vendored => Some((crate::commands::VENDORED_MODE_LABEL, VENDOR_STATE_REL)), + } +} + +/// One listable patch record with its provenance. struct ListEntry<'a> { purl: &'a str, record: &'a PatchRecord, - /// `true` when the record comes from the hosted redirect ledger. - hosted: bool, + source: Source, } -/// Every listable record from both stores, in a stable order: by PURL, the -/// manifest entry before the hosted-ledger record when one purl appears in -/// BOTH (coexistence is real state — e.g. an agent-applied patch alongside -/// live hosted wiring — so both are shown, labeled apart). The record maps -/// (`HashMap` manifest / `BTreeMap` ledger) never impose an order shared -/// consumers could diff, so the sort here is the contract. +/// Every listable record from all three stores, in a stable order: by +/// PURL, then manifest < hosted < vendored when one purl appears in more +/// than one. The record maps (`HashMap` manifest and vendor ledger / +/// `BTreeMap` redirect ledger) never impose an order shared consumers could +/// diff, so the sort here is the contract. Only vendor entries that carry +/// an embedded record fold in — a legacy manifest-tracked entry has no +/// record of its own (the manifest's IS the record) and would otherwise +/// double-list its purl. fn combined_entries<'a>( manifest: Option<&'a PatchManifest>, redirect: Option<&'a RedirectState>, + vendor: Option<&'a std::collections::HashMap>, ) -> Vec> { let mut entries: Vec> = Vec::new(); if let Some(manifest) = manifest { entries.extend(manifest.patches.iter().map(|(purl, record)| ListEntry { purl, record, - hosted: false, + source: Source::Manifest, })); } if let Some(redirect) = redirect { entries.extend(redirect.records.iter().map(|(purl, record)| ListEntry { purl, record, - hosted: true, + source: Source::Hosted, })); } - entries.sort_by(|a, b| a.purl.cmp(b.purl).then(a.hosted.cmp(&b.hosted))); + if let Some(vendor) = vendor { + entries.extend(vendor.iter().filter_map(|(purl, entry)| { + let record = entry.record.as_ref().filter(|_| entry.detached)?; + Some(ListEntry { + purl, + record, + source: Source::Vendored, + }) + })); + } + entries.sort_by(|a, b| a.purl.cmp(b.purl).then(a.source.cmp(&b.source))); entries } /// Build the `list --json` envelope: one `Discovered` event per entry, with /// the rich metadata (vulnerabilities, tier, license, description, /// exportedAt) under `details` per the per-command extension convention. -/// Hosted-ledger records additionally carry `details.mode` (the constant -/// [`crate::commands::HOSTED_MODE_LABEL`]) and `details.ledger` naming the -/// redirect ledger (additive keys, absent on manifest entries), so -/// consumers can tell the stores apart. +/// Ledger records additionally carry `details.mode` (the constants +/// [`crate::commands::HOSTED_MODE_LABEL`] / +/// [`crate::commands::VENDORED_MODE_LABEL`]) and `details.ledger` naming +/// the ledger they came from (additive keys, absent on manifest entries), +/// so consumers can tell the stores apart. /// /// Events are emitted in the entries' given order — [`combined_entries`] /// owns the by-PURL event sort; this builder sorts each event's @@ -116,12 +156,9 @@ fn build_list_envelope(entries: &[ListEntry<'_>]) -> Envelope { "description": patch.description, "vulnerabilities": vulnerabilities, }); - if entry.hosted { - // The shared constant label, never the ledger's own opaque - // `mode` string — see HOSTED_MODE_LABEL's docs (scan's - // `redirectState` block emits the same label, one owner). - details["mode"] = serde_json::json!(crate::commands::HOSTED_MODE_LABEL); - details["ledger"] = serde_json::json!(REDIRECT_STATE_REL); + if let Some((mode, ledger)) = ledger_label(entry.source) { + details["mode"] = serde_json::json!(mode); + details["ledger"] = serde_json::json!(ledger); } env.record( @@ -204,26 +241,6 @@ fn emit_error(args: &ListArgs, code: &str, message: String) { } } -/// The project root whose redirect ledger accompanies the manifest being -/// listed. Both stores must come from the SAME project, so the root is -/// derived from the RESOLVED manifest path rather than hardcoding cwd: -/// the manifest's directory, stepping out of a standard `.socket/` layout -/// when the manifest lives in one. For the default -/// `/.socket/manifest.json` this is exactly `cwd`; for a -/// `--manifest-path` into another project it is that project's root (its -/// `.socket` parent's parent), or — for a bare file like -/// `--manifest-path /tmp/x/abs.json` — the file's own directory. -fn ledger_root(common: &GlobalArgs, manifest_path: &Path) -> PathBuf { - match manifest_path.parent() { - Some(dir) if dir.file_name() == Some(std::ffi::OsStr::new(".socket")) => dir - .parent() - .map(std::path::Path::to_path_buf) - .unwrap_or_else(|| common.cwd.clone()), - Some(dir) => dir.to_path_buf(), - None => common.cwd.clone(), - } -} - pub async fn run(args: ListArgs) -> i32 { apply_env_toggles(&args.common); let manifest_path = args.common.resolved_manifest_path(); @@ -257,26 +274,32 @@ pub async fn run(args: ListArgs) -> i32 { } }; - // Hosted-mode patches live ONLY in the redirect ledger, so `list` - // consults it alongside the manifest — leniently (a malformed ledger - // degrades to "nothing to consult", surfaced on stderr unless --silent; - // the hosted write path hard-errors on it instead), and always from the - // SAME project as the manifest: with `--manifest-path` pointing at - // another project, reading the LOCAL cwd's ledger would interleave two - // projects' patch state (and a local ledger could suppress the flagged - // project's manifest_not_found). - let redirect_state = crate::commands::load_redirect_state_lenient( - &ledger_root(&args.common, &manifest_path), - args.common.silent, - ) - .await; - - // `combined_entries` folds only ledger RECORDS in (an edits-only ledger - // — post-takeover residue / a degraded record-fetch-failed run — - // asserts no patches), so entry emptiness is the whole exit predicate. - let entries = combined_entries(manifest.as_ref(), redirect_state.as_ref()); + // Hosted-mode patches live ONLY in the redirect ledger and vendored-mode + // patches ONLY in the vendor ledger, so `list` consults both alongside + // the manifest — leniently (a malformed ledger degrades to "nothing to + // consult", surfaced on stderr unless --silent; the write paths + // hard-error on it instead), and always from the SAME project as the + // manifest (`project_root` steps out of the manifest's `.socket/`): + // with `--manifest-path` pointing at another project, reading the LOCAL + // cwd's ledgers would interleave two projects' patch state (and a local + // ledger could suppress the flagged project's manifest_not_found). + let project_root = args.common.project_root(); + let redirect_state = + crate::commands::load_redirect_state_lenient(&project_root, args.common.silent).await; + let vendor_state = + crate::commands::load_vendor_state_lenient(&project_root, args.common.silent).await; + + // `combined_entries` folds only ledger RECORDS in (an edits-only + // redirect ledger — post-takeover residue / a degraded record-fetch- + // failed run — and a record-less legacy vendor entry assert no + // patches), so entry emptiness is the whole exit predicate. + let entries = combined_entries( + manifest.as_ref(), + redirect_state.as_ref(), + vendor_state.as_ref().map(|s| &s.entries), + ); if manifest.is_none() && entries.is_empty() { - // No manifest AND no hosted records: nothing is listable anywhere — + // No manifest AND no ledger records: nothing is listable anywhere — // the classic missing-manifest error. `read_manifest` returns // `Ok(None)` only when the file does not exist (its documented // contract), so this is `manifest_not_found`, NOT `manifest_invalid` @@ -322,15 +345,13 @@ pub async fn run(args: ListArgs) -> i32 { let patch = entry.record; println!("Package: {}", entry.purl); println!(" UUID: {}", patch.uuid); - if entry.hosted { + if let Some((mode, ledger)) = ledger_label(entry.source) { // Same labeling rule as the JSON details: the record comes - // from the hosted redirect ledger — installs resolve this - // package to the hosted patch server; no manifest entry - // exists or is needed. - println!( - " Mode: {} (recorded in {REDIRECT_STATE_REL})", - crate::commands::HOSTED_MODE_LABEL - ); + // from a ledger, not the manifest — hosted installs resolve + // the package to the hosted patch server, vendored ones to + // the committed `.socket/vendor/` artifact; no manifest + // entry exists or is needed. + println!(" Mode: {mode} (recorded in {ledger})"); } println!(" Tier: {}", patch.tier); println!(" License: {}", patch.license); @@ -386,7 +407,7 @@ mod tests { /// most tests below need; the hosted tests call `combined_entries` /// directly with a `RedirectState`. fn manifest_envelope(manifest: &PatchManifest) -> Envelope { - build_list_envelope(&combined_entries(Some(manifest), None)) + build_list_envelope(&combined_entries(Some(manifest), None, None)) } fn sample_manifest() -> PatchManifest { @@ -651,7 +672,11 @@ mod tests { .records .insert("pkg:npm/aaa-hosted@1.0.0".to_string(), hosted_record); - let env = build_list_envelope(&combined_entries(Some(&manifest), Some(&redirect))); + let env = build_list_envelope(&combined_entries( + Some(&manifest), + Some(&redirect), + None, + )); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); assert_eq!(v["summary"]["discovered"], 3); let events = v["events"].as_array().unwrap(); @@ -694,13 +719,105 @@ mod tests { "pkg:npm/minimist@1.2.2".to_string(), manifest.patches["pkg:npm/minimist@1.2.2"].clone(), ); - let env = build_list_envelope(&combined_entries(None, Some(&redirect))); + let env = build_list_envelope(&combined_entries(None, Some(&redirect), None)); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); assert_eq!(v["status"], "success"); assert_eq!(v["summary"]["discovered"], 1); assert_eq!(v["events"][0]["details"]["mode"], "hosted"); } + /// A vendor-ledger entry: `detached` with the embedded record when + /// `record` is given (the manifest-free vendored posture), a legacy + /// manifest-tracked entry (no record of its own) otherwise. Built from + /// the on-disk JSON shape so the fixture follows the ledger schema. + fn vendor_entry(purl: &str, record: Option) -> VendorEntry { + serde_json::from_value(serde_json::json!({ + "ecosystem": "npm", + "basePurl": purl, + "uuid": record + .as_ref() + .map_or("legacy-uuid", |r| r.uuid.as_str()), + "artifact": { "path": ".socket/vendor/npm/x/pkg.tgz" }, + "wiring": [], + "detached": record.is_some(), + "record": record, + })) + .expect("vendor entry fixture deserializes") + } + + /// Vendor-ledger records fold in labeled `vendored` with their ledger, + /// sort after the hosted record on a purl tie, and a legacy + /// manifest-tracked entry (no embedded record) never double-lists its + /// manifest purl. A vendored-only listing is a success envelope — the + /// hosted-only rule applied to the manifest-free vendored mode. + #[test] + fn vendored_ledger_records_are_labeled_and_sorted_last() { + let manifest = sample_manifest(); + let record = manifest.patches["pkg:npm/minimist@1.2.2"].clone(); + let mut redirect = RedirectState::new(); + redirect + .records + .insert("pkg:npm/minimist@1.2.2".to_string(), record.clone()); + let mut detached = record.clone(); + detached.uuid = "44444444-4444-4444-8444-444444444444".to_string(); + let mut vendor = HashMap::new(); + vendor.insert( + "pkg:npm/minimist@1.2.2".to_string(), + vendor_entry("pkg:npm/minimist@1.2.2", Some(detached)), + ); + vendor.insert( + "pkg:npm/zzz-vendored@1.0.0".to_string(), + vendor_entry("pkg:npm/zzz-vendored@1.0.0", Some(record)), + ); + // Legacy manifest-tracked vendoring: the manifest holds the record. + vendor.insert( + "pkg:npm/minimist@1.2.2#legacy".to_string(), + vendor_entry("pkg:npm/minimist@1.2.2", None), + ); + + let env = build_list_envelope(&combined_entries( + Some(&manifest), + Some(&redirect), + Some(&vendor), + )); + let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); + let listed: Vec<(&str, &str)> = v["events"] + .as_array() + .unwrap() + .iter() + .map(|e| { + ( + e["purl"].as_str().unwrap(), + e["details"]["mode"].as_str().unwrap_or("manifest"), + ) + }) + .collect(); + assert_eq!( + listed, + vec![ + ("pkg:npm/minimist@1.2.2", "manifest"), + ("pkg:npm/minimist@1.2.2", "hosted"), + ("pkg:npm/minimist@1.2.2", "vendored"), + ("pkg:npm/zzz-vendored@1.0.0", "vendored"), + ], + "purl-sorted, manifest < hosted < vendored on a tie, record-less entries skipped: {v}" + ); + let events = v["events"].as_array().unwrap(); + assert_eq!( + events[2]["details"]["ledger"], ".socket/vendor/state.json", + "{v}" + ); + assert_eq!( + events[2]["uuid"], "44444444-4444-4444-8444-444444444444", + "the ledger's embedded record is the one listed: {v}" + ); + + let only = build_list_envelope(&combined_entries(None, None, Some(&vendor))); + let v: serde_json::Value = serde_json::from_str(&only.to_pretty_json()).unwrap(); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["summary"]["discovered"], 2, "{v}"); + } + #[test] fn ordering_is_deterministic_across_builds() { // Two independent builds of the same manifest must be byte-identical. diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index b969dba1..44c6a761 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -25,6 +25,13 @@ use std::path::Path; /// these keys must not have to know that history. pub(crate) const HOSTED_MODE_LABEL: &str = "hosted"; +/// The documented name of the mode whose ledger is +/// `.socket/vendor/state.json` — `list`'s label for a vendored patch +/// record. Vendored mode is manifest-free: every `scan`/`get --mode +/// vendored` entry is written `detached: true` with its embedded patch +/// `record`, so the ledger is the only place those records live. +pub(crate) const VENDORED_MODE_LABEL: &str = "vendored"; + /// Read-only lenient load of the hosted redirect ledger: missing → `None` /// (a fresh start); malformed → `None` with the corruption surfaced on /// stderr unless `silent`. This is the "read-only consumers may degrade a @@ -47,3 +54,51 @@ pub(crate) async fn load_redirect_state_lenient( } } } + +/// Read-only lenient load of the vendor ledger (`.socket/vendor/state.json`): +/// missing → an empty ledger; malformed/unreadable → `None` with the +/// problem surfaced on stderr unless `silent`. The vendor twin of +/// [`load_redirect_state_lenient`], with the same posture: a read-only +/// consumer (`list`) degrades a broken ledger to nothing-to-consult but +/// must say so, while every path that writes or attests from it fails +/// closed instead. +pub(crate) async fn load_vendor_state_lenient( + root: &Path, + silent: bool, +) -> Option { + match socket_patch_core::vendor::load_state(root).await { + Ok(state) => Some(state), + Err(e) => { + if !silent { + eprintln!( + "Warning: unreadable vendor ledger ({e}); its vendored patches are not listed" + ); + } + None + } + } +} + +/// Fold the vendor ledger's DETACHED entries into a manifest view. Vendored +/// mode is manifest-free (every `scan`/`get --mode vendored` entry carries +/// `detached: true` plus its embedded patch `record`), so the ledger is the +/// only copy of those records: verification (`setup --check`, property 4) +/// and attestation (`vex`) must see them exactly like manifest entries. +/// Keyed by the ledger key; an existing manifest entry wins a collision +/// (that purl is manifest-owned and verifies against the manifest's +/// record). Entries without an embedded record (legacy manifest-tracked +/// vendoring) contribute nothing — their record IS the manifest's. +pub(crate) fn fold_detached_records( + manifest: &mut socket_patch_core::manifest::schema::PatchManifest, + entries: &std::collections::HashMap, +) { + for (key, entry) in entries { + if !entry.detached { + continue; + } + let Some(record) = &entry.record else { continue }; + if !manifest.patches.contains_key(key) { + manifest.patches.insert(key.clone(), record.clone()); + } + } +} diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index 9a2faf53..9b70df55 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -1,5 +1,6 @@ use clap::Args; use socket_patch_core::crawlers::python_crawler::is_python_project; +use socket_patch_core::crawlers::Ecosystem; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{PatchManifest, SetupConfig}; use socket_patch_core::package_json::detect::{is_setup_configured_str, PackageManager}; @@ -19,10 +20,12 @@ use socket_patch_core::setup::pypi::edit::{ add_hook_dependency, pyproject_contains_hook, remove_hook_dependency, ManifestKind, PthEditResult, PthStatus, }; +use socket_patch_core::patch::apply_lock::acquire; use socket_patch_core::telemetry::track_patch_setup; use socket_patch_core::vex::applied_patches_with_vendor; use std::io::{self, Write}; use std::path::{Path, PathBuf}; +use std::time::Duration; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::ecosystem_dispatch::find_manifest_package_paths; @@ -113,7 +116,7 @@ pub async fn run(args: SetupArgs) -> i32 { /// applying the pnpm "root-only" filtering. Returns an empty vec when none are /// found (callers also consider Python before reporting `no_files`). async fn discover(args: &SetupArgs, excludes: &[String]) -> Vec { - if !eco_in_scope(&args.common, ECO_NPM) { + if !eco_in_scope(&args.common, Ecosystem::Npm) { return Vec::new(); } let find_result = find_package_json_files(&args.common.cwd).await; @@ -198,17 +201,14 @@ fn confirm_proceed(prompt: &str) -> bool { /// Whether an ecosystem is in scope for this run, honoring the global /// `--ecosystems` filter (`CLI_CONTRACT.md` → "Setup command contract", /// property 2). With no filter (or an empty one) every ecosystem is in scope. -/// `names` lists the accepted tokens for the ecosystem — its canonical -/// `Ecosystem::cli_name()` plus any friendly alias (e.g. `pypi`/`python`, -/// `gem`/`ruby`) — matched case-insensitively, mirroring the scoping semantics -/// `apply` uses for the in-place ecosystems. -fn eco_in_scope(common: &GlobalArgs, names: &[&str]) -> bool { +/// The exact `cli_name` match is the only one that can ever fire — clap's +/// value parser admits no alias or case variant — and it is the same rule +/// `partition_purls` applies, so setup's scope never diverges from apply's. +fn eco_in_scope(common: &GlobalArgs, eco: Ecosystem) -> bool { match &common.ecosystems { None => true, Some(list) if list.is_empty() => true, - Some(list) => list - .iter() - .any(|e| names.iter().any(|n| e.eq_ignore_ascii_case(n))), + Some(list) => list.iter().any(|e| e == eco.cli_name()), } } @@ -257,21 +257,30 @@ fn is_member_excluded(manifest_path: &Path, cwd: &Path, excludes: &[String]) -> }) } +/// This run's ONE read of `.socket/manifest.json`, shared by the exclude +/// resolution, the `--exclude` persistence's already-persisted check and +/// `--check`'s patch-consistency pass (each used to parse the same bytes +/// again). +async fn read_setup_manifest(common: &GlobalArgs) -> io::Result> { + read_manifest(&common.resolved_manifest_path()).await +} + +/// The manifest as the read-only consumers see it: absent OR unreadable +/// contribute nothing (the persistence step is what reports an unreadable +/// manifest). +fn manifest_view(existing: &io::Result>) -> Option<&PatchManifest> { + existing.as_ref().ok().and_then(Option::as_ref) +} + /// The exclude set in effect for this run: the persisted `setup.exclude` list -/// from `.socket/manifest.json` (empty if no manifest / no setup state) union -/// the `--exclude` flag values (all normalized). This is what a clone inherits -/// — a clone with no flag still reads the persisted set. Read-only. -async fn effective_excludes(common: &GlobalArgs, flag: &[String]) -> Vec { - let mut set: Vec = match read_manifest(&common.resolved_manifest_path()).await { - Ok(Some(m)) => m - .setup - .map(|s| s.exclude) - .unwrap_or_default() - .iter() - .map(|e| normalize_rel_path(e)) - .collect(), - _ => Vec::new(), - }; +/// from the manifest (empty if no manifest / no setup state) union the +/// `--exclude` flag values (all normalized). This is what a clone inherits — +/// a clone with no flag still reads the persisted set. +fn effective_excludes(manifest: Option<&PatchManifest>, flag: &[String]) -> Vec { + let mut set: Vec = manifest + .and_then(|m| m.setup.as_ref()) + .map(|s| s.exclude.iter().map(|e| normalize_rel_path(e)).collect()) + .unwrap_or_default(); for e in flag { let n = normalize_rel_path(e); if !n.is_empty() && !set.contains(&n) { @@ -283,19 +292,60 @@ async fn effective_excludes(common: &GlobalArgs, flag: &[String]) -> Vec /// Persist the effective exclude set into `.socket/manifest.json` (creating a /// minimal manifest if none exists) so `--check` and a fresh clone honor it -/// without re-passing `--exclude`. No-op when the set is empty or already -/// exactly persisted (keeps the manifest byte-stable). Never called under -/// `--dry-run`. -/// Returns a warning string when persistence was SKIPPED (fail-closed) — -/// the caller folds it into the run's warnings so it reaches the human -/// summary AND the `--json` envelope; a `--silent`/`--json` automation run -/// must not see a fully-successful setup whose excludes silently evaporate -/// on the next flag-less invocation. -async fn persist_setup_excludes(common: &GlobalArgs, excludes: &[String]) -> Option { +/// without re-passing `--exclude`. No-op when the set is empty or `existing` +/// (this run's read) already carries it exactly — no lock, no rewrite, the +/// manifest stays byte-stable. Called only past the run's mutation gate +/// (discovery found work, the preview was confirmed or nothing needed +/// confirming) and never under `--dry-run`, so a no-project directory or an +/// aborted prompt leaves no `.socket/` behind. +/// +/// The write is a read-modify-write of the file `apply`/`get`/`remove`/ +/// `rollback` rewrite under `apply.lock`, so it takes the same lock and +/// re-reads under it; a missing `.socket/` is created by the acquire and +/// pruned again by the guard's drop if nothing gets written. +/// +/// Returns a warning string when persistence was SKIPPED (fail-closed: the +/// lock is held elsewhere, the manifest cannot be read, or the write +/// failed) — the caller folds it into the run's warnings so it reaches the +/// human summary AND the `--json` envelope; a `--silent`/`--json` +/// automation run must not see a fully-successful setup whose excludes +/// silently evaporate on the next flag-less invocation. +async fn persist_setup_excludes( + common: &GlobalArgs, + existing: &io::Result>, + excludes: &[String], +) -> Option { if excludes.is_empty() { return None; } + let mut merged: Vec = excludes.to_vec(); + merged.sort(); + merged.dedup(); + let persisted_exactly = |manifest: &Option| { + manifest + .as_ref() + .and_then(|m| m.setup.as_ref()) + .map(|s| &s.exclude) + == Some(&merged) + }; + if matches!(existing, Ok(manifest) if persisted_exactly(manifest)) { + return None; // already persisted exactly — don't lock, don't rewrite + } + let path = common.resolved_manifest_path(); + let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); + let _lock = match acquire(&common.socket_dir(), timeout) { + Ok(guard) => guard, + Err(err) => { + let (code, message) = crate::commands::lock_cli::lock_failure(&err, timeout); + let hint = if code == "lock_held" { + "re-run `setup` (or pass --lock-timeout ) to persist it" + } else { + "the exclude list will need re-passing" + }; + return Some(format!("not persisting --exclude: {message} — {hint}")); + } + }; // Fail closed on a manifest that exists but cannot be read or parsed: it // may still hold recoverable patch records, and flattening the error to // "no manifest yet" would rewrite the file down to a bare setup block — @@ -311,16 +361,8 @@ async fn persist_setup_excludes(common: &GlobalArgs, excludes: &[String]) -> Opt )); } }; - let mut merged: Vec = excludes.to_vec(); - merged.sort(); - merged.dedup(); - if existing - .as_ref() - .and_then(|m| m.setup.as_ref()) - .map(|s| &s.exclude) - == Some(&merged) - { - return None; // already persisted exactly — don't rewrite + if persisted_exactly(&existing) { + return None; // a concurrent run persisted it meanwhile } // Preserve any existing `manual` declarations (property 7) when rewriting. let manual = existing @@ -333,10 +375,16 @@ async fn persist_setup_excludes(common: &GlobalArgs, excludes: &[String]) -> Opt exclude: merged, manual, }); - if let Some(parent) = path.parent() { - let _ = tokio::fs::create_dir_all(parent).await; + // The acquire created the manifest's directory; a failed write is the + // same fail-closed skip as an unreadable manifest, never a silent + // "persisted". + if let Err(e) = write_manifest(&path, &manifest).await { + return Some(format!( + "not persisting --exclude: cannot write {}: {e} — the exclude list will \ + need re-passing", + path.display() + )); } - let _ = write_manifest(&path, &manifest).await; None } @@ -347,8 +395,7 @@ async fn persist_setup_excludes(common: &GlobalArgs, excludes: &[String]) -> Opt /// `--ecosystems` filter (it reports real on-disk state). pub(crate) async fn configured_ecosystems( common: &GlobalArgs, -) -> std::collections::HashSet { - use socket_patch_core::crawlers::Ecosystem; +) -> std::collections::HashSet { let mut set = std::collections::HashSet::new(); // npm: any discovered package.json whose hook scripts are present. @@ -398,12 +445,6 @@ pub(crate) async fn configured_ecosystems( set } -// Canonical `--ecosystems` token sets per setup branch (see `eco_in_scope`). -const ECO_NPM: &[&str] = &["npm"]; -const ECO_PYPI: &[&str] = &["pypi", "python"]; -const ECO_GEM: &[&str] = &["gem", "ruby"]; -const ECO_COMPOSER: &[&str] = &["composer", "php"]; - // ───────────────────────────────────────────────────────────────────────── // Python (.pth hook) helpers // ───────────────────────────────────────────────────────────────────────── @@ -467,7 +508,7 @@ async fn choose_python_manifests( } async fn plan_python(common: &GlobalArgs) -> Option { - if !eco_in_scope(common, ECO_PYPI) { + if !eco_in_scope(common, Ecosystem::Pypi) { return None; } if !is_python_project(&common.cwd).await { @@ -592,16 +633,32 @@ struct SetupOutcome { // Gem (Bundler plugin) helpers // ───────────────────────────────────────────────────────────────────────── +/// The Bundler project this run acts on, discovered ONCE per run (honoring +/// `--ecosystems`); `None` when gem is out of scope or no Gemfile is found. +async fn discover_gem_project(common: &GlobalArgs) -> Option { + if !eco_in_scope(common, Ecosystem::Gem) { + return None; + } + gem::discover_bundler_project(&common.cwd).await +} + /// Build the gem branch's contribution to a setup/remove run: add (or remove) /// the managed `plugin "socket-patch"` block in the Gemfile + the generated -/// `.socket/bundler-plugin/` plugin files. -async fn build_gem_outcome(common: &GlobalArgs, remove: bool, dry_run: bool) -> SetupOutcome { - if !eco_in_scope(common, ECO_GEM) { +/// `.socket/bundler-plugin/` plugin files. `project` comes from +/// [`discover_gem_project`] and `probe` from ONE `gem::probe_bundler` per +/// run, so the preview and the real edit spawn `bundle --version` at most +/// once between them. `probe` is `None` on the remove path: +/// `remove_plugin_directive` is deliberately ungated (it is the recovery +/// path for an already-wired bundler-1.x project) and never probes. +async fn build_gem_outcome( + common: &GlobalArgs, + project: Option<&gem::BundlerProject>, + probe: Option<&gem::BundlerProbe>, + remove: bool, + dry_run: bool, +) -> SetupOutcome { + let Some(project) = project else { return SetupOutcome::default(); - } - let project = match gem::discover_bundler_project(&common.cwd).await { - Some(p) => p, - None => return SetupOutcome::default(), }; let mut out = SetupOutcome { @@ -609,10 +666,10 @@ async fn build_gem_outcome(common: &GlobalArgs, remove: bool, dry_run: bool) -> ..Default::default() }; - let results = if remove { - gem::remove_plugin_directive(&project, dry_run).await - } else { - gem::add_plugin_directive(&project, dry_run).await + let results = match (remove, probe) { + (true, _) => gem::remove_plugin_directive(project, dry_run).await, + (false, Some(probe)) => gem::add_plugin_directive_with(project, probe, dry_run).await, + (false, None) => gem::add_plugin_directive(project, dry_run).await, }; let mut added_paths: Vec = Vec::new(); @@ -663,16 +720,28 @@ fn gem_status_str(s: &GemSetupStatus, for_remove: bool) -> &'static str { // Composer (composer.json scripts post-install/post-update hook) helpers // ───────────────────────────────────────────────────────────────────────── +/// The `composer.json` this run acts on, discovered ONCE per run (honoring +/// `--ecosystems`). +async fn discover_composer_json(common: &GlobalArgs) -> Option { + if !eco_in_scope(common, Ecosystem::Composer) { + return None; + } + composer::discover_composer_project(&common.cwd).await +} + /// Build the composer branch's contribution to a setup/remove run: add (or /// remove) the `socket-patch apply` command in `composer.json`'s -/// `post-install-cmd` / `post-update-cmd` script events. -async fn build_composer_outcome(common: &GlobalArgs, remove: bool, dry_run: bool) -> SetupOutcome { - if !eco_in_scope(common, ECO_COMPOSER) { +/// `post-install-cmd` / `post-update-cmd` script events. `composer_json` +/// comes from [`discover_composer_json`], shared by the preview and the +/// real edit. +async fn build_composer_outcome( + common: &GlobalArgs, + composer_json: Option<&Path>, + remove: bool, + dry_run: bool, +) -> SetupOutcome { + let Some(composer_json) = composer_json else { return SetupOutcome::default(); - } - let composer_json = match composer::discover_composer_project(&common.cwd).await { - Some(p) => p, - None => return SetupOutcome::default(), }; let mut out = SetupOutcome { @@ -681,9 +750,9 @@ async fn build_composer_outcome(common: &GlobalArgs, remove: bool, dry_run: bool }; let r = if remove { - composer::remove_hook(&composer_json, dry_run).await + composer::remove_hook(composer_json, dry_run).await } else { - composer::add_hook(&composer_json, dry_run).await + composer::add_hook(composer_json, dry_run).await }; let mut added_paths: Vec = Vec::new(); @@ -736,7 +805,7 @@ async fn append_composer_check_entries( common: &GlobalArgs, entries: &mut Vec<(&'static str, String, CheckState, Option)>, ) -> bool { - if !eco_in_scope(common, ECO_COMPOSER) { + if !eco_in_scope(common, Ecosystem::Composer) { return false; } let composer_json = match composer::discover_composer_project(&common.cwd).await { @@ -818,7 +887,7 @@ async fn append_gem_check_entries( common: &GlobalArgs, entries: &mut Vec<(&'static str, String, CheckState, Option)>, ) -> bool { - if !eco_in_scope(common, ECO_GEM) { + if !eco_in_scope(common, Ecosystem::Gem) { return false; } let project = match gem::discover_bundler_project(&common.cwd).await { @@ -883,23 +952,34 @@ async fn append_gem_check_entries( /// /// Reuses the same machinery `vex` uses — the qualified-aware rollback resolver /// (so release-variant PURLs resolve) honoring `--ecosystems`, the committed -/// vendor ledger ([`crate::commands::vex::load_vendor_context`]: a vendored +/// vendor ledger ([`crate::commands::vex::vendor_context_from`]: a vendored /// patch is judged by its `.socket/vendor/` artifact — the bytes the next /// install consumes — never the expectedly-unpatched installed tree), then /// [`applied_patches_with_vendor`]. An *uninstalled* package (`package_not_found`, also the /// bucket for out-of-scope PURLs absent from the map) cannot be patched yet, and /// a degenerate zero-file record (`no_files`) has nothing to hash — neither is /// drift, so both are skipped. A missing/empty/unreadable manifest contributes -/// nothing (hook presence alone decides). Read-only: it crawls but never writes. +/// nothing of its own; the vendor ledger's detached records (vendored mode is +/// manifest-free) are folded in exactly as `vex` does, so a vendored-only +/// project is judged too. Read-only: it crawls but never writes. async fn append_patch_consistency_entries( common: &GlobalArgs, + manifest: Option, entries: &mut Vec<(&'static str, String, CheckState, Option)>, ) { - let manifest_path = common.resolved_manifest_path(); - let manifest = match read_manifest(&manifest_path).await { - Ok(Some(m)) if !m.patches.is_empty() => m, - _ => return, - }; + // ONE ledger read serves both the detached fold and the verifier's + // VendorContext below. Without the fold a project whose committed + // `.socket/vendor/**` artifact is missing or corrupt reported + // `configured` — the exact hooks-present-but-state-drifted case + // property 4 exists to catch. + let mut manifest = manifest.unwrap_or_default(); + let ledger = socket_patch_core::vendor::load_state(&common.cwd).await; + if let Ok(state) = &ledger { + crate::commands::fold_detached_records(&mut manifest, &state.entries); + } + if manifest.patches.is_empty() { + return; + } let purls: Vec = manifest.patches.keys().cloned().collect(); // `--json` reserves stdout for the check report: silence the dispatch's @@ -907,7 +987,7 @@ async fn append_patch_consistency_entries( let package_paths = find_manifest_package_paths(&purls, common, common.silent || common.json).await; - let vendor = crate::commands::vex::load_vendor_context(common, &manifest).await; + let vendor = crate::commands::vex::vendor_context_from(common, &manifest, ledger).await; let outcome = applied_patches_with_vendor(&manifest, &package_paths, vendor.as_ref()).await; for failed in &outcome.failed { match failed.reason.as_str() { @@ -961,7 +1041,8 @@ async fn run_check(args: &SetupArgs) -> i32 { // Excluded members (persisted in the manifest + any passed via `--exclude`) // are skipped by discovery. Read-only: `--check` never persists. - let excludes = effective_excludes(&args.common, &args.exclude).await; + let existing = read_setup_manifest(&args.common).await; + let excludes = effective_excludes(manifest_view(&existing), &args.exclude); let npm_files = discover(args, &excludes).await; let py_plan = plan_python(&args.common).await; @@ -1018,7 +1099,7 @@ async fn run_check(args: &SetupArgs) -> i32 { // Property 4: prove a correctly-patched state, not just hook presence — // every in-scope manifest patch must be applied on disk (`apply --check` // invariant). Drifted/un-applied patches add `needs_configuration` entries. - append_patch_consistency_entries(&args.common, &mut entries).await; + append_patch_consistency_entries(&args.common, existing.ok().flatten(), &mut entries).await; if entries.is_empty() { return report_no_files( @@ -1141,11 +1222,17 @@ async fn run_remove(args: &SetupArgs) -> i32 { // Honor the persisted/`--exclude` member set so we never touch a member that // was deliberately excluded from setup. Remove does not change the set. - let excludes = effective_excludes(common, &args.exclude).await; + let existing = read_setup_manifest(common).await; + let excludes = effective_excludes(manifest_view(&existing), &args.exclude); let npm_files = discover(args, &excludes).await; let py_plan = plan_python(common).await; - let gem_preview = build_gem_outcome(common, true, true).await; - let composer_preview = build_composer_outcome(common, true, true).await; + // Gem + Composer projects are discovered ONCE; the preview and the real + // removal below share them. + let gem_project = discover_gem_project(common).await; + let composer_json = discover_composer_json(common).await; + let gem_preview = build_gem_outcome(common, gem_project.as_ref(), None, true, true).await; + let composer_preview = + build_composer_outcome(common, composer_json.as_deref(), true, true).await; if npm_files.is_empty() && py_plan.is_none() && !gem_preview.present @@ -1254,8 +1341,8 @@ async fn run_remove(args: &SetupArgs) -> i32 { // Real gem + composer removal (gem Gemfile `plugin` block + generated plugin // dir; composer.json script-event command). let extra_results = merge_outcomes( - build_gem_outcome(common, true, false).await, - build_composer_outcome(common, true, false).await, + build_gem_outcome(common, gem_project.as_ref(), None, true, false).await, + build_composer_outcome(common, composer_json.as_deref(), true, false).await, ); let errs = npm_results @@ -1542,21 +1629,28 @@ async fn run_setup(args: &SetupArgs) -> i32 { println!("Configuring socket-patch install hooks..."); } - // Resolve the effective exclude set (persisted + `--exclude`) and, on a real - // run, persist it so `--check` and a fresh clone honor it without the flag. - // Dry-run never writes the manifest. Excluded members are then skipped by - // discovery. - let excludes = effective_excludes(common, &args.exclude).await; - let persist_warning = if !common.dry_run { - persist_setup_excludes(common, &excludes).await - } else { - None - }; + // Resolve the effective exclude set (persisted + `--exclude`); excluded + // members are skipped by discovery. Persisting it waits for the mutation + // gate below (past discovery and the confirm prompt) so a no-project + // directory or an aborted run leaves no `.socket/` behind. + let existing = read_setup_manifest(common).await; + let excludes = effective_excludes(manifest_view(&existing), &args.exclude); let npm_files = discover(args, &excludes).await; let py_plan = plan_python(common).await; + // Gem + Composer projects are discovered ONCE and bundler probed ONCE (a + // Gemfile.lock read, or a `bundle --version` spawn bounded by its + // timeout): the preview and the real edit below share both. + let gem_project = discover_gem_project(common).await; + let gem_probe = match &gem_project { + Some(project) => Some(gem::probe_bundler(project).await), + None => None, + }; + let composer_json = discover_composer_json(common).await; // Gem + Composer previews (dry-run); `.present` also tells us each project exists. - let gem_preview = build_gem_outcome(common, false, true).await; - let composer_preview = build_composer_outcome(common, false, true).await; + let gem_preview = + build_gem_outcome(common, gem_project.as_ref(), gem_probe.as_ref(), false, true).await; + let composer_preview = + build_composer_outcome(common, composer_json.as_deref(), false, true).await; if npm_files.is_empty() && py_plan.is_none() @@ -1575,28 +1669,19 @@ async fn run_setup(args: &SetupArgs) -> i32 { let npm_pm = detect_package_manager(&common.cwd).await; - let telemetry_manager = telemetry_manager_str( - !npm_files.is_empty(), - py_plan.is_some(), - gem_present, - composer_present, - npm_pm, - ); - // Attribute the event through the same layered credential chain as every - // other command — flag / env / socket-cli `config.json` — not the raw flag - // values. `setup` builds no API client (it is a purely local edit), so the - // config layer has to be consulted explicitly, exactly as `list` does: - // otherwise a caller authenticated by `socket login` alone reports - // anonymously to the public patch proxy, which with an on-prem - // `apiBaseUrl` also sends the event to a different host than the one the - // client would talk to. - let (telemetry_token, telemetry_org) = crate::commands::list::telemetry_credentials(common); - track_patch_setup( - &telemetry_manager, - telemetry_token.as_deref(), - telemetry_org.as_deref(), - ) - .await; + // `patch_setup` telemetry ("a successful setup") fires only on the two + // exit-0, non-dry-run paths below — never for a dry run, an aborted + // prompt, a no-project directory or an errored run. + let track_setup = || { + track_setup_success( + common, + !npm_files.is_empty(), + py_plan.is_some(), + gem_present, + composer_present, + npm_pm, + ) + }; // Preview (always dry-run first). let mut npm_preview = Vec::new(); @@ -1632,6 +1717,19 @@ async fn run_setup(args: &SetupArgs) -> i32 { + extra_preview.errors; if n_changes == 0 { + // No hook needs editing, so there is no preview to confirm — but an + // EXPLICIT new `--exclude` is the user's stated intent and is still + // persisted (never under --dry-run, which returns below with the + // preview). A skipped (fail-closed) persistence rides the warnings + // channel exactly like on the mutating path. + let warnings: Vec = if !common.dry_run && !args.exclude.is_empty() { + persist_setup_excludes(common, &existing, &excludes) + .await + .into_iter() + .collect() + } else { + Vec::new() + }; if common.json { print_setup_envelope( if preview_errors > 0 { @@ -1644,7 +1742,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { &extra_preview, npm_pm, py_plan.as_ref(), - &[], + &warnings, ); } else if !common.silent { if preview_errors > 0 { @@ -1652,12 +1750,21 @@ async fn run_setup(args: &SetupArgs) -> i32 { } else { println!("All install hooks are already configured with socket-patch!"); } + for w in &warnings { + println!(" warning: {w}"); + } } eprint_errors_when_silent( common, &setup_error_messages(&npm_preview, &py_preview, &extra_preview), ); - return if preview_errors > 0 { 1 } else { 0 }; + if preview_errors > 0 { + return 1; + } + if !common.dry_run { + track_setup().await; + } + return 0; } if common.dry_run { @@ -1687,6 +1794,10 @@ async fn run_setup(args: &SetupArgs) -> i32 { return 0; } + // Past the mutation gate: persist the exclude set now (a dry run + // returned above; an aborted or no-project run never gets here). + let persist_warning = persist_setup_excludes(common, &existing, &excludes).await; + if !quiet { println!("\nApplying changes..."); } @@ -1707,8 +1818,8 @@ async fn run_setup(args: &SetupArgs) -> i32 { // Real gem + composer edits (gem Gemfile `plugin` block + generated plugin // dir; composer.json script-event command). let extra_results = merge_outcomes( - build_gem_outcome(common, false, false).await, - build_composer_outcome(common, false, false).await, + build_gem_outcome(common, gem_project.as_ref(), gem_probe.as_ref(), false, false).await, + build_composer_outcome(common, composer_json.as_deref(), false, false).await, ); // Materialise gem patches now so the first `bundle install` finds them @@ -1726,6 +1837,9 @@ async fn run_setup(args: &SetupArgs) -> i32 { .filter(|r| r.status == PthStatus::Error) .count() + extra_results.errors; + if errors == 0 { + track_setup().await; + } if common.json { print_setup_envelope( @@ -1788,6 +1902,26 @@ async fn run_setup(args: &SetupArgs) -> i32 { } } +/// Fire `patch_setup` — "a successful `setup`". Attributed through the same +/// layered credential chain as every other command (flag / env / socket-cli +/// `config.json`), not the raw flag values: `setup` builds no API client (it +/// is a purely local edit), so the config layer is consulted explicitly, +/// exactly as `list` does — otherwise a caller authenticated by `socket +/// login` alone reports anonymously to the public patch proxy (and, with an +/// on-prem `apiBaseUrl`, to a different host than the client would use). +async fn track_setup_success( + common: &GlobalArgs, + npm: bool, + py: bool, + gem: bool, + composer: bool, + npm_pm: PackageManager, +) { + let manager = telemetry_manager_str(npm, py, gem, composer, npm_pm); + let (token, org) = crate::commands::list::telemetry_credentials(common); + track_patch_setup(&manager, token.as_deref(), org.as_deref()).await; +} + fn print_setup_preview( npm: &[UpdateResult], py: &[PthEditResult], diff --git a/crates/socket-patch-cli/src/commands/update.rs b/crates/socket-patch-cli/src/commands/update.rs index d803bc87..881c6086 100644 --- a/crates/socket-patch-cli/src/commands/update.rs +++ b/crates/socket-patch-cli/src/commands/update.rs @@ -227,8 +227,9 @@ pub async fn run(args: UpdateArgs) -> i32 { format!("socket-patch {current} is already the latest version.") }; if args.common.json { + // `--dry-run` returned above, so this envelope's `dryRun` is + // always `Envelope::new`'s `false`. let mut env = Envelope::new(Command::Update); - env.dry_run = args.common.dry_run; env.record( PatchEvent::artifact(PatchAction::Skipped) .with_reason("already_latest", &msg) diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index fdfecdf5..1414012b 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -21,6 +21,7 @@ use socket_patch_core::crawlers::Ecosystem; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::telemetry::{track_vex_failed, track_vex_generated}; +use socket_patch_core::vendor::state::VendorState; use socket_patch_core::vex::{ build_document, detect_product, BuildOptions, Document, FailedPatch, VendorContext, VerifyOutcome, @@ -218,15 +219,15 @@ pub async fn run(args: VexArgs) -> i32 { // on the same stdout stream. Bail out with a clear error before // doing any work. if args.common.json && args.output.is_none() { - let e = fail( - &args.common, + // A usage error, not a generation failure: no telemetry POST and no + // config read (argument errors never report), just the envelope. + emit_envelope_error( + &args, "json_requires_output", "--json requires --output (the VEX document is itself JSON; \ - route it to a file so the envelope can use stdout)" - .to_string(), - ) - .await; - emit_envelope_error(&args, e.code, &e.message, &[]); + route it to a file so the envelope can use stdout)", + &[], + ); return 2; } @@ -319,6 +320,7 @@ async fn generate_vex( params: &VexBuildParams, manifest: &PatchManifest, redirected: &[String], + ledger: std::io::Result, ) -> Result { // Resolve product. let product_id = match resolve_product_id(common, params.product.as_deref()).await { @@ -362,10 +364,7 @@ async fn generate_vex( // not whether this run hashed it. The committed ledger is as // trustworthy as the manifest beside it, and reading it hashes // nothing. An unreadable ledger degrades to "nothing vendored". - let entries = socket_patch_core::vendor::load_state(&common.cwd) - .await - .map(|state| state.entries) - .unwrap_or_default(); + let entries = ledger.map(|state| state.entries).unwrap_or_default(); let vendored = manifest .patches .keys() @@ -385,7 +384,7 @@ async fn generate_vex( let quiet = common.silent || common.json || params.output.is_none(); let purls: Vec = manifest.patches.keys().cloned().collect(); let package_paths = find_manifest_package_paths(&purls, common, quiet).await; - let vendor = load_vendor_context(common, manifest).await; + let vendor = vendor_context_from(common, manifest, ledger).await; socket_patch_core::vex::applied_patches_with_vendor( manifest, &package_paths, @@ -718,11 +717,20 @@ async fn generate_vex_from_manifest_path_inner( Err(e) => return Err(fail(common, "manifest_unreadable", e.to_string()).await), }; let had_manifest_file = manifest_file.is_some(); - // Detached vendored patches (`scan --vendor --detached`) and redirected + // ONE read of the committed vendor ledger for the whole run: the + // detached fold here, then either the `--no-verify` classification or + // the verify-path `VendorContext` (where a read error is surfaced; an + // unreadable ledger leaves the manifest view unchanged here and + // verification fails closed per entry downstream). + let ledger = socket_patch_core::vendor::load_state(&common.cwd).await; + // Vendored patches (manifest-free by design: every `scan`/`get --mode + // vendored` entry is detached with its embedded record) and redirected // patches (`scan --redirect`) have no manifest record; the vendor and // redirect ledgers' embedded copies must still attest. - let manifest = - augment_with_detached(common, manifest_file.unwrap_or_else(PatchManifest::new)).await; + let mut manifest = manifest_file.unwrap_or_else(PatchManifest::new); + if let Ok(state) = &ledger { + crate::commands::fold_detached_records(&mut manifest, &state.entries); + } let (manifest, redirected) = match augment_with_redirect(common, manifest).await { Ok(augmented) => augmented, Err(corrupt) => { @@ -745,28 +753,7 @@ async fn generate_vex_from_manifest_path_inner( ) .await); } - generate_vex(common, params, &manifest, &redirected).await -} - -/// Fold detached vendor entries' embedded records into a manifest view so -/// verification and document building see them — `scan --vendor -/// --detached` patches have no manifest record by design. Keyed by the -/// ledger key; an existing manifest entry wins a collision (that purl is -/// manifest-owned and verifies against the manifest's record). An -/// unreadable ledger leaves the manifest unchanged here — verification -/// still fails closed per-entry downstream, and `load_vendor_context` -/// already warns about the unreadable state. -async fn augment_with_detached(common: &GlobalArgs, mut manifest: PatchManifest) -> PatchManifest { - if let Ok(state) = socket_patch_core::vendor::load_state(&common.cwd).await { - for (key, entry) in state.entries { - if !entry.detached { - continue; - } - let Some(record) = entry.record else { continue }; - manifest.patches.entry(key).or_insert(record); - } - } - manifest + generate_vex(common, params, &manifest, &redirected, ledger).await } /// Fold the `scan --redirect` ledger's embedded records into a manifest view @@ -838,11 +825,12 @@ async fn resolve_product_id(common: &GlobalArgs, product: Option<&str>) -> Resul }) } -/// Build the [`VendorContext`] for verification: the committed -/// `.socket/vendor/state.json` ledger plus synthesized entries for the -/// legacy `.socket/go-patches/` redirect backend. Shared by `vex` and -/// `setup --check`'s patch-consistency pass — both must judge a vendored -/// patch by the committed artifact, never the installed tree. +/// Build the [`VendorContext`] for verification from `ledger` — the +/// caller's ONE `load_state` of `.socket/vendor/state.json` (it also fed +/// the detached-record fold) — plus synthesized entries for the legacy +/// `.socket/go-patches/` redirect backend. Shared by `vex` and `setup +/// --check`'s patch-consistency pass — both must judge a vendored patch by +/// the committed artifact, never the installed tree. /// /// The go-patches synthesis fixes a latent bug: an apply-redirected Go /// patch leaves the module cache pristine (the `replace` directive routes @@ -856,11 +844,12 @@ async fn resolve_product_id(common: &GlobalArgs, product: Option<&str>) -> Resul /// installed tree, fail verification there, and are omitted — fail-closed, /// never falsely attested. Returns `None` when there is nothing vendored /// and no redirect to synthesize (the common case). -pub(crate) async fn load_vendor_context( +pub(crate) async fn vendor_context_from( common: &GlobalArgs, manifest: &PatchManifest, + ledger: std::io::Result, ) -> Option { - let entries = match socket_patch_core::vendor::load_state(&common.cwd).await { + let entries = match ledger { Ok(state) => state.entries, Err(e) => { if !common.silent { diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 6db96305..288352b1 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -49,7 +49,10 @@ pub fn partition_purls( /// PURLs and, on rollback, remaps base PURLs back to qualified ones). /// /// `$using_label` is the noun in "Using at: " for global -/// scans; pass `""` to suppress that line. +/// scans; pass `""` to suppress that line. The banner is progress chrome +/// and goes to STDERR like the macro's two warnings: stdout belongs to +/// `--json` envelopes and the VEX document, so a caller that forgets to +/// fold `json` into `$silent` can no longer corrupt them. macro_rules! scan_ecosystem { ( out = $out:ident, @@ -76,7 +79,7 @@ macro_rules! scan_ecosystem { && !$silent { if let Some(first) = paths.first() { - println!("Using {} at: {}", using, first.display()); + eprintln!("Using {} at: {}", using, first.display()); } } for path in &paths { diff --git a/crates/socket-patch-cli/src/path_scope.rs b/crates/socket-patch-cli/src/path_scope.rs index 3a47aed2..4ddafbdc 100644 --- a/crates/socket-patch-cli/src/path_scope.rs +++ b/crates/socket-patch-cli/src/path_scope.rs @@ -21,7 +21,7 @@ //! on every copy of the selected package. use glob::{MatchOptions, Pattern}; -use std::path::Path; +use std::path::{Path, PathBuf}; /// `*` and `?` stay within one path component; `**` is the only way to /// cross directories. Case-sensitive on Unix; case-insensitive on Windows, @@ -99,22 +99,56 @@ impl PathScope { &self.raw } + /// Bind the scope to one `--cwd`, absolutizing it ONCE (for the default + /// relative `.` that is a `getcwd` syscall plus an allocation) so a + /// caller filtering a whole crawl asks [`BoundScope::matches`] per + /// package without repeating it. + pub fn bind(&self, cwd: &Path) -> BoundScope<'_> { + BoundScope { + scope: self, + cwd: cwd.to_path_buf(), + abs_cwd: std::path::absolute(cwd).unwrap_or_else(|_| cwd.to_path_buf()), + } + } + /// Is `candidate` (an absolute package directory from a crawler) in - /// scope? An empty scope matches everything. + /// scope? An empty scope matches everything. One-off form of + /// [`PathScope::bind`] + [`BoundScope::matches`]; loops should bind. pub fn matches(&self, cwd: &Path, candidate: &Path) -> bool { if self.patterns.is_empty() { return true; } + self.bind(cwd).matches(candidate) + } +} + +/// A [`PathScope`] bound to one `--cwd` (see [`PathScope::bind`]). +#[derive(Debug)] +pub struct BoundScope<'a> { + scope: &'a PathScope, + /// The cwd exactly as given, kept beside its absolutized form: crawler + /// paths are NOT absolutized (the default `--cwd .` yields candidates + /// like `./node_modules/foo`), so the relative form is the prefix that + /// actually strips in the common case. + cwd: PathBuf, + abs_cwd: PathBuf, +} + +impl BoundScope<'_> { + /// Is `candidate` in scope? An empty scope matches everything. + pub fn matches(&self, candidate: &Path) -> bool { + if self.scope.patterns.is_empty() { + return true; + } // Textual prefix-strip against the absolutized cwd; crawler paths // are already absolute, so this stays a pure string operation. - let abs_cwd = std::path::absolute(cwd).unwrap_or_else(|_| cwd.to_path_buf()); let abs = slashed(candidate); let rel = candidate - .strip_prefix(&abs_cwd) + .strip_prefix(&self.abs_cwd) .ok() - .or_else(|| candidate.strip_prefix(cwd).ok()) + .or_else(|| candidate.strip_prefix(&self.cwd).ok()) .map(slashed); - self.patterns.iter().any(|(pattern, is_absolute)| { + self.scope.patterns.iter().any(|(pattern, is_absolute)| { let target = if *is_absolute { Some(abs.as_str()) } else { @@ -251,6 +285,31 @@ mod tests { assert_eq!(matches, cfg!(windows)); } + /// `bind` absolutizes the cwd once and answers exactly like the + /// per-call form for every candidate, relative and absolute patterns + /// alike. + #[test] + fn bound_scope_matches_like_the_per_call_form() { + let s = scope(&["packages/foo", "/global/store"]); + let bound = s.bind(&cwd()); + for candidate in [ + "/proj/packages/foo/node_modules/lodash", + "/proj/packages/bar/node_modules/lodash", + "/global/store/lib/node_modules/x", + "/other/store/lib", + ] { + let candidate = Path::new(candidate); + assert_eq!( + bound.matches(candidate), + s.matches(&cwd(), candidate), + "{candidate:?}" + ); + } + assert!(bound.matches(Path::new("/proj/packages/foo/x"))); + assert!(!bound.matches(Path::new("/proj/packages/bar/x"))); + assert!(scope(&[]).bind(&cwd()).matches(Path::new("/anywhere"))); + } + #[test] fn matching_is_purely_textual() { // Paths that do not exist on disk still match — no fs access. diff --git a/crates/socket-patch-cli/src/update_notifier.rs b/crates/socket-patch-cli/src/update_notifier.rs index 3f67a665..2229f668 100644 --- a/crates/socket-patch-cli/src/update_notifier.rs +++ b/crates/socket-patch-cli/src/update_notifier.rs @@ -23,6 +23,7 @@ use socket_patch_core::update::{ self as core_update, detect_channel, is_newer, upgrade_hint, ChannelEnv, InstallChannel, UpdateEndpoints, UpdateTimeouts, }; +use socket_patch_core::utils::socket_cli_config::env_truthy; use crate::args::GlobalArgs; use crate::output; @@ -101,17 +102,6 @@ pub fn should_check(ctx: &GuardCtx) -> Result<(), SkipReason> { Ok(()) } -fn env_flag(name: &str) -> bool { - matches!( - std::env::var(name) - .unwrap_or_default() - .trim() - .to_ascii_lowercase() - .as_str(), - "1" | "true" | "yes" | "on" | "y" | "t" - ) -} - /// `CI` set to anything non-empty except an explicit falsy counts; /// `GITHUB_ACTIONS` counts whenever non-empty. Deliberately short list — /// the TTY guard covers other vendors' runners anyway. @@ -129,13 +119,13 @@ impl GuardCtx { /// Capture the real environment + the parsed global flags. pub fn capture(common: &GlobalArgs) -> Self { GuardCtx { - opted_out: env_flag("SOCKET_NO_UPDATE_CHECK"), + opted_out: env_truthy("SOCKET_NO_UPDATE_CHECK"), offline: common.offline, silent: common.silent, json: common.json, ci: in_ci(), stderr_tty: output::stderr_is_tty(), - forced: env_flag("SOCKET_UPDATE_NOTIFIER_FORCE"), + forced: env_truthy("SOCKET_UPDATE_NOTIFIER_FORCE"), state_dir_resolvable: core_update::state::state_dir().is_some(), } } diff --git a/crates/socket-patch-cli/tests/cli_apply_silent.rs b/crates/socket-patch-cli/tests/cli_apply_silent.rs index 54c9b442..42dcf88f 100644 --- a/crates/socket-patch-cli/tests/cli_apply_silent.rs +++ b/crates/socket-patch-cli/tests/cli_apply_silent.rs @@ -22,7 +22,9 @@ //! it's printed by `get_api_client_with_overrides` in core for every ONLINE //! command (offline runs suppress it — see //! `apply_offline_suppresses_public_proxy_notice`) and is out of scope for -//! `apply`'s `--silent` gating. +//! `apply`'s `--silent` gating. `apply` builds that client only once a +//! manifest exists and `--check` is not set, so the no-manifest hook path +//! never prints it at all. use std::path::{Path, PathBuf}; use std::process::Command; @@ -72,6 +74,46 @@ fn write_corrupt_manifest(root: &Path) { std::fs::write(socket.join("manifest.json"), "{ not json").unwrap(); } +fn write_empty_manifest(root: &Path) { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write(socket.join("manifest.json"), r#"{"patches":{}}"#).unwrap(); +} + +/// Valid manifest with one npm patch whose afterHash blob is staged (so +/// offline staging is Ready) but NO installed package anywhere: the crawl +/// finds nothing and every in-scope patch is unmatched. +fn write_unmatched_npm_manifest(root: &Path) { + std::fs::write( + root.join("package.json"), + r#"{ "name": "silent-host", "version": "0.0.0" }"#, + ) + .unwrap(); + let socket = root.join(".socket"); + let after = "b".repeat(64); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + std::fs::write(socket.join("blobs").join(&after), b"patched").unwrap(); + std::fs::write( + socket.join("manifest.json"), + format!( + r#"{{ "patches": {{ + "pkg:npm/ghost@1.0.0": {{ + "uuid": "ghost-uuid-0000", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{ "package/index.js": {{ + "beforeHash": "{before}", + "afterHash": "{after}" + }}}}, + "vulnerabilities": {{}}, "description": "x", + "license": "MIT", "tier": "free" + }} + }}}}"#, + before = "a".repeat(64), + ), + ) + .unwrap(); +} + /// Valid manifest with one golang patch entry and NO committed copy under /// `.socket/go-patches/` — `apply --check` must report `MissingCopy` drift. fn write_drifted_go_manifest(root: &Path) { @@ -179,22 +221,79 @@ fn apply_check_silent_drift_keeps_error_output() { /// must keep it (anti-vacuous half). #[test] fn apply_offline_suppresses_public_proxy_notice() { - // No .socket dir at all: apply exits 0 ("nothing to apply") either way, - // so the only stderr difference is the advisory under test. + // An EMPTY manifest: apply exits 0 ("No patches to apply") either way, + // so the only stderr difference is the advisory under test. (A dir with + // no manifest at all never reaches client construction — see the next + // test — so it cannot serve the anti-vacuous half.) let tmp = tempfile::tempdir().expect("create tempdir"); + write_empty_manifest(tmp.path()); let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"]); - assert_eq!(code, 0, "no-manifest apply is a clean no-op: {stderr}"); + assert_eq!(code, 0, "empty-manifest apply is a clean no-op: {stderr}"); assert!( !stderr.contains("public patch API proxy"), "--offline must not claim proxy (network) use; stderr was: {stderr:?}" ); let (code, _stdout, stderr) = run_apply(tmp.path(), &[]); - assert_eq!(code, 0, "no-manifest apply is a clean no-op: {stderr}"); + assert_eq!(code, 0, "empty-manifest apply is a clean no-op: {stderr}"); assert!( stderr.contains("public patch API proxy"), "anti-vacuous: the same tokenless run WITHOUT --offline must keep the \ advisory; stderr was: {stderr:?}" ); } + +/// The hook path — `apply` on a project with no manifest — does nothing +/// and must build nothing: no API client, so no tokenless advisory (and +/// no org-slug round-trip in CI with a token set) on every `npm install` +/// of a project that has no patches yet. Same for `--check`, documented +/// as lock-free and offline-safe. +#[test] +fn apply_without_manifest_or_under_check_builds_no_api_client() { + let tmp = tempfile::tempdir().expect("create tempdir"); + let (code, stdout, stderr) = run_apply(tmp.path(), &[]); + assert_eq!(code, 0, "no-manifest apply is a clean no-op: {stderr}"); + assert!( + stdout.contains("No patch manifest found; nothing to apply."), + "the calm no-op names the manifest, not the folder: {stdout}" + ); + assert!( + !stderr.contains("SOCKET_API_TOKEN"), + "no client is built before the no-manifest exit; stderr was: {stderr:?}" + ); + + write_empty_manifest(tmp.path()); + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--check"]); + assert_eq!(code, 0, "--check on an empty manifest is in sync: {stderr}"); + assert!( + !stderr.contains("SOCKET_API_TOKEN"), + "no client is built for the read-only --check; stderr was: {stderr:?}" + ); +} + +/// `apply --silent` on a manifest whose every in-scope patch matches no +/// installed package exits 1 — so its diagnostic must print even under +/// `--silent` ("errors only", never "nothing"). Regression: the warning +/// block was gated on `!silent`, so the hooked `apply --silent` exited 1 +/// with zero output on exactly this manifest. +#[test] +fn apply_silent_unmatched_manifest_keeps_warning_output() { + let tmp = tempfile::tempdir().expect("create tempdir"); + write_unmatched_npm_manifest(tmp.path()); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--silent", "--offline"]); + assert_eq!( + code, 1, + "an in-scope patch with no installed package fails the run: {stderr}" + ); + assert!( + stdout.trim().is_empty(), + "silent human mode writes the diagnostic to stderr, not stdout: {stdout}" + ); + assert!( + stderr.contains("Warning: No packages found that match available patches"), + "--silent must keep the exit-flipping diagnostic (errors only, never \ + nothing); stderr was: {stderr:?}" + ); +} diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs index d039bf42..69c3b20b 100644 --- a/crates/socket-patch-cli/tests/cli_parse_list.rs +++ b/crates/socket-patch-cli/tests/cli_parse_list.rs @@ -1326,6 +1326,246 @@ fn local_ledger_never_suppresses_flagged_manifest_not_found_via_binary() { assert_eq!(v["error"]["code"], "manifest_not_found", "envelope={v}"); } +// --------------------------------------------------------------------------- +// Vendor-ledger records — vendored mode is manifest-free: every `scan`/`get +// --mode vendored` patch lives ONLY in `.socket/vendor/state.json`, as a +// `detached` entry carrying its embedded record. The hosted rule applies +// unchanged: a vendored-only project lists its records (labeled `vendored`) +// and exits 0 instead of `manifest_not_found`; a legacy manifest-tracked +// entry (no record of its own) never double-lists its manifest purl. +// --------------------------------------------------------------------------- + +const VENDORED_PURL: &str = "pkg:npm/vendored-pkg@3.0.0"; +const VENDORED_UUID: &str = "44444444-4444-4444-8444-444444444444"; + +/// Seed `.socket/vendor/state.json`. `Some(record)` writes the detached +/// (manifest-free) shape; `None` a legacy manifest-tracked entry. +fn write_vendor_ledger(root: &Path, entries: &[(&str, Option)]) { + let mut map = serde_json::Map::new(); + for (purl, record) in entries { + let uuid = record + .as_ref() + .map_or("legacy-uuid".to_string(), |r| r.uuid.clone()); + map.insert( + (*purl).to_string(), + serde_json::json!({ + "ecosystem": "npm", + "basePurl": purl, + "uuid": uuid, + "artifact": { "path": format!(".socket/vendor/npm/{uuid}/pkg.tgz") }, + "wiring": [], + "detached": record.is_some(), + "record": record, + }), + ); + } + let vendor_dir = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + std::fs::write( + vendor_dir.join("state.json"), + serde_json::to_string_pretty(&serde_json::json!({ "version": 1, "entries": map })) + .unwrap(), + ) + .unwrap(); +} + +#[test] +fn vendored_only_project_list_json_lists_ledger_records_via_binary() { + let tmp = tempfile::tempdir().unwrap(); + write_vendor_ledger( + tmp.path(), + &[(VENDORED_PURL, Some(hosted_record(VENDORED_UUID)))], + ); + + let out = run_list_binary(tmp.path(), &["--json"]); + assert_eq!( + out.status.code(), + Some(0), + "vendored-only list --json must exit 0 (records found), stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON"); + assert_eq!(v["status"], "success", "envelope={v}"); + assert_eq!(v["summary"]["discovered"], 1, "envelope={v}"); + let event = &v["events"][0]; + assert_eq!(event["purl"], VENDORED_PURL, "envelope={v}"); + assert_eq!(event["uuid"], VENDORED_UUID, "envelope={v}"); + assert_eq!(event["details"]["mode"], "vendored", "envelope={v}"); + assert_eq!( + event["details"]["ledger"], ".socket/vendor/state.json", + "envelope={v}" + ); + assert_eq!(event["details"]["tier"], "free", "envelope={v}"); +} + +#[test] +fn vendored_only_project_list_plain_labels_vendored_via_binary() { + let tmp = tempfile::tempdir().unwrap(); + write_vendor_ledger( + tmp.path(), + &[(VENDORED_PURL, Some(hosted_record(VENDORED_UUID)))], + ); + + let out = run_list_binary(tmp.path(), &[]); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "vendored-only list must exit 0, stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + stdout.contains("Found 1 patch(es):"), + "count header must include the vendored record: {stdout}" + ); + assert!( + stdout.contains(&format!("Package: {VENDORED_PURL}")), + "missing vendored purl: {stdout}" + ); + assert!( + stdout.contains("Mode: vendored"), + "vendored record must be labeled: {stdout}" + ); + assert!( + stdout.contains(".socket/vendor/state.json"), + "the label must name the ledger the record came from: {stdout}" + ); +} + +#[test] +fn manifest_hosted_and_vendored_ledgers_coexist_via_binary() { + // One purl in all three stores: every copy listed, purl-sorted, manifest + // then hosted then vendored on the tie. + let tmp = tempfile::tempdir().unwrap(); + write_manifest_in(tmp.path(), &populated_manifest()); + common::write_redirect_ledger( + tmp.path(), + &[("pkg:npm/test-pkg@1.0.0", hosted_record(HOSTED_UUID))], + ); + write_vendor_ledger( + tmp.path(), + &[ + ("pkg:npm/test-pkg@1.0.0", Some(hosted_record(VENDORED_UUID))), + // Legacy manifest-tracked vendoring of the same purl: no record + // of its own, so it must not add a fourth copy. + ("pkg:npm/test-pkg@1.0.0#legacy", None), + ], + ); + + let out = run_list_binary(tmp.path(), &["--json"]); + assert_eq!( + out.status.code(), + Some(0), + "stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON"); + let listed: Vec<(&str, &str)> = v["events"] + .as_array() + .expect("events array") + .iter() + .map(|e| { + ( + e["uuid"].as_str().expect("uuid"), + e["details"]["mode"].as_str().unwrap_or("manifest"), + ) + }) + .collect(); + assert_eq!( + listed, + vec![ + ("11111111-1111-4111-8111-111111111111", "manifest"), + (HOSTED_UUID, "hosted"), + (VENDORED_UUID, "vendored"), + ], + "manifest < hosted < vendored on a purl tie, record-less entries \ + skipped; envelope={v}" + ); +} + +#[test] +fn record_less_vendor_entry_without_manifest_still_manifest_not_found_via_binary() { + // A legacy manifest-tracked entry asserts no patch of its own: with no + // manifest and no other store, `list` stays on the manifest_not_found + // path (mirrors the edits-only redirect ledger). + let tmp = tempfile::tempdir().unwrap(); + write_vendor_ledger(tmp.path(), &[(VENDORED_PURL, None)]); + + let out = run_list_binary(tmp.path(), &["--json"]); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON"); + assert_eq!(out.status.code(), Some(1), "no records anywhere must exit 1"); + assert_eq!(v["error"]["code"], "manifest_not_found", "envelope={v}"); +} + +#[test] +fn silent_gates_the_malformed_vendor_ledger_warning_via_binary() { + // Same posture as the redirect ledger: a corrupt vendor ledger degrades + // to "nothing to consult" with an advisory stderr warning that --silent + // ("errors only") mutes; the manifest still lists, exit 0. + let tmp = tempfile::tempdir().unwrap(); + write_manifest_in(tmp.path(), &populated_manifest()); + let vendor_dir = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + std::fs::write(vendor_dir.join("state.json"), "{ torn ledger").unwrap(); + + let loud = run_list_binary_scrubbed(tmp.path(), &[]); + assert_eq!(loud.status.code(), Some(0), "manifest still lists"); + assert!( + String::from_utf8_lossy(&loud.stderr).contains("unreadable vendor ledger"), + "a corrupt vendor ledger must be surfaced on stderr when not silent; \ + stderr={}", + String::from_utf8_lossy(&loud.stderr) + ); + + let out = run_list_binary_scrubbed(tmp.path(), &["--silent"]); + assert_eq!(out.status.code(), Some(0)); + assert!( + String::from_utf8_lossy(&out.stderr).trim().is_empty(), + "--silent must mute the vendor-ledger warning; stderr={}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn manifest_path_scopes_vendor_ledger_to_target_project_via_binary() { + // The vendor ledger resolves at the SAME project root as the manifest + // and the redirect ledger — never the cwd's. + let cwd = tempfile::tempdir().unwrap(); + write_vendor_ledger( + cwd.path(), + &[("pkg:npm/local-decoy@0.0.1", Some(hosted_record(VENDORED_UUID)))], + ); + let target = tempfile::tempdir().unwrap(); + write_manifest_in(target.path(), &populated_manifest()); + write_vendor_ledger( + target.path(), + &[(VENDORED_PURL, Some(hosted_record(VENDORED_UUID)))], + ); + + let manifest_path = target.path().join(".socket/manifest.json"); + let out = run_list_binary( + cwd.path(), + &["--json", "--manifest-path", manifest_path.to_str().unwrap()], + ); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON"); + let purls: Vec<&str> = v["events"] + .as_array() + .expect("events array") + .iter() + .map(|e| e["purl"].as_str().expect("purl")) + .collect(); + assert_eq!( + purls, + vec!["pkg:npm/test-pkg@1.0.0", VENDORED_PURL], + "only the target project's manifest + vendor ledger may be listed — \ + never the cwd's local ledger; envelope={v}" + ); +} + // --------------------------------------------------------------------------- // Telemetry — `patch_listed`'s `patches_count` predates the hosted folding // and dashboards consume it as "manifest patches". Folding hosted records diff --git a/crates/socket-patch-cli/tests/covgap_commands_apply.rs b/crates/socket-patch-cli/tests/covgap_commands_apply.rs index 81193747..b6cf35ad 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_apply.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_apply.rs @@ -1041,6 +1041,65 @@ fn vendored_gem_base_with_installed_tree_is_skipped_not_repatched() { ); } +/// Vendored mode is manifest-free: a project vendored by `scan`/`get --mode +/// vendored` has ONLY `.socket/vendor/state.json` (detached entries with +/// embedded records) and no manifest. The hooked `apply` on such a project +/// is the calm `noManifest` no-op — it never reads the ledger, never takes +/// the lock (so never creates `apply.lock`) and leaves `.socket/` exactly as +/// it found it: the committed artifacts ARE the patch. +#[test] +fn apply_on_ledger_only_vendored_project_is_a_no_manifest_no_op() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_root_package_json(root); + let uuid = "80808080-8080-4080-8080-808080808080"; + let vendor_dir = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + let ledger = serde_json::to_vec_pretty(&json!({ + "version": 1, + "entries": { "pkg:npm/vend-only@1.0.0": { + "ecosystem": "npm", + "basePurl": "pkg:npm/vend-only@1.0.0", + "uuid": uuid, + "artifact": { "path": format!(".socket/vendor/npm/{uuid}/vend-only-1.0.0.tgz") }, + "wiring": [], + "detached": true, + "record": patch_record( + uuid, + json!({ "package/index.js": { + "beforeHash": git_sha256(MM_BEFORE), + "afterHash": git_sha256(MM_AFTER), + }}), + ), + }} + })) + .unwrap(); + std::fs::write(vendor_dir.join("state.json"), &ledger).unwrap(); + + let (code, stdout, stderr) = run_apply(root, &["--json"], &[]); + let env = parse_json_envelope(stdout.trim()); + assert_eq!(code, 0, "envelope={env}\nstderr={stderr}"); + assert_eq!(env["status"], "noManifest", "envelope: {env}"); + assert_eq!(env["events"], json!([]), "envelope: {env}"); + + let mut names: Vec = std::fs::read_dir(root.join(".socket")) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + assert_eq!( + names, + vec!["vendor"], + "apply must not touch .socket/ on a manifest-less project (no manifest, \ + no blobs/, no apply.lock)" + ); + assert_eq!( + std::fs::read(vendor_dir.join("state.json")).unwrap(), + ledger, + "the ledger survives byte-identical" + ); +} + /// A QUALIFIED gem singleton whose record holds only NEW files (empty /// `beforeHash` ⇒ no representative file) has nothing to disqualify it: /// the gated apply loop must treat it as installed, attempt it, and diff --git a/crates/socket-patch-cli/tests/covgap_commands_setup.rs b/crates/socket-patch-cli/tests/covgap_commands_setup.rs index f773aa67..d09836a4 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_setup.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_setup.rs @@ -792,6 +792,110 @@ fn exclude_already_persisted_skips_manifest_rewrite() { ); } +// --------------------------------------------------------------------------- +// `--exclude` persistence waits for the mutation gate: a directory with no +// project writes nothing, an already-configured project still persists an +// explicit exclusion, a dry run persists nothing, and a failed write is +// reported — never silently lost. +// --------------------------------------------------------------------------- + +/// `setup --exclude` in a directory with no project files reports `no_files` +/// and must NOT leave a `.socket/manifest.json` behind (the exclude list +/// used to be persisted before discovery). +#[test] +fn setup_exclude_in_empty_dir_writes_no_socket_dir() { + let tmp = tempfile::tempdir().expect("tempdir"); + let cwd = tmp.path(); + + let (code, v) = run_json(cwd, &["setup", "--yes", "--json", "--exclude", "packages/x"]); + assert_eq!(code, 0, "{v}"); + assert_eq!(v["status"], "no_files", "{v}"); + assert!( + !cwd.join(".socket").exists(), + "no project → nothing to set up → nothing to persist, no .socket/" + ); +} + +/// An explicit `--exclude` on an already-configured project (nothing to +/// preview or confirm) is still the user's stated intent: it is persisted, +/// under the manifest lock, which is released and removed again. +#[test] +fn setup_exclude_persists_when_hooks_are_already_configured() { + let tmp = tempfile::tempdir().expect("tempdir"); + let cwd = tmp.path(); + write( + &cwd.join("package.json"), + &format!("{{ \"name\": \"root\", \"version\": \"1.0.0\", {WIRED_SCRIPTS_FRAGMENT} }}"), + ); + + let (code, v) = run_json(cwd, &["setup", "--yes", "--json", "--exclude", "packages/b"]); + assert_eq!(code, 0, "{v}"); + assert_eq!(v["status"], "already_configured", "{v}"); + let manifest = read(&cwd.join(".socket/manifest.json")); + let mv: serde_json::Value = serde_json::from_str(&manifest).expect("manifest JSON"); + assert_eq!( + mv["setup"]["exclude"], + serde_json::json!(["packages/b"]), + "the explicit exclusion must be persisted: {manifest}" + ); + assert!( + !cwd.join(".socket/apply.lock").exists(), + "the persistence lock is released and its file removed" + ); +} + +/// `--dry-run` with `--exclude` previews and persists NOTHING. +#[test] +fn setup_dry_run_exclude_persists_nothing() { + let tmp = tempfile::tempdir().expect("tempdir"); + let cwd = tmp.path(); + write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); + + let (code, v) = run_json(cwd, &["setup", "--dry-run", "--json", "--exclude", "packages/b"]); + assert_eq!(code, 0, "{v}"); + assert_eq!(v["status"], "dry_run", "{v}"); + assert!( + !cwd.join(".socket").exists(), + "a dry run must not persist the exclude list" + ); +} + +/// A persistence step that cannot write reports the skip instead of +/// claiming success: a read-only `.socket/` refuses the manifest lock and +/// write alike, the bytes on disk stay untouched, and the `--json` +/// envelope carries the fail-closed warning (same channel as the corrupt- +/// manifest skip). +#[cfg(unix)] +#[test] +fn setup_exclude_write_failure_surfaces_persist_warning() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().expect("tempdir"); + let cwd = tmp.path(); + write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); + let manifest_path = cwd.join(".socket/manifest.json"); + let original = r#"{"patches":{}}"#; + write(&manifest_path, original); + let socket = cwd.join(".socket"); + std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o555)).unwrap(); + + let (code, v) = run_json(cwd, &["setup", "--yes", "--json", "--exclude", "packages/b"]); + std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o755)).unwrap(); + + assert_eq!(code, 0, "the hooks were written; only persistence was skipped: {v}"); + assert_eq!(v["status"], "success", "{v}"); + assert!( + v["warnings"].as_array().is_some_and(|w| w.iter().any(|x| x + .as_str() + .is_some_and(|x| x.contains("not persisting --exclude")))), + "the skipped persistence must appear in the --json warnings: {v}" + ); + assert_eq!( + read(&manifest_path), + original, + "the manifest must be untouched when it cannot be rewritten" + ); +} + // --------------------------------------------------------------------------- // Python edge matrix. // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/setup_contract_gaps.rs b/crates/socket-patch-cli/tests/setup_contract_gaps.rs index af650117..5cb4d942 100644 --- a/crates/socket-patch-cli/tests/setup_contract_gaps.rs +++ b/crates/socket-patch-cli/tests/setup_contract_gaps.rs @@ -192,9 +192,19 @@ const VENDOR_UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; /// Lay down the shared vendored fixture: hook wired, an installed /// `node_modules/vendpkg` at `installed` bytes, a committed dir-shaped /// vendored artifact at `vendored` bytes, the `.socket/vendor/state.json` -/// ledger entry binding the purl to it, and a manifest record whose -/// afterHash is the hash of `patched`. -fn setup_vendored_fixture(proj: &Path, home: &Path, installed: &[u8], vendored: &[u8]) { +/// ledger entry binding the purl to it, and the patch record whose +/// afterHash is the hash of `patched` — in `.socket/manifest.json` for the +/// legacy manifest-tracked shape, or (`detached`) embedded in the ledger +/// entry with NO manifest at all: the manifest-free posture every +/// `scan`/`get --mode vendored` run writes. +fn setup_vendored_fixture( + proj: &Path, + home: &Path, + installed: &[u8], + vendored: &[u8], + detached: bool, +) { + use socket_patch_core::manifest::schema::PatchRecord; use socket_patch_core::vendor::state::{VendorArtifact, VendorEntry, VendorState}; write( @@ -221,6 +231,18 @@ fn setup_vendored_fixture(proj: &Path, home: &Path, installed: &[u8], vendored: &String::from_utf8_lossy(vendored), ); + let record_json = format!( + r#"{{ + "uuid": "{VENDOR_UUID}", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{ "package/index.js": {{ "beforeHash": "{before}", "afterHash": "{after}" }} }}, + "vulnerabilities": {{ "GHSA-aaaa-bbbb-cccc": {{ "cves": ["CVE-2024-0001"], "summary": "x", "severity": "high", "description": "d" }} }}, + "description": "d", "license": "MIT", "tier": "free" + }}"#, + before = git_sha256(original), + after = git_sha256(patched), + ); + let mut state = VendorState::new(); state.entries.insert( "pkg:npm/vendpkg@1.0.0".to_string(), @@ -238,8 +260,10 @@ fn setup_vendored_fixture(proj: &Path, home: &Path, installed: &[u8], vendored: wiring: Vec::new(), lock: None, took_over_go_patches: false, - detached: false, - record: None, + detached, + record: detached.then(|| { + serde_json::from_str::(&record_json).expect("record fixture") + }), flavor: None, uv: None, pnpm: None, @@ -253,22 +277,17 @@ fn setup_vendored_fixture(proj: &Path, home: &Path, installed: &[u8], vendored: &serde_json::to_string_pretty(&state).unwrap(), ); - write( - &proj.join(".socket/manifest.json"), - &format!( - r#"{{ "patches": {{ - "pkg:npm/vendpkg@1.0.0": {{ - "uuid": "{VENDOR_UUID}", - "exportedAt": "2024-01-01T00:00:00Z", - "files": {{ "package/index.js": {{ "beforeHash": "{before}", "afterHash": "{after}" }} }}, - "vulnerabilities": {{ "GHSA-aaaa-bbbb-cccc": {{ "cves": ["CVE-2024-0001"], "summary": "x", "severity": "high", "description": "d" }} }}, - "description": "d", "license": "MIT", "tier": "free" - }} -}} }}"#, - before = git_sha256(original), - after = git_sha256(patched), - ), - ); + if detached { + assert!( + !proj.join(".socket/manifest.json").exists(), + "the detached fixture is manifest-free by construction" + ); + } else { + write( + &proj.join(".socket/manifest.json"), + &format!(r#"{{ "patches": {{ "pkg:npm/vendpkg@1.0.0": {record_json} }} }}"#), + ); + } } #[test] @@ -278,7 +297,7 @@ fn setup_check_judges_vendored_patch_by_committed_artifact() { // Healthy vendored state: the artifact carries the patch; the installed // tree still holds the ORIGINAL bytes (expected until the next install). - setup_vendored_fixture(proj.path(), home.path(), b"original\n", b"patched\n"); + setup_vendored_fixture(proj.path(), home.path(), b"original\n", b"patched\n", false); let (code, stdout) = run(proj.path(), home.path(), &["setup", "--check", "--json"]); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); @@ -301,7 +320,7 @@ fn setup_check_flags_tampered_vendored_artifact_despite_patched_tree() { // Laundering attempt: the committed artifact was tampered with, but the // installed tree LOOKS patched. The artifact is the sole evidence — the // consumed bytes on the next install — so check must fail. - setup_vendored_fixture(proj.path(), home.path(), b"patched\n", b"TAMPERED\n"); + setup_vendored_fixture(proj.path(), home.path(), b"patched\n", b"TAMPERED\n", false); let (code, stdout) = run(proj.path(), home.path(), &["setup", "--check", "--json"]); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); @@ -317,6 +336,55 @@ fn setup_check_flags_tampered_vendored_artifact_despite_patched_tree() { ); } +// =========================================================================== +// Property 4 (vendored, manifest-free) — vendored mode writes NO manifest: +// the ledger entry is `detached` and carries the only copy of the patch +// record. `setup --check` must judge those exactly like manifest-backed +// vendored patches (it folds the ledger's embedded records in, as `vex` +// does); without the fold a vendored-only project with a missing or +// tampered committed artifact reported `configured`. +// =========================================================================== + +#[test] +fn setup_check_judges_detached_vendored_patch_without_manifest() { + let proj = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + setup_vendored_fixture(proj.path(), home.path(), b"original\n", b"patched\n", true); + + let (code, stdout) = run(proj.path(), home.path(), &["setup", "--check", "--json"]); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!( + code, 0, + "a healthy detached vendored patch (committed artifact carries the patch) \ + is a correctly-patched state; stdout=\n{stdout}" + ); + assert_eq!(v["status"], "configured", "stdout=\n{stdout}"); +} + +#[test] +fn setup_check_flags_tampered_detached_vendored_artifact_without_manifest() { + let proj = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + setup_vendored_fixture(proj.path(), home.path(), b"patched\n", b"TAMPERED\n", true); + + let (code, stdout) = run(proj.path(), home.path(), &["setup", "--check", "--json"]); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!( + code, 1, + "a tampered detached vendored artifact must fail check even with no \ + manifest and a patched-looking installed tree; stdout=\n{stdout}" + ); + assert_eq!(v["status"], "needs_configuration", "stdout=\n{stdout}"); + assert!( + v["files"].as_array().is_some_and(|files| files.iter().any(|f| { + f["kind"] == "patch" + && f["path"] == "pkg:npm/vendpkg@1.0.0" + && f["status"] == "needs_configuration" + })), + "the drifted vendored purl must be named as a `patch` entry; stdout=\n{stdout}" + ); +} + // =========================================================================== // Property 7 — reflected in VEX. A patch contributes a VEX statement only for an // ecosystem that is actually set up (or declared `manual`). Here the manifest From 80065cd93e957cd670fd4e2ab3b31e568b1693e9 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 20:43:30 -0400 Subject: [PATCH 18/44] test(cli/repair-vendor): pin the manifest-free vendored footprint (D2) `scan --vendor` no longer writes `.socket/manifest.json`; every ledger entry is detached with its record embedded. Eleven repair tests still read/edited that manifest or expected repair to recover records from it after the ledger was deleted. covgap_commands_repair_vendor.rs - new `to_legacy_manifest_mode` hand-migrates the fixture to the legacy manifest-mode shape (embedded records move into the manifest, entries lose `detached`/`record`) so the manifest-backed arms stay covered: dropped / moved-on manifest record, `(None, None)` uuid recovery (x2), and the three offline soft-restore legs (manifest = only offline record source once the ledger is gone). - no-ledger/offline synthetic-purl test now asserts no manifest exists. - jsr no-backend test stamps the record on the ledger entry instead of a manifest record. repair_vendor_e2e.rs - test 5 renamed vendor_rerun_is_a_noop_and_repair_recovers_registry_ resolution_from_ledger: standalone `vendor` is a `noManifest` no-op (D2 retired the ledger re-vendor path); `repair` rebuilds from the ledger's wiring original. - test 7: a manifest-less reconstruction embeds the API-recovered record (`detached: true`, `record.uuid`); inert blob mount / --download-mode dropped; test 6 cosmetic `--detached` wording. No source changes: every failure pinned pre-D2 behavior. Co-Authored-By: Claude Fable 5.1 --- .../tests/covgap_commands_repair_vendor.rs | 112 ++++++++++++------ .../tests/repair_vendor_e2e.rs | 59 ++++----- 2 files changed, 107 insertions(+), 64 deletions(-) diff --git a/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs b/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs index 3af3242a..a1d9b337 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs @@ -9,6 +9,13 @@ //! //! Fixtures and helpers mirror `repair_vendor_e2e.rs` (this suite owns its //! own copies; that file is owned by another agent). +//! +//! A vendored run (`scan --vendor`) is manifest-free: every ledger entry is +//! `detached` with its record embedded and `.socket/manifest.json` is never +//! written. The manifest-backed repair arms (dropped / moved-on manifest +//! records, the `(None, None)` uuid recovery, pass 2's manifest-by-uuid +//! reconstruction) belong to LEGACY manifest-mode projects, which the tests +//! build by hand-migrating the fixture with [`to_legacy_manifest_mode`]. use std::path::{Path, PathBuf}; use std::process::Command; @@ -449,6 +456,32 @@ fn write_state(root: &Path, state: &serde_json::Value) { .unwrap(); } +/// Hand-migrate the vendored fixture to the LEGACY manifest-mode shape: every +/// ledger entry's embedded record moves into `.socket/manifest.json` (keyed +/// by the ledger key) and the entry loses `detached` + `record` — exactly +/// what a pre-D2 `scan --vendor` (or a standalone `vendor` from a manifest) +/// left behind. Returns the manifest path. +fn to_legacy_manifest_mode(root: &Path) -> PathBuf { + let mut state = read_state(root); + let mut patches = serde_json::Map::new(); + for (key, entry) in state["entries"].as_object_mut().unwrap() { + let entry = entry.as_object_mut().unwrap(); + let record = entry + .remove("record") + .expect("a vendored ledger entry embeds its record"); + entry.remove("detached"); + patches.insert(key.clone(), record); + } + write_state(root, &state); + let manifest_path = root.join(".socket/manifest.json"); + std::fs::write( + &manifest_path, + serde_json::to_vec_pretty(&serde_json::json!({ "patches": patches })).unwrap(), + ) + .unwrap(); + manifest_path +} + // ───────────────────────── pass-1 record resolution ───────────────────────── /// Corrupt `.socket/vendor/state.json` (unparseable JSON) → the vendored @@ -488,9 +521,9 @@ async fn repair_fails_loudly_on_corrupt_vendor_state() { ); } -/// A ledger entry whose record was DROPPED from the manifest is silently -/// skipped — the vendor reconcile owns reverting it, so repair must neither -/// fail nor rebuild the disowned artifact. +/// A legacy manifest-mode ledger entry whose record was DROPPED from the +/// manifest is silently skipped — the vendor reconcile owns reverting it, so +/// repair must neither fail nor rebuild the disowned artifact. #[tokio::test] async fn repair_skips_entry_dropped_from_manifest() { let mock = MockServer::start().await; @@ -503,7 +536,7 @@ async fn repair_skips_entry_dropped_from_manifest() { ); let tgz = vendor_project(tmp.path(), &mock.uri()); - let manifest_path = tmp.path().join(".socket/manifest.json"); + let manifest_path = to_legacy_manifest_mode(tmp.path()); let mut manifest: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap(); manifest["patches"] @@ -527,9 +560,9 @@ async fn repair_skips_entry_dropped_from_manifest() { ); } -/// The manifest record's uuid MOVED ON (a patch update is pending): repair -/// skips with `vendor_uuid_mismatch` instead of rebuilding a stale-uuid -/// artifact. +/// A legacy manifest-mode project whose manifest record's uuid MOVED ON (a +/// patch update is pending): repair skips with `vendor_uuid_mismatch` +/// instead of rebuilding a stale-uuid artifact. #[tokio::test] async fn repair_skips_when_manifest_uuid_moved_on() { let mock = MockServer::start().await; @@ -544,7 +577,7 @@ async fn repair_skips_when_manifest_uuid_moved_on() { let tgz = vendor_project(tmp.path(), &mock.uri()); let tgz_bytes = std::fs::read(&tgz).unwrap(); - let manifest_path = tmp.path().join(".socket/manifest.json"); + let manifest_path = to_legacy_manifest_mode(tmp.path()); let mut manifest: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap(); manifest["patches"][PURL]["uuid"] = @@ -572,11 +605,11 @@ async fn repair_skips_when_manifest_uuid_moved_on() { ); } -/// Non-detached ledger entry with NO manifest at all: the record is -/// recovered from the patch API by uuid (rebuild succeeds), and the same -/// shape under `--offline` fails loudly with `vendor_artifact_unrepairable` -/// naming the missing record (covers `fetch_record_by_uuid`'s offline -/// early-return too). +/// Non-detached (legacy manifest-mode) ledger entry with NO manifest at +/// all: the record is recovered from the patch API by uuid (rebuild +/// succeeds), and the same shape under `--offline` fails loudly with +/// `vendor_artifact_unrepairable` naming the missing record (covers +/// `fetch_record_by_uuid`'s offline early-return too). #[tokio::test] async fn repair_recovers_record_by_uuid_without_manifest_then_fails_offline() { let mock = MockServer::start().await; @@ -590,7 +623,7 @@ async fn repair_recovers_record_by_uuid_without_manifest_then_fails_offline() { let tgz = vendor_project(tmp.path(), &mock.uri()); let tgz_bytes = std::fs::read(&tgz).unwrap(); - std::fs::remove_file(tmp.path().join(".socket/manifest.json")).unwrap(); + std::fs::remove_file(to_legacy_manifest_mode(tmp.path())).unwrap(); std::fs::remove_file(&tgz).unwrap(); // Online: the (None, None) arm fetches the record by uuid and rebuilds. @@ -718,7 +751,8 @@ async fn repair_fails_closed_on_unsafe_ledger_artifact_path() { // ───────────── pass 2: reference with no ledger, no manifest, offline ───────────── -/// Lockfile reference with the ledger AND manifest both gone, `--offline`: +/// Lockfile reference with the ledger gone (a vendored run never writes a +/// manifest, so no local record survives), `--offline`: /// the failure is attributed to a SYNTHETIC purl carrying the recovered /// uuid (`pkg:npm/unknown@`) and advises restoring state.json or /// re-running online. Nothing on disk is touched. @@ -737,7 +771,10 @@ async fn repair_no_ledger_no_manifest_offline_fails_with_synthetic_purl() { let lock_bytes = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); - std::fs::remove_file(tmp.path().join(".socket/manifest.json")).unwrap(); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "vendored runs write no manifest" + ); let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair", "--offline"]); assert_eq!(code, 1, "stdout={stdout} stderr={stderr}"); @@ -1225,7 +1262,8 @@ async fn repair_rebuild_fails_when_installed_patch_file_missing() { /// and counted rebuilt, while the non-soft sibling fails — one run, both /// arms. The gem's content IS harvestable from its healthy artifact, but /// staging is all-or-nothing across the candidate set, exactly the shape -/// this fallback exists for. +/// this fallback exists for. Legacy manifest-mode fixture: offline, the +/// manifest is the only record source once the ledger is gone. #[tokio::test] async fn repair_soft_restore_when_staging_unavailable() { const AFTER_GEM: &[u8] = b"gem after\n"; @@ -1253,9 +1291,11 @@ async fn repair_soft_restore_when_staging_unavailable() { "setup must vendor the patched gem copy" ); - // Ledger gone; npm artifact broken (non-soft), gem artifact healthy - // (soft). Offline: the npm after-blob has no local source, so the - // in-memory staging is Unavailable for the whole candidate set. + // Legacy manifest-mode project with its ledger gone; npm artifact + // broken (non-soft), gem artifact healthy (soft). Offline: the npm + // after-blob has no local source, so the in-memory staging is + // Unavailable for the whole candidate set. + to_legacy_manifest_mode(tmp.path()); std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); std::fs::remove_file(&tgz).unwrap(); @@ -1302,7 +1342,8 @@ async fn repair_soft_restore_when_staging_unavailable() { /// `--offline` + soft candidate + package NOT installed: staging succeeds /// (the healthy artifact's own blobs are harvested), but the pristine /// ladder cannot fetch — the entry is soft-restored fingerprint-less with -/// the offline cause named. +/// the offline cause named. Legacy manifest-mode fixture (the manifest is +/// the offline record source once the ledger is gone). #[tokio::test] async fn repair_offline_soft_restore_without_installed_copy() { let mock = MockServer::start().await; @@ -1311,6 +1352,7 @@ async fn repair_offline_soft_restore_without_installed_copy() { write_gem_fixture(tmp.path(), false); let copy = vendor_gem_project(tmp.path(), &mock.uri(), AFTER); + to_legacy_manifest_mode(tmp.path()); std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); std::fs::remove_dir_all(tmp.path().join("vendor/bundle")).unwrap(); @@ -1882,10 +1924,10 @@ async fn repair_soft_restore_when_pristine_fetch_fails() { // ────────────── record recovery shares ONE api client per run ────────────── -/// TWO manifest-less ledger entries recovered by uuid in one run: the -/// second lookup must reuse the cached API client (constructing per-lookup -/// would re-print the token-shape advisory N times) and still resolve its -/// record — both artifacts rebuild. +/// TWO manifest-less, record-less (legacy manifest-mode) ledger entries +/// recovered by uuid in one run: the second lookup must reuse the cached +/// API client (constructing per-lookup would re-print the token-shape +/// advisory N times) and still resolve its record — both artifacts rebuild. #[tokio::test] async fn repair_recovers_multiple_records_by_uuid_sharing_one_client() { let mock = MockServer::start().await; @@ -1907,7 +1949,7 @@ async fn repair_recovers_multiple_records_by_uuid_sharing_one_client() { let copy = tmp.path().join(gem_copy_rel()); assert!(tgz.is_file() && copy.is_dir(), "setup must vendor both"); - std::fs::remove_file(tmp.path().join(".socket/manifest.json")).unwrap(); + std::fs::remove_file(to_legacy_manifest_mode(tmp.path())).unwrap(); std::fs::remove_file(&tgz).unwrap(); std::fs::remove_dir_all(©).unwrap(); @@ -1975,20 +2017,14 @@ async fn repair_no_backend_for_purl_restores_set_aside_bytes() { ); vendor_project(tmp.path(), &mock.uri()); - // Manifest record for the jsr purl (same patch content, its own uuid). - let manifest_path = tmp.path().join(".socket/manifest.json"); - let mut manifest: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap(); - let mut jsr_record = manifest["patches"][PURL].clone(); - jsr_record["uuid"] = serde_json::json!(JSR_UUID); - manifest["patches"][JSR_PURL] = jsr_record; - std::fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); - - // Ledger entry for the jsr purl with a CORRUPT committed artifact. + // Ledger entry for the jsr purl with a CORRUPT committed artifact; its + // embedded record is the npm one re-stamped with the jsr uuid (same + // patch content, its own uuid). let corrupt_rel = format!(".socket/vendor/npm/{JSR_UUID}/left-pad-1.3.0.tgz"); let mut state = read_state(tmp.path()); let mut jsr_entry = state["entries"][PURL].clone(); jsr_entry["uuid"] = serde_json::json!(JSR_UUID); + jsr_entry["record"]["uuid"] = serde_json::json!(JSR_UUID); jsr_entry["basePurl"] = serde_json::json!(JSR_PURL); jsr_entry["artifact"]["path"] = serde_json::json!(corrupt_rel); jsr_entry["wiring"] = serde_json::json!([]); @@ -2367,7 +2403,8 @@ async fn repair_soft_persist_failure_skips_downstream_ladder() { /// persist already failed: one combined run — the gem soft candidate's /// state write fails, the npm blob has no offline source — yields the state /// failure for the gem (no soft-restore advisory) and the offline failure -/// for the npm candidate. +/// for the npm candidate. Legacy manifest-mode fixture (the manifest is the +/// offline record source once the ledger is gone). #[cfg(unix)] #[tokio::test] async fn repair_unavailable_staging_skips_unpersistable_soft_candidate() { @@ -2393,6 +2430,7 @@ async fn repair_unavailable_staging_skips_unpersistable_soft_candidate() { .path() .join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")); + to_legacy_manifest_mode(tmp.path()); std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); std::fs::remove_file(&tgz).unwrap(); readonly_vendor_dir(tmp.path()); diff --git a/crates/socket-patch-cli/tests/repair_vendor_e2e.rs b/crates/socket-patch-cli/tests/repair_vendor_e2e.rs index faab137f..509ac423 100644 --- a/crates/socket-patch-cli/tests/repair_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/repair_vendor_e2e.rs @@ -183,9 +183,9 @@ async fn mount_patch_api(mock: &MockServer) { .await; } -/// Serve the after-blob for `--download-mode file` repairs (test 7's step 1 -/// runs before the ledger is reconstructed, so its vendored entry is not -/// yet excluded from the download phase). +/// Serve the after-blob for the `--download-mode file` repairs below. A +/// vendored project has no manifest, so repair's download step (manifest +/// records only) never fires and the route is a harmless safety net. async fn mount_blob(mock: &MockServer) { Mock::given(method("GET")) .and(path(format!( @@ -483,12 +483,14 @@ async fn repair_fails_closed_on_tampered_ledger_sha() { ); } -/// 5. Fresh-clone `vendor` re-run with the committed artifact AND -/// node_modules gone: the ledger's wiring original recovers the registry -/// resolution, the pristine tarball is fetched + verified, and the -/// artifact is rebuilt — exit 0 (previously a hard vendor_fetch_failed). +/// 5. Fresh clone with the committed artifact AND node_modules gone. A +/// vendored project has no manifest, so a standalone `vendor` re-run is +/// a clean `noManifest` no-op (it never re-vendors from the ledger); +/// `repair` is the rebuild path: the ledger's wiring original recovers +/// the registry resolution, the pristine tarball is fetched + verified, +/// and the artifact is rebuilt — exit 0. #[tokio::test] -async fn vendor_rerun_recovers_registry_resolution_from_ledger() { +async fn vendor_rerun_is_a_noop_and_repair_recovers_registry_resolution_from_ledger() { let mock = MockServer::start().await; mount_patch_api(&mock).await; let tgz_bytes = pristine_tgz(); @@ -515,11 +517,18 @@ async fn vendor_rerun_recovers_registry_resolution_from_ledger() { let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["vendor"]); assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); let v = parse_env(&stdout); + assert_eq!(v["status"], "noManifest", "envelope={v}"); + assert!(events_of(&v).is_empty(), "envelope={v}"); + assert!(!tgz.exists(), "`vendor` never re-vendors from the ledger"); + + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); assert!( events_of(&v) .iter() - .any(|e| e["errorCode"] == "vendor_artifact_missing"), - "the missing artifact is surfaced as a warning skip: {v}" + .any(|e| e["action"] == "rebuilt" && e["purl"] == PURL), + "the missing artifact is rebuilt from the recovered fetch: {v}" ); assert!(tgz.is_file(), "artifact rebuilt from the recovered fetch"); assert_eq!( @@ -529,8 +538,8 @@ async fn vendor_rerun_recovers_registry_resolution_from_ledger() { ); } -/// 6. Detached vendoring (no manifest ever): repair rebuilds via the -/// ledger-embedded record. +/// 6. Vendored entries are detached (no manifest ever): repair rebuilds via +/// the ledger-embedded record. #[tokio::test] async fn repair_rebuilds_detached_entry_without_manifest() { let mock = MockServer::start().await; @@ -541,10 +550,10 @@ async fn repair_rebuilds_detached_entry_without_manifest() { "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", "sha512-orig==", ); - let tgz = vendor_project(tmp.path(), &mock.uri(), &["--detached"]); + let tgz = vendor_project(tmp.path(), &mock.uri(), &[]); assert!( !tmp.path().join(".socket/manifest.json").exists(), - "detached mode writes no manifest" + "vendored runs write no manifest" ); std::fs::remove_file(&tgz).unwrap(); @@ -555,9 +564,10 @@ async fn repair_rebuilds_detached_entry_without_manifest() { assert!(tgz.is_file()); } -/// 7. The whole `.socket/vendor` tree (state.json included) deleted while -/// the manifest survives: repair reconstructs the ledger entry from the -/// lockfile's vendor-path reference and rebuilds the artifact. +/// 7. The whole `.socket/vendor` tree (state.json included) deleted: repair +/// reconstructs the ledger entry from the lockfile's vendor-path +/// reference — the record comes back from the patch API by uuid, since a +/// vendored project has no manifest — and rebuilds the artifact. #[tokio::test] async fn repair_reconstructs_ledger_from_lockfile_references() { let mock = MockServer::start().await; @@ -573,14 +583,7 @@ async fn repair_reconstructs_ledger_from_lockfile_references() { std::fs::remove_dir_all(tmp.path().join(".socket/vendor")).unwrap(); - // With the ledger gone, step 1 sees the manifest entry as un-vendored - // and downloads its source; serve the blob and use file mode. - mount_blob(&mock).await; - let (code, stdout, stderr) = run_cli( - tmp.path(), - &mock.uri(), - &["repair", "--download-mode", "file"], - ); + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); let v = parse_env(&stdout); assert_eq!(v["summary"]["rebuilt"], 1, "envelope={v}"); @@ -610,14 +613,16 @@ async fn repair_reconstructs_ledger_from_lockfile_references() { ); // The re-synthesized ledger entry: same uuid, fingerprint of the - // rebuilt bytes, NOT detached (the manifest still has the record). + // rebuilt bytes, DETACHED with the API-recovered record embedded (no + // manifest owns it). let state: serde_json::Value = serde_json::from_str( &std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")).unwrap(), ) .unwrap(); let entry = &state["entries"][PURL]; assert_eq!(entry["uuid"], UUID, "state={state}"); - assert!(entry["detached"].is_null(), "state={state}"); + assert_eq!(entry["detached"], serde_json::json!(true), "state={state}"); + assert_eq!(entry["record"]["uuid"], UUID, "state={state}"); assert_eq!( entry["artifact"]["sha256"], sha256_hex(&std::fs::read(&tgz).unwrap()), From e441157ab11386437f58d2c3d17241d58844264e Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 20:45:25 -0400 Subject: [PATCH 19/44] fix(cli/scan+get): vendor-step staging error carries the demoted envelope; re-pin D2 vendored tests Reconcile the cross-group round-2 breakage between get.rs and scan/vendor_flow.rs. cli-scan-vendor-gc reshaped run_scan_vendor_step to a 2-tuple error (dropping the carried Envelope) while the contract (CLI_CONTRACT.md `get --mode vendored`: "a vendor-step error folds the partial envelope + {status:error, error} in"), get's covgap tests and scan_vendor_step_error_e2e.rs pin that a step which ran hands its envelope to the JSON fold demoted to partialFailure. vendor_flow.rs - run_scan_vendor_step returns VendorStepResult = Result<(bool, Envelope), (code, message, Option>)>: lock failures (before the step) carry None; a staging failure (no_local_source, after the lock) carries the step's envelope demoted to partialFailure (events-less: nothing mutates before staging). Scan's JSON fold emits it as `vendor`, exactly like get's fold already did. - The pre-D2 legacy_manifest_vendor_step shim (reconcile_dropped / invalid_manifest / detached=false) is deleted: get passes its records on both paths, so its None arm had no caller. boxed_scan_vendor_step is now the one boxed constructor (records by value, no clone) used by scan's two arms and by get; stage_and_vendor / boxed_vendor_records lose their always-true `detached` parameter. get.rs - run_get_vendored moves `records` into boxed_scan_vendor_step; its fold was already shaped for the 3-tuple. Tests - covgap_commands_scan_vendor_flow.rs: the shared error helper no longer asserts "no vendor key"; lock_held / lock_io / .socket-file tests assert it explicitly (pre-lock, no envelope), the staging test asserts the demoted, events-less envelope (post-lock). - scan_vendor_step_error_e2e.rs: rewritten for D2. The old trigger (an unstageable committed MANIFEST + a ledger entry the manifest reconcile reverted) no longer exists: the step is manifest-free and reconciles nothing. New trigger: discovery selects a patch whose view carries hashes but no blobContent, so the detached download succeeds and staging fails no_local_source. Pins exit 1, error.code, download.downloaded == 1 / detached, vendor.status == partialFailure, an empty events[] (an unselected legacy ledger entry is never reconciled; its bytes are identical), no manifest written, no apply.lock left. Renamed scan_vendor_staging_error_still_reports_the_reconcile -> scan_vendor_staging_error_still_carries_the_demoted_vendor_envelope. - cli_scan_silent.rs scan_vendor_silent_gc_prints_nothing: under D2 the vendored uuid lives in .socket/vendor/state.json (detached: true, record embedded), not in the manifest; the seeded agent manifest still loses its uninstalled entry to the GC and survives as {"patches":{}}. Co-Authored-By: Claude Fable 5.1 --- crates/socket-patch-cli/src/commands/get.rs | 14 +- .../src/commands/scan/vendor_flow.rs | 174 ++++++--------- .../socket-patch-cli/tests/cli_scan_silent.rs | 20 +- .../tests/covgap_commands_scan_vendor_flow.rs | 52 ++++- .../tests/scan_vendor_step_error_e2e.rs | 207 +++++++++++------- 5 files changed, 253 insertions(+), 214 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 8b2b1293..8cb8f69f 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -2979,15 +2979,11 @@ async fn run_get_vendored( fold_narrowing_into_result(&mut result, narrow_skips, narrow_warnings); // The vendor step (scan's, verbatim): apply lock, in-memory staging, the - // engine over exactly the records fetched above. A per-patch download - // failure does not skip it (scan parity). - match super::scan::boxed_scan_vendor_step( - &args.common, - &manifest_path, - &socket_dir, - Some(&records), - ) - .await + // engine over exactly the records fetched above (moved in — nothing + // here needs them afterwards). A per-patch download failure does not + // skip it (scan parity). + match super::scan::boxed_scan_vendor_step(&args.common, &manifest_path, &socket_dir, records) + .await { Ok((vendor_errors, venv)) => { has_errors |= vendor_errors; diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index e1c075a8..e39a20d0 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -32,7 +32,7 @@ use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutco use crate::commands::get::{download_patch_records, DownloadParams}; use crate::commands::lock_cli::lock_failure; use crate::commands::vendor::{ - note_classic_migration_risk, reconcile_dropped, track_outcomes_for_vendor, vendor_records, + note_classic_migration_risk, track_outcomes_for_vendor, vendor_records, }; use crate::json_envelope::{Command as EnvelopeCommand, Envelope, RunWarning}; @@ -50,6 +50,15 @@ const VENDOR_MANIFEST_RECORD_MIGRATED: &str = "vendor_manifest_record_migrated"; /// manifest (or the ledger); the legacy records were left in place. const VENDOR_MANIFEST_MIGRATION_FAILED: &str = "vendor_manifest_migration_failed"; +/// The vendor step's error: the contract code, its message and — when the +/// step had already taken the lock and died at staging — the vendor +/// Envelope it was building, demoted to `partialFailure`, for the caller's +/// JSON fold. Lock failures precede the step and carry `None`. +type VendorStepError = (&'static str, String, Option>); +/// `(has_errors, envelope)` from a step that reached the engine, else a +/// [`VendorStepError`]. +type VendorStepResult = Result<(bool, Envelope), VendorStepError>; + /// Pretty-print one JSON document to stdout — every `--json` consumer /// parses stdout as exactly one document. fn print_json(v: &serde_json::Value) { @@ -178,16 +187,22 @@ fn scan_vendor_service_config( /// nothing to vendor. /// /// `Ok((has_errors, envelope))` on a run that reached the engine; -/// `Err((code, message))` for the lock/stage failures the caller folds -/// into its own output shape (scan's ad-hoc JSON can't use -/// `acquire_or_emit`, which prints an Envelope). Nothing mutates the -/// project before staging, so an error carries no partial envelope. +/// `Err((code, message, envelope))` for the lock/stage failures the caller +/// folds into its own output shape (scan's ad-hoc JSON can't use +/// `acquire_or_emit`, which prints an Envelope). A lock failure precedes +/// the step, so it carries no envelope (`None`); a staging failure +/// (`no_local_source`) hands back the step's envelope demoted to +/// `partialFailure` — the contract's `vendor` sub-object is present +/// whenever the step ran, and a consumer reading `.vendor.status` inside a +/// `"status":"error"` result must never see the fresh-envelope default of +/// `success`. Nothing mutates the project before staging, so that +/// envelope carries no events. async fn run_scan_vendor_step( common: &GlobalArgs, manifest_path: &Path, socket_dir: &Path, records: HashMap, -) -> Result<(bool, Envelope), (&'static str, String)> { +) -> VendorStepResult { let mut env = Envelope::new(EnvelopeCommand::Vendor); env.dry_run = common.dry_run; if records.is_empty() { @@ -198,7 +213,10 @@ async fn run_scan_vendor_step( // it as `LockError::Io` (→ `lock_io`). The guard lives to the end of // the step so the ledger migration and the redirect-ledger reconcile // inside `note_vendor_supersedes_redirect` run under the lock too. - let _guard = apply_lock::acquire(socket_dir, timeout).map_err(|e| lock_failure(&e, timeout))?; + let _guard = apply_lock::acquire(socket_dir, timeout).map_err(|e| { + let (code, message) = lock_failure(&e, timeout); + (code, message, None) + })?; // Staging probes blobs by the records' hashes; a manifest VIEW over the // in-memory records (a move, not a clone) is all it needs. @@ -206,8 +224,16 @@ async fn run_scan_vendor_step( patches: records, setup: None, }; - let has_errors = - stage_and_vendor(common, socket_dir, &manifest, /*detached=*/ true, &mut env).await?; + let has_errors = match stage_and_vendor(common, socket_dir, &manifest, &mut env).await { + Ok(has_errors) => has_errors, + Err((code, message)) => { + // The step ran and is aborting: hand its envelope (demoted) to + // the caller's fold instead of letting the `vendor` sub-object + // vanish from a run that entered the step. + env.mark_partial_failure(); + return Err((code, message, Some(Box::new(env)))); + } + }; migrate_legacy_manifest_records(common, manifest_path, &manifest.patches, &mut env).await; if has_errors { env.mark_partial_failure(); @@ -218,14 +244,13 @@ async fn run_scan_vendor_step( } /// Stage `manifest`'s patch sources in memory and drive the vendor engine -/// over them. The caller holds the apply lock. `Err` is the -/// `no_local_source` fold (staging could not obtain the patch content — -/// offline, or the view fetch failed). +/// over them (detached: every entry embeds its record). The caller holds +/// the apply lock. `Err` is the `no_local_source` fold (staging could not +/// obtain the patch content — offline, or the view fetch failed). async fn stage_and_vendor( common: &GlobalArgs, socket_dir: &Path, manifest: &PatchManifest, - detached: bool, env: &mut Envelope, ) -> Result { let staged = match stage_vendor_sources_in_memory(common, manifest, socket_dir, &common.cwd) @@ -250,15 +275,7 @@ async fn stage_and_vendor( let (client, use_public_proxy) = get_api_client_with_overrides(common.api_client_overrides()).await; let service = scan_vendor_service_config(common, Some(client), use_public_proxy); - Ok(boxed_vendor_records( - common, - &manifest.patches, - &sources, - detached, - Some(&service), - env, - ) - .await) + Ok(boxed_vendor_records(common, &manifest.patches, &sources, Some(&service), env).await) } /// Record a run-level advisory: stderr `Warning (code): detail` in human @@ -407,52 +424,6 @@ async fn migrate_legacy_manifest_records( } } -/// COMPATIBILITY SHIM for `get --mode vendored`'s two manifest-mode callers -/// (`boxed_scan_vendor_step(.., None)`): the pre-D2 vendor step — read the -/// manifest as the work list (`invalid_manifest` on a corrupt one, a clean -/// no-op on none), reconcile dropped entries like the `vendor` command, -/// stage, vendor NON-detached. The `Some(envelope)` error payload exists -/// only because that reconcile mutates the ledger before staging can -/// fail. Deleted by the integration pass once `get` passes its records. -async fn legacy_manifest_vendor_step( - common: &GlobalArgs, - manifest_path: &Path, - socket_dir: &Path, -) -> Result<(bool, Envelope), (&'static str, String, Option>)> { - let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); - let _guard = apply_lock::acquire(socket_dir, timeout).map_err(|e| { - let (code, message) = lock_failure(&e, timeout); - (code, message, None) - })?; - let mut env = Envelope::new(EnvelopeCommand::Vendor); - env.dry_run = common.dry_run; - let manifest = match read_manifest(manifest_path).await { - Ok(Some(m)) => m, - Ok(None) => { - note_classic_migration_risk(&mut env, &common.cwd, common); - note_vendor_supersedes_redirect(&mut env, &common.cwd, common).await; - return Ok((false, env)); - } - Err(e) => return Err(("invalid_manifest", e.to_string(), None)), - }; - let mut has_errors = reconcile_dropped(&manifest, common, &mut env).await; - match stage_and_vendor(common, socket_dir, &manifest, /*detached=*/ false, &mut env).await { - Ok(engine_errors) => has_errors |= engine_errors, - Err((code, message)) => { - // The reconcile may already have reverted entries on disk — - // hand its envelope (demoted: this run is aborting) to the fold. - env.mark_partial_failure(); - return Err((code, message, Some(Box::new(env)))); - } - } - if has_errors { - env.mark_partial_failure(); - } - note_classic_migration_risk(&mut env, &common.cwd, common); - note_vendor_supersedes_redirect(&mut env, &common.cwd, common).await; - Ok((has_errors, env)) -} - /// The `scan --vendor` JSON path: discovery → (dry-run preview | download /// → vendor engine → GC → embedded VEX) → print `result` → exit code. /// The dry-run arm skips the VEX embed (emitting a `vex.skipped` marker @@ -537,7 +508,7 @@ async fn run_vendor_json_path( // 2) The vendor engine, under the same lock as apply/vendor (a no-op // that creates nothing when there is nothing to vendor). - let vendor_code = match boxed_vendor_step(&args.common, manifest_path, socket_dir, records) + let vendor_code = match boxed_scan_vendor_step(&args.common, manifest_path, socket_dir, records) .await { Ok((vendor_errors, venv)) => { @@ -557,7 +528,7 @@ async fn run_vendor_json_path( serde_json::to_value(&venv).unwrap_or_else(|_| serde_json::json!({})); i32::from(has_errors) } - Err((code, message)) => { + Err((code, message, venv)) => { track_patch_vendor_failed( &message, args.common.dry_run, @@ -565,6 +536,13 @@ async fn run_vendor_json_path( telemetry_org, ) .await; + // A step that ran (and died at staging) hands back its demoted + // envelope; it must reach the JSON consumer even though the run + // aborts here. A lock failure carries none — no `vendor` key. + if let Some(venv) = venv { + result["vendor"] = + serde_json::to_value(&*venv).unwrap_or_else(|_| serde_json::json!({})); + } result["status"] = serde_json::json!("error"); result["error"] = serde_json::json!({ "code": code, @@ -618,7 +596,8 @@ async fn run_vendor_interactive_path( ) -> i32 { let (dl_code, _, records) = boxed_download_patch_records(selected, params).await; let mut has_errors = dl_code != 0; - let code = match boxed_vendor_step(&args.common, manifest_path, socket_dir, records).await { + let code = match boxed_scan_vendor_step(&args.common, manifest_path, socket_dir, records).await + { Ok((vendor_errors, venv)) => { has_errors |= vendor_errors; // Run-outcome telemetry, same as the JSON arm above. @@ -632,7 +611,7 @@ async fn run_vendor_interactive_path( .await; i32::from(has_errors) } - Err((code, message)) => { + Err((code, message, _envelope)) => { track_patch_vendor_failed( &message, args.common.dry_run, @@ -800,19 +779,17 @@ pub(super) fn boxed_vendor_interactive_path<'a>( } /// Transient-frame boxed constructor for [`run_scan_vendor_step`] — the -/// future embeds the entire vendor engine, and the vendor-path frames it -/// would otherwise ride must themselves fit Windows' 1 MiB main-thread -/// stack (same rationale as [`boxed_vendor_json_path`], one level down). -/// Moving the records map into the future is stack-neutral (three words). -#[allow(clippy::type_complexity)] -fn boxed_vendor_step<'a>( +/// one entry for scan's two arms and for `get --mode vendored`. The future +/// embeds the entire vendor engine, and the vendor-path frames it would +/// otherwise ride must themselves fit Windows' 1 MiB main-thread stack +/// (same rationale as [`boxed_vendor_json_path`], one level down). Moving +/// the records map into the future is stack-neutral (three words). +pub(crate) fn boxed_scan_vendor_step<'a>( common: &'a GlobalArgs, manifest_path: &'a Path, socket_dir: &'a Path, records: HashMap, -) -> std::pin::Pin< - Box> + 'a>, -> { +) -> std::pin::Pin + 'a>> { Box::pin(run_scan_vendor_step( common, manifest_path, @@ -821,35 +798,6 @@ fn boxed_vendor_step<'a>( )) } -/// `get --mode vendored`'s entry into the vendor step (both its arms call -/// this). `Some(records)` runs the manifest-free step over a copy of the -/// records; `None` is the pre-D2 manifest-mode step -/// ([`legacy_manifest_vendor_step`]) kept only until `get` passes its -/// records — the integration pass collapses this onto -/// [`boxed_vendor_step`]. Same transient-frame rationale as above. -#[allow(clippy::type_complexity)] -pub(crate) fn boxed_scan_vendor_step<'a>( - common: &'a GlobalArgs, - manifest_path: &'a Path, - socket_dir: &'a Path, - detached_records: Option<&'a HashMap>, -) -> std::pin::Pin< - Box< - dyn std::future::Future< - Output = Result<(bool, Envelope), (&'static str, String, Option>)>, - > + 'a, - >, -> { - Box::pin(async move { - match detached_records { - Some(records) => run_scan_vendor_step(common, manifest_path, socket_dir, records.clone()) - .await - .map_err(|(code, message)| (code, message, None)), - None => legacy_manifest_vendor_step(common, manifest_path, socket_dir).await, - } - }) -} - /// Transient-frame boxed constructor for the download-phase future used /// inside the vendor paths, so the frame fits Windows' 1 MiB main-thread /// stack (same rationale as [`boxed_vendor_json_path`]). @@ -873,15 +821,15 @@ fn boxed_vendor_records<'a>( common: &'a GlobalArgs, records: &'a HashMap, sources: &'a socket_patch_core::patch::apply::PatchSources<'a>, - detached: bool, service: Option<&'a VendorServiceConfig>, env: &'a mut Envelope, ) -> std::pin::Pin + 'a>> { // `scan --vendor` threads the SAME service config the `vendor` command // builds (honoring `--vendor-source`), so both entry points vendor the - // same bytes by default. See `run_scan_vendor_step`. + // same bytes by default. See `run_scan_vendor_step`. Always detached: + // vendored mode is manifest-free. Box::pin(vendor_records( - common, records, sources, detached, false, env, service, + common, records, sources, /*detached=*/ true, false, env, service, )) } diff --git a/crates/socket-patch-cli/tests/cli_scan_silent.rs b/crates/socket-patch-cli/tests/cli_scan_silent.rs index afc42193..89fca24b 100644 --- a/crates/socket-patch-cli/tests/cli_scan_silent.rs +++ b/crates/socket-patch-cli/tests/cli_scan_silent.rs @@ -409,7 +409,10 @@ async fn scan_vendor_silent_gc_prints_nothing() { ); // Silent suppresses output, not the work: the prune and the vendoring - // both still happened. + // both still happened. Vendored mode is manifest-free — the vendor + // ledger, not the manifest, records the vendored uuid — while the + // seeded agent-mode manifest still loses its uninstalled entry to the + // GC and survives, emptied, as `{"patches":{}}`. let manifest = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).expect("read manifest"); let v: serde_json::Value = serde_json::from_str(&manifest).expect("parse manifest"); @@ -417,7 +420,20 @@ async fn scan_vendor_silent_gc_prints_nothing() { v["patches"]["pkg:npm/gone@1.0.0"].is_null(), "the uninstalled entry must still be pruned under --silent: {v}" ); - assert_eq!(v["patches"][purl]["uuid"], UUID, "manifest={v}"); + assert!( + v["patches"][purl].is_null(), + "a vendored run never writes a manifest record: {v}" + ); + let ledger = std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")) + .expect("read vendor ledger"); + let state: serde_json::Value = serde_json::from_str(&ledger).expect("parse vendor ledger"); + let entry = &state["entries"][purl]; + assert_eq!( + entry["uuid"], UUID, + "the ledger must still record the vendored uuid under --silent: {state}" + ); + assert_eq!(entry["detached"], true, "ledger={state}"); + assert_eq!(entry["record"]["uuid"], UUID, "ledger={state}"); assert!( tmp.path() .join(format!(".socket/vendor/npm/{UUID}/silent-target-1.0.0.tgz")) diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_vendor_flow.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_vendor_flow.rs index a95f9780..f3aae98e 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_vendor_flow.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_vendor_flow.rs @@ -10,7 +10,9 @@ //! * every error constructor of `run_scan_vendor_step` — `lock_held`, //! `lock_io` (a directory squatting on `apply.lock`; a file squatting on //! `.socket` itself) and `no_local_source` — through the JSON error fold -//! (which must NOT emit a `vendor` key: nothing mutates before staging) +//! (a lock failure precedes the step and carries NO `vendor` key; a +//! staging failure carries the step's envelope demoted to +//! `partialFailure`, events-less because nothing mutates before staging) //! and the interactive `Error (code): message` line; //! * a corrupt legacy manifest, which vendored mode reports and steps //! around (the manifest is not its record source). @@ -291,10 +293,11 @@ fn seed_stale_manifest(root: &Path) { } /// Shared assertions for the vendor-step error fold: exit 1, a JSON -/// envelope with `status: "error"`, the given `error.code`, a `download` +/// envelope with `status: "error"`, the given `error.code` and a `download` /// sub-object (proof the run got PAST the download phase and died inside -/// the vendor step) and NO `vendor` key (nothing mutates before staging, -/// so no vendor sub-object may be fabricated for the aborted step). +/// the vendor step). Whether a `vendor` sub-object rides along depends on +/// WHERE the step died — see [`assert_no_vendor_envelope`] (lock failures) +/// and [`assert_demoted_empty_vendor_envelope`] (staging failures). fn assert_vendor_step_error( code: i32, stdout: &str, @@ -310,11 +313,34 @@ fn assert_vendor_step_error( v["download"].is_object(), "the run must reach the vendor step (download phase completed); envelope={v}" ); + v +} + +/// A lock failure happens BEFORE the step builds its envelope: no `vendor` +/// sub-object may be fabricated for it (get's fold pins the same in +/// `covgap_commands_get::vendored_lock_held_vendor_step_errors_without_vendor_envelope`). +fn assert_no_vendor_envelope(v: &serde_json::Value) { assert!( !v.as_object().unwrap().contains_key("vendor"), - "a None-envelope error must not fabricate a vendor sub-object; envelope={v}" + "a pre-lock failure has no vendor envelope to carry; envelope={v}" + ); +} + +/// A staging failure happens AFTER the lock, inside the step: the fold +/// carries the step's envelope demoted to `partialFailure` (a consumer +/// reading `.vendor.status` inside a `"status":"error"` result must not +/// see the fresh-envelope default `success`) and events-less — nothing +/// mutates before staging, so there is no work to report. +fn assert_demoted_empty_vendor_envelope(v: &serde_json::Value) { + assert_eq!( + v["vendor"]["status"], "partialFailure", + "the carried envelope's status must be demoted; envelope={v}" + ); + assert_eq!( + v["vendor"]["events"], + serde_json::json!([]), + "nothing mutates before staging, so the aborted step reports no events; envelope={v}" ); - v } /// Dry-run preview, same-uuid case: an entry already vendored at the @@ -427,6 +453,7 @@ async fn scan_vendor_lock_held_reports_json_error() { let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); let v = assert_vendor_step_error(code, &stdout, &stderr, "lock_held"); + assert_no_vendor_envelope(&v); assert_eq!( v["error"]["message"], "another socket-patch process is operating in this directory", @@ -450,6 +477,7 @@ async fn scan_vendor_lock_io_reports_json_error() { let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); let v = assert_vendor_step_error(code, &stdout, &stderr, "lock_io"); + assert_no_vendor_envelope(&v); assert!( v["error"]["message"] .as_str() @@ -510,7 +538,8 @@ async fn scan_vendor_socket_dir_file_reports_lock_io() { std::fs::write(tmp.path().join(".socket"), b"not a dir").unwrap(); let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); - assert_vendor_step_error(code, &stdout, &stderr, "lock_io"); + let v = assert_vendor_step_error(code, &stdout, &stderr, "lock_io"); + assert_no_vendor_envelope(&v); assert_eq!( std::fs::read(tmp.path().join(".socket")).unwrap(), b"not a dir", @@ -521,8 +550,12 @@ async fn scan_vendor_socket_dir_file_reports_lock_io() { /// The JSON vendor-step error fold for a staging failure: the download /// phase recorded the patch (hashes only), but the view serves no blob /// content, so the vendor step cannot stage it and the run aborts -/// `no_local_source` with a `download` object and NO `vendor` key — -/// nothing mutated before staging — and creates nothing under `.socket/`. +/// `no_local_source` with a `download` object and the step's own `vendor` +/// envelope carried through the fold — demoted to `partialFailure`, with +/// no events (nothing mutated before staging) — and creates nothing under +/// `.socket/`. Contract: the `vendor` sub-object is present whenever the +/// step ran; `get --mode vendored` shares the fold +/// (`covgap_commands_get::get_uuid_vendored_vendor_step_error_leaves_legacy_state_alone`). #[tokio::test] async fn scan_vendor_staging_error_reports_json_error() { let mock = MockServer::start().await; @@ -533,6 +566,7 @@ async fn scan_vendor_staging_error_reports_json_error() { let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); let v = assert_vendor_step_error(code, &stdout, &stderr, "no_local_source"); + assert_demoted_empty_vendor_envelope(&v); assert_eq!( v["error"]["message"], "patch artifacts unavailable (offline or download failure)", diff --git a/crates/socket-patch-cli/tests/scan_vendor_step_error_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_step_error_e2e.rs index 337d96ef..5be3ba1c 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_step_error_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_step_error_e2e.rs @@ -1,16 +1,22 @@ -//! Regression: the vendor step's ERROR returns must still report the work -//! the step already committed to disk. +//! Regression: the vendor step's ERROR returns must still hand the JSON +//! consumer the step's `vendor` envelope — demoted — instead of dropping it. //! -//! `scan --vendor`'s vendor step (`run_scan_vendor_step`) runs the manifest -//! reconcile — reverting vendored entries whose patch left the manifest, -//! which rewrites lockfiles, deletes `.socket/vendor//` artifacts and -//! rewrites the ledger — BEFORE it stages patch sources. A staging failure -//! (`no_local_source`: a patch view the API would not serve) therefore -//! aborts a run that has already mutated the project, and the `vendor` -//! Envelope holding those `Removed`/`Failed` events is the only record of -//! it. The `vendor` command prints that envelope on the same failure -//! (`vendor::run` emits `env` whatever `run_vendor` returned); scan's JSON -//! arm must not be the one place the events vanish. +//! `scan --vendor`'s vendor step (`run_scan_vendor_step`) takes the apply +//! lock, stages the fetched records' patch content in memory, then drives +//! the vendor engine. Vendored mode is manifest-free: the step never reads +//! the manifest and never reconciles ledger entries against it, so a +//! staging failure (`no_local_source`: the API serves the patch view +//! without blob content) aborts a run that has mutated nothing. The run +//! DID enter the step, though, and the contract's `vendor` sub-object +//! rides the error fold: `status` demoted to `partialFailure` (a consumer +//! reading `.vendor.status` inside a `"status":"error"` result must not +//! see the fresh-envelope default of `success`) and `events[]` present — +//! empty, and in particular holding no revert of a ledger entry the run +//! did not select. The `vendor` command prints its envelope on the same +//! failure (`vendor::run` emits `env` whatever `run_vendor` returned); +//! scan's JSON arm must not be the one place it vanishes. `get --mode +//! vendored` shares the fold +//! (`covgap_commands_get::get_uuid_vendored_vendor_step_error_leaves_legacy_state_alone`). use std::path::{Path, PathBuf}; use std::process::Command; @@ -24,12 +30,14 @@ fn binary() -> PathBuf { } const ORG_SLUG: &str = "test-org"; -/// The manifest patch whose content the mock API refuses to serve. +/// The patch discovery selects; its view carries hashes but no content. const UUID: &str = "11111111-1111-4111-8111-111111111111"; const PURL: &str = "pkg:npm/left-pad@1.3.0"; -/// A ledger entry with NO manifest patch — the reconcile reverts it. -const DROPPED_PURL: &str = "pkg:npm/gone@9.9.9"; -const DROPPED_UUID: &str = "33333333-3333-4333-8333-333333333333"; +const ENCODED: &str = "pkg%3Anpm%2Fleft-pad%401.3.0"; +/// A legacy (non-detached) ledger entry the run never selects: the +/// manifest-free step must leave it alone, event-less and byte-identical. +const UNSELECTED_PURL: &str = "pkg:npm/gone@9.9.9"; +const UNSELECTED_UUID: &str = "33333333-3333-4333-8333-333333333333"; const BEFORE: &[u8] = b"before\n"; const AFTER: &[u8] = b"after\n"; @@ -82,52 +90,90 @@ fn write_fixture(root: &Path) { std::fs::write(pkg.join("index.js"), BEFORE).unwrap(); } -/// A committed manifest whose afterHash blob is NOT on disk: the vendor -/// step must fetch the patch view to stage it, and the mock refuses. -fn seed_unstageable_manifest(root: &Path) { - let socket = root.join(".socket"); - std::fs::create_dir_all(&socket).unwrap(); - let manifest = serde_json::json!({ - "patches": { - PURL: { +/// Discovery (batch) plus the per-package search that selects `UUID` for +/// `PURL` — the endpoints `scan --vendor` hits before the download phase. +async fn mount_discovery(mock: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, + "purl": PURL, + "tier": "free", + "cveIds": ["CVE-2026-0001"], + "ghsaIds": [], + "severity": "high", + "title": "vendor target" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{ENCODED}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ "uuid": UUID, - "exportedAt": "2026-01-01T00:00:00Z", - "files": { - "package/index.js": { - "beforeHash": git_sha256(BEFORE), - "afterHash": git_sha256(AFTER), - } - }, - "vulnerabilities": {}, + "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", "description": "Vendor patch", "license": "MIT", "tier": "free", - } - } - }); - std::fs::write( - socket.join("manifest.json"), - serde_json::to_string_pretty(&manifest).unwrap(), - ) - .unwrap(); + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; } -/// A ledger holding one entry the manifest does not mention: the vendor -/// step's `reconcile_dropped` reverts it (and rewrites `state.json`) -/// before staging is even attempted. -fn seed_dropped_ledger_entry(root: &Path) { +/// The patch view WITHOUT `blobContent`: the download phase records the +/// patch (it needs only the hashes), but the vendor step cannot obtain the +/// patched bytes and staging fails `no_local_source` — however many times +/// the view is fetched along the way. +async fn mount_contentless_view(mock: &MockServer) { + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": git_sha256(BEFORE), + "afterHash": git_sha256(AFTER), + } + }, + "vulnerabilities": {}, + "description": "Vendor patch", + "license": "MIT", + "tier": "free", + }))) + .mount(mock) + .await; +} + +/// Seed the vendor ledger with one legacy entry for a purl the run never +/// selects. Returns the exact bytes for the byte-identical check after +/// the run. +fn seed_unselected_ledger_entry(root: &Path) -> String { let vendor = root.join(".socket/vendor"); std::fs::create_dir_all(&vendor).unwrap(); std::fs::write( vendor.join("state.json"), serde_json::to_vec_pretty(&serde_json::json!({ "version": 1, - "entries": { DROPPED_PURL: { + "entries": { UNSELECTED_PURL: { "ecosystem": "npm", - "basePurl": DROPPED_PURL, - "uuid": DROPPED_UUID, + "basePurl": UNSELECTED_PURL, + "uuid": UNSELECTED_UUID, "artifact": { - "path": format!(".socket/vendor/npm/{DROPPED_UUID}/gone-9.9.9.tgz"), + "path": format!(".socket/vendor/npm/{UNSELECTED_UUID}/gone-9.9.9.tgz"), }, "wiring": [] }} @@ -135,20 +181,7 @@ fn seed_dropped_ledger_entry(root: &Path) { .unwrap(), ) .unwrap(); -} - -/// Discovery reports no available patches (so nothing downloads and the -/// run goes straight to the vendor step), and the patch-view endpoint is -/// deliberately unmounted so staging the committed manifest fails. -async fn mount_empty_discovery(mock: &MockServer) { - Mock::given(method("POST")) - .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "packages": [], - "canAccessPaidPatches": false, - }))) - .mount(mock) - .await; + std::fs::read_to_string(vendor.join("state.json")).unwrap() } fn run_cli(root: &Path, argv: &[&str]) -> (i32, String, String) { @@ -175,13 +208,13 @@ fn run_cli(root: &Path, argv: &[&str]) -> (i32, String, String) { } #[tokio::test] -async fn scan_vendor_staging_error_still_reports_the_reconcile() { +async fn scan_vendor_staging_error_still_carries_the_demoted_vendor_envelope() { let mock = MockServer::start().await; - mount_empty_discovery(&mock).await; + mount_discovery(&mock).await; + mount_contentless_view(&mock).await; let tmp = tempfile::tempdir().unwrap(); write_fixture(tmp.path()); - seed_unstageable_manifest(tmp.path()); - seed_dropped_ledger_entry(tmp.path()); + let ledger_before = seed_unselected_ledger_entry(tmp.path()); let (code, stdout, stderr) = run_cli( tmp.path(), @@ -201,22 +234,20 @@ async fn scan_vendor_staging_error_still_reports_the_reconcile() { assert_eq!( code, 1, - "an unstageable manifest must fail the run; stdout={stdout}; stderr={stderr}" + "an unstageable record must fail the run; stdout={stdout}; stderr={stderr}" ); let v: serde_json::Value = serde_json::from_str(stdout.trim()) .unwrap_or_else(|e| panic!("stdout must be one JSON object ({e}); stdout={stdout}")); + assert_eq!(v["status"], "error", "envelope={v}"); assert_eq!( v["error"]["code"], "no_local_source", "precondition: the run must abort at staging; envelope={v}" ); - // Non-vacuous: the reconcile really did run and really did persist — - // the ledger's only entry is gone, so `save_state` deleted state.json. - assert!( - !tmp.path().join(".socket/vendor/state.json").exists(), - "precondition: the reconcile must have reverted the dropped entry \ - and rewritten the ledger; envelope={v}" - ); + // Non-vacuous: the run got PAST the download phase (the record was + // fetched — hashes only — into memory, detached) and INTO the step. + assert_eq!(v["download"]["downloaded"], 1, "envelope={v}"); + assert_eq!(v["download"]["detached"], true, "envelope={v}"); // The carried envelope must not claim the vendor step succeeded: the // run aborted at staging, so a consumer reading `.vendor.status` inside @@ -227,15 +258,29 @@ async fn scan_vendor_staging_error_still_reports_the_reconcile() { "the carried envelope's own status must be demoted; envelope={v}" ); - // The point: that on-disk mutation must be visible to the JSON consumer. + // The point: the envelope survives the error fold — `events[]` is where + // any pre-failure work would be reported — and the manifest-free step + // reconciles nothing: no event names the unselected legacy entry, whose + // ledger bytes are untouched. let events = v["vendor"]["events"].as_array().unwrap_or_else(|| { - panic!( - "the vendor envelope must survive the staging error — the \ - reconcile already reverted {DROPPED_PURL} on disk; envelope={v}" - ) + panic!("the vendor envelope must survive the staging error; envelope={v}") }); assert!( - events.iter().any(|e| e["purl"] == DROPPED_PURL), - "the reconcile's event for {DROPPED_PURL} must be reported; envelope={v}" + events.is_empty(), + "nothing mutates before staging — in particular the unselected {UNSELECTED_PURL} \ + is never reconciled; envelope={v}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")).unwrap(), + ledger_before, + "the detached vendor step never reconciles unselected ledger entries" + ); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "vendored mode never writes a manifest" + ); + assert!( + !tmp.path().join(".socket/apply.lock").exists(), + "the lock file is removed when the aborted step releases the lock" ); } From 0a6e38848724590576a16c2bd68520987bc7bb05 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 20:33:15 -0400 Subject: [PATCH 20/44] test(cli/apply): re-pin no-manifest tests to the v5.0 manifest-naming line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply's no-manifest no-op now prints "No patch manifest found; nothing to apply." (decisions.md: name the manifest, not the folder — hosted and vendored projects have a `.socket/` with nothing for `apply` to do; CLI_CONTRACT.md's apply row already documents the v5.0 wording). The three integration tests that pinned the retired "No .socket folder found, skipping patch application." text are re-pinned to the new line; exit 0 and the --silent / --json gating they check are unchanged. Co-Authored-By: Claude Fable 5.1 --- crates/socket-patch-cli/tests/apply_invariants.rs | 2 +- crates/socket-patch-cli/tests/interactive_prompts_e2e.rs | 6 ++++-- crates/socket-patch-cli/tests/output_modes_e2e.rs | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/tests/apply_invariants.rs b/crates/socket-patch-cli/tests/apply_invariants.rs index d1c36a1e..1f2e553f 100644 --- a/crates/socket-patch-cli/tests/apply_invariants.rs +++ b/crates/socket-patch-cli/tests/apply_invariants.rs @@ -501,7 +501,7 @@ fn apply_with_no_socket_dir_silent_emits_nothing() { assert_eq!(loud.status.code(), Some(0)); let loud_stdout = String::from_utf8_lossy(&loud.stdout); assert!( - loud_stdout.contains("No .socket folder found"), + loud_stdout.contains("No patch manifest found; nothing to apply."), "non-silent no-manifest run must print the skip message; got {loud_stdout:?}" ); } diff --git a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs index 1283b4e7..d940cee5 100644 --- a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs +++ b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs @@ -589,9 +589,11 @@ fn apply_in_pty_with_no_manifest_prints_friendly_message() { let (code, output) = run_in_pty(&["apply"], tmp.path(), "", Duration::from_secs(15)); assert_eq!(code, 0); // Assert the full message, not either half of it. The `||` previously - // let a truncated/garbled message ("...skipping...") pass. + // let a truncated/garbled message ("...nothing to apply...") pass. + // v5.0 wording names the missing manifest, not the `.socket/` folder + // (hosted/vendored projects have one with nothing for `apply` to do). assert!( - output.contains("No .socket folder found, skipping patch application."), + output.contains("No patch manifest found; nothing to apply."), "PTY apply no-manifest must print the friendly message; got: {output}" ); } diff --git a/crates/socket-patch-cli/tests/output_modes_e2e.rs b/crates/socket-patch-cli/tests/output_modes_e2e.rs index 8f39bb3d..da1b3587 100644 --- a/crates/socket-patch-cli/tests/output_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/output_modes_e2e.rs @@ -191,7 +191,7 @@ fn apply_no_manifest_non_json_prints_message() { let (code, stdout, _stderr) = common::run_with_env(tmp.path(), &["apply"], &[]); assert_eq!(code, 0); assert!( - stdout.contains("No .socket folder found, skipping patch application"), + stdout.contains("No patch manifest found; nothing to apply."), "non-JSON no-manifest must print friendly message; got: {stdout}" ); } From 2a9d5b6d0a29a2670a15953d3b9c745ab7caf747 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 21:13:12 -0400 Subject: [PATCH 21/44] refactor(cli/vendored): one client + one lock window per vendored run; typed vendor-GC outcomes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S1-vendored-pipeline (integration worklist T-A1..A5, T-B1..B3, T-C1..C4, T-G3, T-G9, W-1, W-3, W-4, W-14). Bug fix (T-A1): `scan --prune --json` labelled every non-purl vendor-GC marker `gc.skipped.code == "lock_held"` — a `lock_io` fault or a failed ledger/manifest rewrite read as benign contention. `VendorGcSummary` now carries typed `skipped: Option<(code, message)>` and `write_failures: Vec<(code, detail)>`; `failed` holds only purls. gc.rs absorbs them as `gc.skipped` (own reason wins) and the additive `gc.warnings: [{code, detail}]` (+ human `GC: .`). One lock window for `scan --prune` (T-A2): `run_vendor_gc` is split into the self-locking wrapper and `run_vendor_gc_locked`; `run_apply_gc` gates on `manifest exists || ledger non-empty` before ONE acquire and runs both halves under it (a nested acquire reads as Held — flock is per open description — and silently skipped every revert). The swallowed manifest write now records `manifest_write_failed`. One API client per vendored run (T-A3): scan/get thread the run-level client + `use_public_proxy` through `download_patch_records_with` and into the vendor step's service config; `download_patch_records` (2-arg, own client) and vendor_flow's second `get_api_client_with_overrides` are gone (W-3). `GlobalArgs::vendor_service_config` is the one assembler (T-C3/W-14). No view fetched twice (T-A4/T-A5): `preverify_vendor_baselines` returns the views it fetched and runs after the human dry-run return (a preview fetches no views); the download phase serves records from them and returns a blob seed (decoded `blobContent` by after-hash) that pre-populates `stage_vendor_sources_in_memory`, which also takes the caller's ledger load (`harvest_artifact_blobs_from`, T-G3) instead of re-reading state.json. Also: repair passes its reference scan + ledger load into `repair_vendored_artifacts_with_references` (T-C2; the re-scanning wrapper is deleted, W-4); `vendor --revert` uses the shared `rollback::revert_vendor_entry` (T-C1); the dead agent-engine Bun preflight gate is deleted (T-B1); `is_valid_blob_hash` re-export → import (T-B2); `canonical_purl` adopted at the five hand-rolled sites (T-B3); npm_flavor doc no longer claims detached entries are lockfile-invisible (T-C4/T-G9). Tests: gc.rs/vendor.rs GC unit tests re-pinned to the typed fields (+ locked-body test); fetch_stage seed/ledger tests; get.rs blob-seed pin + cfg(test) 2-arg helper; discovery preverify view-cache tests; service-config tests moved to args.rs; covgap_commands_get staging-error tests re-fixtured on a contentless view (they relied on the second fetch this removes); scan_vendor_e2e pins one view fetch per patch for both arms. Co-Authored-By: Claude Fable 5.1 --- crates/socket-patch-cli/src/args.rs | 95 +++++- .../src/commands/fetch_stage.rs | 163 +++++++-- crates/socket-patch-cli/src/commands/get.rs | 154 ++++++--- .../socket-patch-cli/src/commands/repair.rs | 23 +- .../src/commands/repair_vendor.rs | 47 ++- .../src/commands/scan/discovery.rs | 53 ++- .../socket-patch-cli/src/commands/scan/gc.rs | 244 +++++++------ .../src/commands/scan/hosted.rs | 3 +- .../socket-patch-cli/src/commands/scan/mod.rs | 61 ++-- .../src/commands/scan/vendor_flow.rs | 257 +++++++------- .../socket-patch-cli/src/commands/vendor.rs | 321 +++++++++++------- .../tests/covgap_commands_get.rs | 22 +- .../socket-patch-cli/tests/scan_vendor_e2e.rs | 21 ++ crates/socket-patch-core/src/vendor/mod.rs | 23 +- .../src/vendor/npm_flavor.rs | 4 +- 15 files changed, 973 insertions(+), 518 deletions(-) diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 3e553cf2..34c566b1 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -17,10 +17,10 @@ use std::path::{Path, PathBuf}; use clap::Args; -use socket_patch_core::api::client::ApiClientEnvOverrides; +use socket_patch_core::api::client::{ApiClient, ApiClientEnvOverrides}; use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; use socket_patch_core::crawlers::Ecosystem; -use socket_patch_core::vendor::VendorSource; +use socket_patch_core::vendor::{VendorServiceConfig, VendorSource}; /// clap value-parser for each `--ecosystems` / `SOCKET_ECOSYSTEMS` token. /// @@ -363,6 +363,30 @@ impl GlobalArgs { proxy_url: self.proxy_url.clone().filter(|s| !s.is_empty()), } } + + /// The vendoring-service config every vendor entry point (`vendor`, + /// `scan`/`get --mode vendored`) builds from the same flags — + /// `--vendor-source` / `--vendor-url` / `--patch-server-url` / + /// `--offline` — so they commit byte-identical artifacts and lock + /// integrity for the same patch. `client` is the run-level API client + /// (moved in; the service reuses it for the package-reference request) + /// and `use_public_proxy` its proxy-fallback state. `vendor_source` was + /// validated by clap, so the parse cannot fail; the `auto` default is + /// the defensive fallback. A pure assembler (no async, no network). + pub(crate) fn vendor_service_config( + &self, + client: Option, + use_public_proxy: bool, + ) -> VendorServiceConfig { + VendorServiceConfig { + source: VendorSource::parse(&self.vendor_source).unwrap_or_default(), + client, + use_public_proxy, + vendor_url: self.vendor_url.clone(), + patch_server_url: self.patch_server_url.clone(), + offline: self.offline, + } + } } /// The `.socket/`-role directory for `manifest_path`: its parent, falling @@ -789,6 +813,73 @@ mod tests { }); } + // ---- vendor_service_config ------------------------------------------ + // Moved from scan's vendored flow: the config every vendor entry point + // builds must be the same assembler, so `scan --mode vendored` and a + // plain `vendor` commit byte-identical artifacts for the same patch. + + fn common_with_source(source: &str) -> GlobalArgs { + GlobalArgs { + vendor_source: source.to_string(), + ..Default::default() + } + } + + /// Regression: scan's vendored flow must build its service config FROM + /// `--vendor-source`, not hardcode build-only (the pre-fix `service = + /// None`). Under the default (`auto`), the config must permit the + /// vendoring service exactly as the `vendor` command's default does — + /// otherwise `scan --mode vendored` silently builds locally while a + /// plain `vendor` service-downloads, and the two commit different bytes / + /// lock integrity for the same patch (lock churn / merge conflicts). + #[test] + fn vendor_service_config_default_source_permits_service() { + let cfg = common_with_source("auto").vendor_service_config(None, false); + assert_eq!(cfg.source, VendorSource::Auto); + assert!( + cfg.source.may_use_service(), + "the default must be able to use the service (matching `vendor`)" + ); + assert!(!cfg.source.requires_service()); + assert!(cfg.client.is_none()); + assert!(!cfg.use_public_proxy); + } + + /// `--vendor-source service` reaches the fail-closed service path and + /// `--vendor-source build` never contacts the service. + #[test] + fn vendor_service_config_honors_service_and_build_sources() { + let cfg = common_with_source("service").vendor_service_config(None, true); + assert_eq!(cfg.source, VendorSource::Service); + assert!(cfg.source.requires_service()); + assert!(cfg.use_public_proxy, "the proxy-fallback state threads through"); + + let cfg = common_with_source("build").vendor_service_config(None, false); + assert_eq!(cfg.source, VendorSource::Build); + assert!(!cfg.source.may_use_service()); + } + + /// The service overrides (`--vendor-url` / `--patch-server-url` / + /// `--offline`) thread through unchanged, so every entry point targets + /// the same hosts. + #[test] + fn vendor_service_config_threads_overrides_through() { + let common = GlobalArgs { + vendor_source: "service".to_string(), + vendor_url: Some("https://vendor.example".to_string()), + patch_server_url: Some("https://patch.example".to_string()), + offline: true, + ..Default::default() + }; + let cfg = common.vendor_service_config(None, false); + assert_eq!(cfg.vendor_url.as_deref(), Some("https://vendor.example")); + assert_eq!( + cfg.patch_server_url.as_deref(), + Some("https://patch.example") + ); + assert!(cfg.offline); + } + /// The new URL knobs flow through to the parsed args from CLI and env. #[test] #[serial_test::serial] diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index c7c58651..d5d25278 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -15,12 +15,13 @@ use socket_patch_core::api::blob_fetcher::{ get_missing_blobs, DownloadMode, }; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; -use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{is_valid_blob_hash, PatchSources}; use tempfile::TempDir; use super::get::base64_decode; use crate::args::GlobalArgs; +use crate::commands::bun_preflight::LedgerLoad; /// Resolved artifact locations for the patch pipeline. Holds the overlay /// `TempDir` alive — sources become invalid when this is dropped. @@ -366,11 +367,20 @@ pub(crate) enum MemStageOutcome { /// disk stager there is no hard-failure mode (no download-mode parse, no /// tempdir), so this returns the outcome directly — every failure is the /// soft `Unavailable`. +/// +/// `ledger` is the caller's single `load_state` outcome (the harvest reads +/// the committed artifacts it names; an unreadable ledger harvests +/// nothing). `seed` pre-populates the in-memory blob set — the vendored +/// download phase already holds every fetched view's `blobContent`, so a +/// fresh `scan`/`get --mode vendored` never fetches a view a second time +/// here; manifest-driven callers pass an empty map. pub(crate) async fn stage_vendor_sources_in_memory( common: &GlobalArgs, manifest: &PatchManifest, socket_dir: &Path, project_root: &Path, + ledger: LedgerLoad<'_>, + seed: HashMap>, ) -> MemStageOutcome { let quiet = common.silent || common.json; let blobs = socket_dir.join("blobs"); @@ -379,46 +389,51 @@ pub(crate) async fn stage_vendor_sources_in_memory( let missing_blobs = get_missing_blobs(manifest, &blobs).await; let missing_package_archives = get_missing_archives(manifest, &packages).await; + let mut mem = seed; // A diff archive alone is NOT a sufficient source here, unlike the disk // stager: vendoring runs the auto-force policy, where a beforeHash // mismatch (already-applied tree, patch built against different bytes) // is overwritten with the FULL after-blob — which a diff cannot // produce. On-disk diffs still serve Strategy 2 for clean files; the - // after-blob content must additionally exist (disk, harvest, or fetch). + // after-blob content must additionally exist (disk, seed/harvest, or + // fetch). + let covered = |record: &PatchRecord, mem: &HashMap>| { + record + .files + .values() + .all(|f| !missing_blobs.contains(&f.after_hash) || mem.contains_key(&f.after_hash)) + || !missing_package_archives.contains(&record.uuid) + }; let mut to_fetch: Vec<(&str, &str)> = manifest .patches .iter() - .filter_map(|(purl, record)| { - let all_blobs_present = record - .files - .values() - .all(|f| !missing_blobs.contains(&f.after_hash)); - let pkg_present = !missing_package_archives.contains(&record.uuid); - if all_blobs_present || pkg_present { - None - } else { - Some((purl.as_str(), record.uuid.as_str())) - } - }) + .filter(|(_, record)| !covered(record, &mem)) + .map(|(purl, record)| (purl.as_str(), record.uuid.as_str())) .collect(); - let mut mem = HashMap::new(); if !to_fetch.is_empty() { // The committed vendor artifact IS the patched content: harvest its // afterHash blobs into memory so in-sync re-runs and fresh clones of // already-vendored projects stage with no network and no disk blobs. - mem = socket_patch_core::vendor::harvest_artifact_blobs(project_root, &manifest.patches) - .await; - if !mem.is_empty() { - to_fetch.retain(|(purl, _)| { - manifest.patches.get(*purl).is_none_or(|record| { - !record.files.values().all(|f| { - !missing_blobs.contains(&f.after_hash) || mem.contains_key(&f.after_hash) - }) - }) - }); + // Harvested bytes are hash-verified, so they win over a same-hash + // seed entry. + if let Ok(entries) = ledger { + mem.extend( + socket_patch_core::vendor::harvest_artifact_blobs_from( + project_root, + entries, + &manifest.patches, + ) + .await, + ); } + to_fetch.retain(|(purl, _)| { + manifest + .patches + .get(*purl) + .is_none_or(|record| !covered(record, &mem)) + }); } if !to_fetch.is_empty() { @@ -661,6 +676,8 @@ mod tests { &manifest_with_one_patch(), &socket_dir, &project_root, + Ok(&HashMap::new()), + HashMap::new(), ) .await; assert!( @@ -669,6 +686,102 @@ mod tests { ); } + /// The download phase's blob seed IS a source: with every after-hash + /// seeded, an offline run with no disk blobs, no archives and no + /// committed artifact is Ready and stages the seeded bytes (no fetch, + /// no harvest needed) — the vendored flows never fetch a view twice. + #[tokio::test] + async fn mem_stage_seeded_blobs_are_ready_offline_without_any_disk_source() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + let project_root = tmp.path().join("proj"); + std::fs::create_dir_all(&project_root).unwrap(); + let seed: HashMap> = [(HASH.to_string(), b"seeded".to_vec())].into(); + + let outcome = stage_vendor_sources_in_memory( + &offline_args(), + &manifest_with_one_patch(), + &socket_dir, + &project_root, + Ok(&HashMap::new()), + seed, + ) + .await; + let MemStageOutcome::Ready(staged) = outcome else { + panic!("a fully seeded stage must be Ready"); + }; + assert_eq!( + staged.mem.get(HASH).map(Vec::as_slice), + Some(&b"seeded"[..]), + "the seeded bytes are the staged content" + ); + assert!( + !socket_dir.exists(), + "in-memory staging must not create .socket/" + ); + } + + /// A seed covering only SOME hashes still leaves the rest to the + /// ladder: offline with nothing else, the record is Unavailable (the + /// seed is merged, never treated as complete coverage). + #[tokio::test] + async fn mem_stage_partial_seed_still_needs_the_missing_hash() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + let project_root = tmp.path().join("proj"); + std::fs::create_dir_all(&project_root).unwrap(); + let mut manifest = manifest_with_one_patch(); + manifest + .patches + .get_mut("pkg:npm/left-pad@1.3.0") + .unwrap() + .files + .insert( + "other.js".to_string(), + PatchFileInfo { + before_hash: "d".repeat(64), + after_hash: "e".repeat(64), + }, + ); + let seed: HashMap> = [(HASH.to_string(), b"seeded".to_vec())].into(); + + let outcome = stage_vendor_sources_in_memory( + &offline_args(), + &manifest, + &socket_dir, + &project_root, + Ok(&HashMap::new()), + seed, + ) + .await; + assert!( + matches!(outcome, MemStageOutcome::Unavailable), + "one seeded hash out of two is not coverage" + ); + } + + /// An unreadable ledger (`Err`) harvests nothing — and is not an + /// error here: the caller reports the corrupt ledger itself. + #[tokio::test] + async fn mem_stage_unreadable_ledger_skips_the_harvest() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + let project_root = tmp.path().join("proj"); + std::fs::create_dir_all(&project_root).unwrap(); + let err = std::io::Error::other("corrupt state.json"); + + let outcome = stage_vendor_sources_in_memory( + &offline_args(), + &manifest_with_one_patch(), + &socket_dir, + &project_root, + Err(&err), + HashMap::new(), + ) + .await; + assert!(matches!(outcome, MemStageOutcome::Unavailable)); + } + /// GlobalArgs wired to a guaranteed-unreachable API endpoint: explicit /// token + org overrides keep client construction network-free, and the /// URL points at a port that was just bound and released, so every fetch diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 8cb8f69f..111d1bb4 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -13,12 +13,12 @@ use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{ PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, }; -// Re-exported for `fetch_stage`, which imports the blob-hash guard from here. -pub(crate) use socket_patch_core::patch::apply::is_valid_blob_hash; -use socket_patch_core::patch::apply::select_installed_variants; +use socket_patch_core::patch::apply::{is_valid_blob_hash, select_installed_variants}; use socket_patch_core::patch::apply_lock::{self, LockError}; use socket_patch_core::telemetry::{track_patch_fetch_failed, track_patch_fetched}; -use socket_patch_core::utils::purl::{is_purl, normalize_purl, strip_purl_qualifiers}; +use socket_patch_core::utils::purl::{ + canonical_purl, is_purl, normalize_purl, strip_purl_qualifiers, +}; use socket_patch_core::vendor::{load_state, lookup_entry, VendorEntry}; use std::collections::HashMap; use std::fmt; @@ -1072,7 +1072,7 @@ async fn filter_to_installed_purls( use socket_patch_core::vendor::lock_inventory; use std::collections::HashSet; - let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + let canon = canonical_purl; // Deduped base purls, probed against the installed tree. The resolver // keys its result by the purls we pass, so canonicalize the found keys @@ -1554,33 +1554,43 @@ async fn fetch_selected_patches( batch } +/// The vendored download phase's result: `(exit code, download JSON, +/// records by purl, blob seed)` — the seed is every fetched view's decoded +/// `blobContent` keyed by after-hash, for the vendor stager +/// (`fetch_stage::stage_vendor_sources_in_memory`), so the step never +/// fetches a view this phase already holds. +pub(crate) type DetachedDownload = ( + i32, + serde_json::Value, + HashMap, + HashMap>, +); + /// Download patches WITHOUT touching the manifest and return the fetched /// records keyed by purl — the download phase of every vendored run /// (`scan` / `get --mode vendored`), where the vendor ledger carries the /// records (`detached`). Honors the same installed-release narrowing as /// [`download_and_apply_patches`]. A purl already vendored detached at the /// selected uuid skips the network fetch and reuses the ledger's embedded -/// record, so idempotent re-runs stay cheap. Builds its own client from -/// `params`; callers holding the run's client use -/// [`download_patch_records_with`]. -pub(crate) async fn download_patch_records( - selected: &[PatchSearchResult], - params: &DownloadParams, -) -> (i32, serde_json::Value, HashMap) { - let api_client = api_client_for(params).await; - download_patch_records_with(selected, params, &api_client, HashMap::new()).await -} - -/// [`download_patch_records`] over the caller's client. `prefetched` maps -/// uuid → an already-fetched view: the `get ` path resolved its -/// identifier by fetching the view and must not fetch it again (a fresh -/// client could re-hit the 401 the proxy fallback just recovered from). +/// record, so idempotent re-runs stay cheap. +/// +/// `api_client` is the run's client (built once, proxy fallback included). +/// `prefetched` maps uuid → an already-fetched view: the `get ` path +/// resolved its identifier by fetching the view, and scan's interactive +/// arm pre-verified baselines from the views — neither must fetch again (a +/// fresh fetch could re-hit the 401 the proxy fallback just recovered +/// from). The ledger idempotency check runs before the cache lookup, and a +/// cache miss still fetches. +/// +/// The blob seed is best-effort: an undecodable or missing `blobContent` +/// contributes nothing and is NOT a failed record (the stager reports what +/// it cannot source). pub(crate) async fn download_patch_records_with( selected: &[PatchSearchResult], params: &DownloadParams, api_client: &ApiClient, prefetched: HashMap, -) -> (i32, serde_json::Value, HashMap) { +) -> DetachedDownload { // The ledger load outcome is handed to the preflight AS a result: an // unreadable ledger must surface as `vendor_state_unreadable` from the // one refusal this phase emits (fail closed, nothing exempt), not be @@ -1617,7 +1627,21 @@ pub(crate) async fn download_patch_records_with( let downloaded = batch.fetched.len(); let mut records: HashMap = batch.reused.into_iter().collect(); + let mut blobs: HashMap> = HashMap::new(); for FetchedPatch { patch, files, .. } in batch.fetched { + for info in patch.files.values() { + // Same key guard as the blob writers: the hash names the lookup + // key the apply pipeline gates writes on. + let (Some(b64), Some(hash)) = (&info.blob_content, &info.after_hash) else { + continue; + }; + if !is_valid_blob_hash(hash) || blobs.contains_key(hash) { + continue; + } + if let Ok(bytes) = base64_decode(b64) { + blobs.insert(hash.clone(), bytes); + } + } records.insert(patch.purl.clone(), build_patch_record(&patch, files)); } let mut result_json = serde_json::json!({ @@ -1631,7 +1655,7 @@ pub(crate) async fn download_patch_records_with( if !batch.warnings.is_empty() { result_json["warnings"] = serde_json::json!(batch.warnings); } - (i32::from(batch.failed > 0), result_json, records) + (i32::from(batch.failed > 0), result_json, records, blobs) } /// Emit a warning (stderr `[note]` + `warnings[]`) for every added/updated @@ -1807,18 +1831,9 @@ pub async fn download_and_apply_patches_with( } }; - // Bun preflight for the one non-agent caller left: scan's manifest-mode - // vendored download (`save_only && !persist_blobs`), which feeds the - // vendor engine and must refuse the same projects before fetching. - // Agent/save-only flows keep their record-only intent (no preflight). - // Retire together with that caller once scan's vendored path is - // detached-only. - let bun_refusal = if params.save_only && !params.persist_blobs { - bun_vendor_preflight(¶ms.cwd, selected).await - } else { - None - }; - + // No Bun preflight here: this is the agent (manifest) engine, and + // agent/save-only flows keep their record-only intent. The vendored + // download phase (`download_patch_records_with`) runs its own. let blobs_dir = socket_dir.join("blobs"); let batch = fetch_selected_patches( selected, @@ -1826,7 +1841,7 @@ pub async fn download_and_apply_patches_with( run.api_client, RecordStore::Manifest(&manifest), params.persist_blobs.then_some(blobs_dir.as_path()), - bun_refusal.as_ref(), + None, HashMap::new(), ) .await; @@ -2119,6 +2134,7 @@ pub async fn run(args: GetArgs) -> i32 { run_get_vendored( &args, &api_client, + use_public_proxy, &selected, Some(&patch), &[], @@ -2467,6 +2483,7 @@ pub async fn run(args: GetArgs) -> i32 { return run_get_vendored( &args, &api_client, + use_public_proxy, &selected, None, &narrow_skips, @@ -2848,9 +2865,10 @@ async fn run_get_hosted( /// `get … --mode vendored`, both identifier paths: scan's vendored posture /// end to end — the detached download phase ([`download_patch_records_with`]: /// records fetched into memory, no manifest, no blobs) feeding scan's -/// detached vendor step (apply lock, in-memory staging, the vendor engine; -/// the ledger carries every record `detached: true`), telemetry included — -/// so the result matches `scan --mode vendored` selecting the same patches. +/// detached vendor step (apply lock, in-memory staging seeded with the +/// downloaded blobs, the vendor engine over the same run-level client; the +/// ledger carries every record `detached: true`), telemetry included — so +/// the result matches `scan --mode vendored` selecting the same patches. /// `.socket/manifest.json` is never read or written here. /// /// `prefetched` is the `get ` path's already-fetched view: it resolved @@ -2864,6 +2882,7 @@ async fn run_get_hosted( async fn run_get_vendored( args: &GetArgs, api_client: &ApiClient, + use_public_proxy: bool, selected: &[PatchSearchResult], prefetched: Option<&PatchResponse>, narrow_skips: &[serde_json::Value], @@ -2968,7 +2987,7 @@ async fn run_get_vendored( let prefetched_views: HashMap = prefetched .map(|p| HashMap::from([(p.uuid.clone(), p.clone())])) .unwrap_or_default(); - let (dl_code, mut result, records) = Box::pin(download_patch_records_with( + let (dl_code, mut result, records, blobs) = Box::pin(download_patch_records_with( selected, ¶ms, api_client, @@ -2978,12 +2997,21 @@ async fn run_get_vendored( let mut has_errors = dl_code != 0; fold_narrowing_into_result(&mut result, narrow_skips, narrow_warnings); - // The vendor step (scan's, verbatim): apply lock, in-memory staging, the - // engine over exactly the records fetched above (moved in — nothing - // here needs them afterwards). A per-patch download failure does not + // The vendor step (scan's, verbatim): apply lock, in-memory staging + // seeded with the blobs fetched above, the engine over exactly the + // records fetched above (moved in — nothing here needs them afterwards) + // and over this run's client. A per-patch download failure does not // skip it (scan parity). - match super::scan::boxed_scan_vendor_step(&args.common, &manifest_path, &socket_dir, records) - .await + match super::scan::boxed_scan_vendor_step( + &args.common, + &manifest_path, + &socket_dir, + records, + blobs, + api_client.clone(), + use_public_proxy, + ) + .await { Ok((vendor_errors, venv)) => { has_errors |= vendor_errors; @@ -4429,6 +4457,19 @@ mod tests { } } + /// The 2-arg shape the vendored-download unit tests below drive: builds + /// the client from `params` the way the wrappers used to, and drops the + /// blob seed (the stager's concern, pinned by fetch_stage's tests). + async fn download_patch_records( + selected: &[PatchSearchResult], + params: &DownloadParams, + ) -> (i32, serde_json::Value, HashMap) { + let api_client = api_client_for(params).await; + let (code, json, records, _blobs) = + download_patch_records_with(selected, params, &api_client, HashMap::new()).await; + (code, json, records) + } + #[tokio::test] #[serial_test::serial] async fn download_patch_records_no_applicable_files_is_failed_and_unrecorded() { @@ -5436,10 +5477,17 @@ mod tests { let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); let server = MockServer::start().await; // trap: no mounts let tmp = tempfile::tempdir().unwrap(); - let mut patch = patch_with_files(HashMap::from([( - "package/index.js".to_string(), - file_resp(Some(&"0".repeat(64)), Some(&"1".repeat(64))), - )])); + // Two files: one with served `blobContent` (→ the blob seed), one + // without (→ contributes nothing, and is NOT a failure). + let mut seeded = file_resp(Some(&"0".repeat(64)), Some(&"1".repeat(64))); + seeded.blob_content = Some("cGF0Y2hlZA==".to_string()); // "patched" + let mut patch = patch_with_files(HashMap::from([ + ("package/index.js".to_string(), seeded), + ( + "package/other.js".to_string(), + file_resp(Some(&"2".repeat(64)), Some(&"3".repeat(64))), + ), + ])); patch.uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".into(); patch.purl = "pkg:npm/covgap-prefetched@1.0.0".into(); let selected = vec![mk_patch(&patch.uuid, &patch.purl, "free", "2024-01-01")]; @@ -5447,11 +5495,19 @@ mod tests { let client = api_client_for(¶ms).await; let prefetched = HashMap::from([(patch.uuid.clone(), patch.clone())]); - let (code, json, records) = + let (code, json, records, blobs) = download_patch_records_with(&selected, ¶ms, &client, prefetched).await; assert_eq!(code, 0, "json={json}"); assert_eq!(json["downloaded"], 1, "json={json}"); + // The blob seed carries every served `blobContent` by after-hash — + // decoded — and only those; the vendor stager starts from it. + assert_eq!( + blobs.get(&"1".repeat(64)).map(Vec::as_slice), + Some(&b"patched"[..]), + "the served blob is seeded under its after-hash" + ); + assert_eq!(blobs.len(), 1, "a file with no blobContent seeds nothing"); assert_eq!(json["detached"], true, "json={json}"); assert_eq!(json["patches"][0]["action"], "downloaded", "json={json}"); assert!( diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 4a7d828b..d0c91a78 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -276,15 +276,19 @@ async fn repair_inner( // packages` — repair must not re-litter them (or fail trying). The // cleanup phase below still uses the FULL manifest, so it never sweeps // sources an in-place apply may need for rollback. - let vendor_state = socket_patch_core::vendor::load_state(&args.common.cwd) - .await - .unwrap_or_default(); + // Loaded ONCE under the lock; the vendored phase below takes the raw + // result (an unreadable ledger is ITS loud failure), while this scoping + // degrades to "nothing vendored" — a corrupt ledger must not hide the + // manifest's own missing sources. + let ledger = socket_patch_core::vendor::load_state(&args.common.cwd).await; + let no_entries = std::collections::HashMap::new(); + let vendor_entries = ledger.as_ref().map(|s| &s.entries).unwrap_or(&no_entries); // Lockfile vendor references count as vendored even before the ledger // is reconstructed, so a no-ledger repair doesn't download sources for // entries the vendored phase is about to own. let referenced_uuids: std::collections::HashSet = vendor_references - .into_iter() - .map(|(_, uuid, _)| uuid) + .iter() + .map(|(_, uuid, _)| uuid.clone()) .collect(); let scoped_manifest = manifest.as_ref().map(|m| { let patches = m @@ -292,7 +296,7 @@ async fn repair_inner( .iter() .filter(|(purl, rec)| { !referenced_uuids.contains(&rec.uuid) - && socket_patch_core::vendor::lookup_entry(&vendor_state.entries, purl) + && socket_patch_core::vendor::lookup_entry(vendor_entries, purl) .is_none_or(|e| e.uuid != rec.uuid) }) .map(|(k, v)| (k.clone(), v.clone())) @@ -410,12 +414,15 @@ async fn repair_inner( // Step 1.5: vendored artifacts — health-check the ledger (and any // lockfile vendor references with no ledger coverage) and rebuild // missing/corrupt artifacts. Runs under `--download-only` too: - // restoring artifacts IS repair's download half. - let vendor_rebuilt = crate::commands::repair_vendor::repair_vendored_artifacts( + // restoring artifacts IS repair's download half. The reference scan + // and ledger load above are handed over, not repeated. + let vendor_rebuilt = crate::commands::repair_vendor::repair_vendored_artifacts_with_references( &args.common, manifest.as_ref(), socket_dir, &mut env, + &vendor_references, + ledger, ) .await; if !quiet && vendor_rebuilt > 0 { diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index 0d9b8f7b..c266089f 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -62,8 +62,8 @@ use socket_patch_core::utils::purl::{ use socket_patch_core::vendor::state::{VendorArtifact, WiringRecord}; use socket_patch_core::vendor::{ self, artifact_is_file_shaped, check_vendored_artifact, compute_dir_inventory, file_sha256_hex, - load_state, lock_inventory, parse_vendor_path, registry_fetch, ArtifactHealth, VendorEntry, - VendorOutcome, VendorWarning, + lock_inventory, parse_vendor_path, registry_fetch, ArtifactHealth, VendorEntry, VendorOutcome, + VendorState, VendorWarning, }; use socket_patch_core::vex::time::now_rfc3339; @@ -511,32 +511,21 @@ async fn restore_orphaned_pre_rebuild_dirs(common: &GlobalArgs) { /// Returns the number of artifacts rebuilt (for the human summary line); /// failures are carried by `env` (`Failed` events + partial-failure status). /// -/// Scans the wiring files for vendored references itself; a caller that -/// already ran [`scan_vendor_references`] under the same lock (repair.rs -/// does, for its `referenced_uuids`) should pass that result to -/// [`repair_vendored_artifacts_with_references`] instead of paying for the -/// ~20-file scan a second time. -pub(crate) async fn repair_vendored_artifacts( - common: &GlobalArgs, - manifest: Option<&PatchManifest>, - socket_dir: &Path, - env: &mut Envelope, -) -> usize { - let references = scan_vendor_references(&common.cwd).await; - repair_vendored_artifacts_with_references(common, manifest, socket_dir, env, &references).await -} - -/// [`repair_vendored_artifacts`] with the wiring-file reference scan -/// supplied by the caller: `references` is [`scan_vendor_references`]'s -/// `(ecosystem, uuid, artifact relpath)` output for `common.cwd`, taken -/// under the apply lock this phase runs under (the lockfiles it describes -/// are the ones the reconstruction below rewires). +/// `references` is [`scan_vendor_references`]'s `(ecosystem, uuid, +/// artifact relpath)` output for `common.cwd` and `ledger` the caller's +/// `load_state` outcome — both taken by repair.rs under the apply lock +/// this phase runs under (the lockfiles and ledger they describe are the +/// ones the reconstruction below rewires), so neither is re-read here. An +/// unreadable ledger fails this phase loudly (`vendor_state_unreadable`); +/// the caller's own degrade-to-empty policy for its download scoping is +/// its own. pub(crate) async fn repair_vendored_artifacts_with_references( common: &GlobalArgs, manifest: Option<&PatchManifest>, socket_dir: &Path, env: &mut Envelope, references: &[(String, String, String)], + ledger: std::io::Result, ) -> usize { let quiet = common.json || common.silent; let mut rebuilt = 0usize; @@ -545,7 +534,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( restore_orphaned_pre_rebuild_dirs(common).await; } - let mut state = match load_state(&common.cwd).await { + let mut state = match ledger { Ok(s) => s, Err(e) => { env.record( @@ -1062,7 +1051,17 @@ pub(crate) async fn repair_vendored_artifacts_with_references( patches: records_map, setup: None, }; - let staged = match stage_vendor_sources_in_memory(common, &synth, socket_dir, &common.cwd).await + // The ledger this pass already holds feeds the staging harvest; repair + // has no download phase, so no seed. + let staged = match stage_vendor_sources_in_memory( + common, + &synth, + socket_dir, + &common.cwd, + Ok(&state.entries), + HashMap::new(), + ) + .await { MemStageOutcome::Ready(s) => s, MemStageOutcome::Unavailable => { diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index 66e220ce..cd2a810b 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -3,7 +3,9 @@ //! baseline pre-verification, and the table's vuln-ID / severity helpers. use socket_patch_core::api::ranking::cmp_batch_infos; -use socket_patch_core::api::types::{BatchPackagePatches, BatchPatchInfo, PatchSearchResult}; +use socket_patch_core::api::types::{ + BatchPackagePatches, BatchPatchInfo, PatchResponse, PatchSearchResult, +}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; use socket_patch_core::vendor::lock_inventory::LockfileEntry; @@ -272,21 +274,27 @@ async fn vendored_purls_from_artifacts(common: &GlobalArgs) -> Vec { /// content; see `force_apply_staged`), but the user should learn it BEFORE /// the confirm prompt, not from a post-hoc warning event. /// +/// Returns `(mismatched uuids, fetched views by uuid)`: the download phase +/// serves its records from the views instead of fetching each one a second +/// time. Only `Ok(Some)` views are cached — an errored or 404'd fetch is +/// left for the download phase to retry and report per patch. +/// /// Best-effort and read-only: a detail-fetch failure or an unresolvable /// installed path just skips the annotation — it never blocks the flow and -/// writes nothing (unlike `download_patch_records`, which stages blobs). +/// writes nothing. pub(super) async fn preverify_vendor_baselines( api_client: &socket_patch_core::api::client::ApiClient, org_slug: Option<&str>, selected: &[PatchSearchResult], crawled: &[socket_patch_core::crawlers::types::CrawledPackage], lockfile_only: &HashSet, -) -> HashSet { +) -> (HashSet, HashMap) { use socket_patch_core::manifest::schema::PatchFileInfo; use socket_patch_core::patch::apply::{verify_file_patch, VerifyStatus}; use socket_patch_core::utils::purl::purl_eq; let mut mismatched: HashSet = HashSet::new(); + let mut views: HashMap = HashMap::new(); for patch in selected { // API purls come percent-encoded, crawler purls literal — purl_eq // bridges the two spellings. @@ -316,8 +324,9 @@ pub(super) async fn preverify_vendor_baselines( break; } } + views.insert(patch.uuid.clone(), detail); } - mismatched + (mismatched, views) } /// Fold both ledgers' patch records into the manifest view update detection @@ -1505,13 +1514,14 @@ mod tests { let lockfile_only: HashSet = std::iter::once("pkg:npm/@scope/lockonly@1.0.0".to_string()).collect(); - let mismatched = + let (mismatched, views) = preverify_vendor_baselines(&client, None, &selected, &crawled, &lockfile_only).await; assert!(mismatched.is_empty()); assert!( mock.received_requests().await.unwrap().is_empty(), "both skip shapes must decide before any detail fetch" ); + assert!(views.is_empty(), "nothing fetched, nothing cached"); } /// Mount `GET /patch/view/` (the public-proxy detail route) with @@ -1559,14 +1569,40 @@ mod tests { let crawled = vec![crawled_pkg("newfile", "pkg:npm/newfile@1.0.0", pkg_dir)]; let selected = vec![search_result("u3", "pkg:npm/newfile@1.0.0")]; - let mismatched = + let (mismatched, views) = preverify_vendor_baselines(&client, None, &selected, &crawled, &HashSet::new()).await; assert!( mismatched.is_empty(), "a new-file-only patch never annotates a baseline mismatch" ); - // Unlike the pre-fetch skips, this one DID fetch the detail. + // Unlike the pre-fetch skips, this one DID fetch the detail — and + // hands the view on so the download phase never fetches it again. assert_eq!(mock.received_requests().await.unwrap().len(), 1); + assert_eq!( + views.keys().collect::>(), + vec!["u3"], + "the fetched view is cached by uuid" + ); + assert_eq!(views["u3"].purl, "pkg:npm/newfile@1.0.0"); + } + + /// A view the server does not serve (404 → `Ok(None)`) is NOT cached: + /// the download phase retries it and reports the miss per patch. + #[tokio::test] + async fn preverify_does_not_cache_a_missing_view() { + let mock = wiremock::MockServer::start().await; + let client = api_client_for(&mock.uri()); + let tmp = tempfile::tempdir().unwrap(); + let pkg_dir = tmp.path().join("node_modules/newfile"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + let crawled = vec![crawled_pkg("newfile", "pkg:npm/newfile@1.0.0", pkg_dir)]; + let selected = vec![search_result("u404", "pkg:npm/newfile@1.0.0")]; + + let (mismatched, views) = + preverify_vendor_baselines(&client, None, &selected, &crawled, &HashSet::new()).await; + assert!(mismatched.is_empty()); + assert_eq!(mock.received_requests().await.unwrap().len(), 1, "it did try"); + assert!(views.is_empty(), "a 404'd view must not be cached"); } #[tokio::test] @@ -1598,13 +1634,14 @@ mod tests { let crawled = vec![crawled_pkg("newfile", "pkg:npm/newfile@1.0.0", pkg_dir)]; let selected = vec![search_result("u4", "pkg:npm/newfile@1.0.0")]; - let mismatched = + let (mismatched, views) = preverify_vendor_baselines(&client, None, &selected, &crawled, &HashSet::new()).await; assert_eq!( mismatched, std::iter::once("u4".to_string()).collect::>(), "the new-file skip must not swallow a sibling file's mismatch" ); + assert!(views.contains_key("u4"), "a mismatched view is cached too"); } #[test] diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index c9bfc9b7..cfc75aa2 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -8,14 +8,15 @@ use socket_patch_core::manifest::cleanup_blobs::{ use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::patch::apply_lock; -use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; +use socket_patch_core::utils::purl::{canonical_purl, strip_purl_qualifiers}; +use socket_patch_core::vendor::load_state; use std::collections::HashSet; use std::path::Path; use std::time::Duration; use crate::args::GlobalArgs; use crate::commands::lock_cli::lock_failure; -use crate::commands::vendor::VendorGcSummary; +use crate::commands::vendor::{run_vendor_gc, run_vendor_gc_locked, VendorGcSummary}; /// Aggregated outcome of a GC pass (or preview). Serialized into the /// `scan --json` output's `gc` sub-object. See CLI_CONTRACT.md for the @@ -49,12 +50,18 @@ pub(super) struct GcSummary { /// Orphan `.socket/vendor//` dirs swept (or sweepable). vendor_orphan_dirs: usize, /// Set when a wet pass could not take the apply lock and so skipped - /// its manifest prune + blob sweep: `lock_held` (another run holds it - /// — the contract's skip-not-fail posture) or `lock_io` (the lock file - /// could not be created or opened). `(code, message)` exactly as + /// its whole mutating half (vendored reverts, manifest prune, blob + /// sweep): `lock_held` (another run holds it — the contract's + /// skip-not-fail posture) or `lock_io` (the lock file could not be + /// created or opened). `(code, message)` exactly as /// `lock_cli::lock_failure` renders them. Never set in preview mode /// (the preview is lock-free and read-only). skipped: Option<(&'static str, String)>, + /// Post-revert/prune rewrites that failed (`vendor_state_write_failed` + /// / `manifest_write_failed` + detail): the reverts or prunes already + /// happened on disk, so the stale record is reported, not the pass + /// failed. Serialized as additive `warnings[]` on the apply shape only. + warnings: Vec<(&'static str, String)>, } impl GcSummary { @@ -70,11 +77,10 @@ impl GcSummary { gc } - /// Fold a vendored-state GC pass into this summary. `failed` is - /// partitioned: ledger keys (every one a `pkg:` purl) are entries - /// whose revert failed; anything else is the pass-level lock-skip - /// marker `run_vendor_gc` records in place of a purl, which lands in - /// `skipped` unless this pass already recorded its own reason. + /// Fold a vendored-state GC pass into this summary: purl lists sorted, + /// the vendored half's lock skip becomes this pass's `skipped` unless + /// it already recorded its own reason, and its failed post-revert + /// rewrites join `warnings`. fn absorb_vendor_gc(&mut self, v: VendorGcSummary) { self.vendored_reverted = v .dropped_reverted @@ -84,20 +90,16 @@ impl GcSummary { self.vendored_reverted.sort(); self.vendored_kept = v.kept; self.vendored_kept.sort(); - let (failed, markers): (Vec, Vec) = - v.failed.into_iter().partition(|f| f.starts_with("pkg:")); - self.vendored_failed = failed; + self.vendored_failed = v.failed; self.vendored_failed.sort(); - if self.skipped.is_none() { - if let Some(marker) = markers.into_iter().next() { - self.skipped = Some(("lock_held", marker)); - } - } + self.skipped = self.skipped.take().or(v.skipped); + self.warnings.extend(v.write_failures); self.vendor_orphan_dirs = v.orphan_dirs; } - /// Serialize for a *mutating* GC pass (post-apply). `skipped` is - /// additive: present only when the lock could not be taken. + /// Serialize for a *mutating* GC pass (post-apply). `skipped` and + /// `warnings` are additive: present only when the lock could not be + /// taken / a post-revert rewrite failed. fn to_apply_json(&self) -> serde_json::Value { let mut json = serde_json::json!({ "prunedManifestEntries": self.pruned, @@ -113,6 +115,13 @@ impl GcSummary { if let Some((code, message)) = &self.skipped { json["skipped"] = serde_json::json!({ "code": code, "message": message }); } + if !self.warnings.is_empty() { + json["warnings"] = self + .warnings + .iter() + .map(|(code, detail)| serde_json::json!({ "code": code, "detail": detail })) + .collect(); + } json } @@ -158,11 +167,16 @@ async fn run_gc( } } -/// Apply-mode GC: run the vendored-state GC, then — when a manifest exists -/// — prune manifest entries for PURLs not in `scanned_purls`, write the -/// manifest back, and sweep orphan blob/diff/package files. Callers must -/// gate on the `prune` flag — when GC isn't requested, simply don't call -/// this function and don't emit a `gc` sub-object. +/// Apply-mode GC, under ONE apply-lock window: the vendored-state GC +/// (reverts manifest-dropped and lockfile-unused vendored entries, dropping +/// the latter's manifest records), then — when a manifest exists — prune +/// manifest entries for PURLs not in `scanned_purls`, write the manifest +/// back, and sweep orphan blob/diff/package files, so the sweep reclaims +/// the blobs the vendored half just orphaned in the same pass (the stale +/// `vendored` exemption set is harmless: the entries it would exempt are +/// already gone). Callers must gate on the `prune` flag — when GC isn't +/// requested, simply don't call this function and don't emit a `gc` +/// sub-object. pub(super) async fn run_apply_gc( common: &GlobalArgs, manifest_path: &Path, @@ -170,49 +184,51 @@ pub(super) async fn run_apply_gc( scanned_purls: &HashSet, vendored: &HashSet, ) -> GcSummary { - // Vendored-state GC FIRST: it reverts manifest-dropped and - // lockfile-unused vendored entries, dropping the latter's manifest - // entries — so the manifest prune + blob sweep below reclaims their - // blobs in this same pass (and the stale `vendored` exemption set is - // harmless: the entries it would exempt are already gone). It takes - // the apply lock itself for its wet work and releases it on return. - let vendor_gc = - crate::commands::vendor::run_vendor_gc(common, manifest_path, /*dry_run=*/ false).await; - - // No manifest ⇒ nothing to prune, and the blob sweep has no - // referenced-set to work from. Decided BEFORE the lock: `acquire` - // creates `.socket/` when it is missing, and a plain `scan --prune` on - // a project that has none (every manifest-free vendored project) must - // not conjure the directory just to find nothing to do. - if !tokio::fs::metadata(manifest_path) + // Existence gate BEFORE the lock: `acquire` creates `.socket/` when it + // is missing, and a plain `scan --prune` on a project with neither a + // manifest nor a ledger entry (a pristine checkout) must not conjure + // the directory just to find nothing to do. Either store alone is + // enough: the vendored half runs without a manifest, the manifest half + // without a ledger. + let has_manifest = tokio::fs::metadata(manifest_path) .await - .is_ok_and(|m| m.is_file()) - { - return GcSummary::vendor_only(vendor_gc); + .is_ok_and(|m| m.is_file()); + let has_ledger_entries = load_state(&common.cwd) + .await + .is_ok_and(|s| !s.entries.is_empty()); + if !has_manifest && !has_ledger_entries { + return GcSummary::default(); } - // The prune below is a manifest read-modify-write plus a blob/archive - // sweep — the writes the apply lock serializes everywhere else (apply, - // get, remove, repair, rollback, and the vendored half above). Run - // unlocked against a live holder mid-write, the stale read would - // clobber the holder's new manifest entry on write-back and the sweep + // Both halves are read-modify-writes the apply lock serializes + // everywhere else (apply, get, remove, repair, rollback, vendor). Run + // unlocked against a live holder mid-write, the stale manifest read + // would clobber the holder's new entry on write-back and the sweep // would delete its just-downloaded blobs. Contention skips the pass // without failing the scan; an I/O fault on the lock file skips it - // too — both are recorded so the output explains the untouched - // manifest instead of reading as a clean all-zero pass. + // too — both are recorded so the output explains the untouched state + // instead of reading as a clean all-zero pass. One acquire for both + // halves: flock is per open file description, so a nested acquire in + // the vendored half would read as a live holder and silently skip it. let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); let _guard = match apply_lock::acquire(socket_dir, timeout) { Ok(g) => g, Err(e) => { - let mut gc = GcSummary::vendor_only(vendor_gc); - gc.skipped = Some(lock_failure(&e, timeout)); - return gc; + return GcSummary { + skipped: Some(lock_failure(&e, timeout)), + ..Default::default() + }; } }; + // Vendored-state GC FIRST (see the fn doc), under this guard. + let vendor_gc = run_vendor_gc_locked(common, manifest_path, /*dry_run=*/ false).await; + // Re-read the manifest under the lock (the apply step may have added // or updated entries we now want to consider for pruning; the probe - // above was only the cheap pre-lock gate). + // above was only the cheap pre-lock gate). Missing or unreadable ⇒ + // nothing to prune, and the blob sweep has no referenced-set to work + // from. let mut manifest = match read_manifest(manifest_path).await { Ok(Some(m)) => m, _ => return GcSummary::vendor_only(vendor_gc), @@ -221,13 +237,27 @@ pub(super) async fn run_apply_gc( for purl in &prunable { manifest.patches.remove(purl); } + let mut write_failure = None; if !prunable.is_empty() { - // If pruning failed mid-write the manifest may be stale, but the - // file-level cleanup below still operates on the in-memory copy. - let _ = write_manifest(manifest_path, &manifest).await; + // A failed write leaves the on-disk manifest stale (the entries + // are still listed as pruned — they are gone from the in-memory + // copy the sweep below works from), so it is reported, not + // swallowed. + if let Err(e) = write_manifest(manifest_path, &manifest).await { + write_failure = Some(( + "manifest_write_failed", + format!( + "pruned {} manifest entr{} but could not update {}: {e}", + prunable.len(), + if prunable.len() == 1 { "y" } else { "ies" }, + manifest_path.display() + ), + )); + } } let mut gc = run_gc(&manifest, prunable, socket_dir, /*dry_run=*/ false).await; gc.absorb_vendor_gc(vendor_gc); + gc.warnings.extend(write_failure); gc } @@ -242,8 +272,7 @@ async fn preview_apply_gc( vendored: &HashSet, ) -> GcSummary { // Read-only preview of the vendored-state GC (lists, never reverts). - let vendor_gc = - crate::commands::vendor::run_vendor_gc(common, manifest_path, /*dry_run=*/ true).await; + let vendor_gc = run_vendor_gc(common, manifest_path, /*dry_run=*/ true).await; let mut manifest = match read_manifest(manifest_path).await { Ok(Some(m)) => m, @@ -298,12 +327,15 @@ pub(super) async fn gc_json( } /// Human-readable line(s) for the vendored-state half of a GC pass (and -/// the lock-skip reason, when the manifest half could not run); prints -/// nothing when there is nothing to report. +/// the lock-skip reason / failed rewrites, when the pass could not run or +/// persist in full); prints nothing when there is nothing to report. pub(super) fn print_gc_vendored_line(gc: &GcSummary) { if let Some((code, message)) = &gc.skipped { println!("GC: skipped ({code}): {message}."); } + for (_, detail) in &gc.warnings { + println!("GC: {detail}."); + } if !gc.vendored_reverted.is_empty() || gc.vendor_orphan_dirs > 0 { println!( "GC: reverted {} vendored entr{}; swept {} orphan vendor dir{}.", @@ -391,16 +423,12 @@ fn detect_prunable( scanned_purls: &HashSet, vendored: &HashSet, ) -> Vec { - let scanned_bases: HashSet = scanned_purls - .iter() - .map(|p| normalize_purl(strip_purl_qualifiers(p)).into_owned()) - .collect(); + let scanned_bases: HashSet = scanned_purls.iter().map(|p| canonical_purl(p)).collect(); manifest .patches .keys() .filter(|p| { - let base = normalize_purl(strip_purl_qualifiers(p)); - !scanned_bases.contains(base.as_ref()) + !scanned_bases.contains(&canonical_purl(p)) && !vendored.contains(p.as_str()) && !vendored.contains(strip_purl_qualifiers(p)) && crate::ecosystem_dispatch::crawl_covers_purl(p.as_str()) @@ -1313,23 +1341,27 @@ mod tests { assert!(uuid_dir.exists(), "kept artifacts must survive the sweep"); } - /// The `keptVendoredEntries` / `failedVendoredEntries` / `skipped` - /// plumbing in isolation: absorbed sorted, serialized on the apply - /// shape, absent from the preview shape (a read-only preview cannot - /// detect drift, reverts nothing and takes no lock, so emitting a - /// constant `[]`/marker would claim a check that never ran). The - /// vendored half's `failed` is partitioned: purls are failed reverts, - /// the pass-level lock marker becomes the `skipped` reason. + /// The `keptVendoredEntries` / `failedVendoredEntries` / `skipped` / + /// `warnings` plumbing in isolation: absorbed sorted, serialized on the + /// apply shape, absent from the preview shape (a read-only preview + /// cannot detect drift, reverts nothing and takes no lock, so emitting + /// a constant `[]`/marker would claim a check that never ran). The + /// vendored half's typed fields land where they belong: `failed` holds + /// only purls, its lock skip becomes the `skipped` reason, its failed + /// rewrites become `warnings` — never mislabelled as `lock_held`. #[test] fn gc_json_shapes_carry_drift_keeps_only_on_apply() { + const LOCK_MARKER: &str = "vendor GC skipped: another socket-patch run holds the apply lock"; let mut gc = GcSummary::default(); gc.absorb_vendor_gc(VendorGcSummary { kept: vec!["pkg:npm/b@1.0.0".into(), "pkg:npm/a@1.0.0".into()], - failed: vec![ - "pkg:npm/d@1.0.0".into(), - "vendor GC skipped: another socket-patch run holds the apply lock".into(), - "pkg:npm/c@1.0.0".into(), - ], + failed: vec!["pkg:npm/d@1.0.0".into(), "pkg:npm/c@1.0.0".into()], + skipped: Some(("lock_held", LOCK_MARKER.into())), + write_failures: vec![( + "vendor_state_write_failed", + "reverted vendored entries but could not update .socket/vendor/state.json: EROFS" + .into(), + )], ..Default::default() }); assert_eq!( @@ -1340,15 +1372,12 @@ mod tests { assert_eq!( gc.vendored_failed, vec!["pkg:npm/c@1.0.0".to_string(), "pkg:npm/d@1.0.0".to_string()], - "failed reverts are absorbed sorted, the marker filtered out" + "failed reverts are absorbed sorted" ); assert_eq!( gc.skipped, - Some(( - "lock_held", - "vendor GC skipped: another socket-patch run holds the apply lock".to_string() - )), - "the vendored half's lock marker is the skip reason" + Some(("lock_held", LOCK_MARKER.to_string())), + "the vendored half's lock skip is the skip reason" ); let apply = gc.to_apply_json(); assert_eq!( @@ -1361,32 +1390,57 @@ mod tests { ); assert_eq!(apply["revertedVendoredEntries"], serde_json::json!([])); assert_eq!(apply["skipped"]["code"], "lock_held", "{apply}"); + assert_eq!( + apply["warnings"], + serde_json::json!([{ + "code": "vendor_state_write_failed", + "detail": "reverted vendored entries but could not update \ + .socket/vendor/state.json: EROFS", + }]), + "{apply}" + ); let preview = gc.to_preview_json(); - for key in ["keptVendoredEntries", "failedVendoredEntries", "skipped"] { + for key in [ + "keptVendoredEntries", + "failedVendoredEntries", + "skipped", + "warnings", + ] { assert!( preview.get(key).is_none(), "preview must not claim a check it cannot run ({key}): {preview}" ); } - // A pass that took its own lock fine reports NO skip, and the - // apply shape omits the key entirely (additive: absent, not null). + // A pass that took its own lock fine reports NO skip and NO + // warnings, and the apply shape omits both keys entirely (additive: + // absent, not null). let clean = GcSummary::vendor_only(VendorGcSummary::default()); assert!(clean.skipped.is_none()); - assert!( - clean.to_apply_json().get("skipped").is_none(), - "{}", - clean.to_apply_json() - ); - // This pass's own reason wins over the vendored half's marker. + let clean_json = clean.to_apply_json(); + assert!(clean_json.get("skipped").is_none(), "{clean_json}"); + assert!(clean_json.get("warnings").is_none(), "{clean_json}"); + // This pass's own reason wins over the vendored half's skip. let mut own = GcSummary { skipped: Some(("lock_io", "failed to open lock file".to_string())), ..Default::default() }; own.absorb_vendor_gc(VendorGcSummary { - failed: vec!["vendor GC skipped: another socket-patch run holds the apply lock".into()], + skipped: Some(("lock_held", LOCK_MARKER.into())), ..Default::default() }); assert_eq!(own.skipped.as_ref().map(|(c, _)| *c), Some("lock_io")); + // A `lock_io` fault or a failed rewrite in the vendored half is + // never reported as contention (the pre-fix `starts_with("pkg:")` + // partition labelled every non-purl marker `lock_held`). + let mut io = GcSummary::default(); + io.absorb_vendor_gc(VendorGcSummary { + skipped: Some(("lock_io", "could not open apply.lock".into())), + write_failures: vec![("manifest_write_failed", "could not update manifest".into())], + ..Default::default() + }); + assert_eq!(io.skipped.as_ref().map(|(c, _)| *c), Some("lock_io")); + assert_eq!(io.to_apply_json()["warnings"][0]["code"], "manifest_write_failed"); + assert!(io.vendored_failed.is_empty()); } } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index a0700899..68ec4642 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1152,8 +1152,7 @@ pub(crate) async fn run_redirect_selected( if !candidates.iter().any(|c| takeover_capable(&c.purl)) { // No takeover-capable candidates — nothing to reconcile. } else { - use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; - let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + use socket_patch_core::utils::purl::{canonical_purl as canon, strip_purl_qualifiers}; // Loaded ONCE and mutated in place per reverted purl (the wet loop // saves after each revert): this run holds the apply lock, so no // other writer can move the on-disk ledger under it. diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 6f75f42d..25849544 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -17,7 +17,7 @@ use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::telemetry::{track_patch_scan_failed, track_patch_scanned}; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::io::IsTerminal; use std::path::Path; @@ -2324,6 +2324,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { return boxed_vendor_json_path( &args, &api_client, + use_public_proxy, effective_org_slug, &all_packages_with_patches, can_access_paid_patches, @@ -2657,22 +2658,6 @@ pub async fn run(mut args: ScanArgs) -> i32 { return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } - // Vendor mode: pre-verify baselines so a content mismatch surfaces - // BEFORE the confirm prompt (vendoring still proceeds for these — - // the stage force-applies the verified patched content). - let mismatched_baselines: HashSet = if vendor && !args.common.silent { - preverify_vendor_baselines( - &api_client, - effective_org_slug, - &selected, - &filtered_crawled, - &lockfile_only.purls, - ) - .await - } else { - HashSet::new() - }; - // Display detailed summary of selected patches before confirming // (presentational only — skipped wholesale under --silent). if !args.common.silent { @@ -2715,11 +2700,6 @@ pub async fn run(mut args: ScanArgs) -> i32 { patch.tier.to_uppercase(), sev_colored, ); - if mismatched_baselines.contains(&patch.uuid) { - println!( - " (installed content differs from patch baseline — will vendor patched content)" - ); - } if !vuln_ids.is_empty() { println!(" Fixes: {}", vuln_ids.join(", ")); } @@ -2786,7 +2766,39 @@ pub async fn run(mut args: ScanArgs) -> i32 { && !args.prune && !args.common.yes && !crate::output::stdin_is_tty(); - if report_only || !confirm(&prompt, true, args.common.yes, false) { + if report_only { + if !args.common.silent { + print_get_hint(false); + } + return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + } + + // Vendor mode: pre-verify baselines so a content mismatch surfaces + // BEFORE the confirm prompt (vendoring still proceeds for these — the + // stage force-applies the verified patched content). Runs after the + // dry-run return above so a preview fetches no views; the views it + // does fetch seed the download phase, which never fetches them again. + let prefetched = if vendor && !args.common.silent { + let (mismatched, views) = preverify_vendor_baselines( + &api_client, + effective_org_slug, + &selected, + &filtered_crawled, + &lockfile_only.purls, + ) + .await; + for patch in selected.iter().filter(|p| mismatched.contains(&p.uuid)) { + println!( + " {}: installed content differs from patch baseline — will vendor patched content", + normalize_purl(&patch.purl) + ); + } + views + } else { + HashMap::new() + }; + + if !confirm(&prompt, true, args.common.yes, false) { if !args.common.silent { print_get_hint(false); } @@ -2807,8 +2819,11 @@ pub async fn run(mut args: ScanArgs) -> i32 { // JSON path (see `run_vendor_json_path`). boxed_vendor_interactive_path( &args, + &api_client, + use_public_proxy, &selected, ¶ms, + prefetched, &manifest_path, &socket_dir, &scanned_purls, diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index e39a20d0..3a1696d3 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -5,23 +5,26 @@ //! out of `run`'s poll frame (Windows 1 MiB main-thread stack). //! //! Vendored mode is manifest-free: the download phase fetches the patch -//! records in memory ([`download_patch_records`]), the vendor engine +//! records in memory ([`download_patch_records_with`]), the vendor engine //! embeds each record in its ledger entry (`detached: true`), and //! `.socket/manifest.json` is never written — a project vendored by an //! older, manifest-mode CLI is migrated on its next vendored run (see //! [`migrate_legacy_manifest_records`]). `--detached` is accepted as a //! no-op for compatibility. +//! +//! One API client per run: `scan`/`get` build it once (proxy fallback +//! included) and thread it through the download phase and into the vendor +//! engine's service config; the views the download phase fetched seed the +//! in-memory stager, so no view is fetched twice. -use socket_patch_core::api::client::get_api_client_with_overrides; -use socket_patch_core::api::types::{BatchPackagePatches, PatchSearchResult}; +use socket_patch_core::api::client::ApiClient; +use socket_patch_core::api::types::{BatchPackagePatches, PatchResponse, PatchSearchResult}; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::apply_lock; use socket_patch_core::telemetry::track_patch_vendor_failed; use socket_patch_core::utils::purl::strip_purl_qualifiers; -use socket_patch_core::vendor::{ - load_state, lookup_entry, save_state, VendorServiceConfig, VendorSource, VendorState, -}; +use socket_patch_core::vendor::{load_state, lookup_entry, save_state, VendorState}; use std::collections::{HashMap, HashSet}; use std::path::Path; use std::time::Duration; @@ -29,7 +32,7 @@ use std::time::Duration; use crate::args::GlobalArgs; use crate::commands::bun_preflight::bun_vendor_preflight_with_ledger; use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; -use crate::commands::get::{download_patch_records, DownloadParams}; +use crate::commands::get::{download_patch_records_with, DetachedDownload, DownloadParams}; use crate::commands::lock_cli::lock_failure; use crate::commands::vendor::{ note_classic_migration_risk, track_outcomes_for_vendor, vendor_records, @@ -147,39 +150,15 @@ pub(crate) fn print_dry_run_refusals(preview: &serde_json::Value) { } } -/// Build the vendoring-service config for scan's vendored flow — the SAME -/// shape the standalone `vendor` command builds (see `vendor::run`), so both -/// entry points honor `--vendor-source` / `--vendor-url` / -/// `--patch-server-url` and commit byte-identical artifacts + lock integrity -/// for the same patch. `vendor_source` was validated by clap, so the parse -/// cannot fail; fall back to the `auto` default defensively — the SAME -/// default as the `vendor` command (service download), not build-only. -/// -/// `client` / `use_public_proxy` come from the run-level API client. A pure -/// assembler (no async, no network) so the flow's byte-for-byte parity with -/// the `vendor` command is unit-testable without a live client. -fn scan_vendor_service_config( - common: &GlobalArgs, - client: Option, - use_public_proxy: bool, -) -> VendorServiceConfig { - VendorServiceConfig { - source: VendorSource::parse(&common.vendor_source).unwrap_or_default(), - client, - use_public_proxy, - vendor_url: common.vendor_url.clone(), - patch_server_url: common.patch_server_url.clone(), - offline: common.offline, - } -} - /// The vendor step shared by `scan --vendor`'s JSON and interactive arms /// (and, through [`boxed_scan_vendor_step`], `get --mode vendored`): /// acquire the apply lock, stage the in-memory `records` (from -/// [`download_patch_records`]), drive [`vendor_records`] detached — every -/// ledger entry embeds its record; `.socket/manifest.json` is never a -/// record source — then migrate any legacy manifest records the ledger now -/// owns and run the run-level advisories, all under the lock. +/// [`download_patch_records_with`], whose blob `seed` spares the stager a +/// second view fetch), drive [`vendor_records`] detached — every ledger +/// entry embeds its record; `.socket/manifest.json` is never a record +/// source — over the run's `client`, then migrate any legacy manifest +/// records the ledger now owns and run the run-level advisories, all under +/// the lock. /// /// An empty `records` map (nothing selected, or everything refused/failed /// in the download phase) is a no-op BEFORE the lock: nothing is staged, @@ -202,6 +181,9 @@ async fn run_scan_vendor_step( manifest_path: &Path, socket_dir: &Path, records: HashMap, + seed: HashMap>, + client: ApiClient, + use_public_proxy: bool, ) -> VendorStepResult { let mut env = Envelope::new(EnvelopeCommand::Vendor); env.dry_run = common.dry_run; @@ -224,7 +206,17 @@ async fn run_scan_vendor_step( patches: records, setup: None, }; - let has_errors = match stage_and_vendor(common, socket_dir, &manifest, &mut env).await { + let has_errors = match stage_and_vendor( + common, + socket_dir, + &manifest, + seed, + client, + use_public_proxy, + &mut env, + ) + .await + { Ok(has_errors) => has_errors, Err((code, message)) => { // The step ran and is aborting: hand its envelope (demoted) to @@ -243,18 +235,33 @@ async fn run_scan_vendor_step( Ok((has_errors, env)) } -/// Stage `manifest`'s patch sources in memory and drive the vendor engine -/// over them (detached: every entry embeds its record). The caller holds -/// the apply lock. `Err` is the `no_local_source` fold (staging could not -/// obtain the patch content — offline, or the view fetch failed). +/// Stage `manifest`'s patch sources in memory (seeded with the download +/// phase's blobs, harvesting the committed artifacts the ledger names for +/// the rest) and drive the vendor engine over them (detached: every entry +/// embeds its record). The caller holds the apply lock. `Err` is the +/// `no_local_source` fold (staging could not obtain the patch content — +/// offline, or the view fetch failed). async fn stage_and_vendor( common: &GlobalArgs, socket_dir: &Path, manifest: &PatchManifest, + seed: HashMap>, + client: ApiClient, + use_public_proxy: bool, env: &mut Envelope, ) -> Result { - let staged = match stage_vendor_sources_in_memory(common, manifest, socket_dir, &common.cwd) - .await + // Loaded under the lock for the staging harvest; an unreadable ledger + // harvests nothing and is the engine's report. + let ledger = load_state(&common.cwd).await; + let staged = match stage_vendor_sources_in_memory( + common, + manifest, + socket_dir, + &common.cwd, + ledger.as_ref().map(|s| &s.entries), + seed, + ) + .await { MemStageOutcome::Ready(s) => s, MemStageOutcome::Unavailable => { @@ -266,15 +273,12 @@ async fn stage_and_vendor( }; let sources = staged.as_patch_sources(); // Honor `--vendor-source` (and `--vendor-url` / `--patch-server-url`) - // exactly as the `vendor` command does: build the SAME service config so - // `scan --mode vendored` and a plain `vendor` commit byte-identical - // artifacts by default (both service-download under `auto`) instead of - // scan silently building locally. Built here (once, from the run-level - // flags) on the already-boxed scan-vendored frame; dry runs never reach - // this step, so there is no wasted client build in preview mode. - let (client, use_public_proxy) = - get_api_client_with_overrides(common.api_client_overrides()).await; - let service = scan_vendor_service_config(common, Some(client), use_public_proxy); + // exactly as the `vendor` command does: the SAME service-config + // assembler, over the run's one client, so `scan --mode vendored` and a + // plain `vendor` commit byte-identical artifacts by default (both + // service-download under `auto`) instead of scan silently building + // locally. + let service = common.vendor_service_config(Some(client), use_public_proxy); Ok(boxed_vendor_records(common, &manifest.patches, &sources, Some(&service), env).await) } @@ -437,7 +441,8 @@ async fn migrate_legacy_manifest_records( #[allow(clippy::too_many_arguments)] async fn run_vendor_json_path( args: &ScanArgs, - api_client: &socket_patch_core::api::client::ApiClient, + api_client: &ApiClient, + use_public_proxy: bool, effective_org_slug: Option<&str>, all_packages_with_patches: &[BatchPackagePatches], can_access_paid_patches: bool, @@ -502,14 +507,23 @@ async fn run_vendor_json_path( let params = download_params( args, /*save_only=*/ true, /*json=*/ true, /*silent=*/ true, ); - let (dl_code, dl_json, records) = boxed_download_patch_records(&selected, ¶ms).await; + let (dl_code, dl_json, records, blobs) = + boxed_download_patch_records(&selected, ¶ms, api_client, HashMap::new()).await; let mut has_errors = dl_code != 0; result["download"] = dl_json; // 2) The vendor engine, under the same lock as apply/vendor (a no-op // that creates nothing when there is nothing to vendor). - let vendor_code = match boxed_scan_vendor_step(&args.common, manifest_path, socket_dir, records) - .await + let vendor_code = match boxed_scan_vendor_step( + &args.common, + manifest_path, + socket_dir, + records, + blobs, + api_client.clone(), + use_public_proxy, + ) + .await { Ok((vendor_errors, venv)) => { has_errors |= vendor_errors; @@ -579,13 +593,18 @@ async fn run_vendor_json_path( } /// The `scan --vendor` interactive arm: download → vendor engine → GC, -/// with human-readable output. Extracted + boxed for the same +/// with human-readable output. `prefetched` holds the views the pre-prompt +/// baseline check already fetched (uuid-keyed), so the download phase +/// serves those records from memory. Extracted + boxed for the same /// Windows-1-MiB-poll-frame reason as [`run_vendor_json_path`]. #[allow(clippy::too_many_arguments)] async fn run_vendor_interactive_path( args: &ScanArgs, + api_client: &ApiClient, + use_public_proxy: bool, selected: &[PatchSearchResult], params: &DownloadParams, + prefetched: HashMap, manifest_path: &Path, socket_dir: &Path, scanned_purls: &HashSet, @@ -594,9 +613,19 @@ async fn run_vendor_interactive_path( telemetry_token: Option<&str>, telemetry_org: Option<&str>, ) -> i32 { - let (dl_code, _, records) = boxed_download_patch_records(selected, params).await; + let (dl_code, _, records, blobs) = + boxed_download_patch_records(selected, params, api_client, prefetched).await; let mut has_errors = dl_code != 0; - let code = match boxed_scan_vendor_step(&args.common, manifest_path, socket_dir, records).await + let code = match boxed_scan_vendor_step( + &args.common, + manifest_path, + socket_dir, + records, + blobs, + api_client.clone(), + use_public_proxy, + ) + .await { Ok((vendor_errors, venv)) => { has_errors |= vendor_errors; @@ -719,7 +748,8 @@ pub(super) fn fold_vendored_skips_into_apply( #[allow(clippy::too_many_arguments)] pub(super) fn boxed_vendor_json_path<'a>( args: &'a ScanArgs, - api_client: &'a socket_patch_core::api::client::ApiClient, + api_client: &'a ApiClient, + use_public_proxy: bool, effective_org_slug: Option<&'a str>, all_packages_with_patches: &'a [BatchPackagePatches], can_access_paid_patches: bool, @@ -735,6 +765,7 @@ pub(super) fn boxed_vendor_json_path<'a>( Box::pin(run_vendor_json_path( args, api_client, + use_public_proxy, effective_org_slug, all_packages_with_patches, can_access_paid_patches, @@ -754,8 +785,11 @@ pub(super) fn boxed_vendor_json_path<'a>( #[allow(clippy::too_many_arguments)] pub(super) fn boxed_vendor_interactive_path<'a>( args: &'a ScanArgs, + api_client: &'a ApiClient, + use_public_proxy: bool, selected: &'a [PatchSearchResult], params: &'a DownloadParams, + prefetched: HashMap, manifest_path: &'a Path, socket_dir: &'a Path, scanned_purls: &'a HashSet, @@ -766,8 +800,11 @@ pub(super) fn boxed_vendor_interactive_path<'a>( ) -> std::pin::Pin + 'a>> { Box::pin(run_vendor_interactive_path( args, + api_client, + use_public_proxy, selected, params, + prefetched, manifest_path, socket_dir, scanned_purls, @@ -783,35 +820,41 @@ pub(super) fn boxed_vendor_interactive_path<'a>( /// embeds the entire vendor engine, and the vendor-path frames it would /// otherwise ride must themselves fit Windows' 1 MiB main-thread stack /// (same rationale as [`boxed_vendor_json_path`], one level down). Moving -/// the records map into the future is stack-neutral (three words). +/// the records and seed maps and the client into the future is +/// stack-neutral (a few words each). +#[allow(clippy::too_many_arguments)] pub(crate) fn boxed_scan_vendor_step<'a>( common: &'a GlobalArgs, manifest_path: &'a Path, socket_dir: &'a Path, records: HashMap, + seed: HashMap>, + client: ApiClient, + use_public_proxy: bool, ) -> std::pin::Pin + 'a>> { Box::pin(run_scan_vendor_step( common, manifest_path, socket_dir, records, + seed, + client, + use_public_proxy, )) } /// Transient-frame boxed constructor for the download-phase future used /// inside the vendor paths, so the frame fits Windows' 1 MiB main-thread /// stack (same rationale as [`boxed_vendor_json_path`]). -#[allow(clippy::type_complexity)] fn boxed_download_patch_records<'a>( selected: &'a [PatchSearchResult], params: &'a DownloadParams, -) -> std::pin::Pin< - Box< - dyn std::future::Future)> - + 'a, - >, -> { - Box::pin(download_patch_records(selected, params)) + api_client: &'a ApiClient, + prefetched: HashMap, +) -> std::pin::Pin + 'a>> { + Box::pin(download_patch_records_with( + selected, params, api_client, prefetched, + )) } /// Transient-frame boxed constructor for the vendor engine itself @@ -821,7 +864,7 @@ fn boxed_vendor_records<'a>( common: &'a GlobalArgs, records: &'a HashMap, sources: &'a socket_patch_core::patch::apply::PatchSources<'a>, - service: Option<&'a VendorServiceConfig>, + service: Option<&'a socket_patch_core::vendor::VendorServiceConfig>, env: &'a mut Envelope, ) -> std::pin::Pin + 'a>> { // `scan --vendor` threads the SAME service config the `vendor` command @@ -1050,78 +1093,6 @@ mod migration_tests { } } -#[cfg(test)] -mod service_config_tests { - use super::*; - use crate::args::GlobalArgs; - - fn common_with_source(source: &str) -> GlobalArgs { - GlobalArgs { - vendor_source: source.to_string(), - ..Default::default() - } - } - - /// Regression: scan's vendored flow must build its service config FROM - /// `--vendor-source`, not hardcode build-only (the pre-fix `service = - /// None`). Under the default (`auto`), the config must permit the - /// vendoring service exactly as the `vendor` command's default does — - /// otherwise `scan --mode vendored` silently builds locally while a - /// plain `vendor` service-downloads, and the two commit different bytes / - /// lock integrity for the same patch (lock churn / merge conflicts). - #[test] - fn default_source_permits_service_like_vendor_command() { - let common = common_with_source("auto"); - let cfg = scan_vendor_service_config(&common, None, false); - assert_eq!(cfg.source, VendorSource::Auto); - assert!( - cfg.source.may_use_service(), - "default scan --vendor must be able to use the service (matching `vendor`)" - ); - assert!(!cfg.source.requires_service()); - } - - /// `--vendor-source service` must reach the fail-closed service path, - /// exactly as the `vendor` command interprets the same flag. - #[test] - fn service_source_requires_service() { - let common = common_with_source("service"); - let cfg = scan_vendor_service_config(&common, None, false); - assert_eq!(cfg.source, VendorSource::Service); - assert!(cfg.source.requires_service()); - } - - /// `--vendor-source build` stays build-only (never contacts the service). - #[test] - fn build_source_never_uses_service() { - let common = common_with_source("build"); - let cfg = scan_vendor_service_config(&common, None, false); - assert_eq!(cfg.source, VendorSource::Build); - assert!(!cfg.source.may_use_service()); - } - - /// The service overrides (`--vendor-url` / `--patch-server-url` / - /// `--offline`) thread through unchanged, so scan and `vendor` target the - /// same hosts. - #[test] - fn overrides_thread_through() { - let common = GlobalArgs { - vendor_source: "service".to_string(), - vendor_url: Some("https://vendor.example".to_string()), - patch_server_url: Some("https://patch.example".to_string()), - offline: true, - ..Default::default() - }; - let cfg = scan_vendor_service_config(&common, None, false); - assert_eq!(cfg.vendor_url.as_deref(), Some("https://vendor.example")); - assert_eq!( - cfg.patch_server_url.as_deref(), - Some("https://patch.example") - ); - assert!(cfg.offline); - } -} - #[cfg(test)] mod preview_tests { use super::preview_vendor_json; diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 3674241f..0ea23305 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -26,12 +26,12 @@ use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{verify_file_patch, PatchSources}; use socket_patch_core::patch::apply_lock::{self, LockError}; use socket_patch_core::telemetry::{track_patch_vendor_failed, track_patch_vendored}; -use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; +use socket_patch_core::utils::purl::{canonical_purl, normalize_purl, strip_purl_qualifiers}; use socket_patch_core::utils::socket_dir::remove_tree_and_prune; use socket_patch_core::vendor::{ self, ecosystem_dir_for_purl, load_state, lock_inventory, lookup_entry, registry_fetch, save_state, RevertOpts, RevertOutcome, VendorEntry, VendorOutcome, VendorServiceConfig, - VendorSource, VendorState, VendorWarning, + VendorState, VendorWarning, }; use socket_patch_core::vex::time::now_rfc3339; use std::collections::{HashMap, HashSet}; @@ -43,6 +43,7 @@ use crate::commands::apply::{representative_file, result_to_event, variant_match use crate::commands::bun_preflight::bun_vendor_preflight_pairs; use crate::commands::fetch_stage::{stage_vendor_sources_in_memory, MemStageOutcome}; use crate::commands::lock_cli::{acquire_or_emit, lock_failure}; +use crate::commands::rollback::VendorRevertStep; use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls}; use crate::json_envelope::{ @@ -421,14 +422,8 @@ pub async fn run(args: VendorArgs) -> i32 { get_api_client_with_overrides(args.common.api_client_overrides()).await; let telemetry_ids = (client.api_token().cloned(), client.org_slug().cloned()); Some(( - VendorServiceConfig { - source: VendorSource::parse(&args.common.vendor_source).unwrap_or_default(), - client: Some(client), - use_public_proxy, - vendor_url: args.common.vendor_url.clone(), - patch_server_url: args.common.patch_server_url.clone(), - offline: args.common.offline, - }, + args.common + .vendor_service_config(Some(client), use_public_proxy), telemetry_ids, )) }; @@ -598,24 +593,34 @@ async fn run_vendor( // Reconcile first (mirrors apply's placement): entries vendored by a // previous run whose patches were dropped from the manifest are reverted - // even when zero in-scope patches remain. - let mut has_errors = reconcile_dropped(&manifest, common, env).await; + // even when zero in-scope patches remain. Its post-reconcile ledger + // feeds the staging harvest below (one load, not two). + let (mut has_errors, ledger) = reconcile_dropped(&manifest, common, env).await; let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); // Vendor stages patch content IN MEMORY: existing .socket artifacts are // read in place, missing content is fetched per patch — vendoring never - // writes blobs or temp files (the committed artifact is the patch). - let staged = - match stage_vendor_sources_in_memory(common, &manifest, socket_dir, &common.cwd).await { - MemStageOutcome::Ready(s) => s, - MemStageOutcome::Unavailable => { - env.mark_error(EnvelopeError::new( - "no_local_source", - "patch artifacts unavailable (offline or download failure)", - )); - return 1; - } - }; + // writes blobs or temp files (the committed artifact is the patch). No + // seed: this manifest-driven command has no download phase. + let staged = match stage_vendor_sources_in_memory( + common, + &manifest, + socket_dir, + &common.cwd, + ledger.as_ref().map(|s| &s.entries), + HashMap::new(), + ) + .await + { + MemStageOutcome::Ready(s) => s, + MemStageOutcome::Unavailable => { + env.mark_error(EnvelopeError::new( + "no_local_source", + "patch artifacts unavailable (offline or download failure)", + )); + return 1; + } + }; let sources = staged.as_patch_sources(); has_errors |= vendor_records( @@ -906,7 +911,7 @@ pub(crate) async fn vendor_records( let crawler = socket_patch_core::crawlers::npm_crawler::NpmCrawler::new(); for package in crawler.crawl_all(&crawler_options).await { for purl in &missing_npm { - if normalize_purl(strip_purl_qualifiers(purl)) == normalize_purl(&package.purl) { + if canonical_purl(purl) == normalize_purl(&package.purl) { all_packages .entry(purl.clone()) .or_insert_with(|| package.path.clone()); @@ -1232,10 +1237,11 @@ pub(crate) async fn vendor_records( } continue; } - let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); - let claimed = redirect_ledger - .as_ref() - .is_some_and(|l| l.records.keys().any(|k| canon(k) == canon(candidate))); + let claimed = redirect_ledger.as_ref().is_some_and(|l| { + l.records + .keys() + .any(|k| canonical_purl(k) == canonical_purl(candidate)) + }); if claimed && common.dry_run { // Probe the takeover exactly as the wet run would — the // per-purl revert's dry run resolves every inverse and @@ -1653,15 +1659,17 @@ fn manifest_dropped_purls( } /// Revert vendored entries whose patches were dropped from the manifest. -/// Shared with `scan --vendor` (which runs the same engine in-process). +/// Returns `(had_error, ledger)`: the post-reconcile ledger load, for the +/// caller's staging harvest (an unreadable ledger is `Err` — reported by +/// the engine, not here). pub(crate) async fn reconcile_dropped( manifest: &PatchManifest, common: &GlobalArgs, env: &mut Envelope, -) -> bool { +) -> (bool, std::io::Result) { let mut state = match load_state(&common.cwd).await { Ok(s) => s, - Err(_) => return false, // unreadable state is reported by the main path + Err(e) => return (false, Err(e)), }; let stale = manifest_dropped_purls(&state, manifest, common); let mut had_error = false; @@ -1715,7 +1723,7 @@ pub(crate) async fn reconcile_dropped( ); } } - had_error + (had_error, Ok(state)) } async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { @@ -1735,50 +1743,58 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { let mut recorded: Vec = state.entries.keys().cloned().collect(); recorded.sort(); + // The one vendored-revert primitive every reverting command shares + // (rollback's vendored leg, both of remove's paths): dispatch → + // drift-keep → per-entry ledger save. Only the event vocabulary and + // the human lines are this command's. for purl in &recorded { - let entry = state.entries.get(purl).cloned().expect("key listed above"); - let outcome = dispatch_revert_one(&entry, &common.cwd, common.dry_run).await; - for w in &outcome.warnings { + let result = crate::commands::rollback::revert_vendor_entry( + &common.cwd, + purl, + &mut state, + RevertOpts::new(common.dry_run), + ) + .await; + for w in &result.warnings { record_warning(env, purl, w, common); } - if outcome.success { - if outcome.kept_artifact { - // Drift-skip keep (residual #131): the backend left the - // drifted lock alone and kept the artifacts, so the ledger - // entry must survive too — and the genuine outcome is a - // COUNTED skip, not a removal. (`record_warning` above - // already surfaced the per-record details as uncounted - // advisory events.) + match result.step { + // Every key came from this ledger; `--revert` never preserves. + VendorRevertStep::Missing | VendorRevertStep::Preserved => {} + VendorRevertStep::Failed(why) => { + has_errors = true; env.record( - PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason( - "vendor_revert_kept", - "lock entries drifted since vendoring; artifacts and ledger entry kept \ - — undo the drift and re-run `vendor --revert` to finish", - ), + PatchEvent::new(PatchAction::Failed, purl.clone()) + .with_error("revert_failed", why), ); - continue; - } - env.record(PatchEvent::new(PatchAction::Removed, purl.clone())); - if !common.dry_run { - state.entries.remove(purl); - if let Err(e) = save_state(&common.cwd, &state).await { - has_errors = true; - env.record( - PatchEvent::new(PatchAction::Failed, purl.clone()) - .with_error("vendor_state_write_failed", e.to_string()), - ); + if !common.silent && !common.json { + eprintln!("Failed to revert {purl}"); } } - } else { - has_errors = true; - env.record( - PatchEvent::new(PatchAction::Failed, purl.clone()).with_error( - "revert_failed", - outcome.error.unwrap_or_else(|| "unknown error".into()), + // Drift-skip keep (residual #131): the backend left the + // drifted lock alone and kept the artifacts, so the ledger + // entry must survive too — and the genuine outcome is a + // COUNTED skip, not a removal. (`record_warning` above + // already surfaced the per-record details as uncounted + // advisory events.) + VendorRevertStep::Kept => env.record( + PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason( + "vendor_revert_kept", + "lock entries drifted since vendoring; artifacts and ledger entry kept \ + — undo the drift and re-run `vendor --revert` to finish", ), - ); - if !common.silent && !common.json { - eprintln!("Failed to revert {purl}"); + ), + VendorRevertStep::WouldRevert | VendorRevertStep::Reverted => { + env.record(PatchEvent::new(PatchAction::Removed, purl.clone())); + } + // Reverted on disk; the record of it could not be persisted. + VendorRevertStep::LedgerWriteFailed(e) => { + env.record(PatchEvent::new(PatchAction::Removed, purl.clone())); + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, purl.clone()) + .with_error("vendor_state_write_failed", e), + ); } } } @@ -1868,10 +1884,19 @@ pub(crate) struct VendorGcSummary { pub kept: Vec, /// (c) orphan uuid dirs (no owning ledger entry) swept. pub orphan_dirs: usize, - /// Entries that could not be reverted (kept in the ledger), plus any - /// pass-level marker: the lock skip (`lock_held` contention or a - /// `lock_io` fault) and a failed post-revert ledger/manifest rewrite. + /// Entries (ledger keys) that could not be reverted — kept in the + /// ledger, nothing reclaimed. Only purls; the pass-level outcomes below + /// have their own fields. pub failed: Vec, + /// Set when a wet pass skipped ALL its work because the apply lock + /// could not be taken: `("lock_held", )` for a live holder, + /// `lock_cli::lock_failure`'s `("lock_io", )` for a lock file + /// that could not be created or opened. Never set on dry runs. + pub skipped: Option<(&'static str, String)>, + /// Post-revert rewrites that failed: `("vendor_state_write_failed" | + /// "manifest_write_failed", )`. The reverts themselves already + /// happened on disk; the stale record is what the caller must report. + pub write_failures: Vec<(&'static str, String)>, } /// The vendored-state GC behind `scan --prune`: @@ -1904,16 +1929,68 @@ pub(crate) struct VendorGcSummary { /// /// Wet runs take the apply lock (lockfiles + the manifest are rewritten), /// honoring `--lock-timeout`; a live holder records the contention skip -/// marker and returns — it never fails the scan — while a lock that cannot -/// even be opened (`lock_io`) records a distinct marker and a human-mode -/// warning. The ledger and manifest are rewritten only when a pass removed -/// something; a failed rewrite is recorded as a pass-level marker (the -/// reverts themselves already happened on disk). Dry runs are read-only, -/// lock-free, and list-only. +/// ([`VendorGcSummary::skipped`]) and returns — it never fails the scan — +/// while a lock that cannot even be opened (`lock_io`) records the distinct +/// reason plus a human-mode warning. The ledger and manifest are rewritten +/// only when a pass removed something; a failed rewrite is recorded in +/// [`VendorGcSummary::write_failures`] (the reverts themselves already +/// happened on disk). Dry runs are read-only, lock-free, and list-only. +/// +/// A caller already holding the apply lock (`scan --prune`'s manifest +/// prune runs under the same guard) uses [`run_vendor_gc_locked`]: flock is +/// per open file description, so a nested acquire here would read as a +/// live holder and silently skip every revert. pub(crate) async fn run_vendor_gc( common: &GlobalArgs, manifest_path: &Path, dry_run: bool, +) -> VendorGcSummary { + if dry_run { + return run_vendor_gc_locked(common, manifest_path, true).await; + } + // Existence gate BEFORE the lock (`acquire` creates `.socket/`): a + // project with no ledger entries has nothing this pass could reclaim. + if !load_state(&common.cwd) + .await + .is_ok_and(|s| !s.entries.is_empty()) + { + return VendorGcSummary::default(); + } + let socket_dir = crate::args::socket_dir_of(manifest_path, &common.cwd); + let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); + let _guard = match apply_lock::acquire(&socket_dir, timeout) { + Ok(g) => g, + Err(LockError::Held) => { + return VendorGcSummary { + skipped: Some(( + "lock_held", + "vendor GC skipped: another socket-patch run holds the apply lock".to_string(), + )), + ..Default::default() + }; + } + Err(e) => { + // Not contention: a file squatting on `.socket/`, a directory + // on `apply.lock`, a permissions problem. Mislabelling it as + // a live holder would hide a real fault behind a benign skip. + let (code, message) = lock_failure(&e, timeout); + gc_note(common, code, &format!("vendor GC skipped: {message}")); + return VendorGcSummary { + skipped: Some((code, message)), + ..Default::default() + }; + } + }; + run_vendor_gc_locked(common, manifest_path, false).await +} + +/// [`run_vendor_gc`]'s body, lock-free: the caller holds the apply lock +/// for a wet pass (or `dry_run` is set, which needs none). Re-reads the +/// ledger itself — under the caller's lock it is the authoritative copy. +pub(crate) async fn run_vendor_gc_locked( + common: &GlobalArgs, + manifest_path: &Path, + dry_run: bool, ) -> VendorGcSummary { let mut out = VendorGcSummary::default(); let mut state = match load_state(&common.cwd).await { @@ -1923,35 +2000,6 @@ pub(crate) async fn run_vendor_gc( _ => return out, }; - let socket_dir = manifest_path - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| common.cwd.clone()); - let _guard = if dry_run { - None - } else { - let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); - match apply_lock::acquire(&socket_dir, timeout) { - Ok(g) => Some(g), - Err(LockError::Held) => { - out.failed.push( - "vendor GC skipped: another socket-patch run holds the apply lock".to_string(), - ); - return out; - } - Err(e) => { - // Not contention: a file squatting on `.socket/`, a directory - // on `apply.lock`, a permissions problem. Mislabelling it as - // a live holder would hide a real fault behind a benign skip. - let (code, message) = lock_failure(&e, timeout); - gc_note(common, code, &format!("vendor GC skipped: {message}")); - out.failed - .push(format!("vendor GC skipped ({code}): {message}")); - return out; - } - } - }; - // (a) manifest-dropped entries. Everything (a) touches is excluded from // (b): in a dry run the ledger keeps the entry, and after a wet revert // failure it does too — either way (b) would list/fail the same purl a @@ -2053,7 +2101,8 @@ pub(crate) async fn run_vendor_gc( .socket/vendor/state.json: {e}" ); gc_note(common, "vendor_state_write_failed", &detail); - out.failed.push(format!("vendor GC: {detail}")); + out.write_failures + .push(("vendor_state_write_failed", detail)); } } if manifest_dirty { @@ -2064,7 +2113,7 @@ pub(crate) async fn run_vendor_gc( manifest_path.display() ); gc_note(common, "manifest_write_failed", &detail); - out.failed.push(format!("vendor GC: {detail}")); + out.write_failures.push(("manifest_write_failed", detail)); } } } @@ -2080,8 +2129,8 @@ pub(crate) async fn run_vendor_gc( } /// Human-mode stderr line for a pass-level GC problem (the GC has no -/// envelope of its own; JSON consumers see the marker in -/// [`VendorGcSummary::failed`]). Muted under `--json` and `--silent`. +/// envelope of its own; JSON consumers see it as `scan --prune --json`'s +/// `gc.skipped` / `gc.warnings`). Muted under `--json` and `--silent`. fn gc_note(common: &GlobalArgs, code: &str, detail: &str) { if !common.json && !common.silent { eprintln!("Warning ({code}): {detail}"); @@ -2117,14 +2166,12 @@ mod dispatch_tests { diffs_path: None, mem_blobs: None, }; - let service = VendorServiceConfig { - source: VendorSource::Service, - client: None, - use_public_proxy: false, - vendor_url: None, - patch_server_url: None, - offline: false, - }; + let service = GlobalArgs { + vendor_source: "service".to_string(), + ..Default::default() + } + .vendor_service_config(None, false); + assert_eq!(service.source, VendorSource::Service); let outcome = dispatch_vendor_one( "pkg:maven/org.apache.logging.log4j/log4j-core@2.17.0", tmp.path(), @@ -2765,15 +2812,20 @@ mod gc_tests { .unwrap(); let out = run_vendor_gc(&common, &manifest_path, false).await; - assert_eq!(out.failed.len(), 1, "{out:?}"); + let (code, message) = out.skipped.as_ref().expect("the I/O fault is the skip reason"); + assert_eq!(*code, "lock_io", "{out:?}"); assert!( - out.failed[0].contains("lock_io") && out.failed[0].contains("apply.lock"), + message.contains("apply.lock"), "an I/O fault is reported as lock_io naming the path: {out:?}" ); assert!( - !out.failed[0].contains("holds the apply lock"), + !message.contains("holds the apply lock"), "an I/O fault must not be mislabelled as contention: {out:?}" ); + assert!( + out.failed.is_empty(), + "a pass-level skip is not a per-purl failure: {out:?}" + ); assert!(out.dropped_reverted.is_empty(), "{out:?}"); assert!( load_state(tmp.path()) @@ -3230,10 +3282,14 @@ mod gc_tests { let out = run_vendor_gc(&common, &manifest_path, false).await; assert_eq!( - out.failed, - vec!["vendor GC skipped: another socket-patch run holds the apply lock".to_string()], + out.skipped, + Some(( + "lock_held", + "vendor GC skipped: another socket-patch run holds the apply lock".to_string() + )), "{out:?}" ); + assert!(out.failed.is_empty(), "{out:?}"); assert!(out.dropped_reverted.is_empty(), "{out:?}"); assert!(out.unused_reverted.is_empty(), "{out:?}"); assert_eq!(out.orphan_dirs, 0, "{out:?}"); @@ -3259,6 +3315,23 @@ mod gc_tests { "the lock-free dry preview still lists: {dry:?}" ); assert!(dry.failed.is_empty(), "{dry:?}"); + assert!(dry.skipped.is_none(), "a dry run takes no lock: {dry:?}"); + + // A caller that already HOLDS the lock runs the body directly: the + // same held lock is no obstacle (flock is per open description, so + // a nested `run_vendor_gc` would have skipped here) and the pass + // reclaims normally. + let locked = run_vendor_gc_locked(&common, &manifest_path, false).await; + assert!(locked.skipped.is_none(), "{locked:?}"); + assert_eq!(locked.dropped_reverted, vec![PURL.to_string()], "{locked:?}"); + assert!( + !load_state(tmp.path()) + .await + .unwrap() + .entries + .contains_key(PURL), + "the locked body reverts under the caller's guard" + ); } /// (a) revert FAILURE accounting: a ledger entry whose ecosystem has no diff --git a/crates/socket-patch-cli/tests/covgap_commands_get.rs b/crates/socket-patch-cli/tests/covgap_commands_get.rs index 81d2d9fb..0c041626 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_get.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_get.rs @@ -106,11 +106,21 @@ async fn mount_view_files(server: &MockServer, uuid: &str, purl: &str, files: se /// `view/{uuid}` served exactly ONCE: the get's own fetch succeeds, and the /// vendor step's in-memory staging — which fetches the view again — then /// 404s, tripping the `no_local_source` staging refusal. -async fn mount_view_once(server: &MockServer, uuid: &str, purl: &str) { +/// A view whose files carry hashes but NO `blobContent`: the download +/// phase records it fine (hashes only), but the vendor step has nothing to +/// stage from — not in the download phase's blob seed, not on disk, and not +/// from the view it re-fetches — so it dies `no_local_source`. (Serving a +/// good view exactly once no longer produces that: the step stages from +/// the seed and never fetches the view a second time.) +async fn mount_contentless_view(server: &MockServer, uuid: &str, purl: &str) { + let mut files = good_files(); + files["package/index.js"] + .as_object_mut() + .unwrap() + .remove("blobContent"); Mock::given(method("GET")) .and(path(format!("/v0/orgs/{ORG}/patches/view/{uuid}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(view_json(uuid, purl, good_files()))) - .up_to_n_times(1) + .respond_with(ResponseTemplate::new(200).set_body_json(view_json(uuid, purl, files))) .mount(server) .await; } @@ -1540,7 +1550,7 @@ fn assert_vendor_error_envelope(v: &serde_json::Value) { #[tokio::test] async fn get_uuid_vendored_vendor_step_error_leaves_legacy_state_alone() { let server = MockServer::start().await; - mount_view_once(&server, UUID, PURL).await; + mount_contentless_view(&server, UUID, PURL).await; let tmp = tempfile::tempdir().unwrap(); let (manifest_before, state_before) = seed_legacy_state(tmp.path()); @@ -1560,7 +1570,7 @@ async fn get_uuid_vendored_vendor_step_error_leaves_legacy_state_alone() { async fn get_search_vendored_vendor_step_error_leaves_legacy_state_alone() { let server = MockServer::start().await; mount_ghsa_single(&server).await; - mount_view_once(&server, UUID, PURL).await; + mount_contentless_view(&server, UUID, PURL).await; let tmp = tempfile::tempdir().unwrap(); let (manifest_before, state_before) = seed_legacy_state(tmp.path()); @@ -1591,7 +1601,7 @@ async fn get_search_vendored_vendor_step_error_leaves_legacy_state_alone() { #[tokio::test] async fn human_vendored_uuid_prints_fetch_and_vendor_error_without_manifest_note() { let server = MockServer::start().await; - mount_view_once(&server, UUID, PURL).await; + mount_contentless_view(&server, UUID, PURL).await; let tmp = tempfile::tempdir().unwrap(); let (manifest_before, state_before) = seed_legacy_state(tmp.path()); diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index 58df29d0..1e3a6942 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -180,6 +180,16 @@ fn run_cli_env(root: &Path, argv: &[&str], extra_env: &[(&str, &str)]) -> (i32, ) } +/// How many `/patches/view/…` requests the mock has served. +async fn view_fetches(mock: &MockServer) -> usize { + mock.received_requests() + .await + .unwrap() + .iter() + .filter(|r| r.url.path().contains("/patches/view/")) + .count() +} + fn run_scan_vendor(root: &Path, mock_uri: &str, extra: &[&str]) -> (i32, String, String) { let mut argv = vec![ "scan", @@ -236,6 +246,9 @@ async fn scan_vendor_end_to_end_is_manifest_free() { !tmp.path().join(".socket/manifest.json").exists(), "vendored mode never writes a manifest" ); + // One view fetch per patch for the whole run: the download phase's + // blob content seeds the vendor stager, which never re-fetches it. + assert_eq!(view_fetches(&mock).await, 1, "the view is fetched exactly once"); // Vendor phase: a full vendor Envelope with one applied event. let venv = v["vendor"].as_object().expect("vendor sub-object"); @@ -1176,6 +1189,10 @@ async fn scan_vendor_annotates_mismatched_baseline_and_vendors_anyway() { stdout.contains("installed content differs from patch baseline"), "pre-prompt annotation present; stdout={stdout}" ); + assert!( + stdout.contains(&format!(" {PURL}: installed content differs")), + "the annotation names the purl; stdout={stdout}" + ); assert!( stderr.contains("vendor_content_mismatch_overwritten"), "overwrite warning surfaced; stderr={stderr}" @@ -1185,6 +1202,10 @@ async fn scan_vendor_annotates_mismatched_baseline_and_vendors_anyway() { .path() .join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")) .is_file()); + // The pre-verify fetched the view; the download phase served the + // record from that view and the stager from its blob content — one + // fetch for the whole interactive run, not three. + assert_eq!(view_fetches(&mock).await, 1, "the view is fetched exactly once"); } // ───────────── lockfile auto-fetch + scan lockfile supplement ───────────── diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index cd78b00c..db75ab01 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -360,6 +360,19 @@ pub(crate) async fn missing_existing_patch_files( pub async fn harvest_artifact_blobs( project_root: &Path, manifest_patches: &HashMap, +) -> HashMap> { + let Ok(state) = load_state(project_root).await else { + return HashMap::new(); + }; + harvest_artifact_blobs_from(project_root, &state.entries, manifest_patches).await +} + +/// [`harvest_artifact_blobs`] over an already-loaded ledger (`entries`), +/// for callers that hold the run's single `load_state` result. +pub async fn harvest_artifact_blobs_from( + project_root: &Path, + entries: &HashMap, + manifest_patches: &HashMap, ) -> HashMap> { use crate::hash::git_sha256::compute_git_sha256_from_bytes; @@ -367,10 +380,7 @@ pub async fn harvest_artifact_blobs( const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; let mut out: HashMap> = HashMap::new(); - let Ok(state) = load_state(project_root).await else { - return out; - }; - if state.entries.is_empty() { + if entries.is_empty() { return out; } @@ -384,9 +394,8 @@ pub async fn harvest_artifact_blobs( if needed.is_empty() { continue; } - let Some(entry) = state.entries.get(purl).or_else(|| { - state - .entries + let Some(entry) = entries.get(purl).or_else(|| { + entries .values() .find(|e| e.base_purl == strip_purl_qualifiers(purl)) }) else { diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index d74b3650..f08be952 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -370,8 +370,8 @@ pub async fn vendor_npm_any( /// `overrides:` section is excluded by the flavor probe, and the other /// flavors carry no declaration inside the lock at all). `None`: cannot /// determine (missing lock, unknown flavor) — callers keep the entry, -/// fail-safe. Detached entries are lockfile-invisible BY DESIGN and must -/// never be routed here (the probe would always call them unused). +/// fail-safe. Detached entries are wired into the lock exactly like +/// manifest-tracked ones, so the probe applies to every entry. pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Option { match entry.flavor.as_deref() { Some("pnpm") => pnpm_lock::pnpm_entry_in_use(entry, project_root).await, From 2281c0fb1104b70075f4da131720a636db1d38b7 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 21:45:09 -0400 Subject: [PATCH 22/44] refactor(cli/scan+hosted): one ledger load per hosted run, one records map, one JSON printer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosted engine (T-A7, T-A8): the vendored ledger is loaded once per run under the apply lock (hoisted out of the takeover block) and the post-write overlap classification runs over the in-memory ledgers (`classify_overlap_takeover_with` over the merged redirect ledger and the post-takeover vendor state) instead of re-reading both files; the stale install probes (gem + python) take the single merged records map, so the last `records.clone()` is gone. `overlapping_ledger_purls` becomes a test-only load-then-derive wrapper over `overlap_from_states`. T-A6 adapted: the engine keeps loading the redirect ledger itself, under the lock (a pre-loaded copy handed in by scan would be read before the lock and could merge over a concurrent writer's edits, breaking the D1 lock-before-load invariant). The duplicate corrupt-ledger report is fixed at its source instead: a hosted scan mutes the lenient `updates[]` consult's warning, so the corruption prints exactly once as the engine's hard error (pinned in in_process_redirect). scan/mod.rs: the agent arms drive `download_and_apply_patches_with` with the run's client and `--lock-timeout` (T-A11); the 2-arg `download_and_apply_patches` wrapper and `api_client_for` (now test-only) leave production, with the 15 integration-test call sites ported through a local helper (W-2). The gem bundle-store re-probe is gone: the crawl hands back its `skipped_config_path` (`RubyCrawler::crawl_all_with_discovery`, `crawl_all_ecosystems` 3-tuple — T-A10/T-G7). The zero-discovery hosted JSON arm uses hosted.rs' shared builders (T-A13); `PathScope::bind` once outside the scan and rollback hot loops (T-A14); the vendor-GC comment corrected and every `--json` envelope goes through one `output::print_json` (T-A15, W-15). core (T-A16/T-G1, W-10): `VendorState::purl_keys` is the one vendor-ledger key derivation; `vendored_purl_keys(root)` delegates and the CLI copies in scan/discovery.rs and rollback.rs (`vendored_purl_keys_of`) are deleted. Co-Authored-By: Claude Fable 5.1 --- crates/socket-patch-cli/src/commands/get.rs | 60 ++--- .../socket-patch-cli/src/commands/remove.rs | 6 +- .../socket-patch-cli/src/commands/rollback.rs | 25 +-- .../src/commands/scan/discovery.rs | 49 +--- .../src/commands/scan/hosted.rs | 106 ++++----- .../src/commands/scan/hosted/python.rs | 8 +- .../socket-patch-cli/src/commands/scan/mod.rs | 211 ++++++++++-------- .../src/commands/scan/vendor_flow.rs | 10 +- .../src/ecosystem_dispatch.rs | 17 +- crates/socket-patch-cli/src/output.rs | 10 + .../tests/covgap_commands_get.rs | 22 +- .../tests/in_process_get_update_count.rs | 20 +- .../tests/in_process_redirect.rs | 9 + .../src/crawlers/ruby_crawler.rs | 68 ++++-- crates/socket-patch-core/src/vendor/mod.rs | 30 +-- crates/socket-patch-core/src/vendor/state.rs | 45 +++- 16 files changed, 378 insertions(+), 318 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 111d1bb4..fcccce2a 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -34,7 +34,7 @@ use crate::commands::lock_cli::lock_failure; use crate::ecosystem_dispatch::{ crawl_all_ecosystems, find_packages_for_rollback, partition_purls, }; -use crate::output::{confirm, select_one, SelectError}; +use crate::output::{confirm, print_json, select_one, SelectError}; /// Best-effort ecosystem extractor for a `pkg:/...` PURL. Used as /// the telemetry `ecosystem` field. Returns an empty string when the @@ -186,14 +186,6 @@ fn merge_metadata(record: &mut serde_json::Value, meta: serde_json::Value) { } } -/// Print a `serde_json::Value` as pretty JSON to stdout. -fn print_json(v: &serde_json::Value) { - println!( - "{}", - serde_json::to_string_pretty(v).expect("serializing an in-memory JSON value cannot fail") - ); -} - /// Truncate `s` to at most `limit` displayed characters, appending an /// ellipsis when it was longer (so the result is never wider than /// `limit`). Operates on `char` boundaries, NOT bytes: a byte-index slice @@ -688,16 +680,12 @@ pub(crate) fn select_patches( }) }) .collect(); - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "status": "selection_required", - "error": format!("Multiple patches available for {purl}. Re-run with the chosen UUID as the identifier (`socket-patch get `) to select one."), - "purl": purl, - "options": options_json, - })) - .expect("serializing an in-memory JSON value cannot fail") - ); + print_json(&serde_json::json!({ + "status": "selection_required", + "error": format!("Multiple patches available for {purl}. Re-run with the chosen UUID as the identifier (`socket-patch get `) to select one."), + "purl": purl, + "options": options_json, + })); return Err(1); } Err(SelectError::Cancelled) => { @@ -1252,7 +1240,9 @@ fn resolved_api_overrides( } /// Build the API client for a download run driven without a run-level -/// client (the plain `download_*` wrappers other commands call). +/// client — the shape the retired 2-arg `download_*` wrappers had; kept +/// for the in-file engine unit tests below, which drive `params` alone. +#[cfg(test)] async fn api_client_for(params: &DownloadParams) -> ApiClient { get_api_client_with_overrides(resolved_api_overrides(params)) .await @@ -1777,24 +1767,10 @@ async fn run_nested_apply(common: GlobalArgs, quiet: bool) -> bool { /// Download the selected patches into `.socket/` (manifest records + /// blobs) and, unless `save_only`, apply them in place — the agent-mode -/// engine behind `get` and `scan --apply/--sync`. Returns `(exit_code, -/// json)`. Builds its own client from `params` and takes the manifest lock -/// non-blocking; callers holding the run's client (and `--lock-timeout`) -/// use [`download_and_apply_patches_with`]. -pub async fn download_and_apply_patches( - selected: &[PatchSearchResult], - params: &DownloadParams, -) -> (i32, serde_json::Value) { - let api_client = api_client_for(params).await; - let run = DownloadRun { - api_client: &api_client, - lock_timeout: None, - verbose: false, - }; - download_and_apply_patches_with(selected, params, &run).await -} - -/// [`download_and_apply_patches`] over the caller's run-level context. +/// engine behind `get` and `scan --apply/--sync`, over the caller's +/// run-level context (`run`: the client the run already built, plus the +/// `--lock-timeout` / `--verbose` the manifest lock and the nested apply +/// honor). Returns `(exit_code, json)`. pub async fn download_and_apply_patches_with( selected: &[PatchSearchResult], params: &DownloadParams, @@ -2222,7 +2198,7 @@ pub async fn run(args: GetArgs) -> i32 { if !quiet { println!("Enumerating packages..."); } - let (all_packages, _) = crawl_all_ecosystems(&crawler_options_for(&args.common)).await; + let (all_packages, _, _) = crawl_all_ecosystems(&crawler_options_for(&args.common)).await; if all_packages.is_empty() { if args.common.json { @@ -2516,11 +2492,7 @@ pub async fn run(args: GetArgs) -> i32 { fold_narrowing_into_result(&mut result_json, &narrow_skips, &narrow_warnings); if args.common.json { - println!( - "{}", - serde_json::to_string_pretty(&result_json) - .expect("serializing an in-memory JSON value cannot fail") - ); + print_json(&result_json); } code diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index abaa7c1c..8545ca56 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -18,8 +18,8 @@ use std::time::Duration; use super::get::short_uuid; use super::rollback::{ all_files_already_original, pin_before_hash_blobs, revert_vendor_entry, - rollback_patches_inner, run_hosted_leg, sweep_unused_artifacts, vendored_purl_keys_of, - HostedLegOutcome, InnerSelection, VendorRevertStep, + rollback_patches_inner, run_hosted_leg, sweep_unused_artifacts, HostedLegOutcome, + InnerSelection, VendorRevertStep, }; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::lock_cli::acquire_or_emit; @@ -399,7 +399,7 @@ pub async fn run(args: RemoveArgs) -> i32 { // "nothing vendored" here and fails closed at that leg. let vendored_keys: HashSet = vendor_state_result .as_ref() - .map(vendored_purl_keys_of) + .map(socket_patch_core::vendor::VendorState::purl_keys) .unwrap_or_default(); let mut rollback_count = 0; // In-scope manifest entries the nested rollback SKIPPED because the diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 68ac11c0..6db3126b 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -938,7 +938,7 @@ pub async fn run(args: RollbackArgs) -> i32 { // leg (its own containment is the `vendor_state_unreadable` exit below). let vendored_keys: HashSet = vendor_state_result .as_ref() - .map(vendored_purl_keys_of) + .map(VendorState::purl_keys) .unwrap_or_default(); // ── scope resolution ──────────────────────────────────────────────── @@ -1479,12 +1479,13 @@ pub async fn run(args: RollbackArgs) -> i32 { )); } if !path_scope.is_empty() { + let scope = path_scope.bind(&cwd); let out_of_scope: Vec<&str> = results .iter() .filter(|r| { r.success && !r.files_rolled_back.is_empty() - && !path_scope.matches(&cwd, Path::new(&r.package_path)) + && !scope.matches(Path::new(&r.package_path)) }) .map(|r| r.package_key.as_str()) .collect(); @@ -1812,27 +1813,9 @@ pub async fn run(args: RollbackArgs) -> i32 { } } -/// Every purl spelling under which `state`'s entries are addressable — -/// each entry's ledger key, its base purl and the qualifier-stripped key -/// (the same triple as core's `vendored_purl_keys`, computed from an -/// already-loaded ledger instead of re-reading it). -pub(crate) fn vendored_purl_keys_of(state: &VendorState) -> HashSet { - state - .entries - .iter() - .flat_map(|(key, entry)| { - [ - key.clone(), - entry.base_purl.clone(), - strip_purl_qualifiers(key).to_string(), - ] - }) - .collect() -} - /// The in-place (agent) rollback engine over an already-loaded `manifest`. /// `vendored_keys` is the ledger's ownership set (see -/// [`vendored_purl_keys_of`]): vendor-owned purls are excluded from the +/// [`VendorState::purl_keys`]): vendor-owned purls are excluded from the /// in-place restore. Both `run()` and `remove`'s delegation load each /// store once under the lock and thread it in here. pub(crate) async fn rollback_patches_inner( diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index cd2a810b..25409aef 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -149,30 +149,6 @@ fn crawled_from_purl( }) } -/// The vendor ledger's purl keys in every spelling the CLI matches on — the -/// ledger map key, its qualifier-stripped form, and the entry's base purl -/// (the same three `socket_patch_core::vendor::vendored_purl_keys` derives, -/// minus the load: `run` loads the ledger ONCE and shares it). Feeds the -/// prune exemption and the agent-path vendored skip. A corrupt ledger -/// degrades to the EMPTY set — fail-open by that helper's documented -/// contract (the supplement below is the fail-closed half). -pub(super) fn vendored_purl_keys(state: &std::io::Result) -> HashSet { - let Ok(state) = state else { - return HashSet::new(); - }; - state - .entries - .iter() - .flat_map(|(key, entry)| { - [ - key.clone(), - entry.base_purl.clone(), - strip_purl_qualifiers(key).to_string(), - ] - }) - .collect() -} - /// Vendored-ledger packages with no crawled counterpart: on a fresh clone /// the committed artifact IS the dependency, so these stay discoverable /// (updates[] detection, the table, and `scan --vendor` re-vendor/in-sync @@ -195,7 +171,7 @@ pub(super) async fn vendored_ledger_supplement( .collect(), // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above). // Returning empty here silently dropped every vendored purl from - // `scanned_purls` — and since the `vendored_purl_keys` prune + // `scanned_purls` — and since the purl-keys prune // exemption degrades to empty on the same Err (fail-open by its // documented contract), `scan --prune` then deleted still-vendored // packages' manifest entries and blobs while their committed @@ -1037,7 +1013,7 @@ mod tests { // The prune-safety chain for vendored packages: their purls enter // `scanned_purls` via this supplement, which shields their manifest // entries (and blobs) from `scan --prune`'s GC even when the - // `vendored_purl_keys` exemption degrades to empty (fail-open by its + // `VendorState::purl_keys` exemption degrades to empty (fail-open by its // documented contract). A corrupt `.socket/vendor/state.json` // (`load_state` → Err; a MISSING file is Ok(empty)) must therefore fall // back to the committed ground truth — manifest entries whose patch uuid @@ -1090,27 +1066,6 @@ mod tests { vendored_ledger_supplement(&args, crawled, &state).await } - /// The shared-load key set: every spelling the prune exemption and the - /// agent-path vendored skip match on, and EMPTY (fail-open) on a corrupt - /// ledger — the supplement's artifact fallback is the fail-closed half. - #[tokio::test] - async fn vendored_purl_keys_carry_every_spelling_and_degrade_to_empty() { - let state = vendor_ledger_with(&[("pkg:npm/%40scope/pkg@1.0.0?artifact_id=x", "u", true)]); - let keys = vendored_purl_keys(&Ok(state)); - for spelling in [ - "pkg:npm/%40scope/pkg@1.0.0?artifact_id=x", - "pkg:npm/%40scope/pkg@1.0.0", - ] { - assert!(keys.contains(spelling), "missing {spelling}: {keys:?}"); - } - assert!(vendored_purl_keys(&Ok(VendorState::new())).is_empty()); - let tmp = tempfile::tempdir().unwrap(); - seed_corrupt_ledger(tmp.path()); - let corrupt = socket_patch_core::vendor::load_state(tmp.path()).await; - assert!(corrupt.is_err(), "the fixture must be unreadable"); - assert!(vendored_purl_keys(&corrupt).is_empty()); - } - #[tokio::test] async fn corrupt_ledger_recovers_vendored_purls_from_committed_artifacts() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 68ec4642..355f2e6f 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -655,11 +655,12 @@ async fn gem_stale_install_warnings( global: bool, global_prefix: Option, confirmed: &[(String, String)], + // This run's fetched records MERGED with the ledger's persisted ones + // (the caller hands the post-merge ledger map): the persisted half is + // the fallback judgment source when this run's /patches/view fetch + // failed transiently, so the warning keeps firing until the stale + // materialization is gone. records: &std::collections::BTreeMap, - ledger_records: &std::collections::BTreeMap< - String, - socket_patch_core::manifest::schema::PatchRecord, - >, gem_artifact_shas: &std::collections::BTreeMap<(String, String), String>, ) -> StaleInstallOutcome { use socket_patch_core::crawlers::types::CrawlerOptions; @@ -669,12 +670,7 @@ async fn gem_stale_install_warnings( use socket_patch_core::vex::verify::verify_patch_record; let mut out = StaleInstallOutcome::default(); - let find_record = |uuid: &str| -> Option<&PatchRecord> { - records - .values() - .chain(ledger_records.values()) - .find(|r| r.uuid == uuid) - }; + let find_record = |uuid: &str| -> Option<&PatchRecord> { records.values().find(|r| r.uuid == uuid) }; // Record availability folds into the candidate filter (a zero-file map // included: nothing to hash means no judgment either way) so the no-op // cases return here, before the crawler is built. On `--dry-run` the @@ -894,9 +890,14 @@ pub(super) async fn run_redirect( /// first) → candidate-file read → rewrite → pnpm trust config → /// confirmation probe → ledger merge-then-persist → file writes → gem stale /// probe → warnings → optional VEX. Shared VERBATIM by `scan --mode hosted` -/// (whose `run_redirect` wrapper selects via `discover_selected`) and -/// `get --mode hosted` (which pins the advisory-resolved uuid), so both -/// produce identical on-disk results for the same selection. +/// — its `--json` arm through the `run_redirect` wrapper (which selects via +/// `discover_selected`), its human arm through +/// [`boxed_run_redirect_selected`] directly, after its own table + confirm +/// prompt (`scan/mod.rs`) — and by `get --mode hosted` (which pins the +/// advisory-resolved uuid), so all produce identical on-disk results for +/// the same selection. The redirect ledger is loaded HERE, under the apply +/// lock (never handed in pre-loaded: a copy read before the lock could +/// merge over a concurrent writer's edits). /// /// `scan_result` must be `Some` exactly when `common.json` is set (the /// human/JSON split keys on `common.json`; a `--json` caller passing `None` @@ -1100,11 +1101,12 @@ pub(crate) async fn run_redirect_selected( // possible; a dry-run reports the same hard error but moves nothing. // // Held as the ONE in-memory ledger for the whole run: the write below - // merges into it in place, and the stale-install probes read its - // records (persisted ones included — their fallback judgment source - // when this run's /patches/view fetch fails transiently: the warning - // must keep firing until the stale materialization is gone, not until - // the first flaky fetch). + // merges into it in place, the stale-install probes read its records + // (persisted ones included — their fallback judgment source when this + // run's /patches/view fetch fails transiently: the warning must keep + // firing until the stale materialization is gone, not until the first + // flaky fetch), and the takeover classification at the end reads the + // merged state. let mut ledger = match socket_patch_core::patch::redirect::load_redirect_state(&common.cwd).await { Ok(state) => state.unwrap_or_else(RedirectState::new), @@ -1120,6 +1122,14 @@ pub(crate) async fn run_redirect_selected( return 1; } }; + // The vendored ledger, loaded ONCE per run (under the same lock, so no + // other writer can move the on-disk file under it): the takeover below + // mutates it in place per reverted purl (saving after each), and the + // post-write overlap classification reads that post-takeover state — + // never a pre-takeover snapshot, which would flag every migrated purl + // as still vendored. `Err` (unreadable / malformed) is "no vendored + // ownership known" for both consumers. + let mut vendor_state = socket_patch_core::vendor::load_state(&common.cwd).await; // Cross-mode takeover: a purl this run is about to redirect may still be // VENDORED — for cargo a committed `[patch.crates-io]` path entry, a @@ -1153,10 +1163,6 @@ pub(crate) async fn run_redirect_selected( // No takeover-capable candidates — nothing to reconcile. } else { use socket_patch_core::utils::purl::{canonical_purl as canon, strip_purl_qualifiers}; - // Loaded ONCE and mutated in place per reverted purl (the wet loop - // saves after each revert): this run holds the apply lock, so no - // other writer can move the on-disk ledger under it. - let mut vendor_state = socket_patch_core::vendor::load_state(&common.cwd).await; // Each takeover-capable candidate with its vendored ledger entry, if // any (cloned out so the loop can mutate the state). let takeover: Vec<(&Candidate, Option)> = @@ -2129,7 +2135,7 @@ pub(crate) async fn run_redirect_selected( ledger.edits.push(edit.clone()); } } - ledger.records.extend(records.clone()); + ledger.records.extend(records); // The ledger is the only revert path and the VEX record store — // a swallowed write failure would let the lockfile writes below // proceed with no revert data persisted while reporting success. @@ -2203,7 +2209,6 @@ pub(crate) async fn run_redirect_selected( common.global, common.global_prefix.clone(), &confirmed, - &records, &ledger.records, &gem_artifact_shas, ) @@ -2216,7 +2221,6 @@ pub(crate) async fn run_redirect_selected( common, &confirmed, &rewrite.confirmed_pipenv_uuids, - &records, &ledger.records, ) .await @@ -2231,9 +2235,17 @@ pub(crate) async fn run_redirect_selected( // points at the vendored files stays silent instead of pointing cleanup at // the live vendored ledger. Warn (JSON `warnings[]` and stderr) WITHOUT // deleting the other mode's ledger; reconciliation is deferred (see PR Scope). - // Read after the ledger write above so a non-dry-run reflects this run. + // Classified over this run's in-memory ledgers — the redirect ledger as + // merged and persisted above, the vendored ledger as the takeover left + // it — so a non-dry-run reflects this run without re-reading either file. let mut takeover_warnings: Vec = Vec::new(); - let superseded = super::classify_overlap_takeover(&common.cwd).await.redirect; + let superseded = super::classify_overlap_takeover_with( + &common.cwd, + Some(&ledger), + vendor_state.as_ref().ok(), + ) + .await + .redirect; if !superseded.is_empty() { takeover_warnings.push(serde_json::json!({ "code": super::REDIRECT_SUPERSEDES_VENDORED, @@ -2994,8 +3006,9 @@ mod tests { } /// Probe invocation with the default surface (project-local discovery, - /// no ledger fallback, no artifact shas) — tests override the knobs - /// they exercise. + /// no artifact shas) — tests override the knobs they exercise. + /// `records` is the merged map production hands over (this run's + /// fetched records plus the ledger's persisted ones). async fn probe( cwd: &std::path::Path, confirmed: &[(String, String)], @@ -3008,7 +3021,6 @@ mod tests { confirmed, records, &std::collections::BTreeMap::new(), - &std::collections::BTreeMap::new(), ) .await } @@ -3256,25 +3268,21 @@ mod tests { ); } - /// RE-FIRE guarantee: when this run's record fetch failed (fresh records - /// empty) the probe falls back to the redirect ledger's persisted - /// records, so a transient /patches/view failure cannot silently retire - /// the warning while the stale materialization is still there. + /// RE-FIRE guarantee: when this run's record fetch failed (no fresh + /// records), the merged map the caller hands over still carries the + /// redirect ledger's PERSISTED record under whatever purl key the + /// ledger used — and the probe's uuid lookup judges from it, so a + /// transient /patches/view failure cannot silently retire the warning + /// while the stale materialization is still there. #[tokio::test] - async fn gem_stale_probe_falls_back_to_ledger_records() { + async fn gem_stale_probe_judges_from_persisted_ledger_records() { let stale = tempfile::tempdir().unwrap(); materialize_gem(stale.path(), GEM_UPSTREAM); - let fresh = std::collections::BTreeMap::new(); - let out = gem_stale_install_warnings( - stale.path(), - false, - None, - &one_confirmed(), - &fresh, - &one_record(), // the ledger snapshot - &std::collections::BTreeMap::new(), - ) - .await; + // Persisted under the API's qualified spelling, not the confirmed + // purl: only the uuid links them. + let mut ledger_only = std::collections::BTreeMap::new(); + ledger_only.insert(format!("{GEM_PURL}?platform=ruby"), gem_record()); + let out = probe(stale.path(), &one_confirmed(), &ledger_only).await; assert_eq!( out.warnings.len(), 1, @@ -3302,7 +3310,6 @@ mod tests { &one_confirmed(), &one_record(), &std::collections::BTreeMap::new(), - &std::collections::BTreeMap::new(), ) .await; assert_eq!( @@ -3376,7 +3383,6 @@ mod tests { None, &one_confirmed(), &one_record(), - &std::collections::BTreeMap::new(), &shas, ) .await; @@ -3416,7 +3422,6 @@ mod tests { None, &one_confirmed(), &one_record(), - &std::collections::BTreeMap::new(), &shas, ) .await; @@ -3441,7 +3446,6 @@ mod tests { None, &one_confirmed(), &one_record(), - &std::collections::BTreeMap::new(), &patched_shas, ) .await; @@ -3464,7 +3468,6 @@ mod tests { None, &one_confirmed(), &one_record(), - &std::collections::BTreeMap::new(), &shas, ) .await; @@ -3548,7 +3551,6 @@ mod tests { None, &one_confirmed(), &one_record(), - &std::collections::BTreeMap::new(), &shas, ) .await; diff --git a/crates/socket-patch-cli/src/commands/scan/hosted/python.rs b/crates/socket-patch-cli/src/commands/scan/hosted/python.rs index 975df585..9284970e 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted/python.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted/python.rs @@ -17,8 +17,9 @@ pub(super) async fn stale_install_warnings( common: &crate::args::GlobalArgs, confirmed: &[(String, String)], pipenv_uuids: &BTreeSet, + // This run's fetched records MERGED with the ledger's persisted ones + // (the caller hands the post-merge ledger map), looked up by uuid. records: &BTreeMap, - ledger_records: &BTreeMap, ) -> StaleInstallOutcome { let mut out = StaleInstallOutcome::default(); let candidates: Vec<_> = confirmed @@ -27,7 +28,6 @@ pub(super) async fn stale_install_warnings( .filter_map(|(purl, uuid)| { records .values() - .chain(ledger_records.values()) .find(|record| &record.uuid == uuid) .filter(|record| !record.files.is_empty()) .map(|record| (purl, record)) @@ -190,7 +190,7 @@ mod tests { ("one".into(), record("first-uuid", "first.py", b"patched")), ("two".into(), record("second-uuid", "second.py", b"patched")), ]); - let out = stale_install_warnings(&common, &confirmed, &BTreeSet::new(), &BTreeMap::new(), &ledger).await; + let out = stale_install_warnings(&common, &confirmed, &BTreeSet::new(), &ledger).await; assert_eq!(out.stale_purls, BTreeSet::from([first.to_string()])); assert_eq!(out.warnings.len(), 1); assert!(out.warnings[0]["detail"] @@ -205,7 +205,7 @@ mod tests { "variant".into(), )); ledger.insert("three".into(), record("variant", "first.py", b"upstream")); - let out = stale_install_warnings(&common, &confirmed, &BTreeSet::new(), &BTreeMap::new(), &ledger).await; + let out = stale_install_warnings(&common, &confirmed, &BTreeSet::new(), &ledger).await; assert!(out.stale_purls.is_empty()); assert!(out.warnings.is_empty()); } diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 25849544..f528874f 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -8,15 +8,16 @@ use clap::Args; use socket_patch_core::api::client::{ - build_proxy_fallback_client, get_api_client_with_overrides, is_fallback_candidate, + build_proxy_fallback_client, get_api_client_with_overrides, is_fallback_candidate, ApiClient, }; use socket_patch_core::api::types::{BatchPackagePatches, PatchSearchResult}; use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning; -use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem, RubyCrawler}; +use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::telemetry::{track_patch_scan_failed, track_patch_scanned}; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; +use socket_patch_core::vendor::VendorState; use std::collections::{HashMap, HashSet}; use std::io::IsTerminal; use std::path::Path; @@ -24,10 +25,11 @@ use std::path::Path; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; use crate::ecosystem_dispatch::crawl_all_ecosystems; -use crate::output::{color, confirm, format_severity}; +use crate::output::{color, confirm, format_severity, print_json}; use super::get::{ - download_and_apply_patches, select_patches, truncate_with_ellipsis, DownloadParams, + download_and_apply_patches_with, select_patches, truncate_with_ellipsis, DownloadParams, + DownloadRun, }; mod discovery; @@ -38,7 +40,7 @@ mod vendor_flow; use self::discovery::{ collect_vuln_ids, detect_updates, lockfile_only_contains, lockfile_supplement, merge_ledger_records_for_updates, preverify_vendor_baselines, severity_order, - vendored_ledger_supplement, vendored_purl_keys, LockfileSupplement, + vendored_ledger_supplement, LockfileSupplement, }; // Shared with `get --mode hosted|vendored` (commands::get): the advisory- // pinned entry into the hosted engine, the vendor step + its dry-run @@ -498,11 +500,7 @@ async fn fetch_patch_details( fn emit_discovery_error_json(result: &mut serde_json::Value, message: &str) { result["status"] = serde_json::json!("error"); result["error"] = serde_json::json!(message); - println!( - "{}", - serde_json::to_string_pretty(result) - .expect("serializing an in-memory JSON value cannot fail") - ); + print_json(result); } /// The report-only / declined-prompt hint: how to consume one patch @@ -597,6 +595,18 @@ fn download_params(args: &ScanArgs, save_only: bool, json: bool, silent: bool) - } } +/// The run-level context the agent engine borrows from scan: the client +/// `run` already built (proxy fallback included) and the flags the nested +/// apply inherits — so `scan --apply` honors `--lock-timeout` and never +/// rebuilds the client. +fn download_run<'a>(args: &ScanArgs, api_client: &'a ApiClient) -> DownloadRun<'a> { + DownloadRun { + api_client, + lock_timeout: args.common.lock_timeout, + verbose: args.common.verbose, + } +} + // --------------------------------------------------------------------------- // Cross-mode ledger takeover detection (hosted ⇄ vendored) // --------------------------------------------------------------------------- @@ -654,16 +664,35 @@ pub(super) const REDIRECT_PRUNE_IGNORED_DETAIL: &str = /// one way). Empty when either ledger is missing/empty/unreadable, or when the /// two ledgers describe disjoint packages (a legitimate split: some redirected, /// others vendored) — so there are no false positives. +/// +/// Production classifies through [`classify_overlap_takeover_with`] over +/// ledgers it already holds; this load-then-derive form is the unit tests' +/// entry point. +#[cfg(test)] pub(super) async fn overlapping_ledger_purls(cwd: &Path) -> Vec { // A malformed redirect ledger classifies like a missing one here — this // path only feeds takeover WARNINGS, and the corruption itself is already // a hard error on every path that would write (`run_redirect`) or attest // (`vex`) from the ledger. - let Ok(Some(redirect)) = socket_patch_core::patch::redirect::load_redirect_state(cwd).await - else { + let redirect = socket_patch_core::patch::redirect::load_redirect_state(cwd) + .await + .ok() + .flatten(); + let Ok(vendor) = socket_patch_core::vendor::load_state(cwd).await else { return Vec::new(); }; - let Ok(vendor) = socket_patch_core::vendor::load_state(cwd).await else { + overlap_from_states(redirect.as_ref(), &vendor) +} + +/// [`overlapping_ledger_purls`] over ALREADY-LOADED ledgers — loads nothing, +/// so a flow holding both in memory (the hosted engine, post-merge) shares +/// its copies instead of re-reading them. `None` / an empty vendor ledger +/// yield the empty overlap, exactly like the missing-file cases above. +fn overlap_from_states( + redirect: Option<&socket_patch_core::patch::redirect::RedirectState>, + vendor: &VendorState, +) -> Vec { + let Some(redirect) = redirect else { return Vec::new(); }; if vendor.entries.is_empty() { @@ -757,17 +786,39 @@ pub(super) struct OverlapTakeover { } pub(super) async fn classify_overlap_takeover(cwd: &Path) -> OverlapTakeover { - let overlap = overlapping_ledger_purls(cwd).await; + // Both ledgers loaded ONCE here. A malformed ledger classifies like a + // missing one, matching `overlapping_ledger_purls` (this path only + // feeds takeover warnings; corruption is a hard error on the + // write/attest paths). + let redirect = socket_patch_core::patch::redirect::load_redirect_state(cwd) + .await + .ok() + .flatten(); + let vendor = socket_patch_core::vendor::load_state(cwd).await.ok(); + classify_overlap_takeover_with(cwd, redirect.as_ref(), vendor.as_ref()).await +} + +/// [`classify_overlap_takeover`] over ALREADY-LOADED ledgers: loads neither +/// (the hosted engine holds both in memory — its post-merge redirect ledger +/// and the post-takeover vendor ledger — and must classify against those, +/// never a pre-takeover snapshot) but still inventories the LIVE lockfiles +/// in `cwd`, the truth source for direction. `None` for either ledger +/// yields no overlap. +pub(super) async fn classify_overlap_takeover_with( + cwd: &Path, + redirect: Option<&socket_patch_core::patch::redirect::RedirectState>, + vendor: Option<&VendorState>, +) -> OverlapTakeover { let mut out = OverlapTakeover::default(); + let Some(vendor) = vendor else { + return out; + }; + let overlap = overlap_from_states(redirect, vendor); if overlap.is_empty() { return out; } - // Re-load the vendored ledger to recover each overlapping entry's uuid + - // the lockfiles it wired (revert reads the same set); `overlapping_ledger_purls` - // already proved it loads and is non-empty. - let Ok(vendor) = socket_patch_core::vendor::load_state(cwd).await else { - return out; - }; + // Each overlapping vendored entry's uuid + the lockfiles it wired + // (revert reads the same set). let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); let mut vendor_by_purl: std::collections::HashMap< String, @@ -781,19 +832,12 @@ pub(super) async fn classify_overlap_takeover(cwd: &Path) -> OverlapTakeover { } // The hosted proof needs the redirect ledger too: each record's patch // uuid (embedded in every hosted artifact URL, whatever the host) and - // the lockfiles the redirect actually edited. A malformed ledger - // classifies like a missing one, matching `overlapping_ledger_purls` - // (this path only feeds takeover warnings; corruption is a hard error - // on the write/attest paths) — and that guard already returned empty - // overlap for the corrupt case, so this consult never runs then. - let redirect_state = socket_patch_core::patch::redirect::load_redirect_state(cwd) - .await - .ok() - .flatten(); + // the lockfiles the redirect actually edited. A non-empty overlap + // proves the ledger is `Some`. let mut redirect_uuid_by_purl: std::collections::HashMap = std::collections::HashMap::new(); let mut redirect_files: Vec<&str> = Vec::new(); - if let Some(redirect) = &redirect_state { + if let Some(redirect) = redirect { for (key, record) in &redirect.records { redirect_uuid_by_purl .entry(canon(key)) @@ -1512,11 +1556,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { "updates": [], "paths": path_scope.raw(), }); - println!( - "{}", - serde_json::to_string_pretty(&result) - .expect("serializing an in-memory JSON value cannot fail") - ); + print_json(&result); } else { eprintln!("Error: {err}"); } @@ -1594,7 +1634,8 @@ pub async fn run(mut args: ScanArgs) -> i32 { } // Crawl packages - let (mut all_crawled, mut eco_counts) = crawl_all_ecosystems(&crawler_options).await; + let (mut all_crawled, mut eco_counts, skipped_bundle_config_path) = + crawl_all_ecosystems(&crawler_options).await; // Lockfile supplement: dependencies the project's lockfile resolves // that have NO installed copy (fresh clone, partial install). They join @@ -1610,23 +1651,17 @@ pub async fn run(mut args: ScanArgs) -> i32 { // guard (a committed `.bundle/config` whose BUNDLE_PATH resolves // outside the project — untrusted input that would otherwise become a // scan/apply WRITE-target root). The crawl above consulted and - // silently skipped it; surface the skip on the SAME run-level channel - // as the layout refusals (JSON `warnings[]` on both the zero-package - // and ≥1-package envelopes; a gated stderr line on the human path). - // Scoped like the crawl that hit it: local mode, with gem not filtered - // out by `--ecosystems`. Cheap re-probe: filesystem only, no `gem env` - // shell-out. - if !crawler_options.global - && crawler_options.global_prefix.is_none() - && args + // silently skipped it, handing the skip back (local mode only); surface + // it on the SAME run-level channel as the layout refusals (JSON + // `warnings[]` on both the zero-package and ≥1-package envelopes; a + // gated stderr line on the human path) unless `--ecosystems` filtered + // gem out of this run. + if let Some(value) = skipped_bundle_config_path { + if args .common .ecosystems .as_ref() .is_none_or(|list| list.iter().any(|e| e == Ecosystem::Gem.cli_name())) - { - if let Some(value) = RubyCrawler::discover_bundle_stores(&args.common.cwd) - .await - .skipped_config_path { let (code, detail) = config_path_ignored_warning(&value); layout_refusals.push((code.to_string(), detail)); @@ -1679,8 +1714,13 @@ pub async fn run(mut args: ScanArgs) -> i32 { // Vendor-ledger purl keys (from the single load above), shared by the // prune exemption (a vendored package is consumed from the committed // artifact, so "absent from the crawl" is its normal state, not - // grounds for pruning) and the vendored-skip in the apply path. - let vendored_purls = vendored_purl_keys(&vendor_state); + // grounds for pruning) and the vendored-skip in the apply path. A + // corrupt ledger degrades to the EMPTY set — fail-open by the key set's + // documented contract (the supplement above is the fail-closed half). + let vendored_purls: HashSet = vendor_state + .as_ref() + .map(VendorState::purl_keys) + .unwrap_or_default(); // Filter by --ecosystems if provided let filtered_crawled: Vec<_> = if let Some(ref allowed) = args.common.ecosystems { @@ -1719,10 +1759,11 @@ pub async fn run(mut args: ScanArgs) -> i32 { ), )); } + let scope = path_scope.bind(&args.common.cwd); let in_scope: HashSet = filtered_crawled .iter() .filter(|pkg| !supplement_purls.contains(&pkg.purl)) - .filter(|pkg| path_scope.matches(&args.common.cwd, &pkg.path)) + .filter(|pkg| scope.matches(&pkg.path)) .map(|pkg| pkg.purl.clone()) .collect(); filtered_crawled @@ -1794,19 +1835,15 @@ pub async fn run(mut args: ScanArgs) -> i32 { if hosted { let mut warnings: Vec = Vec::new(); if prune { - warnings.push(serde_json::json!({ - "code": REDIRECT_PRUNE_IGNORED, - "detail": REDIRECT_PRUNE_IGNORED_DETAIL, - })); + warnings.push(hosted::prune_ignored_warning()); } - result["redirect"] = serde_json::json!({ - "mode": "hosted", - "redirected": 0, - "rewrittenFiles": [], - "skipped": [], - "warnings": warnings, - "dryRun": args.common.dry_run, - }); + result["redirect"] = hosted::redirect_json_block( + 0, + Vec::new(), + Vec::new(), + warnings, + args.common.dry_run, + ); } else if !vendor { // The `redirectState` block rides the empty-discovery // envelope too (same rule as the ≥1-package path below: @@ -1832,11 +1869,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { } let code = embed_vex_into_json(&args.common, &args.vex, &manifest_path, 0, &mut result).await; - println!( - "{}", - serde_json::to_string_pretty(&result) - .expect("serializing an in-memory JSON value cannot fail") - ); + print_json(&result); return code; } else if args.common.silent { // Errors only: the empty-scan hint is informational. @@ -2000,11 +2033,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { "updates": [], "paths": path_scope.raw(), }); - println!( - "{}", - serde_json::to_string_pretty(&result) - .expect("serializing an in-memory JSON value cannot fail") - ); + print_json(&result); } else { eprintln!("Error: all {total_batches} API batch queries failed: {err}"); } @@ -2073,11 +2102,17 @@ pub async fn run(mut args: ScanArgs) -> i32 { // structurally empty and a superseding patch is never reported. The // envelope schema is unchanged. A malformed redirect ledger is only // warned about here (and muted by --silent — the warning is advisory) - // — this is a read-only consult, and the hosted write path hard-errors - // on it; a malformed vendor ledger contributes nothing (the supplement - // above already recovered its purls from the committed artifacts). - let redirect_state = - crate::commands::load_redirect_state_lenient(&args.common.cwd, args.common.silent).await; + // — this is a read-only consult; a malformed vendor ledger contributes + // nothing (the supplement above already recovered its purls from the + // committed artifacts). A HOSTED run mutes the warning outright: its + // engine loads the same ledger strictly, under the apply lock, and + // reports the corruption ONCE as the hard error it is (quarantine + // included), so the advisory here would only duplicate that message. + let redirect_state = crate::commands::load_redirect_state_lenient( + &args.common.cwd, + args.common.silent || hosted, + ) + .await; let update_manifest = merge_ledger_records_for_updates( existing_manifest.as_ref(), redirect_state.as_ref(), @@ -2277,7 +2312,9 @@ pub async fn run(mut args: ScanArgs) -> i32 { let params = download_params( &args, /*save_only=*/ false, /*json=*/ true, /*silent=*/ true, ); - let (code, apply_json) = download_and_apply_patches(&selected, ¶ms).await; + let (code, apply_json) = + download_and_apply_patches_with(&selected, ¶ms, &download_run(&args, &api_client)) + .await; apply_code = code; let mut apply_obj = apply_json; fold_vendored_skips_into_apply(&mut apply_obj, &vendored_records); @@ -2361,11 +2398,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { &mut result, ) .await; - println!( - "{}", - serde_json::to_string_pretty(&result) - .expect("serializing an in-memory JSON value cannot fail") - ); + print_json(&result); return final_code; } @@ -2834,7 +2867,9 @@ pub async fn run(mut args: ScanArgs) -> i32 { ) .await } else { - let (code, _) = download_and_apply_patches(&selected, ¶ms).await; + let (code, _) = + download_and_apply_patches_with(&selected, ¶ms, &download_run(&args, &api_client)) + .await; code }; @@ -2863,8 +2898,8 @@ pub async fn run(mut args: ScanArgs) -> i32 { // Post-apply GC: only runs when the user opted in via `--prune` or // `--sync`. Default `scan --yes` no longer touches the manifest // beyond what `--apply` added — users wanting to clean up should - // run `socket-patch gc` (or `repair`) explicitly. (Vendor mode - // already ran its GC before the vendor step.) + // run `socket-patch gc` (or `repair`) explicitly. (Vendor mode runs + // its own GC after the vendor step, inside `vendor_flow`.) if prune && !vendor { let gc = run_apply_gc( &args.common, diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 3a1696d3..efa3ec5d 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -38,6 +38,7 @@ use crate::commands::vendor::{ note_classic_migration_risk, track_outcomes_for_vendor, vendor_records, }; use crate::json_envelope::{Command as EnvelopeCommand, Envelope, RunWarning}; +use crate::output::print_json; use super::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; use super::{ @@ -62,15 +63,6 @@ type VendorStepError = (&'static str, String, Option>); /// [`VendorStepError`]. type VendorStepResult = Result<(bool, Envelope), VendorStepError>; -/// Pretty-print one JSON document to stdout — every `--json` consumer -/// parses stdout as exactly one document. -fn print_json(v: &serde_json::Value) { - println!( - "{}", - serde_json::to_string_pretty(v).expect("serializing an in-memory JSON value cannot fail") - ); -} - /// Dry-run preview for `scan --vendor` (and `get … --mode vendored /// --dry-run`): classify each selected patch against the vendor ledger /// without writing anything or touching the network beyond discovery. diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 288352b1..8a417973 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -486,10 +486,14 @@ pub async fn find_manifest_package_paths( find_packages_for_rollback(&partitioned, &crawler_options, quiet).await } -/// Crawl all ecosystems and return all packages plus per-ecosystem counts. +/// Crawl all ecosystems and return all packages, per-ecosystem counts and +/// the gem crawl's refused config-sourced `BUNDLE_PATH` +/// (`BundleStoreDiscovery::skipped_config_path`, local mode only) — +/// recovered from the crawl that hit it, so callers surfacing the advisory +/// never probe the Bundler roots a second time. pub async fn crawl_all_ecosystems( options: &CrawlerOptions, -) -> (Vec, HashMap) { +) -> (Vec, HashMap, Option) { let mut all_packages = Vec::new(); let mut counts: HashMap = HashMap::new(); @@ -504,14 +508,17 @@ pub async fn crawl_all_ecosystems( crawl!(Ecosystem::Npm, NpmCrawler); crawl!(Ecosystem::Pypi, PythonCrawler); crawl!(Ecosystem::Cargo, CargoCrawler); - crawl!(Ecosystem::Gem, RubyCrawler); + let (gems, gem_discovery) = RubyCrawler.crawl_all_with_discovery(options).await; + counts.insert(Ecosystem::Gem, gems.len()); + all_packages.extend(gems); crawl!(Ecosystem::Golang, GoCrawler); crawl!(Ecosystem::Maven, MavenCrawler); crawl!(Ecosystem::Composer, ComposerCrawler); crawl!(Ecosystem::Nuget, NuGetCrawler); crawl!(Ecosystem::Deno, DenoCrawler); - (all_packages, counts) + let skipped_config_path = gem_discovery.and_then(|d| d.skipped_config_path); + (all_packages, counts, skipped_config_path) } #[cfg(test)] @@ -1259,7 +1266,7 @@ mod tests { #[tokio::test] async fn crawl_all_includes_every_ecosystem_unconditionally() { let tmp = tempfile::tempdir().unwrap(); - let (_, counts) = crawl_all_ecosystems(&local_options(tmp.path().to_path_buf())).await; + let (_, counts, _) = crawl_all_ecosystems(&local_options(tmp.path().to_path_buf())).await; for eco in [ Ecosystem::Npm, Ecosystem::Pypi, diff --git a/crates/socket-patch-cli/src/output.rs b/crates/socket-patch-cli/src/output.rs index 2b332387..e21b7864 100644 --- a/crates/socket-patch-cli/src/output.rs +++ b/crates/socket-patch-cli/src/output.rs @@ -5,6 +5,16 @@ pub(crate) fn stdin_is_tty() -> bool { std::io::stdin().is_terminal() } +/// Print one JSON document, pretty-printed, to stdout — the one writer +/// behind every `--json` envelope, so each consumer parses stdout as +/// exactly one document. +pub(crate) fn print_json(v: &serde_json::Value) { + println!( + "{}", + serde_json::to_string_pretty(v).expect("serializing an in-memory JSON value cannot fail") + ); +} + /// The update notifier's TTY gate reads *stderr*, not stdin: the notice /// prints there, and stdout may be legitimately piped (`list | jq`) in a /// perfectly interactive session. diff --git a/crates/socket-patch-cli/tests/covgap_commands_get.rs b/crates/socket-patch-cli/tests/covgap_commands_get.rs index 0c041626..f69e8c9d 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_get.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_get.rs @@ -17,14 +17,32 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use serial_test::serial; -use socket_patch_cli::commands::get::{download_and_apply_patches, run, DownloadParams, GetArgs}; +use socket_patch_cli::commands::get::{ + download_and_apply_patches_with, run, DownloadParams, DownloadRun, GetArgs, +}; use socket_patch_cli::commands::scan::ScanMode; -use socket_patch_core::api::client::ApiClientEnvOverrides; +use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClientEnvOverrides}; use socket_patch_core::api::types::PatchSearchResult; use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +/// The agent engine driven from `params` alone: builds the run's client +/// from the params' API overrides (what scan/get do once per run) with the +/// default try-once lock, then runs [`download_and_apply_patches_with`]. +async fn download_and_apply_patches( + selected: &[PatchSearchResult], + params: &DownloadParams, +) -> (i32, serde_json::Value) { + let (api_client, _) = get_api_client_with_overrides(params.api_overrides.clone()).await; + let run = DownloadRun { + api_client: &api_client, + lock_timeout: None, + verbose: false, + }; + download_and_apply_patches_with(selected, params, &run).await +} + #[path = "common/mod.rs"] mod common; diff --git a/crates/socket-patch-cli/tests/in_process_get_update_count.rs b/crates/socket-patch-cli/tests/in_process_get_update_count.rs index 6756f29a..5a71b85b 100644 --- a/crates/socket-patch-cli/tests/in_process_get_update_count.rs +++ b/crates/socket-patch-cli/tests/in_process_get_update_count.rs @@ -11,13 +11,29 @@ use std::path::Path; use serial_test::serial; -use socket_patch_cli::commands::get::{download_and_apply_patches, DownloadParams}; -use socket_patch_core::api::client::ApiClientEnvOverrides; +use socket_patch_cli::commands::get::{download_and_apply_patches_with, DownloadParams, DownloadRun}; +use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClientEnvOverrides}; use socket_patch_core::api::types::PatchSearchResult; use std::collections::HashMap; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; +/// The agent engine driven from `params` alone: builds the run's client +/// from the params' API overrides (what scan/get do once per run) with the +/// default try-once lock, then runs [`download_and_apply_patches_with`]. +async fn download_and_apply_patches( + selected: &[PatchSearchResult], + params: &DownloadParams, +) -> (i32, serde_json::Value) { + let (api_client, _) = get_api_client_with_overrides(params.api_overrides.clone()).await; + let run = DownloadRun { + api_client: &api_client, + lock_timeout: None, + verbose: false, + }; + download_and_apply_patches_with(selected, params, &run).await +} + const ORG: &str = "test-org"; const PURL: &str = "pkg:npm/upd-pkg@1.0.0"; const OLD_UUID: &str = "00000000-0000-4000-8000-000000000000"; diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 36226cd4..a5ccfb46 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -3155,6 +3155,15 @@ async fn corrupt_ledger_fails_closed_and_preserves_the_bytes() { message.contains("redirect-state.json.corrupt"), "error must point at the moved-aside file: {message}" ); + // The corruption is reported ONCE, as the engine's hard error: the + // read-only `updates[]` consult of the same file must not also print + // its advisory warning for a hosted run. + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + stderr.matches("is malformed").count(), + 1, + "the corrupt-ledger message must print exactly once; stderr=\n{stderr}" + ); // Nothing was rewritten, and the corrupt bytes survived verbatim in the // quarantine file — never overwritten by a fresh ledger. diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index 042bed49..c3dc66f9 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -61,11 +61,28 @@ impl RubyCrawler { app_config_env: Option<&OsStr>, home_env: Option<&OsStr>, ) -> Result, std::io::Error> { + Ok( + Self::gem_paths_and_discovery(options, bundle_path_env, app_config_env, home_env) + .await + .0, + ) + } + + /// The gem paths plus the local-mode bundle-store discovery they came + /// from (`None` in global / `--global-prefix` mode, which never probes + /// the Bundler roots), so a caller that needs the discovery's advisories + /// (`skipped_config_path`) does not probe the roots a second time. + async fn gem_paths_and_discovery( + options: &CrawlerOptions, + bundle_path_env: Option<&OsStr>, + app_config_env: Option<&OsStr>, + home_env: Option<&OsStr>, + ) -> (Vec, Option) { if options.global || options.global_prefix.is_some() { if let Some(ref custom) = options.global_prefix { - return Ok(vec![custom.clone()]); + return (vec![custom.clone()], None); } - return Ok(Self::get_global_gem_paths().await); + return (Self::get_global_gem_paths().await, None); } // Local mode: probe the Bundler install roots first. @@ -76,6 +93,7 @@ impl RubyCrawler { home_env, ) .await; + let mut paths = discovery.stores.clone(); // Historic early-return, kept ONLY for the implicit project-local // `vendor/bundle` probe: a deployment-style install is the @@ -86,20 +104,15 @@ impl RubyCrawler { // env-`BUNDLE_PATH` project still needs the `gem env` homes to see // them (the explicit-roots feature briefly suppressed that // pre-existing fallback). - if discovery.default_root_has_stores { - return Ok(discovery.stores); - } - - let mut paths = discovery.stores; - - // Only consult the installed gem homes if this looks like a Ruby - // project. A non-deployment `bundle install` puts the project's gems - // in the ambient gem homes, so every home `gem env` reports counts — - // not just `gemdir`: bundler resolves from all of `Gem.path`, and a - // gem the project loads routinely lives in a non-`gemdir` home (rvm - // keeps shared gems in the `@global` gemset; `--user-install` puts - // them under `~/.gem`/`$XDG_DATA_HOME`). - if Self::has_bundler_manifest(&options.cwd).await { + // + // Otherwise only consult the installed gem homes if this looks like + // a Ruby project. A non-deployment `bundle install` puts the + // project's gems in the ambient gem homes, so every home `gem env` + // reports counts — not just `gemdir`: bundler resolves from all of + // `Gem.path`, and a gem the project loads routinely lives in a + // non-`gemdir` home (rvm keeps shared gems in the `@global` gemset; + // `--user-install` puts them under `~/.gem`/`$XDG_DATA_HOME`). + if !discovery.default_root_has_stores && Self::has_bundler_manifest(&options.cwd).await { let mut seen: HashSet = paths.iter().cloned().collect(); for gems_dir in Self::gem_env_gems_dirs().await { if seen.insert(gems_dir.clone()) { @@ -108,22 +121,39 @@ impl RubyCrawler { } } - Ok(paths) + (paths, Some(discovery)) } /// Crawl all discovered gem paths and return every package found. pub async fn crawl_all(&self, options: &CrawlerOptions) -> Vec { + self.crawl_all_with_discovery(options).await.0 + } + + /// [`Self::crawl_all`] plus the bundle-store discovery the local-mode + /// crawl consulted (`None` in global / `--global-prefix` mode), so the + /// CLI can surface its `skipped_config_path` advisory without probing + /// the Bundler roots again. + pub async fn crawl_all_with_discovery( + &self, + options: &CrawlerOptions, + ) -> (Vec, Option) { let mut packages = Vec::new(); let mut seen = HashSet::new(); - let gem_paths = self.get_gem_paths(options).await.unwrap_or_default(); + let (gem_paths, discovery) = Self::gem_paths_and_discovery( + options, + std::env::var_os("BUNDLE_PATH").as_deref(), + std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), + ambient_home().as_deref(), + ) + .await; for gem_path in &gem_paths { let found = self.scan_gem_dir(gem_path, &mut seen).await; packages.extend(found); } - packages + (packages, discovery) } /// Find specific packages by PURL inside a single gem directory. diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index db75ab01..60ab4c87 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -712,28 +712,16 @@ pub fn is_vendorable(purl: &str) -> bool { ecosystem_dir_for_purl(purl).is_some() } -/// Every purl spelling under which the ledger's entries are addressable: -/// each entry's map key (the manifest purl, possibly qualified), its -/// resolved base purl, and the qualifier-stripped key. Loaded once for -/// callers that match whole purl sets against vendor ownership (apply / -/// rollback / scan prune). An unreadable ledger degrades to the empty set -/// (fail-open); mutating callers that need fail-closed semantics use -/// [`load_state`] directly. +/// [`VendorState::purl_keys`] over the ledger in `project_root`, loaded +/// once for callers that match whole purl sets against vendor ownership +/// (apply / rollback / scan prune). An unreadable ledger degrades to the +/// empty set (fail-open); mutating callers that need fail-closed semantics +/// use [`load_state`] directly. pub async fn vendored_purl_keys(project_root: &Path) -> HashSet { - match load_state(project_root).await { - Ok(state) => state - .entries - .iter() - .flat_map(|(key, entry)| { - [ - key.clone(), - entry.base_purl.clone(), - strip_purl_qualifiers(key).to_string(), - ] - }) - .collect(), - Err(_) => HashSet::new(), - } + load_state(project_root) + .await + .map(|state| state.purl_keys()) + .unwrap_or_default() } #[cfg(test)] diff --git a/crates/socket-patch-core/src/vendor/state.rs b/crates/socket-patch-core/src/vendor/state.rs index a3c8d52a..647ea7ee 100644 --- a/crates/socket-patch-core/src/vendor/state.rs +++ b/crates/socket-patch-core/src/vendor/state.rs @@ -23,7 +23,7 @@ //! flavor strings they have no backend for. Both keep an old binary safe //! against a newer project checkout. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -31,6 +31,7 @@ use serde::{Deserialize, Serialize}; use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; use crate::utils::fs::{atomic_write_bytes, read_regular_to_bytes}; +use crate::utils::purl::strip_purl_qualifiers; use crate::utils::serde::serialize_sorted; use crate::utils::socket_dir::{prune_empty_dirs, remove_file_and_prune, write_json_ledger}; @@ -283,6 +284,25 @@ impl VendorState { entries: HashMap::new(), } } + + /// Every purl spelling under which this ledger's entries are + /// addressable: each entry's map key (the manifest purl, possibly + /// qualified), its resolved base purl, and the qualifier-stripped key. + /// The one derivation behind every whole-set vendor-ownership match + /// (apply / rollback / remove / scan prune); [`super::vendored_purl_keys`] + /// is its load-then-derive convenience. + pub fn purl_keys(&self) -> HashSet { + self.entries + .iter() + .flat_map(|(key, entry)| { + [ + key.clone(), + entry.base_purl.clone(), + strip_purl_qualifiers(key).to_string(), + ] + }) + .collect() + } } impl Default for VendorState { @@ -620,6 +640,29 @@ mod tests { } } + /// Every spelling `purl_keys` promises: the (possibly qualified, + /// percent-encoded) map key, the entry's base purl and the + /// qualifier-stripped key; an empty ledger yields the empty set. + #[test] + fn purl_keys_carry_every_spelling() { + let mut state = VendorState::new(); + let mut entry = sample_entry(); + entry.base_purl = "pkg:npm/@scope/pkg@1.0.0".into(); + state + .entries + .insert("pkg:npm/%40scope/pkg@1.0.0?artifact_id=x".into(), entry); + let keys = state.purl_keys(); + for spelling in [ + "pkg:npm/%40scope/pkg@1.0.0?artifact_id=x", + "pkg:npm/%40scope/pkg@1.0.0", + "pkg:npm/@scope/pkg@1.0.0", + ] { + assert!(keys.contains(spelling), "missing {spelling}: {keys:?}"); + } + assert_eq!(keys.len(), 3); + assert!(VendorState::new().purl_keys().is_empty()); + } + #[tokio::test] async fn round_trip_and_determinism() { let tmp = tempfile::tempdir().unwrap(); From 89ef1d71a23d3e629df80c53ed0f758cf9d35481 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 22:14:03 -0400 Subject: [PATCH 23/44] =?UTF-8?q?refactor(cli/apply+get+misc):=20one=20loc?= =?UTF-8?q?k=20window=20for=20download=20=E2=86=92=20manifest=20=E2=86=92?= =?UTF-8?q?=20nested=20apply;=20small=20consolidations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T-A12 — `apply::run` is split at its acquire: `run` keeps the noManifest / PnP / `--check` gates, the client build and `acquire_or_emit`; `pub(crate) run_locked(args, manifest_path, &client, LockGuard)` is everything from the manifest read on (layout gate, apply loop, embedded VEX, output, telemetry), releasing the guard it was handed once the last mutation is done, exactly where apply's own release sat. Agent-mode `get` and `scan --apply/--sync` now keep their manifest-write guard alive and hand it — with the run's ONE client — to the nested apply through `run_nested_apply`, so download → manifest write → apply is a single lock window and the nested apply never re-acquires or builds a second client. `save_patch_record`'s acquire is hoisted into `save_and_apply_patch`; the uuid path's nested apply runs on the (possibly proxy-fallback) client the fetch used. `nested_apply_args_from_params` no longer re-threads the API flags (inert now); `resolved_api_overrides` folds into the test-only `api_client_for` and its two unit tests go with it (their subject — the nested apply's own org resolution — no longer exists). T-E1 — the yarn-PnP human-mode negative pin names the v5.0 no-manifest line. T-E2 — `output::read_yes_no` is the one stdin yes/no reader; `confirm` maps an empty answer to its default, setup's `confirm_proceed` proceeds only on an explicit yes; every pinned prompt string is kept. T-E3/T-G4/W-12 — `api::client::resolve_ambient_credentials` holds the credential chain (flag → SOCKET_NO_API_TOKEN veto → env → socket-cli config, with the debug echoes); `get_api_client_with_overrides` calls it, and the CLI's local commands (`list`, `setup`, `vex`) resolve telemetry attribution through `GlobalArgs::telemetry_credentials` instead of list.rs's hand-rolled mirror (which also served setup and vex). T-E4/W-6 — the dead `find_packages_for_purls` leaves production; the in-file tests keep a test-side helper to pin the base-keyed contrast, and every comment naming it is repointed. T-E5 — the `VITEST` telemetry kill-switch scrubs return to args.rs's telemetry harness and the repair telemetry lifecycle test (the switch itself was restored in f09f643). T-E6 — `Commands::Repair` doc drops the lock-reset claim. T-E7 — the gem setup branch takes a `GemEdit` (`Add(&BundlerProbe)` / `Remove`) from a `(project, probe)` pair discovered once, so the unreachable probing `add_plugin_directive` arm is gone. T-E8 — the machine-probe tests assert exactly one `bundle --version` spawn per run; the host gem roundtrip pins that `setup --remove` leaves no `.socket/`. Verified: cargo check --workspace --all-targets; clippy --workspace --all-features -D warnings; core api::client + setup::gem unit tests; the whole CLI lib unit suite (583); get_nested_apply_api_flags_e2e, covgap_commands_get, e2e_safety_lock, apply_invariants, output_modes_e2e, interactive_prompts_e2e, e2e_safety_yarn_pnp, covgap_commands_setup, covgap_setup_gem_version, in_process_remove_repair_lifecycle, telemetry_e2e, covgap_commands_list, ecosystem_dispatch_e2e, in_process_rollback_all_ecosystems, cli_config_fallback, covgap_commands_vex, and setup_matrix_gem under --features setup-e2e on the host (19/19). Co-Authored-By: Claude Fable 5.1 --- crates/socket-patch-cli/src/args.rs | 54 +++- crates/socket-patch-cli/src/commands/apply.rs | 31 ++- crates/socket-patch-cli/src/commands/get.rs | 234 +++++++++--------- crates/socket-patch-cli/src/commands/list.rs | 98 +------- .../src/commands/repair_vendor.rs | 6 +- crates/socket-patch-cli/src/commands/setup.rs | 91 ++++--- .../socket-patch-cli/src/commands/vendor.rs | 12 +- crates/socket-patch-cli/src/commands/vex.rs | 6 +- .../src/ecosystem_dispatch.rs | 71 +++--- crates/socket-patch-cli/src/lib.rs | 3 +- crates/socket-patch-cli/src/output.rs | 21 +- .../tests/covgap_setup_gem_version.rs | 15 +- .../tests/e2e_safety_yarn_pnp.rs | 4 +- .../tests/ecosystem_dispatch_e2e.rs | 4 +- .../tests/get_nested_apply_api_flags_e2e.rs | 15 +- .../in_process_remove_repair_lifecycle.rs | 3 +- .../in_process_rollback_all_ecosystems.rs | 6 +- .../tests/setup_matrix_gem.rs | 5 + crates/socket-patch-core/src/api/client.rs | 92 +++++-- crates/socket-patch-core/src/vex/verify.rs | 2 +- 20 files changed, 414 insertions(+), 359 deletions(-) diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 34c566b1..60310eb2 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -17,7 +17,9 @@ use std::path::{Path, PathBuf}; use clap::Args; -use socket_patch_core::api::client::{ApiClient, ApiClientEnvOverrides}; +use socket_patch_core::api::client::{ + resolve_ambient_credentials, ApiClient, ApiClientEnvOverrides, +}; use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; use socket_patch_core::crawlers::Ecosystem; use socket_patch_core::vendor::{VendorServiceConfig, VendorSource}; @@ -364,6 +366,20 @@ impl GlobalArgs { } } + /// The `(api_token, org_slug)` telemetry is attributed with, resolved + /// through the API client's own credential chain (flag → the + /// `SOCKET_NO_API_TOKEN` veto → env → `socket login` config) WITHOUT + /// building a client. For the purely local commands (`list`, `setup`, + /// `vex`): a client would add the org-slug auto-resolve round-trip and + /// the "No SOCKET_API_TOKEN set" advisory to a command that needs + /// neither, while anything less than the full chain reported a + /// `socket login`-only caller's events anonymously to the public proxy + /// — off the on-prem host every other command reports to. + pub(crate) fn telemetry_credentials(&self) -> (Option, Option) { + let overrides = self.api_client_overrides(); + resolve_ambient_credentials(overrides.api_token, overrides.org_slug) + } + /// The vendoring-service config every vendor entry point (`vendor`, /// `scan`/`get --mode vendored`) builds from the same flags — /// `--vendor-source` / `--vendor-url` / `--patch-server-url` / @@ -606,11 +622,12 @@ mod tests { } /// Clear the extra env the core telemetry gate reads beyond the - /// `SOCKET_*` set (`is_telemetry_disabled` also consults the legacy + /// `SOCKET_*` set (`is_telemetry_disabled` also consults `VITEST` — the + /// kill-switch socket-cli's vitest suite relies on — and the legacy /// `SOCKET_PATCH_TELEMETRY_DISABLED` name), so the airgap tests below /// can't pass or fail vacuously. Restores afterwards. fn with_clean_telemetry_env(f: impl FnOnce()) { - with_env_cleared(&["SOCKET_PATCH_TELEMETRY_DISABLED"], f); + with_env_cleared(&["VITEST", "SOCKET_PATCH_TELEMETRY_DISABLED"], f); } /// `--offline` promises "never contact the network", but the telemetry @@ -1084,6 +1101,37 @@ mod tests { assert!(o.org_slug.is_none()); } + /// Telemetry attribution runs the client's credential chain over the + /// same overrides: explicit values — the flag, or the env var clap folds + /// into the same field — are used verbatim, and empty means "unset" + /// (`Some("")` would build a malformed `/v0/orgs//telemetry` URL and an + /// empty `Bearer ` header). The ambient layers below the flags are + /// pinned in core (`resolve_ambient_credentials_*`) and end-to-end by + /// `tests/cli_config_fallback.rs::list_telemetry_follows_socket_cli_login`. + #[test] + fn telemetry_credentials_prefer_explicit_values_and_treat_empty_as_unset() { + let explicit = GlobalArgs { + api_token: Some("sktsec_flag_api".to_string()), + org: Some("flag-org".to_string()), + ..GlobalArgs::default() + }; + assert_eq!( + explicit.telemetry_credentials(), + ( + Some("sktsec_flag_api".to_string()), + Some("flag-org".to_string()) + ) + ); + let empty = GlobalArgs { + api_token: Some(String::new()), + org: Some(String::new()), + ..GlobalArgs::default() + }; + let (api_token, org_slug) = empty.telemetry_credentials(); + assert_ne!(api_token.as_deref(), Some("")); + assert_ne!(org_slug.as_deref(), Some("")); + } + /// Empty strings for url/token/org are filtered out, not forwarded as /// `Some("")` — otherwise an empty CLI value would mask env-var fallback. #[test] diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index bae40dea..aff40f50 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -10,6 +10,7 @@ use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRec use socket_patch_core::patch::apply::{ apply_package_patch, verify_file_patch, ApplyResult, MismatchPolicy, PatchSources, VerifyStatus, }; +use socket_patch_core::patch::apply_lock::LockGuard; use socket_patch_core::patch::redirect::golang_local::{ apply_go_redirect, reconcile_go_redirects, verify_go_redirect_state, }; @@ -722,13 +723,9 @@ pub async fn run(args: ApplyArgs) -> i32 { // lock, so none of that lengthens the lock hold. It serves the staging // fetch, the mismatch blob top-up and telemetry. let (client, _) = get_api_client_with_overrides(args.common.api_client_overrides()).await; - let api_token = client.api_token().cloned(); - let org_slug = client.org_slug().cloned(); // Serialize against concurrent socket-patch runs targeting the same - // `.socket/` directory. Released explicitly once every mutation is done - // (output and a possibly slow telemetry POST must not keep a sibling - // waiting), otherwise on return; see `socket_patch_core::patch::apply_lock`. + // `.socket/` directory; see `socket_patch_core::patch::apply_lock`. let lock = match acquire_or_emit( &args.common.socket_dir(), Command::Apply, @@ -740,6 +737,28 @@ pub async fn run(args: ApplyArgs) -> i32 { Err(code) => return code, }; + run_locked(args, manifest_path, &client, lock).await +} + +/// The locked half of `apply`: everything from the manifest read on — the +/// package-manager layout gate, the apply loop, embedded VEX, output and +/// telemetry — over a `lock` the caller already holds and the caller's +/// `client`. [`run`] takes the lock itself; agent-mode `get` and +/// `scan --apply/--sync` call this straight after their manifest write, so +/// download → manifest write → apply is ONE lock window (a same-process +/// re-acquire would contend) and the nested apply never builds a second +/// client. `lock` is released explicitly once every mutation is done +/// (output and a possibly slow telemetry POST must not keep a sibling +/// waiting), otherwise on return. +pub(crate) async fn run_locked( + args: ApplyArgs, + manifest_path: PathBuf, + client: &ApiClient, + lock: LockGuard, +) -> i32 { + let api_token = client.api_token().cloned(); + let org_slug = client.org_slug().cloned(); + // ONE parse of the manifest for the whole run — the PnP gate and the // apply loop (embedded VEX re-reads it by design, after the writes). // Apply never modifies it, so a read under the lock is final. `Ok(None)` @@ -801,7 +820,7 @@ pub async fn run(args: ApplyArgs) -> i32 { NpmPkgManager::Npm | NpmPkgManager::YarnClassic | NpmPkgManager::Unknown => {} } - match apply_patches_inner(&args, manifest, &client).await { + match apply_patches_inner(&args, manifest, client).await { Ok(ApplyOutcome { success, results, diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index fcccce2a..e873cb9a 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -14,7 +14,7 @@ use socket_patch_core::manifest::schema::{ PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, }; use socket_patch_core::patch::apply::{is_valid_blob_hash, select_installed_variants}; -use socket_patch_core::patch::apply_lock::{self, LockError}; +use socket_patch_core::patch::apply_lock::{self, LockError, LockGuard}; use socket_patch_core::telemetry::{track_patch_fetch_failed, track_patch_fetched}; use socket_patch_core::utils::purl::{ canonical_purl, is_purl, normalize_purl, strip_purl_qualifiers, @@ -718,10 +718,11 @@ pub struct DownloadParams { pub silent: bool, /// `--download-mode` value forwarded to the apply step. pub download_mode: String, - /// API client overrides — propagates the caller's CLI flags - /// (`--api-url`, `--api-token`, `--proxy-url`) into the nested API - /// client constructed here. Without this, `download_and_apply_patches` - /// would only honor env vars and ignore the user's flags. + /// The API-client flags (`--api-url`, `--api-token`, `--org`, + /// `--proxy-url`) the run's client was built from. The engines consume + /// the caller's client ([`DownloadRun`]) — the nested apply included — + /// so this is read only by a `params`-alone driver (the in-file engine + /// tests) building that same client. pub api_overrides: socket_patch_core::api::client::ApiClientEnvOverrides, /// When `false` (the default — narrow), a PyPI package with multiple /// release variants (`?artifact_id=...`) is filtered down to the one @@ -774,9 +775,10 @@ impl DownloadParams { /// included, so the engines never rebuild it from flags and repeat the org /// auto-resolve round-trip — and the flags the nested apply must inherit. pub struct DownloadRun<'a> { + /// The run's one API client; the nested apply runs on it too. pub api_client: &'a ApiClient, - /// `--lock-timeout`: the wait budget for the manifest-write lock here - /// and for the nested apply's own acquire. + /// `--lock-timeout`: the wait budget for the apply lock, taken once + /// around the manifest write and the nested apply. pub lock_timeout: Option, /// `--verbose`, forwarded to the nested apply. pub verbose: bool, @@ -1223,30 +1225,17 @@ fn fold_narrowing_into_result( } } -/// The API-client overrides for a download run: the caller's CLI flags with -/// the override org slug defaulted to `--org` when none was given. -/// -/// Shared by the client the plain engine wrappers build AND by the nested -/// `apply` step, which constructs its own client and must resolve to the -/// same endpoint/token — see [`nested_apply_args_from_params`]. -fn resolved_api_overrides( - params: &DownloadParams, -) -> socket_patch_core::api::client::ApiClientEnvOverrides { - let mut overrides = params.api_overrides.clone(); - if overrides.org_slug.is_none() { - overrides.org_slug = params.org.clone(); - } - overrides -} - /// Build the API client for a download run driven without a run-level /// client — the shape the retired 2-arg `download_*` wrappers had; kept /// for the in-file engine unit tests below, which drive `params` alone. +/// `--org` fills a missing override org, as `get`'s own client build does. #[cfg(test)] async fn api_client_for(params: &DownloadParams) -> ApiClient { - get_api_client_with_overrides(resolved_api_overrides(params)) - .await - .0 + let mut overrides = params.api_overrides.clone(); + if overrides.org_slug.is_none() { + overrides.org_slug = params.org.clone(); + } + get_api_client_with_overrides(overrides).await.0 } /// Which state store the shared fetch loop classifies each selected patch @@ -1691,10 +1680,10 @@ async fn warn_on_vendored_uuid_drift( } /// The `GlobalArgs` a nested apply runs with: the caller's flags verbatim -/// (`--lock-timeout`, `--verbose`, `--strict`, the API flags, `--ecosystems` -/// … all flow through — apply builds its own clients from these, so a token -/// supplied purely as a flag must reach it), with the fields `get` owns -/// overridden: the already-resolved manifest path (apply re-resolves a +/// (`--verbose`, `--strict`, `--ecosystems`, `--download-mode` … all flow +/// through; the API flags ride along but are inert — the nested apply runs +/// on the caller's client), with the fields `get` owns overridden: the +/// already-resolved manifest path (apply re-resolves a /// relative path against ITS `--cwd`, which double-joins ours — absolutize /// so it passes through verbatim), `silent` = quiet and `json: false` (the /// nested apply must never print a second JSON document), and `dry_run: @@ -1713,25 +1702,20 @@ fn nested_apply_args(common: &GlobalArgs, manifest_path: &Path, quiet: bool) -> } /// The caller flags a `DownloadParams` + [`DownloadRun`] pair reconstructs -/// for the nested apply (the engine never sees a `GlobalArgs`). The API -/// fields come from [`resolved_api_overrides`] so the nested apply resolves -/// to the same endpoint/token as the download. +/// for the nested apply (the engine never sees a `GlobalArgs`). No API +/// fields: the nested apply runs on the run's client (`run.api_client`), +/// which was built from the caller's flags. fn nested_apply_args_from_params( params: &DownloadParams, run: &DownloadRun<'_>, manifest_path: &Path, ) -> GlobalArgs { - let api = resolved_api_overrides(params); let common = GlobalArgs { cwd: params.cwd.clone(), global: params.global, global_prefix: params.global_prefix.clone(), download_mode: params.download_mode.clone(), strict: params.strict, - api_url: api.api_url, - api_token: api.api_token, - org: api.org_slug, - proxy_url: api.proxy_url, // Scope the nested apply like the caller was scoped: leaving this // at the default `None` made `scan --ecosystems gem --sync` apply // the WHOLE manifest, mutating other ecosystems' packages the user @@ -1744,21 +1728,29 @@ fn nested_apply_args_from_params( nested_apply_args(&common, manifest_path, params.quiet()) } -/// Run the nested `apply` step with `common` (see [`nested_apply_args`]). -/// Returns whether apply exited 0. Callers print their own "Applying -/// patches..." line (they differ on stdout vs stderr). The read-only -/// cargo-redirect verifier stays off and embedded VEX is opt-in on the -/// top-level command only, never on this internal invocation. The caller -/// must have released its own apply lock first: apply acquires its own, -/// and a same-process re-acquire contends. -async fn run_nested_apply(common: GlobalArgs, quiet: bool) -> bool { +/// Run the nested `apply` step with `common` (see [`nested_apply_args`]) +/// on the caller's `client`, under the apply `lock` the caller took for +/// its manifest write — one lock window for download → manifest write → +/// apply (a same-process re-acquire would contend), released by apply once +/// its last mutation is done. Returns whether apply exited 0. Callers print +/// their own "Applying patches..." line (they differ on stdout vs stderr). +/// The read-only cargo-redirect verifier stays off and embedded VEX is +/// opt-in on the top-level command only, never on this internal +/// invocation. +async fn run_nested_apply( + common: GlobalArgs, + quiet: bool, + client: &ApiClient, + lock: LockGuard, +) -> bool { + let manifest_path = common.resolved_manifest_path(); let apply_args = super::apply::ApplyArgs { common, force: false, check: false, vex: Default::default(), }; - let code = super::apply::run(apply_args).await; + let code = super::apply::run_locked(apply_args, manifest_path, client, lock).await; if code != 0 && !quiet { eprintln!("\nSome patches could not be applied."); } @@ -1786,9 +1778,8 @@ pub async fn download_and_apply_patches_with( // it, and an unlocked writer here lost their update or had its own // record clobbered. `acquire` creates `.socket/` itself; the guard's // drop removes `apply.lock` and prunes an otherwise-empty `.socket/`, so - // a run that records nothing leaves no residue. Released BEFORE the - // nested apply, which takes its own lock (a same-process re-acquire - // would contend). + // a run that records nothing leaves no residue. The nested apply runs + // under this SAME guard (one lock window; see `run_nested_apply`). let guard = match apply_lock::acquire(&socket_dir, lock_timeout) { Ok(guard) => guard, Err(e) => return (1, report_lock_failure(params.json, &e, lock_timeout)), @@ -1857,7 +1848,15 @@ pub async fn download_and_apply_patches_with( return (1, err_json); } } - drop(guard); + // The lock outlives the manifest write only when a nested apply follows + // (it is handed the guard and releases it after its last mutation); + // otherwise nothing more is written and it is released here. + let apply_lock = if !params.save_only && downloaded > 0 { + Some(guard) + } else { + drop(guard); + None + }; // Vendored-uuid drift: an explicit `get` is allowed to move the // manifest past the patch uuid the vendor ledger still wires (the user @@ -1883,15 +1882,17 @@ pub async fn download_and_apply_patches_with( } } - // Auto-apply unless --save-only + // Auto-apply unless --save-only (the lock decision above). let mut apply_succeeded = false; - if !params.save_only && downloaded > 0 { + if let Some(lock) = apply_lock { if !quiet { eprintln!("\nApplying patches..."); } apply_succeeded = run_nested_apply( nested_apply_args_from_params(params, run, &manifest_path), quiet, + run.api_client, + lock, ) .await; } @@ -2093,14 +2094,16 @@ pub async fn run(args: GetArgs) -> i32 { telemetry_org.as_deref(), ) .await; - // Mode dispatch. All three reuse THIS fetched patch (and, - // for hosted, this possibly-proxy-fallback client) rather - // than re-fetching with a fresh client, which would re-hit - // the 401/403 the fallback just recovered from. An explicit + // Mode dispatch. All three reuse THIS fetched patch and + // this possibly-proxy-fallback client rather than + // re-fetching with a fresh one, which would re-hit the + // 401/403 the fallback just recovered from. An explicit // UUID is exempt from installed narrowing (exact intent). return match mode { // Save to manifest and apply in place (today's flow). - super::scan::ScanMode::Agent => save_and_apply_patch(&args, &patch).await, + super::scan::ScanMode::Agent => { + save_and_apply_patch(&args, &api_client, &patch).await + } super::scan::ScanMode::Hosted => { let selected = vec![search_result_from_response(&patch)]; run_get_hosted(&args, &api_client, &selected, &[], &[]).await @@ -2556,25 +2559,18 @@ fn display_search_results(patches: &[PatchSearchResult], can_access_paid: bool) /// caller's client may have fallen back to the public proxy after a /// 401/403, and a fresh client would hit the same auth failure again. A /// same-uuid re-get writes nothing (matching the multi-patch engine's -/// `skipped`); the lock is released on return, before the nested apply -/// takes its own. +/// `skipped`). Runs under the apply lock the caller (`save_and_apply_patch`) +/// holds — the RMW must be serialized against `remove`/`rollback`, and the +/// nested apply then runs under that same guard. /// /// Errors are reported here and surface as `Err(exit_code)`. -async fn save_patch_record(args: &GetArgs, patch: &PatchResponse) -> Result { - let manifest_path = args.common.resolved_manifest_path(); - let socket_dir = manifest_path - .parent() - .unwrap_or(Path::new(".")) - .to_path_buf(); - let lock_timeout = Duration::from_secs(args.common.lock_timeout.unwrap_or(0)); - // See `download_and_apply_patches_with`: the RMW runs under the lock, - // which also creates `.socket/` and prunes it again when nothing lands. - let _guard = apply_lock::acquire(&socket_dir, lock_timeout).map_err(|e| { - report_lock_failure(args.common.json, &e, lock_timeout); - 1 - })?; - - let mut manifest = match read_manifest(&manifest_path).await { +async fn save_patch_record( + args: &GetArgs, + manifest_path: &Path, + socket_dir: &Path, + patch: &PatchResponse, +) -> Result { + let mut manifest = match read_manifest(manifest_path).await { Ok(Some(m)) => m, Ok(None) => PatchManifest::new(), // Fail closed like the download flow: an unreadable manifest @@ -2645,24 +2641,48 @@ async fn save_patch_record(args: &GetArgs, patch: &PatchResponse) -> Result i32 { +/// The uuid path's agent arm: record `patch` in the manifest and, unless +/// `--save-only`, apply it — under ONE apply lock, on the `client` the +/// fetch used (a fresh client could re-hit the 401/403 its proxy fallback +/// just recovered from). +async fn save_and_apply_patch(args: &GetArgs, client: &ApiClient, patch: &PatchResponse) -> i32 { // Same "errors only" gate as `run` — informational prints respect // `--silent`; errors and the JSON envelope do not. let quiet = args.common.json || args.common.silent; let manifest_path = args.common.resolved_manifest_path(); + let socket_dir = args.common.socket_dir(); + let lock_timeout = Duration::from_secs(args.common.lock_timeout.unwrap_or(0)); + // See `download_and_apply_patches_with`: the RMW runs under the lock, + // which also creates `.socket/` and prunes it again when nothing lands; + // an error return below drops the guard. + let guard = match apply_lock::acquire(&socket_dir, lock_timeout) { + Ok(guard) => guard, + Err(e) => { + report_lock_failure(args.common.json, &e, lock_timeout); + return 1; + } + }; - let action = match save_patch_record(args, patch).await { + let action = match save_patch_record(args, &manifest_path, &socket_dir, patch).await { Ok(action) => action, Err(code) => return code, }; let changed = action != PatchAction::Skipped; + // Carried into the nested apply when one follows (it releases the lock + // after its last mutation), released here otherwise. + let apply_lock = if !args.save_only && changed { + Some(guard) + } else { + drop(guard); + None + }; let action_label = match &action { PatchAction::Added => "added", PatchAction::Updated { .. } => "updated", @@ -2700,13 +2720,15 @@ async fn save_and_apply_patch(args: &GetArgs, patch: &PatchResponse) -> i32 { } let mut apply_succeeded = false; - if !args.save_only && changed { + if let Some(lock) = apply_lock { if !quiet { println!("\nApplying patches..."); } apply_succeeded = run_nested_apply( nested_apply_args(&args.common, &manifest_path, quiet), quiet, + client, + lock, ) .await; } @@ -4273,10 +4295,8 @@ mod tests { ); } - // --- resolved_api_overrides -------------------------------------------- - // The org the nested client resolves to is behavior-bearing: an explicit - // override wins; otherwise `--org` (params.org) fills the gap. - + /// Engine params with `--org` / an explicit override org, for the + /// nested-apply arg tests below. fn dl_params_for_org(org: Option, org_slug: Option) -> DownloadParams { DownloadParams { cwd: PathBuf::from("."), @@ -4301,26 +4321,6 @@ mod tests { } } - #[test] - fn resolved_api_overrides_falls_back_to_params_org() { - let p = dl_params_for_org(Some("from-org".into()), None); - assert_eq!( - resolved_api_overrides(&p).org_slug.as_deref(), - Some("from-org"), - "a missing override org must fall back to --org" - ); - } - - #[test] - fn resolved_api_overrides_explicit_org_slug_wins() { - let p = dl_params_for_org(Some("from-org".into()), Some("explicit".into())); - assert_eq!( - resolved_api_overrides(&p).org_slug.as_deref(), - Some("explicit"), - "an explicit override org must not be clobbered by --org" - ); - } - // --- format_patch_option: vulnerability summaries in the option lines -- #[test] @@ -5365,8 +5365,8 @@ mod tests { ); } - /// The nested apply inherits the caller's flags verbatim (`--lock-timeout` - /// and `--verbose` were dropped when its args were rebuilt from Default), + /// The nested apply inherits the caller's flags verbatim (`--verbose` + /// and `--strict` were dropped when its args were rebuilt from Default), /// with `json`/`dry_run` forced off — one JSON document per run, and /// agent-mode `get` ignores `--dry-run` — `silent` following the caller's /// quiet gate, and the manifest path absolutized so apply does not @@ -5374,25 +5374,17 @@ mod tests { #[test] fn nested_apply_args_flow_caller_flags_and_force_a_real_quiet_apply() { let common = GlobalArgs { - lock_timeout: Some(30), verbose: true, strict: true, json: true, dry_run: true, - api_token: Some("flag-token".into()), ..GlobalArgs::default() }; let nested = nested_apply_args(&common, Path::new("proj/.socket/manifest.json"), true); - assert_eq!( - nested.lock_timeout, - Some(30), - "--lock-timeout must reach the nested apply" - ); assert!( nested.verbose && nested.strict, "--verbose / --strict must flow through" ); - assert_eq!(nested.api_token.as_deref(), Some("flag-token")); assert!( !nested.json && !nested.dry_run, "the nested apply is always a real, non-JSON run" @@ -5406,11 +5398,11 @@ mod tests { } /// The engine's variant rebuilds the same shape from `DownloadParams` + - /// `DownloadRun`: the API flags via `resolved_api_overrides` (so `--org` - /// fills a missing override org), the run's lock/verbosity flags, and - /// quiet = json || silent. + /// `DownloadRun`: the run's verbosity flag, the caller's scope/mode + /// flags, and quiet = json || silent. No API fields: the nested apply + /// runs on the run's client, so `--org` need not be re-threaded. #[test] - fn nested_apply_args_from_params_carry_run_flags_and_resolved_api_overrides() { + fn nested_apply_args_from_params_carry_run_flags() { let client = ApiClient::new(socket_patch_core::api::client::ApiClientOptions { api_url: "http://127.0.0.1:1".into(), api_token: None, @@ -5425,12 +5417,10 @@ mod tests { let params = dl_params_for_org(Some("from-org".into()), None); let nested = nested_apply_args_from_params(¶ms, &run, Path::new(".socket/manifest.json")); - assert_eq!(nested.lock_timeout, Some(7)); assert!(nested.verbose); - assert_eq!( - nested.org.as_deref(), - Some("from-org"), - "a missing override org must fall back to --org" + assert!( + nested.org.is_none() && nested.api_token.is_none(), + "API fields are not re-threaded: the nested apply runs on the run's client" ); assert_eq!(nested.download_mode, "diff"); assert!(nested.silent, "json || silent params run a quiet apply"); diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 7db9c54b..ed3362e6 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -3,7 +3,6 @@ use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::redirect::{RedirectState, REDIRECT_STATE_REL}; use socket_patch_core::telemetry::track_patch_listed; -use socket_patch_core::utils::socket_cli_config; use socket_patch_core::vendor::state::{VendorEntry, VENDOR_STATE_REL}; use crate::args::{apply_env_toggles, GlobalArgs}; @@ -172,62 +171,6 @@ fn build_list_envelope(entries: &[ListEntry<'_>]) -> Envelope { env } -/// Resolve the credentials the `patch_listed` telemetry event is attributed -/// to: `--api-token` / `--org` (clap already folds in `SOCKET_API_TOKEN` / -/// `SOCKET_ORG_SLUG` and their promoted `SOCKET_CLI_*` aliases), then the -/// socket-cli `config.json` written by `socket login`. -/// -/// The config layer is part of the contract for both settings ("Persisted -/// configuration" in CLI_CONTRACT.md), and -/// `telemetry::resolve_telemetry_endpoint` only uses the org-scoped -/// `/v0/orgs//telemetry` endpoint when BOTH a token and a slug reach -/// it. Passing the raw flag values here skipped the config layer, so a -/// caller authenticated by `socket login` alone had every `list` reported -/// anonymously to the public patch proxy — while `apply`/`repair`/`remove`/ -/// `rollback` (which take theirs from `get_api_client_with_overrides`) -/// reported to that caller's org. With an on-prem `apiBaseUrl` that also -/// broke the "telemetry can never target a different host than the client" -/// property, sending the event off to `patches-api.socket.dev` instead. -/// -/// The API client is deliberately NOT built to get these: `list` is a purely -/// local read, and constructing one would add the org-slug auto-resolve -/// round-trip and the "No SOCKET_API_TOKEN set" advisory to a command that -/// needs neither. Only the two credential lookups are mirrored — including -/// the `SOCKET_NO_API_TOKEN` veto over *ambient* tokens (`main` scrubs the -/// env var for the flag layer; core applies the same veto to the config -/// layer) and the `--debug` echo naming the resolution source. -pub(crate) fn telemetry_credentials(common: &GlobalArgs) -> (Option, Option) { - let api_token = common - .api_token - .clone() - .filter(|t| !t.is_empty()) - .or_else(|| { - if socket_cli_config::no_api_token_veto() { - return None; - } - socket_cli_config::load() - .and_then(|c| c.api_token.clone()) - .inspect(|_| { - if common.debug { - eprintln!( - "[socket-patch debug] api token: from socket-cli config \ - (`socket login`)" - ); - } - }) - }); - let org_slug = common.org.clone().filter(|s| !s.is_empty()).or_else(|| { - socket_cli_config::load() - .and_then(|c| c.default_org.clone()) - .inspect(|slug| { - if common.debug { - eprintln!("[socket-patch debug] org slug: `{slug}` from socket-cli config"); - } - }) - }); - (api_token, org_slug) -} - /// Emit the top-level envelope for `list` in error states. Used for the /// "manifest not found" and "manifest unreadable" paths so they share /// the same JSON shape as a successful list. @@ -323,7 +266,7 @@ pub async fn run(args: ListArgs) -> i32 { // purls present in both stores. Hosted visibility, if wanted, belongs // in a new dedicated field. let manifest_patch_count = manifest.as_ref().map_or(0, |m| m.patches.len()); - let (api_token, org_slug) = telemetry_credentials(&args.common); + let (api_token, org_slug) = args.common.telemetry_credentials(); track_patch_listed( manifest_patch_count, api_token.as_deref(), @@ -615,45 +558,6 @@ mod tests { assert_eq!(paths, vec!["z/a.js", "z/b.js"]); } - // -- Telemetry credential resolution --------------------------------- - // The socket-cli `config.json` layer is exercised end-to-end (it is read - // once per process, so it needs a subprocess) by - // `tests/cli_config_fallback.rs::list_telemetry_follows_socket_cli_login`. - // These pin the two layers above it, which need no fixture. - - /// Explicit values — the flag, or the env var clap folds into the same - /// field — are used verbatim, never overridden by a lower layer. - #[test] - fn telemetry_credentials_prefer_explicit_values() { - let common = GlobalArgs { - api_token: Some("sktsec_flag_api".to_string()), - org: Some("flag-org".to_string()), - ..GlobalArgs::default() - }; - assert_eq!( - telemetry_credentials(&common), - ( - Some("sktsec_flag_api".to_string()), - Some("flag-org".to_string()) - ) - ); - } - - /// Empty means "unset" repo-wide, so an empty value must never be - /// forwarded: `Some("")` would build a malformed `/v0/orgs//telemetry` - /// URL and an empty `Bearer ` header. - #[test] - fn telemetry_credentials_treat_empty_as_unset() { - let common = GlobalArgs { - api_token: Some(String::new()), - org: Some(String::new()), - ..GlobalArgs::default() - }; - let (api_token, org_slug) = telemetry_credentials(&common); - assert_ne!(api_token.as_deref(), Some("")); - assert_ne!(org_slug.as_deref(), Some("")); - } - /// Hosted redirect-ledger records fold into the envelope labeled apart /// from manifest entries: `details.mode` / `details.ledger` ride the /// hosted events ONLY (additive keys), and the global purl sort holds diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index c266089f..0504d171 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -1112,9 +1112,9 @@ pub(crate) async fn repair_vendored_artifacts_with_references( }; // Ledger keys are the manifest spelling — QUALIFIED for release-variant // ecosystems (gem `?platform=`, pypi `?artifact_id=`, maven - // `?classifier=&ext=`) — while the crawler knows only base purls. - // `find_packages_for_purls` keys its result by the base purl, so the - // `contains_key(&c.purl)` checks below would miss every installed + // `?classifier=&ext=`) — while the crawler knows only base purls. A + // base-keyed result map would make the `contains_key(&c.purl)` checks + // below miss every installed // qualified-key package and fall through to a needless registry fetch // (or, offline, a spurious unrepairable / fingerprint-less restore). // The rollback variant fans each base path back out to every qualified diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index 9b70df55..454fbe04 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -29,7 +29,7 @@ use std::time::Duration; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::ecosystem_dispatch::find_manifest_package_paths; -use crate::output::stdin_is_tty; +use crate::output::{read_yes_no, stdin_is_tty}; /// Stringify the detected npm-family manager for telemetry. fn manager_name(pm: PackageManager) -> &'static str { @@ -187,15 +187,8 @@ fn confirm_proceed(prompt: &str) -> bool { io::stdout() .flush() .expect("failed to write the confirmation prompt to stdout"); - let mut answer = String::new(); - if io::stdin().read_line(&mut answer).is_err() { - // Terminals can deliver non-UTF-8 bytes (e.g. a Latin-1 paste); - // `read_line` reports those as InvalidData. Treat any read - // failure like an unrecognized answer (abort), not a panic. - return false; - } - let answer = answer.trim().to_lowercase(); - answer == "y" || answer == "yes" + // Only an explicit yes proceeds: empty and unreadable answers abort. + read_yes_no() == Some(true) } /// Whether an ecosystem is in scope for this run, honoring the global @@ -642,22 +635,39 @@ async fn discover_gem_project(common: &GlobalArgs) -> Option Option<(gem::BundlerProject, gem::BundlerProbe)> { + let project = discover_gem_project(common).await?; + let probe = gem::probe_bundler(&project).await; + Some((project, probe)) +} + +/// What the gem branch does to a project. +enum GemEdit<'a> { + /// Wire the plugin; `add_plugin_directive_with` refuses below the + /// bundler floor, judged from the run's probe. + Add(&'a gem::BundlerProbe), + /// Unwire. Deliberately ungated and never probes: it is the recovery + /// path for an already-wired bundler-1.x project. + Remove, +} + /// Build the gem branch's contribution to a setup/remove run: add (or remove) /// the managed `plugin "socket-patch"` block in the Gemfile + the generated -/// `.socket/bundler-plugin/` plugin files. `project` comes from -/// [`discover_gem_project`] and `probe` from ONE `gem::probe_bundler` per -/// run, so the preview and the real edit spawn `bundle --version` at most -/// once between them. `probe` is `None` on the remove path: -/// `remove_plugin_directive` is deliberately ungated (it is the recovery -/// path for an already-wired bundler-1.x project) and never probes. +/// `.socket/bundler-plugin/` plugin files. `target` is the discovered project +/// with the edit to make ([`discover_gem_target`] pairs the add path with the +/// run's one probe); `None` when the project has no Gemfile. async fn build_gem_outcome( common: &GlobalArgs, - project: Option<&gem::BundlerProject>, - probe: Option<&gem::BundlerProbe>, - remove: bool, + target: Option<(&gem::BundlerProject, GemEdit<'_>)>, dry_run: bool, ) -> SetupOutcome { - let Some(project) = project else { + let Some((project, edit)) = target else { return SetupOutcome::default(); }; @@ -666,10 +676,10 @@ async fn build_gem_outcome( ..Default::default() }; - let results = match (remove, probe) { - (true, _) => gem::remove_plugin_directive(project, dry_run).await, - (false, Some(probe)) => gem::add_plugin_directive_with(project, probe, dry_run).await, - (false, None) => gem::add_plugin_directive(project, dry_run).await, + let remove = matches!(edit, GemEdit::Remove); + let results = match edit { + GemEdit::Add(probe) => gem::add_plugin_directive_with(project, probe, dry_run).await, + GemEdit::Remove => gem::remove_plugin_directive(project, dry_run).await, }; let mut added_paths: Vec = Vec::new(); @@ -1230,7 +1240,12 @@ async fn run_remove(args: &SetupArgs) -> i32 { // removal below share them. let gem_project = discover_gem_project(common).await; let composer_json = discover_composer_json(common).await; - let gem_preview = build_gem_outcome(common, gem_project.as_ref(), None, true, true).await; + let gem_preview = build_gem_outcome( + common, + gem_project.as_ref().map(|p| (p, GemEdit::Remove)), + true, + ) + .await; let composer_preview = build_composer_outcome(common, composer_json.as_deref(), true, true).await; if npm_files.is_empty() @@ -1341,7 +1356,12 @@ async fn run_remove(args: &SetupArgs) -> i32 { // Real gem + composer removal (gem Gemfile `plugin` block + generated plugin // dir; composer.json script-event command). let extra_results = merge_outcomes( - build_gem_outcome(common, gem_project.as_ref(), None, true, false).await, + build_gem_outcome( + common, + gem_project.as_ref().map(|p| (p, GemEdit::Remove)), + false, + ) + .await, build_composer_outcome(common, composer_json.as_deref(), true, false).await, ); @@ -1637,18 +1657,13 @@ async fn run_setup(args: &SetupArgs) -> i32 { let excludes = effective_excludes(manifest_view(&existing), &args.exclude); let npm_files = discover(args, &excludes).await; let py_plan = plan_python(common).await; - // Gem + Composer projects are discovered ONCE and bundler probed ONCE (a - // Gemfile.lock read, or a `bundle --version` spawn bounded by its - // timeout): the preview and the real edit below share both. - let gem_project = discover_gem_project(common).await; - let gem_probe = match &gem_project { - Some(project) => Some(gem::probe_bundler(project).await), - None => None, - }; + // Gem + Composer projects are discovered ONCE and bundler probed ONCE: + // the preview and the real edit below share both. + let gem = discover_gem_target(common).await; + let gem_add = || gem.as_ref().map(|(project, probe)| (project, GemEdit::Add(probe))); let composer_json = discover_composer_json(common).await; // Gem + Composer previews (dry-run); `.present` also tells us each project exists. - let gem_preview = - build_gem_outcome(common, gem_project.as_ref(), gem_probe.as_ref(), false, true).await; + let gem_preview = build_gem_outcome(common, gem_add(), true).await; let composer_preview = build_composer_outcome(common, composer_json.as_deref(), false, true).await; @@ -1818,7 +1833,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { // Real gem + composer edits (gem Gemfile `plugin` block + generated plugin // dir; composer.json script-event command). let extra_results = merge_outcomes( - build_gem_outcome(common, gem_project.as_ref(), gem_probe.as_ref(), false, false).await, + build_gem_outcome(common, gem_add(), false).await, build_composer_outcome(common, composer_json.as_deref(), false, false).await, ); @@ -1918,7 +1933,7 @@ async fn track_setup_success( npm_pm: PackageManager, ) { let manager = telemetry_manager_str(npm, py, gem, composer, npm_pm); - let (token, org) = crate::commands::list::telemetry_credentials(common); + let (token, org) = common.telemetry_credentials(); track_patch_setup(&manager, token.as_deref(), org.as_deref()).await; } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 0ea23305..06b3480a 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -878,12 +878,12 @@ pub(crate) async fn vendor_records( global: common.global, global_prefix: common.global_prefix.clone(), }; - // Resolve installed packages with the qualified-purl-aware resolver, NOT - // `find_packages_for_purls`: the manifest keys release-variant ecosystems - // (gem `?platform=`, pypi `?artifact_id=`, maven `?classifier=&ext=`) by - // *qualified* purls, but the crawler only knows the *base* purl. - // `find_packages_for_purls` keys the result map by the base purl, so the - // `missing`/`contains_key` check below would miss every installed + // Resolve installed packages with the qualified-purl-aware resolver, never + // a base-keyed one: the manifest keys release-variant ecosystems (gem + // `?platform=`, pypi `?artifact_id=`, maven `?classifier=&ext=`) by + // *qualified* purls, but the crawler only knows the *base* purl. A + // base-keyed result map would make the `missing`/`contains_key` check + // below miss every installed // qualified-purl package and falsely classify it "not installed" — // triggering a spurious `vendor_fetched_missing`, a redundant per-run // registry download, and (for gem) a HashMap-order platform coin-flip. diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 1414012b..2906ae31 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -547,7 +547,7 @@ async fn generate_vex( ) { Some(doc) => doc, None => { - let (token, org) = crate::commands::list::telemetry_credentials(common); + let (token, org) = common.telemetry_credentials(); track_vex_failed("no_applicable_patches", token.as_deref(), org.as_deref()).await; // When nothing attested and EVERY omission was the property-7 // filter, say so: those patches ARE applied with vulnerability @@ -613,7 +613,7 @@ async fn generate_vex( } }; - let (token, org) = crate::commands::list::telemetry_credentials(common); + let (token, org) = common.telemetry_credentials(); track_vex_generated( doc.statements.len(), "openvex-0.2.0", @@ -788,7 +788,7 @@ async fn augment_with_redirect( /// `list`/`setup` (flag / env / socket-cli `config.json`), not the raw /// flags — a `socket login`-only user must not report anonymously. async fn fail(common: &GlobalArgs, code: &'static str, message: String) -> VexGenError { - let (token, org) = crate::commands::list::telemetry_credentials(common); + let (token, org) = common.telemetry_credentials(); track_vex_failed(code, token.as_deref(), org.as_deref()).await; VexGenError { code, diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 8a417973..c4fb84d6 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -429,29 +429,15 @@ pub async fn find_all_packages_for_rollback( dispatch_find(partitioned, options, silent, merge_qualified).await } -/// For each ecosystem in the partitioned map, create the crawler, discover -/// source paths, and look up the given PURLs. Returns a unified -/// `purl -> path` map (one representative copy per PURL). -/// -/// No production caller since `repair` moved onto the qualified-aware -/// [`find_packages_for_rollback`] (ledger keys are qualified for -/// release-variant ecosystems, and this base-keyed map never matched them); -/// kept for the in-file tests that pin that contrast. Integration pass: -/// delete it or make it `#[cfg(test)]`. -#[allow(dead_code)] -pub async fn find_packages_for_purls( - partitioned: &HashMap>, - options: &CrawlerOptions, - silent: bool, -) -> HashMap { - collapse_to_first(find_all_packages_for_purls(partitioned, options, silent).await) -} - -/// Variant of `find_packages_for_purls` for rollback and narrow-release -/// resolution, which needs to remap qualified PURLs (PyPI -/// `?artifact_id=`, RubyGems `?platform=`, Maven `?classifier=&ext=`) to -/// the base PURL found by the crawler. Returns one representative copy per -/// PURL. +/// Qualified-aware PURL resolution for rollback, vendor, repair and +/// narrow-release lookups: remaps qualified PURLs (PyPI `?artifact_id=`, +/// RubyGems `?platform=`, Maven `?classifier=&ext=`) to the base PURL the +/// crawler found, keyed back by the caller's qualified spelling. Returns one +/// representative copy per PURL. (Its base-keyed twin, +/// `collapse_to_first(find_all_packages_for_purls(..))`, has no production +/// caller: manifest and ledger keys are qualified for the release-variant +/// ecosystems, and a base-keyed map never matched them — the in-file tests +/// pin that contrast.) pub async fn find_packages_for_rollback( partitioned: &HashMap>, options: &CrawlerOptions, @@ -462,12 +448,12 @@ pub async fn find_packages_for_rollback( /// Resolve manifest PURLs to their installed on-disk paths (partition, /// build crawler options from the global args, dispatch). Uses the -/// rollback (qualified-aware) resolver, NOT `find_packages_for_purls`: -/// release-variant ecosystems (PyPI / RubyGems / Maven) key the manifest -/// by *qualified* PURLs (`?artifact_id=`, `?platform=`, -/// `?classifier=&ext=`), but the crawler only knows the *base* PURL. -/// `find_packages_for_purls` would key the result map by the base PURL, -/// so qualified manifest lookups would all miss and every PyPI/Gem/Maven +/// rollback (qualified-aware) resolver, never a base-keyed collapse of +/// [`find_all_packages_for_purls`]: release-variant ecosystems (PyPI / +/// RubyGems / Maven) key the manifest by *qualified* PURLs +/// (`?artifact_id=`, `?platform=`, `?classifier=&ext=`), but the crawler +/// only knows the *base* PURL. A base-keyed result map would make every +/// qualified manifest lookup miss, so every PyPI/Gem/Maven /// patch would silently resolve as `package_not_found`. The rollback /// variant fans each base path back out to every qualified manifest PURL /// — the same mapping the manifest was written with (`get` uses the same @@ -1050,6 +1036,18 @@ mod tests { } } + /// The base-keyed PURL lookup (`find_all_packages_for_purls` collapsed + /// to one copy per PURL). No production caller — every resolver keys by + /// the caller's qualified spelling — kept here so the tests below can + /// pin the contrast with [`find_packages_for_rollback`]. + async fn find_packages_for_purls( + partitioned: &HashMap>, + options: &CrawlerOptions, + silent: bool, + ) -> HashMap { + collapse_to_first(find_all_packages_for_purls(partitioned, options, silent).await) + } + #[tokio::test] async fn find_packages_for_purls_maps_npm_purl_to_install_dir() { let tmp = tempfile::tempdir().unwrap(); @@ -1228,13 +1226,14 @@ mod tests { "installed qualified gem must resolve under its qualified key" ); - // The old resolver keyed by the BASE PURL only, so a `contains_key` - // on the qualified PURL missed — the exact false "not installed". + // A base-keyed collapse keys by the BASE PURL only, so a + // `contains_key` on the qualified PURL misses — the exact false + // "not installed" the retired resolver produced. let base_keyed = find_packages_for_purls(&partitioned, &options, true).await; assert!( !base_keyed.contains_key(&qualified), - "find_packages_for_purls must NOT be used by vendor: it keys by \ - the base PURL, so the qualified lookup falsely misses" + "a base-keyed lookup must NOT serve vendor: it keys by the base \ + PURL, so the qualified lookup falsely misses" ); } @@ -1375,9 +1374,9 @@ mod tests { } } - /// The PURL-lookup path (`find_packages_for_purls` — apply/vendor's - /// resolver) must resolve a maven package from a local repository with - /// no env opt-in of any kind. + /// The PURL-lookup path (`find_all_packages_for_purls`, the dispatch + /// behind every resolver) must resolve a maven package from a local + /// repository with no env opt-in of any kind. #[tokio::test] #[serial_test::serial(maven_repo_env)] async fn find_packages_resolves_maven_without_any_opt_in() { diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index 550b068b..fc60998c 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -80,8 +80,7 @@ pub enum Commands { /// Remove a patch from the manifest by PURL or UUID (rolls back files first) Remove(commands::remove::RemoveArgs), - /// Download missing blobs, clean up unused blobs, and reset the - /// advisory lock state. + /// Download missing blobs and clean up unused blobs. /// /// `repair` (alias `gc`) is a first-class command for cleaning up /// the `.socket/` directory without running a scan. For the diff --git a/crates/socket-patch-cli/src/output.rs b/crates/socket-patch-cli/src/output.rs index e21b7864..a1265c8c 100644 --- a/crates/socket-patch-cli/src/output.rs +++ b/crates/socket-patch-cli/src/output.rs @@ -75,18 +75,27 @@ pub(crate) fn confirm(prompt: &str, default_yes: bool, skip_prompt: bool, is_jso io::stderr() .flush() .expect("stderr is unbuffered, so flush cannot fail"); + // An empty answer takes the default; an unreadable one declines. + read_yes_no().unwrap_or(default_yes) +} + +/// Read one yes/no answer from stdin: `Some(true)` for `y`/`yes` (any +/// case, surrounding whitespace ignored), `Some(false)` for any other +/// answer — including a line that could not be read: terminals can deliver +/// non-UTF-8 bytes (a Latin-1 paste), which `read_line` reports as +/// `InvalidData`, and that is a decline, never a panic — and `None` when +/// nothing was answered (an empty line), which callers map to their own +/// default. +pub(crate) fn read_yes_no() -> Option { let mut answer = String::new(); if io::stdin().read_line(&mut answer).is_err() { - // Terminals can deliver non-UTF-8 bytes (e.g. a Latin-1 paste); - // `read_line` reports those as InvalidData. Treat any read - // failure like an unrecognized answer (decline), not a panic. - return false; + return Some(false); } let answer = answer.trim().to_lowercase(); if answer.is_empty() { - return default_yes; + return None; } - answer == "y" || answer == "yes" + Some(answer == "y" || answer == "yes") } /// Prompt the user to select one option from a list using dialoguer. diff --git a/crates/socket-patch-cli/tests/covgap_setup_gem_version.rs b/crates/socket-patch-cli/tests/covgap_setup_gem_version.rs index 00555461..846ec3f0 100644 --- a/crates/socket-patch-cli/tests/covgap_setup_gem_version.rs +++ b/crates/socket-patch-cli/tests/covgap_setup_gem_version.rs @@ -70,15 +70,18 @@ fn run_setup_with_bundle_shim(cwd: &Path, bin_dir: &Path) -> (i32, serde_json::V (code, v) } -/// Assert the shim's argv log shows the probe ran exactly `bundle --version` -/// — proof the verdict under test came from the machine probe, not from some -/// other source (or from the real host bundler further down PATH). +/// Assert the shim's argv log shows the probe ran `bundle --version` exactly +/// ONCE — proof the verdict under test came from the machine probe, not from +/// some other source (or from the real host bundler further down PATH), and +/// that setup probes once per run: the dry-run preview and the real edit +/// share the one probe instead of each spawning their own. fn assert_probe_spawned_bundle_version(log: &Path) { let argvs = std::fs::read_to_string(log) .expect("the bundle shim must have been invoked (argv log missing)"); - assert!( - argvs.lines().any(|l| l == "--version"), - "the machine probe must spawn `bundle --version`; argv log:\n{argvs}" + assert_eq!( + argvs.lines().filter(|l| *l == "--version").count(), + 1, + "the machine probe must spawn `bundle --version` exactly once per run; argv log:\n{argvs}" ); } diff --git a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs index 97dd3972..149da66b 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs @@ -1048,7 +1048,7 @@ fn apply_without_manifest_on_pnp_project_refuses_loudly() { } /// Human-mode twin of the no-manifest refusal: exit 1 with the stderr -/// pointer, and the old calm "No .socket folder found" message must not be +/// pointer, and the calm "No patch manifest found" no-op line must not be /// what the user sees instead. #[test] fn apply_without_manifest_on_pnp_project_refuses_in_human_mode() { @@ -1061,7 +1061,7 @@ fn apply_without_manifest_on_pnp_project_refuses_in_human_mode() { "expected exit 1.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); assert!( - !stdout.contains("No .socket folder found"), + !stdout.contains("No patch manifest found"), "the calm noManifest message must not mask the PnP refusal, got:\n{stdout}" ); assert!( diff --git a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs index e5fa681c..b3324566 100644 --- a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs +++ b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs @@ -1,6 +1,6 @@ //! End-to-end tests that exercise every ecosystem dispatch branch in -//! `ecosystem_dispatch::find_packages_for_purls` and -//! `find_packages_for_rollback`. Each ecosystem has a separate code +//! `ecosystem_dispatch::find_all_packages_for_purls` (apply's resolver) +//! and `find_packages_for_rollback`. Each ecosystem has a separate code //! branch in those functions; this file ensures every branch executes //! at least once AND that it actually routed the PURL to the right //! ecosystem — not merely that the binary exited without crashing. diff --git a/crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs b/crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs index 608dd003..14950428 100644 --- a/crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs +++ b/crates/socket-patch-cli/tests/get_nested_apply_api_flags_e2e.rs @@ -1,13 +1,12 @@ //! `get` must forward its API-client flags into the nested `apply` step. //! -//! `get` drives `apply` in-process (`get.rs::run_nested_apply`). That step -//! builds its OWN `ApiClient` from the `GlobalArgs` it is handed -//! (`apply.rs` → `fetch_stage::stage_patch_sources` → -//! `get_api_client_with_overrides(common.api_client_overrides())`), so any -//! `--api-url` / `--api-token` / `--org` / `--proxy-url` the caller passed on -//! the COMMAND LINE has to be threaded through. Regression guard: the nested -//! `ApplyArgs` was built from `GlobalArgs::default()`, whose api fields are -//! all `None` — the nested apply silently fell back to env-var / config / +//! `get` drives `apply` in-process (`get.rs::run_nested_apply` → +//! `apply::run_locked`) on the ONE `ApiClient` the `get` run built from its +//! flags, so any `--api-url` / `--api-token` / `--org` / `--proxy-url` the +//! caller passed on the COMMAND LINE must reach the nested apply's blob +//! fetch through that client. Regression guard: the nested apply once built +//! its own client from `ApplyArgs` made of `GlobalArgs::default()`, whose +//! api fields are all `None` — it silently fell back to env-var / config / //! built-in-default resolution and never saw the user's flags. //! //! Reachable whenever the patch view does not embed `blobContent` for every diff --git a/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs b/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs index cb4afb12..7ad18fa4 100644 --- a/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs +++ b/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs @@ -846,10 +846,11 @@ async fn repair_telemetry_attributed_to_env_credentials() { std::env::set_var("SOCKET_ORG_SLUG", ORG); // The telemetry kill-switch must not be ambiently on, or the oracle // below would fail for the wrong reason (`is_telemetry_disabled` - // reads these at runtime). + // reads these at runtime — `VITEST=true` included). std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); std::env::remove_var("SOCKET_OFFLINE"); + std::env::remove_var("VITEST"); let code = repair_run(make_repair_args(tmp.path(), "file")).await; std::env::remove_var("SOCKET_API_URL"); std::env::remove_var("SOCKET_API_TOKEN"); diff --git a/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs b/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs index dc8345ca..4d8e8fbc 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs @@ -6,7 +6,7 @@ //! `rollback`. Verifies the file is restored to the original content. //! //! Exercises `find_packages_for_rollback` for every ecosystem — a -//! distinct code path from `find_packages_for_purls`. +//! distinct code path from apply's `find_all_packages_for_purls`. //! //! That distinction is only *observable* for the release-variant //! ecosystems (PyPI / RubyGems / Maven): there the rollback resolver @@ -21,7 +21,7 @@ //! //! For those three ecosystems we therefore deliberately use a QUALIFIED //! manifest PURL: a regression that swapped the rollback resolver back to -//! `find_packages_for_purls` would silently leave the file patched and +//! a base-keyed `find_all_packages_for_purls` would silently leave the file patched and //! the byte-restore assertion below would fail. With a bare PURL both //! merge functions behave identically, so the test would prove nothing — //! that is the loophole this file used to have. @@ -223,7 +223,7 @@ async fn rollback_pypi_restores_original_content() { // QUALIFIED PURL on purpose — see module header. The crawler emits the // base `pkg:pypi/rbpypi@1.0.0`; only `merge_qualified` (used by // `find_packages_for_rollback`) fans it back out to this `?artifact_id=` - // key so the manifest lookup hits. `find_packages_for_purls` + // key so the manifest lookup hits. A base-keyed `find_all_packages_for_purls` // (`merge_first_wins`) would key it under the bare base, the patch // lookup would miss, and the file below would stay patched. write_manifest_with_patch( diff --git a/crates/socket-patch-cli/tests/setup_matrix_gem.rs b/crates/socket-patch-cli/tests/setup_matrix_gem.rs index 57e29759..8edfbb8e 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_gem.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_gem.rs @@ -395,6 +395,11 @@ mod host_guard { "socket-patch was the only registered plugin: the emptied index \ must be deleted, not left as an all-empty husk" ); + assert!( + !root.join(".socket").exists(), + "remove must prune the emptied .socket/ (nothing else lived there): \ + --remove restores the pre-setup tree" + ); // ── check (after remove): needs_configuration again, exit 1 ───────── let (code, out, _) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index a7f0b2ff..c072d8e5 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -1086,18 +1086,24 @@ pub async fn get_api_client_from_env(org_slug: Option<&str>) -> (ApiClient, bool .await } -/// Like [`get_api_client_from_env`] but with explicit overrides for every -/// env-driven knob. Each `Some(value)` in `overrides` wins over the -/// corresponding env var. Used by CLI commands that expose `--api-url`, -/// `--api-token`, `--org`, `--proxy-url` flags via [`crate::utils`] in the -/// CLI crate. -pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> (ApiClient, bool) { - // Per-key fallback chain: explicit override (CLI flag) → env var → - // socket-cli config file → built-in default. Empty strings mean - // "unset" at every layer. `SOCKET_NO_API_TOKEN` vetoes the *ambient* - // token sources (env + config) so unauthenticated behavior can be - // forced without unsetting anything; an explicit override still wins. - let api_token = overrides.api_token.filter(|t| !t.is_empty()).or_else(|| { +/// The credential half of the client's fallback chain, on its own: the +/// `(api_token, org_slug)` a run authenticates and attributes telemetry +/// with. Per key: explicit override (CLI flag) → env var (`SOCKET_API_TOKEN` +/// / `SOCKET_ORG_SLUG`) → socket-cli config file (`socket login`'s +/// `apiToken` / `defaultOrg`). Empty strings mean "unset" at every layer. +/// `SOCKET_NO_API_TOKEN` vetoes the *ambient* token sources (env + config) +/// so unauthenticated behavior can be forced without unsetting anything; an +/// explicit override still wins. Each config hit is echoed in `--debug`. +/// +/// Shared by [`get_api_client_with_overrides`] and by the purely local +/// commands (`list`) that report telemetry without building a client — the +/// two must resolve identically, or a `socket login`-only caller's events +/// land on the public proxy instead of their org. +pub fn resolve_ambient_credentials( + api_token: Option, + org_slug: Option, +) -> (Option, Option) { + let api_token = api_token.filter(|t| !t.is_empty()).or_else(|| { if socket_cli_config::no_api_token_veto() { debug_log("api token: suppressed by SOCKET_NO_API_TOKEN"); return None; @@ -1113,8 +1119,7 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> }) }) }); - let resolved_org_slug = overrides - .org_slug + let org_slug = org_slug .filter(|s| !s.is_empty()) // Treat an empty slug as "not provided" (mirroring the api_token // handling above). Otherwise `SOCKET_ORG_SLUG=""` would be taken as @@ -1132,6 +1137,17 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> debug_log(&format!("org slug: `{slug}` from socket-cli config")); }) }); + (api_token, org_slug) +} + +/// Like [`get_api_client_from_env`] but with explicit overrides for every +/// env-driven knob. Each `Some(value)` in `overrides` wins over the +/// corresponding env var. Used by CLI commands that expose `--api-url`, +/// `--api-token`, `--org`, `--proxy-url` flags via [`crate::utils`] in the +/// CLI crate. +pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> (ApiClient, bool) { + let (api_token, resolved_org_slug) = + resolve_ambient_credentials(overrides.api_token, overrides.org_slug); if api_token.is_none() { let proxy_url = overrides @@ -1623,6 +1639,54 @@ mod tests { assert!(client.api_token.is_none()); } + /// Explicit values — the flag, or the env var the CLI folds into the + /// same field — are used verbatim, never overridden by a lower layer. + #[test] + #[serial_test::serial] + fn resolve_ambient_credentials_prefers_explicit_values() { + assert_eq!( + resolve_ambient_credentials( + Some("sktsec_flag_api".to_string()), + Some("flag-org".to_string()) + ), + ( + Some("sktsec_flag_api".to_string()), + Some("flag-org".to_string()) + ) + ); + } + + /// Empty means "unset" repo-wide, so an empty value must never be + /// forwarded: `Some("")` would build a malformed `/v0/orgs//telemetry` + /// URL and an empty `Bearer ` header. + #[test] + #[serial_test::serial] + fn resolve_ambient_credentials_treats_empty_as_unset() { + let (api_token, org_slug) = + resolve_ambient_credentials(Some(String::new()), Some(String::new())); + assert_ne!(api_token.as_deref(), Some("")); + assert_ne!(org_slug.as_deref(), Some("")); + } + + /// The veto suppresses the ambient token layers (env here) but leaves an + /// explicit token — and the org, which it never governs — alone. + #[test] + #[serial_test::serial] + fn resolve_ambient_credentials_veto_drops_only_ambient_tokens() { + let saved_token = std::env::var("SOCKET_API_TOKEN").ok(); + std::env::set_var("SOCKET_API_TOKEN", "sktsec_ambient_api"); + std::env::set_var("SOCKET_NO_API_TOKEN", "1"); + let vetoed = resolve_ambient_credentials(None, Some("org".to_string())); + let explicit = resolve_ambient_credentials(Some("sktsec_flag_api".to_string()), None); + std::env::remove_var("SOCKET_NO_API_TOKEN"); + match saved_token { + Some(v) => std::env::set_var("SOCKET_API_TOKEN", v), + None => std::env::remove_var("SOCKET_API_TOKEN"), + } + assert_eq!(vetoed, (None, Some("org".to_string()))); + assert_eq!(explicit.0.as_deref(), Some("sktsec_flag_api")); + } + /// An explicit override (the `--api-token` flag) survives the veto — /// `SOCKET_NO_API_TOKEN` suppresses only ambient sources. The org /// override skips auto-resolution so no network fires. diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index a7f3948c..5b78c931 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -75,7 +75,7 @@ pub struct VendorContext { /// Walk the manifest and bucket each PURL into `applied` / `failed`. /// /// `package_paths` is the CLI-supplied `purl -> on-disk package dir` -/// map (from `find_packages_for_purls`). A PURL absent from the map is +/// map (the CLI's `find_manifest_package_paths`). A PURL absent from the map is /// recorded as `package_not_found` and ends up in `failed`. pub async fn applied_patches( manifest: &PatchManifest, From a6f6245bd7872efc65ec2ed7a14075fb0eb1ef87 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 22 Sep 2026 23:24:43 -0400 Subject: [PATCH 24/44] refactor(cli/rollback+remove+repair+gc): one artifact sweep, recorded sweep failures, ledger helpers in core Integration step S4 of the cleanup (worklist T-D1, T-D2/T-G5, T-D4/T-G2, T-D5, T-D6, W-5, T-F7): - scan/gc.rs reuses rollback's sweep_unused_artifacts instead of a fourth inline GC-sweep copy; every sweep arm (repair, rollback, remove, gc) now reports per-file unlink failures through CleanupResult.failed instead of losing the partial counts behind the first error (core manifest/cleanup_blobs.rs records them after the pass). - The ledger-matching helpers (patch_matches, VendorEntry::matches_identifier / covers_purl) move into core next to lookup_entry; remove.rs and rollback.rs call them. - One .socket derivation via GlobalArgs in remove/repair/vendor/rollback (drops the wrong "." fallback under a non-default --cwd); the #[cfg(test)] path-taking rollback_patches wrapper is gone (tests use the inner engine). - New covgap_commands_get test: `get --mode hosted` under a held lock reports top-level errorCode lock_held, exit 1, and --dry-run still exits 0. - Fixed the moved patch_matches unit test: a PURL identifier is compared against the purl field only, so the negative case needs a non-matching purl. Verified: cargo check --workspace --all-targets; clippy (CI invocation) clean; core purl/state/cleanup_blobs units; CLI lib rollback/remove/repair/gc/vendor; repair_invariants, covgap_commands_repair, in_process_remove_repair_lifecycle, covgap_commands_rollback/remove, rollback(_duality)_invariants, cli_remove_silent, covgap_commands_get, scan_vendor_e2e all green. Co-Authored-By: Claude Fable 5.1 --- .../socket-patch-cli/src/commands/remove.rs | 89 +++------ .../socket-patch-cli/src/commands/repair.rs | 76 ++++--- .../socket-patch-cli/src/commands/rollback.rs | 188 +++++++++--------- .../socket-patch-cli/src/commands/scan/gc.rs | 46 +++-- .../socket-patch-cli/src/commands/vendor.rs | 9 +- .../tests/covgap_commands_get.rs | 105 ++++++++++ .../src/manifest/cleanup_blobs.rs | 56 ++++-- crates/socket-patch-core/src/utils/purl.rs | 37 ++++ crates/socket-patch-core/src/vendor/state.rs | 65 +++++- 9 files changed, 439 insertions(+), 232 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 8545ca56..6a26e2d6 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -7,62 +7,32 @@ use socket_patch_core::patch::redirect::{ load_redirect_state, persist_redirect_state, RedirectState, REDIRECT_STATE_REL, }; use socket_patch_core::telemetry::{track_patch_remove_failed, track_patch_removed}; -use socket_patch_core::utils::purl::{purl_matches_identifier, strip_purl_qualifiers}; +use socket_patch_core::utils::purl::patch_matches; use socket_patch_core::vendor::{ load_state, RevertOpts, VendorEntry, VendorState, VENDOR_STATE_REL, }; use std::collections::HashSet; -use std::path::Path; use std::time::Duration; use super::get::short_uuid; use super::rollback::{ all_files_already_original, pin_before_hash_blobs, revert_vendor_entry, - rollback_patches_inner, run_hosted_leg, sweep_unused_artifacts, HostedLegOutcome, - InnerSelection, VendorRevertStep, + rollback_patches_inner, run_hosted_leg, sweep_failure, sweep_unused_artifacts, + HostedLegOutcome, InnerSelection, VendorRevertStep, }; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::lock_cli::acquire_or_emit; use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status}; use crate::output::confirm; -/// A remove/rollback identifier matches a patch by PURL for `pkg:` -/// identifiers (a base PURL matches every release variant of that -/// package@version; a qualified PURL targets a single patch), or by patch -/// uuid otherwise. -pub(crate) fn patch_matches(purl: &str, uuid: &str, identifier: &str) -> bool { - if identifier.starts_with("pkg:") { - purl_matches_identifier(purl, identifier) - } else { - uuid == identifier - } -} - -/// A vendor-ledger entry matches a remove/rollback identifier by its -/// ledger key or by its base purl (mirroring the manifest matching; a -/// golang key is case-encoded while `base_purl` holds the decoded spelling -/// users type). -pub(crate) fn vendor_entry_matches(key: &str, entry: &VendorEntry, identifier: &str) -> bool { - patch_matches(key, &entry.uuid, identifier) - || patch_matches(&entry.base_purl, &entry.uuid, identifier) -} - -/// Does the ledger entry under `key` own the manifest purl `purl`? The -/// ledger-key / qualifier-stripped-key / base-purl triple — the per-entry -/// form of the set core's `vendored_purl_keys` flattens. -pub(crate) fn vendor_entry_covers_purl(key: &str, entry: &VendorEntry, purl: &str) -> bool { - key == purl - || strip_purl_qualifiers(key) == strip_purl_qualifiers(purl) - || entry.base_purl == strip_purl_qualifiers(purl) -} - -/// Vendor-ledger entries matching a remove identifier, sorted by key for -/// deterministic event order. +/// Vendor-ledger entries matching a remove identifier (by ledger key, +/// base purl or uuid — `VendorEntry::matches_identifier`), sorted by key +/// for deterministic event order. fn vendor_entries_matching(state: &VendorState, identifier: &str) -> Vec<(String, VendorEntry)> { let mut matches: Vec<(String, VendorEntry)> = state .entries .iter() - .filter(|(key, entry)| vendor_entry_matches(key, entry, identifier)) + .filter(|(key, entry)| entry.matches_identifier(key, identifier)) .map(|(k, e)| (k.clone(), e.clone())) .collect(); matches.sort_by(|a, b| a.0.cmp(&b.0)); @@ -238,9 +208,9 @@ pub async fn run(args: RemoveArgs) -> i32 { // the rollback, the ledger reverts and the manifest mutation, and its // drop removes `apply.lock` (and an emptied `.socket/`) on every exit // path. - let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); + let socket_dir = crate::args::socket_dir_of(&manifest_path, &args.common.cwd); let _lock = match acquire_or_emit( - socket_dir, + &socket_dir, Command::Remove, args.common.json, args.common.dry_run, @@ -426,7 +396,7 @@ pub async fn run(args: RemoveArgs) -> i32 { }; match rollback_patches_inner( &delegated, - socket_dir, + &socket_dir, &manifest, &vendored_keys, InnerSelection::Identifier(Some(&args.identifier)), @@ -632,7 +602,7 @@ pub async fn run(args: RemoveArgs) -> i32 { vendored_matches .iter() .find(|(k, _)| k == key) - .is_some_and(|(k, e)| vendor_entry_covers_purl(k, e, purl)) + .is_some_and(|(k, e)| e.covers_purl(k, purl)) }) }) .collect(); @@ -748,32 +718,33 @@ pub async fn run(args: RemoveArgs) -> i32 { let mut blobs_removed = 0; let mut archives_removed = 0; if !args.preserve_state { - let sweep = sweep_unused_artifacts(&cleanup_reference, socket_dir, args.common.dry_run).await; - match sweep.blobs { - Ok(r) => { - blobs_removed = r.blobs_removed; - if loud && r.blobs_removed > 0 { - println!("\n{}", format_cleanup_result(&r, args.common.dry_run)); - } + let sweep = + sweep_unused_artifacts(&cleanup_reference, &socket_dir, args.common.dry_run).await; + // repair's posture: a failed pass (or a pass that could not unlink + // every orphan) warns and continues, never fatal; its partial + // counts still stand. + if let Some(detail) = sweep_failure("blob", &sweep.blobs) { + if loud { + eprintln!("Warning: {detail}"); } - Err(e) => { - // repair's posture: warn and continue, never fatal. - if loud { - eprintln!("Warning: blob cleanup failed: {e}"); - } + } + if let Ok(r) = sweep.blobs { + blobs_removed = r.blobs_removed; + if loud && r.blobs_removed > 0 { + println!("\n{}", format_cleanup_result(&r, args.common.dry_run)); } } // Diff/package archives use the same manifest-uuid keep rule // (parity with repair and scan --prune). for (dir, result) in [("diffs", sweep.diffs), ("packages", sweep.packages)] { - match result { - Ok(r) => archives_removed += r.blobs_removed, - Err(e) => { - if loud { - eprintln!("Warning: {dir} cleanup failed: {e}"); - } + if let Some(detail) = sweep_failure(dir, &result) { + if loud { + eprintln!("Warning: {detail}"); } } + if let Ok(r) = result { + archives_removed += r.blobs_removed; + } } } diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index d0c91a78..bd894f57 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -13,7 +13,7 @@ use std::time::Duration; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; use crate::commands::lock_cli::{acquire_or_emit, error_envelope}; -use crate::commands::rollback::sweep_unused_artifacts; +use crate::commands::rollback::{sweep_failure, sweep_unused_artifacts}; use crate::json_envelope::{Command, Envelope, PatchAction, PatchEvent, Status}; #[derive(Args)] @@ -138,9 +138,9 @@ pub async fn run(args: RepairArgs) -> i32 { // otherwise-empty `.socket/` — on every exit path, dry-run included. // A live holder makes repair refuse with `lock_held`; it never steals // the lock. - let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); + let socket_dir = crate::args::socket_dir_of(&manifest_path, &args.common.cwd); let _lock = match acquire_or_emit( - socket_dir, + &socket_dir, Command::Repair, args.common.json, args.common.dry_run, @@ -236,9 +236,7 @@ async fn repair_inner( .await .map_err(|e| e.to_string())?; - let socket_dir = manifest_path - .parent() - .expect("manifest path names a file, so it has a parent"); + let socket_dir = crate::args::socket_dir_of(manifest_path, &args.common.cwd); let blobs_path = socket_dir.join("blobs"); let diffs_path = socket_dir.join("diffs"); let packages_path = socket_dir.join("packages"); @@ -419,7 +417,7 @@ async fn repair_inner( let vendor_rebuilt = crate::commands::repair_vendor::repair_vendored_artifacts_with_references( &args.common, manifest.as_ref(), - socket_dir, + &socket_dir, &mut env, &vendor_references, ledger, @@ -434,7 +432,7 @@ async fn repair_inner( if !quiet { println!(); } - let sweep = sweep_unused_artifacts(manifest, socket_dir, args.common.dry_run).await; + let sweep = sweep_unused_artifacts(manifest, &socket_dir, args.common.dry_run).await; // The blob pass prints its status unconditionally ("all are in // use" included — the core helper owns that wording); the archive // passes print only when they removed something, relabeled. @@ -444,40 +442,38 @@ async fn repair_inner( ("package", Some("package archive(s)"), sweep.packages), ]; for (label, relabel, result) in passes { - match result { - Ok(cleanup_result) => { - blobs_checked += cleanup_result.blobs_checked; - blobs_cleaned += cleanup_result.blobs_removed; - bytes_freed += cleanup_result.bytes_freed; - if quiet { - continue; - } - let text = format_cleanup_result(&cleanup_result, args.common.dry_run); - match relabel { - None => println!("{text}"), - Some(relabel) if cleanup_result.blobs_removed > 0 => { - println!("{}", text.replace("blob(s)", relabel)); - } - Some(_) => {} - } + // A failed cleanup — the pass aborted, or it could not unlink + // every orphan — is error output: `--silent` (suppress + // NON-error output) must not mute it, and the JSON envelope + // must carry it — a bare `status: success` with no events is + // indistinguishable from "nothing to clean". Recorded as an + // informational skip (not `Failed`) to preserve the human + // path's warn-and-continue contract: status stays success, + // exit stays 0, and the loop goes on to the next directory. + if let Some(detail) = sweep_failure(label, &result) { + if !args.common.json { + eprintln!("Warning: {detail}"); } - Err(e) => { - // A failed cleanup is error output: `--silent` (suppress - // NON-error output) must not mute it, and the JSON - // envelope must carry it — a bare `status: success` with - // no events is indistinguishable from "nothing to - // clean". Recorded as an informational skip (not - // `Failed`) to preserve the human path's - // warn-and-continue contract: status stays success, exit - // stays 0, and the loop goes on to the next directory. - if !args.common.json { - eprintln!("Warning: {label} cleanup failed: {e}"); - } - env.record( - PatchEvent::artifact(PatchAction::Skipped) - .with_reason("cleanup_failed", format!("{label} cleanup failed: {e}")), - ); + env.record( + PatchEvent::artifact(PatchAction::Skipped).with_reason("cleanup_failed", detail), + ); + } + let Ok(cleanup_result) = result else { + continue; + }; + blobs_checked += cleanup_result.blobs_checked; + blobs_cleaned += cleanup_result.blobs_removed; + bytes_freed += cleanup_result.bytes_freed; + if quiet { + continue; + } + let text = format_cleanup_result(&cleanup_result, args.common.dry_run); + match relabel { + None => println!("{text}"), + Some(relabel) if cleanup_result.blobs_removed > 0 => { + println!("{}", text.replace("blob(s)", relabel)); } + Some(_) => {} } } } diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 6db3126b..dbbf6035 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -15,7 +15,7 @@ use socket_patch_core::patch::rollback::{ VerifyRollbackResult, VerifyRollbackStatus, }; use socket_patch_core::telemetry::{track_patch_rollback_failed, track_patch_rolled_back}; -use socket_patch_core::utils::purl::strip_purl_qualifiers; +use socket_patch_core::utils::purl::{patch_matches, strip_purl_qualifiers}; use socket_patch_core::vendor::{save_state, RevertOpts, VendorState, VendorWarning}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -24,7 +24,6 @@ use std::time::Duration; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; use crate::commands::apply::is_local_go; use crate::commands::lock_cli::acquire_or_emit; -use crate::commands::remove::{patch_matches, vendor_entry_covers_purl, vendor_entry_matches}; use crate::commands::vendor::dispatch_revert_one_opts; use crate::ecosystem_dispatch::{find_all_packages_for_rollback, partition_purls}; use crate::json_envelope::Command as EnvelopeCommand; @@ -629,6 +628,21 @@ pub(crate) async fn sweep_unused_artifacts( } } +/// The `cleanup_failed` detail for one sweep pass labelled `label`: the +/// directory-level error that stopped the pass, or — after a pass that +/// kept sweeping past unlink failures — the files it could not remove +/// (their counts of what WAS reclaimed still stand). `None` for a clean +/// pass. Every consumer renders it as `