diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index b081b31..9ed24cc 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -441,3 +441,221 @@ pub(crate) async fn stage_vendor_sources_in_memory( mem, })) } + +#[cfg(test)] +mod tests { + use super::*; + use socket_patch_core::manifest::schema::{PatchFileInfo, PatchRecord}; + + const UUID: &str = "11111111-1111-4111-8111-111111111111"; + // 64 ascii-hex, the shape `is_valid_blob_hash` accepts. + const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn manifest_with_one_patch() -> PatchManifest { + let mut files = HashMap::new(); + files.insert( + "index.js".to_string(), + PatchFileInfo { + before_hash: "b".repeat(64), + after_hash: HASH.to_string(), + }, + ); + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + "pkg:npm/left-pad@1.3.0".to_string(), + PatchRecord { + uuid: UUID.to_string(), + exported_at: "2026-01-01T00:00:00Z".to_string(), + files, + vulnerabilities: HashMap::new(), + description: String::new(), + license: "MIT".to_string(), + tier: "free".to_string(), + }, + ); + manifest + } + + fn offline_args() -> GlobalArgs { + GlobalArgs { + offline: true, + silent: true, + ..GlobalArgs::default() + } + } + + /// Everything cached → read `.socket/` in place: no overlay tempdir, and + /// the returned paths are the persistent cache dirs themselves. + #[tokio::test] + async fn stage_reads_socket_dir_in_place_when_fully_cached() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + 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 StageOutcome::Ready(staged) = outcome else { + panic!("fully-cached staging must be Ready"); + }; + assert!(staged._stage.is_none(), "no overlay when nothing to fetch"); + assert_eq!(staged.blobs, socket_dir.join("blobs")); + } + + /// Offline with no usable source → Unavailable, and the read-only + /// contract holds: staging must not create or write `.socket/`. + #[tokio::test] + async fn stage_offline_with_missing_sources_is_unavailable_and_writes_nothing() { + 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"); + assert!( + matches!(outcome, StageOutcome::Unavailable), + "offline + no local source must be Unavailable" + ); + assert!( + !socket_dir.exists(), + "the stager is read-only against .socket/ — it must not create it" + ); + } + + /// A diff archive alone satisfies the disk stager (the pipeline can apply + /// via the diff path), even with every blob missing. + #[tokio::test] + async fn stage_offline_accepts_diff_archive_as_sole_source() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + std::fs::create_dir_all(socket_dir.join("diffs")).unwrap(); + std::fs::write( + socket_dir.join("diffs").join(format!("{UUID}.tar.gz")), + b"x", + ) + .unwrap(); + + let outcome = stage_patch_sources(&offline_args(), &manifest_with_one_patch(), &socket_dir) + .await + .expect("no hard failure"); + assert!( + matches!(outcome, StageOutcome::Ready(_)), + "a present diff archive is a usable source for the disk stager" + ); + } + + /// The vendor (in-memory) stager documents the opposite policy: a diff + /// archive is NOT sufficient (auto-force can need the full after-blob), + /// so the same fixture that satisfies the disk stager is Unavailable + /// offline here. Pins the asymmetry both module docs describe. + #[tokio::test] + async fn mem_stage_offline_rejects_diff_archive_as_sole_source() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + std::fs::create_dir_all(socket_dir.join("diffs")).unwrap(); + std::fs::write( + socket_dir.join("diffs").join(format!("{UUID}.tar.gz")), + b"x", + ) + .unwrap(); + let project_root = tmp.path().join("proj"); + std::fs::create_dir_all(&project_root).unwrap(); + + let outcome = stage_vendor_sources_in_memory( + &offline_args(), + &manifest_with_one_patch(), + &socket_dir, + &project_root, + ) + .await + .expect("no hard failure"); + assert!( + matches!(outcome, MemStageOutcome::Unavailable), + "vendor staging must not treat a diff archive as a usable source" + ); + } + + /// An unknown `--download-mode` is a hard setup failure (Err), not a + /// soft Unavailable. + #[tokio::test] + async fn stage_rejects_unknown_download_mode() { + let tmp = tempfile::tempdir().unwrap(); + let args = GlobalArgs { + download_mode: "bogus".to_string(), + silent: true, + ..GlobalArgs::default() + }; + let Err(err) = stage_patch_sources(&args, &manifest_with_one_patch(), tmp.path()).await + else { + panic!("an unparseable download mode is a hard failure"); + }; + assert!( + err.contains("bogus"), + "diagnostic names the bad mode: {err}" + ); + } + + /// `writable_blobs` promotes an in-place (no-overlay) source set to a + /// transient overlay: the returned dir is NOT `.socket/blobs`, existing + /// blobs are pre-seeded into it, and a late download that lands there + /// leaves the persistent cache untouched. + #[tokio::test] + async fn writable_blobs_promotes_to_overlay_and_preserves_cache() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + 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 StageOutcome::Ready(mut staged) = outcome else { + panic!("fully-cached staging must be Ready"); + }; + + let writable = staged.writable_blobs().await.expect("overlay created"); + assert_ne!( + writable, + socket_dir.join("blobs"), + "late downloads must never target the persistent cache" + ); + assert!( + writable.join(HASH).exists(), + "the overlay is pre-seeded with the cached blobs" + ); + + std::fs::write(writable.join("late-download"), b"new").unwrap(); + assert!( + !socket_dir.join("blobs").join("late-download").exists(), + "a write into the overlay must not appear in .socket/blobs" + ); + // Stable across calls: a second call reuses the same overlay. + let again = staged.writable_blobs().await.unwrap().to_path_buf(); + assert!(again.join("late-download").exists()); + } + + /// `overlay_dir` mirrors regular files only, and never clobbers a file + /// already present at the destination. + #[tokio::test] + async fn overlay_dir_mirrors_files_skips_dirs_and_existing() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("src"); + let dst = tmp.path().join("dst"); + std::fs::create_dir_all(src.join("subdir")).unwrap(); + std::fs::create_dir_all(&dst).unwrap(); + std::fs::write(src.join("a"), b"from-src").unwrap(); + std::fs::write(src.join("b"), b"from-src").unwrap(); + std::fs::write(dst.join("b"), b"already-there").unwrap(); + + overlay_dir(&src, &dst).await; + + assert_eq!(std::fs::read(dst.join("a")).unwrap(), b"from-src"); + assert_eq!( + std::fs::read(dst.join("b")).unwrap(), + b"already-there", + "existing destination files are never overwritten" + ); + assert!(!dst.join("subdir").exists(), "directories are not mirrored"); + } +} diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 19dcdfe..7eddaf7 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -62,6 +62,22 @@ fn parse_purl_simple(purl: &str) -> Option<(String, String, String)> { Some((typ.to_string(), name, version.to_string())) } +/// The hosted-mode JSON error envelope, for bail-outs that return before the +/// result envelope at the bottom of [`run_redirect`] is built. A `--json` +/// consumer must always get parseable stdout — `status`/`error` mirror the +/// success envelope's error fold — never empty output plus an exit code. +fn emit_json_error(message: &str) { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "error", + "error": message, + "redirect": { "mode": "hosted" }, + })) + .unwrap() + ); +} + /// `scan --redirect`: resolve hosted-patch references for the selected patches, /// then rewrite ONLY those dependencies' lockfile/registry-config entries to /// point at the hosted vendored patches (the byte-identical counterpart of the @@ -89,11 +105,16 @@ pub(super) async fn run_redirect( { Ok(s) => s, // Hosted mode has no discovery envelope to fold the message into at - // this point (it builds its `redirect` result further down) and its - // other bail-outs — e.g. the reference resolve below — report on - // stderr the same way. `discover_selected` already printed the - // message; behavior here is unchanged. - Err((code, _message)) => return code, + // this point (it builds its `redirect` result further down). + // `discover_selected` already printed the message to stderr; a + // `--json` run additionally gets the machine-readable envelope so + // stdout is never empty on failure. + Err((code, message)) => { + if args.common.json { + emit_json_error(&message); + } + return code; + } }; let mut skipped: Vec = Vec::new(); @@ -111,7 +132,11 @@ pub(super) async fn run_redirect( let references = match api_client.fetch_registry_references(&uuids).await { Ok(r) => r, Err(e) => { - eprintln!("failed to resolve patch references: {e}"); + let message = format!("failed to resolve patch references: {e}"); + eprintln!("{message}"); + if args.common.json { + emit_json_error(&message); + } return 1; } }; @@ -388,7 +413,11 @@ pub(super) async fn run_redirect( let _ = std::fs::create_dir_all(parent); } if let Err(e) = std::fs::write(&path, content) { - eprintln!("failed to write {rel}: {e}"); + let message = format!("failed to write {rel}: {e}"); + eprintln!("{message}"); + if args.common.json { + emit_json_error(&message); + } return 1; } } @@ -424,7 +453,11 @@ pub(super) async fn run_redirect( vendor_dir.join("redirect-state.json"), format!("{}\n", serde_json::to_string_pretty(&ledger).unwrap()), ) { - eprintln!("failed to write .socket/vendor/redirect-state.json: {e}"); + let message = format!("failed to write .socket/vendor/redirect-state.json: {e}"); + eprintln!("{message}"); + if args.common.json { + emit_json_error(&message); + } return 1; } } diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index b16e920..0e26598 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -288,7 +288,23 @@ async fn persist_setup_excludes(common: &GlobalArgs, excludes: &[String]) { return; } let path = common.resolved_manifest_path(); - let existing = read_manifest(&path).await.ok().flatten(); + // 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 — + // destroying them for the sake of persisting an exclude list. Skip + // persistence loudly instead; nothing else in this run needs the file. + let existing = match read_manifest(&path).await { + Ok(existing) => existing, + Err(e) => { + if !common.silent { + eprintln!( + "Warning: not persisting --exclude: cannot read {}: {e}", + path.display() + ); + } + return; + } + }; let mut merged: Vec = excludes.to_vec(); merged.sort(); merged.dedup(); diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index d2dc768..1e8f50c 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1706,3 +1706,114 @@ async fn cargo_redirect_writes_the_legacy_dot_cargo_config() { "anchor: the Cargo.toml dep must name the managed registry: {manifest}" ); } + +/// `scan --redirect --json` must emit a machine-readable error envelope on +/// stdout for EVERY failure exit, never empty stdout plus an exit code. +/// +/// Regression pin for the long-open hosted-mode JSON gap: the early +/// bail-outs (discovery-detail failure, reference-resolve failure) returned +/// with the message on stderr only, so a `--json` consumer saw exit 1 with +/// nothing to parse. Two legs, one per bail-out. +#[tokio::test] +#[serial] +async fn redirect_json_mode_failures_emit_error_envelope() { + let assert_error_envelope = |out: &std::process::Output, leg: &str| { + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(1), + "{leg}: failure exit; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("{leg}: --json stdout must be a parseable envelope even on failure ({e}); stdout=\n{stdout}") + }); + assert_eq!( + v["status"], "error", + "{leg}: envelope status; stdout=\n{stdout}" + ); + assert!( + v["error"].as_str().is_some_and(|m| !m.is_empty()), + "{leg}: envelope must carry the error message; stdout=\n{stdout}" + ); + assert_eq!( + v["redirect"]["mode"], "hosted", + "{leg}: envelope must identify the mode; stdout=\n{stdout}" + ); + }; + + // Leg 1 — batch discovery succeeds, every patch-detail query fails → + // `discover_selected` bails with (1, message). + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "redirect fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + let out = scrubbed_cli() + .args([ + "scan", + "--redirect", + "--yes", + "--json", + "--cwd", + tmp.path().to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + assert_error_envelope(&out, "discovery-detail failure"); + + // Leg 2 — discovery + selection succeed, the reference resolve fails. + let server = MockServer::start().await; + mock_discovery(&server).await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + let out = scrubbed_cli() + .args([ + "scan", + "--redirect", + "--yes", + "--json", + "--cwd", + tmp.path().to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + assert_error_envelope(&out, "reference-resolve failure"); +} diff --git a/crates/socket-patch-cli/tests/setup_contract_gaps.rs b/crates/socket-patch-cli/tests/setup_contract_gaps.rs index 92a55cc..e4e95e9 100644 --- a/crates/socket-patch-cli/tests/setup_contract_gaps.rs +++ b/crates/socket-patch-cli/tests/setup_contract_gaps.rs @@ -466,6 +466,58 @@ fn setup_honors_exclude_for_a_workspace_member() { ); } +/// `--exclude` persistence must fail closed on a manifest it cannot parse. +/// +/// Regression pin: `persist_setup_excludes` flattened a read/parse error to +/// `None` ("no manifest yet") and rewrote the file as a fresh manifest +/// holding only the setup block — silently destroying every patch record a +/// merely-corrupt (and possibly hand-recoverable) manifest still held. The +/// load-bearing assertion is bytes-unchanged; setup itself still exits 0 +/// (the hooks were written), it just skips persisting and says so on stderr. +#[test] +fn exclude_persistence_fails_closed_on_corrupt_manifest() { + let proj = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + write( + &proj.path().join("package.json"), + r#"{ "name": "root", "version": "1.0.0" }"#, + ); + let manifest_path = proj.path().join(".socket/manifest.json"); + let corrupt = r#"{ "patches": { "pkg:npm/left-pad@1.3.0": TRUNCATED-MID-WRITE"#; + write(&manifest_path, corrupt); + + let mut cmd = Command::new(binary()); + cmd.args(["setup", "--json", "--yes", "--exclude", "packages/b"]) + .current_dir(proj.path()); + for (name, _) in std::env::vars() { + if name.starts_with("SOCKET_") && name != "SOCKET_NO_CONFIG" { + cmd.env_remove(name); + } + } + cmd.env("HOME", home.path()); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + let out = cmd.output().expect("run socket-patch"); + let stderr = String::from_utf8_lossy(&out.stderr); + + assert_eq!( + out.status.code(), + Some(0), + "setup itself succeeds (hooks written); only the persistence step is \ + skipped; stderr=\n{stderr}" + ); + let after = std::fs::read_to_string(&manifest_path).expect("manifest still present"); + assert_eq!( + after, corrupt, + "a corrupt manifest must survive `setup --exclude` byte-identical — \ + rewriting it destroys every patch record it may still hold; \ + stderr=\n{stderr}" + ); + assert!( + stderr.contains("not persisting --exclude"), + "skipping persistence must be loud, not silent: {stderr}" + ); +} + /// Property 9, CSV spelling: `--exclude` is comma-delimited, so /// `--exclude "packages/a, packages/b"` (and the `SOCKET_SETUP_EXCLUDE=a, b` /// form CI YAML produces) must exclude BOTH members.