From ec39dcde70d55170f2e509f130d62526c34bcdc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 18:51:44 +0000 Subject: [PATCH 1/3] Start fix for #1089 (vendor slice) Assisted-by: Claude Code:claude-opus-5-5 From 226664a1e13f35ae6846a865802902bef667309f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:04:45 +0000 Subject: [PATCH 2/3] Vendor suite asserts codes, not sentences The vendor covgap tests pinned English sentences in --json mode, so a copy edit to a vendor message broke them even when the exit code, error code, events and bytes on disk were unchanged. The --json tests now read the takeover, symlink and revert messages only for what they must name (the lockfile, the git checkout remedy, the ecosystem), and the patch-target test checks its applied event instead of human output. The human-only twin of the stale-ledger test is dropped; its --json twin covers the same path. The file moves into the vendor suite binary, one fewer test binary to link: vendor_failure_paths.rs (--json), vendor_render.rs (human output) and npm_fixture.rs (their shared fixture). The CI shard timings fold its time into the vendor target. Refs #1089. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/tests/vendor/main.rs | 9 +- .../tests/vendor/npm_fixture.rs | 340 ++++++ .../vendor_failure_paths.rs} | 1060 +---------------- .../tests/vendor/vendor_render.rs | 631 ++++++++++ scripts/ci-test-durations.json | 3 +- 5 files changed, 1021 insertions(+), 1022 deletions(-) create mode 100644 crates/socket-patch-cli/tests/vendor/npm_fixture.rs rename crates/socket-patch-cli/tests/{covgap_commands_vendor.rs => vendor/vendor_failure_paths.rs} (54%) create mode 100644 crates/socket-patch-cli/tests/vendor/vendor_render.rs diff --git a/crates/socket-patch-cli/tests/vendor/main.rs b/crates/socket-patch-cli/tests/vendor/main.rs index 468c27472..d7c4d834c 100644 --- a/crates/socket-patch-cli/tests/vendor/main.rs +++ b/crates/socket-patch-cli/tests/vendor/main.rs @@ -1,4 +1,4 @@ -//! `vendor`: lockfile-only gems, pristine fetch order, no-network re-runs, bun and the hosted redirects. +//! `vendor`: lockfile-only gems, pristine fetch order, no-network re-runs, bun, the hosted redirects, the failure paths and the human render. //! //! One test binary per command: each module was its own binary. @@ -15,9 +15,16 @@ mod docker_vendor_common; mod docker_vendor_common_selftest; mod e2e_golang_redirect; mod in_process_vendor_bun; +mod npm_fixture; mod redirect_npm_allow_remote; +mod vendor_failure_paths; mod vendor_gem_lockfile_only_e2e; +mod vendor_render; mod vendor_rerun_no_network_e2e; #[path = "../prebuilt_common/mod.rs"] mod prebuilt_common; +#[path = "../vlt_hosted_common/mod.rs"] +mod vlt_hosted_common; +#[path = "../vlt_hosted_common/vendored.rs"] +mod vlt_vendored; diff --git a/crates/socket-patch-cli/tests/vendor/npm_fixture.rs b/crates/socket-patch-cli/tests/vendor/npm_fixture.rs new file mode 100644 index 000000000..6e79a6240 --- /dev/null +++ b/crates/socket-patch-cli/tests/vendor/npm_fixture.rs @@ -0,0 +1,340 @@ +//! The offline npm fixture and runners `vendor_failure_paths.rs` and +//! `vendor_render.rs` share: a self-contained npm project with a staged +//! patch blob, driven either in-process (`vendor_run`) or through the built +//! binary with a scrubbed child environment (`run_cli` / `vendor_cli`). No +//! test mutates this process's environment. + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; +use socket_patch_cli::args::GlobalArgs; +use socket_patch_cli::commands::vendor::{run as actual_vendor_run, VendorArgs}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use socket_patch_core::vendor::state::VendorArtifact; +use socket_patch_core::vendor::{save_state, VendorEntry, VendorState}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::common::hermetic; +use crate::prebuilt_common; + +pub(crate) async fn vendor_run(mut args: VendorArgs) -> i32 { + let server = prebuilt_common::Server::project(&args.common.cwd); + server.configure(&mut args.common); + actual_vendor_run(args).await +} + +/// Canonical-grammar patch UUID (the vendor path layer validates it). +pub(crate) const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +pub(crate) const PURL: &str = "pkg:npm/left-pad@1.3.0"; +pub(crate) const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; +pub(crate) const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; +pub(crate) const REG_RESOLVED: &str = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; +pub(crate) const REG_INTEGRITY: &str = "sha512-orig=="; + +pub(crate) fn rel_tgz() -> String { + format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz") +} + +// ───────────────────────────── fixture ───────────────────────────── + +pub(crate) struct NpmFixture { + pub(crate) tmp: tempfile::TempDir, + pub(crate) original_lock: Vec, +} + +impl NpmFixture { + pub(crate) fn root(&self) -> &Path { + self.tmp.path() + } + pub(crate) fn lock_path(&self) -> PathBuf { + self.root().join("package-lock.json") + } + pub(crate) fn lock_bytes(&self) -> Vec { + std::fs::read(self.lock_path()).expect("read package-lock.json") + } + pub(crate) fn manifest_path(&self) -> PathBuf { + self.root().join(".socket/manifest.json") + } + pub(crate) fn vendor_dir(&self) -> PathBuf { + self.root().join(".socket/vendor") + } + pub(crate) fn tgz_path(&self) -> PathBuf { + self.root().join(rel_tgz()) + } + pub(crate) fn state_path(&self) -> PathBuf { + self.root().join(".socket/vendor/state.json") + } + pub(crate) fn redirect_state_path(&self) -> PathBuf { + self.root().join(".socket/vendor/redirect-state.json") + } +} + +/// One manifest patch record (camelCase, the TS-compatible manifest shape). +pub(crate) fn patch_record(before_hash: &str, after_hash: &str) -> Value { + json!({ + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { "beforeHash": before_hash, "afterHash": after_hash } + }, + "vulnerabilities": {}, + "description": "synthetic covgap vendor test patch", + "license": "MIT", + "tier": "free" + }) +} + +pub(crate) fn npm_fixture_with_purls(manifest_purls: &[&str]) -> NpmFixture { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path(); + + let pkg = root.join("node_modules/left-pad"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); + + std::fs::write( + root.join("package.json"), + br#"{"name":"fixture","version":"1.0.0","private":true}"#, + ) + .unwrap(); + let lock = json!({ + "name": "fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fixture", + "version": "1.0.0", + "dependencies": { "left-pad": "^1.3.0" } + }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": REG_RESOLVED, + "integrity": REG_INTEGRITY, + "license": "WTFPL" + } + } + }); + let mut original_lock = serde_json::to_vec_pretty(&lock).unwrap(); + original_lock.push(b'\n'); + std::fs::write(root.join("package-lock.json"), &original_lock).unwrap(); + + let before_hash = compute_git_sha256_from_bytes(ORIG_INDEX); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + let mut patches = serde_json::Map::new(); + for purl in manifest_purls { + patches.insert(purl.to_string(), patch_record(&before_hash, &after_hash)); + } + let manifest = json!({ "patches": patches }); + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let mut manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap(); + manifest_bytes.push(b'\n'); + std::fs::write(socket.join("manifest.json"), &manifest_bytes).unwrap(); + std::fs::write(socket.join("blobs").join(&after_hash), PATCHED_INDEX).unwrap(); + + NpmFixture { tmp, original_lock } +} + +pub(crate) fn npm_fixture() -> NpmFixture { + npm_fixture_with_purls(&[PURL]) +} + +/// In-process `VendorArgs` (json+silent+offline), for staging a vendored +/// state a subsequent subprocess run asserts against. +pub(crate) fn vendor_args(cwd: &Path) -> VendorArgs { + VendorArgs { + common: GlobalArgs { + cwd: cwd.to_path_buf(), + json: true, + silent: true, + offline: false, + // See in_process_vendor.rs: absorbs the fork→exec fd window of + // concurrent tests in this binary. + lock_timeout: Some(5), + ..GlobalArgs::default() + }, + force: false, + revert: false, + check: false, + local_repo: None, + vex: Default::default(), + } +} + +// ───────────────────────── subprocess runner ───────────────────────── + +/// Run the built `socket-patch` binary in the hermetic env (every ambient +/// `SOCKET_*` env var scrubbed from the child, telemetry hard-disabled). +pub(crate) fn run_cli( + cwd: &Path, + args: &[&str], + extra_env: &[(&str, &str)], +) -> (i32, String, String) { + let mut cmd = hermetic::binary_command(); + let fixture = (!args + .iter() + .any(|a| matches!(*a, "--api-url" | "--vendor-url")) + && !extra_env + .iter() + .any(|(k, _)| matches!(*k, "SOCKET_API_URL" | "SOCKET_VENDOR_URL"))) + .then(|| prebuilt_common::Server::project(cwd)); + let mut filtered = Vec::new(); + let mut args_iter = args.iter().copied(); + while let Some(arg) = args_iter.next() { + if fixture.is_some() && arg == "--patch-server-url" { + args_iter.next(); + } else { + filtered.push(arg); + } + } + cmd.args(&filtered).current_dir(cwd); + for (k, v) in extra_env { + cmd.env(k, v); + } + if let Some(fixture) = &fixture { + fixture.command(&mut cmd); + } + let out = cmd.output().expect("spawn socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// `vendor --json --offline --cwd ` through the binary. +pub(crate) fn vendor_cli(cwd: &Path, extra: &[&str]) -> (i32, Value) { + let mut args = vec!["vendor", "--json", "--cwd", cwd.to_str().unwrap()]; + args.extend_from_slice(extra); + let (code, stdout, stderr) = run_cli(cwd, &args, &[]); + let env: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("vendor --json must emit an envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (code, env) +} + +/// A synthetic ledger with one entry of `eco` for [`PURL`], written via the +/// real `save_state` serializer (what a tampered-but-parseable state.json +/// deserializes into). +pub(crate) async fn write_ledger_entry(root: &Path, eco: &str) { + let mut state = VendorState::default(); + state.entries.insert( + PURL.to_string(), + VendorEntry::new( + eco.into(), + PURL.into(), + UUID.into(), + VendorArtifact { + yarn_berry10c0: None, + path: format!(".socket/vendor/{eco}/{UUID}/left-pad-1.3.0.tgz"), + sha256: String::new(), + size: None, + platform_locked: None, + file_inventory: None, + }, + Vec::new(), + ), + ); + save_state(root, &state).await.unwrap(); +} + +/// The mock patch-server origin the hosted pins below live on. Only +/// `https://patch.socket.dev` and the `--patch-server-url` origin count as +/// hosted, so every run over these pins passes that flag. +pub(crate) const HOSTED_ORIGIN: &str = "https://patch.socket.dev"; +pub(crate) const HOSTED_INTEGRITY: &str = "sha512-HOSTEDpatchedHOSTEDpatched=="; +/// What the mock npm registry's version document hands back for the +/// upstream restore. +pub(crate) const UPSTREAM_INTEGRITY: &str = "sha512-UPSTREAMupstreamUPSTREAM=="; + +pub(crate) fn hosted_url() -> String { + format!( + "{HOSTED_ORIGIN}/patch/npm/left-pad/1.3.0/55555555-5555-4555-8555-555555555555/{UUID}/left-pad-1.3.0.tgz" + ) +} + +/// Pin the fixture's left-pad entry to the hosted tarball (what `scan +/// --mode hosted` leaves in package-lock.json) and return the lock bytes. +pub(crate) fn pin_hosted(fx: &NpmFixture) -> Vec { + let mut lock: Value = serde_json::from_slice(&fx.lock_bytes()).unwrap(); + lock["packages"]["node_modules/left-pad"]["resolved"] = Value::String(hosted_url()); + lock["packages"]["node_modules/left-pad"]["integrity"] = + Value::String(HOSTED_INTEGRITY.to_string()); + let mut bytes = serde_json::to_vec_pretty(&lock).unwrap(); + bytes.push(b'\n'); + std::fs::write(fx.lock_path(), &bytes).unwrap(); + bytes +} + +/// A wiremock npm registry answering left-pad@1.3.0's version document. +pub(crate) async fn mock_npm_registry() -> MockServer { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/left-pad/1.3.0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": "left-pad", + "version": "1.3.0", + "dist": { + "tarball": format!("{}/left-pad/-/left-pad-1.3.0.tgz", server.uri()), + "integrity": UPSTREAM_INTEGRITY, + "shasum": "0000000000000000000000000000000000000000" + } + }))) + .mount(&server) + .await; + server +} + +/// `vendor --json --patch-server-url ` through the binary, +/// ONLINE against the mock registry (the upstream restore needs it) but +/// anonymous, so no other network path opens. +pub(crate) fn vendor_online(fx: &NpmFixture, registry: &str, extra: &[&str]) -> (i32, Value) { + let root = fx.root().to_str().unwrap(); + let mut args = vec![ + "vendor", + "--json", + "--patch-server-url", + HOSTED_ORIGIN, + "--cwd", + root, + ]; + args.extend_from_slice(extra); + let (code, stdout, stderr) = run_cli( + fx.root(), + &args, + &[ + ("SOCKET_NO_API_TOKEN", "1"), + ("SOCKET_NPM_REGISTRY", registry), + ], + ); + let env: Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}")); + (code, env) +} + +pub(crate) fn human_vendor(fx: &NpmFixture, extra: &[&str]) -> (i32, String, String) { + let mut args = vec!["vendor", "--cwd", fx.root().to_str().unwrap()]; + args.extend_from_slice(extra); + run_cli(fx.root(), &args, &[]) +} + +/// `sha512-…` SRI of `bytes` (same helper shape as the sibling repair and +/// scan-vendor suites): a syntactically VALID integrity so the pristine +/// ladder proceeds past the no-integrity `Unverifiable` refusal and +/// actually attempts the fetch. +pub(crate) fn sri_of(bytes: &[u8]) -> String { + use base64::Engine as _; + use sha2::{Digest as _, Sha512}; + format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(bytes)) + ) +} diff --git a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs b/crates/socket-patch-cli/tests/vendor/vendor_failure_paths.rs similarity index 54% rename from crates/socket-patch-cli/tests/covgap_commands_vendor.rs rename to crates/socket-patch-cli/tests/vendor/vendor_failure_paths.rs index 751668fdb..857c84280 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs +++ b/crates/socket-patch-cli/tests/vendor/vendor_failure_paths.rs @@ -1,259 +1,21 @@ -//! Coverage-gap tests for `commands/vendor.rs`: -//! the fail-closed ledger/manifest exit contracts, the fresh-clone -//! committed-artifact staging error ladder, the hosted-pin takeover guard -//! (v5: hosted state is the lockfile pins), the human-mode (no `--json`) -//! output surface, and the unix fault-injection paths for the state-write -//! failure events. -//! -//! Fixture + runner shapes mirror `in_process_vendor.rs` (which this suite -//! deliberately does not touch): an offline, self-contained npm project with -//! a staged patch blob, driven either in-process (`vendor_run`) or through -//! the built binary with a scrubbed child environment (`run_cli` / -//! `vendor_cli`). No test mutates this process's environment. - -#[path = "common/envelope.rs"] -mod envelope; -#[path = "common/hermetic.rs"] -mod hermetic; -#[path = "prebuilt_common/mod.rs"] -mod prebuilt_common; -use envelope::{events, find_event}; - -async fn vendor_run(mut args: VendorArgs) -> i32 { - let server = prebuilt_common::Server::project(&args.common.cwd); - server.configure(&mut args.common); - actual_vendor_run(args).await -} +//! `vendor`'s failure paths, asserted through `--json` codes, events and +//! the bytes on disk (#1089): the fail-closed ledger/manifest exit +//! contracts, the fresh-clone committed-artifact staging error ladder, the +//! hosted-pin takeover guard (v5: hosted state is the lockfile pins), the +//! unix fault-injection paths for the state-write failure events, and the +//! service-outage re-runs. A message is read only for what it must name +//! (a lockfile, a remedy command, an ecosystem). The human output lives in +//! `vendor_render.rs`. use std::path::{Path, PathBuf}; -use std::process::Command; use serde_json::{json, Value}; -use socket_patch_cli::args::GlobalArgs; -use socket_patch_cli::commands::vendor::{run as actual_vendor_run, VendorArgs}; use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; -use socket_patch_core::vendor::state::VendorArtifact; -use socket_patch_core::vendor::{save_state, VendorEntry, VendorState}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -/// Canonical-grammar patch UUID (the vendor path layer validates it). -const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; -const PURL: &str = "pkg:npm/left-pad@1.3.0"; -const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; -const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; -const REG_RESOLVED: &str = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; -const REG_INTEGRITY: &str = "sha512-orig=="; - -fn rel_tgz() -> String { - format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz") -} - -// ───────────────────────────── fixture ───────────────────────────── - -struct NpmFixture { - tmp: tempfile::TempDir, - original_lock: Vec, -} - -impl NpmFixture { - fn root(&self) -> &Path { - self.tmp.path() - } - fn lock_path(&self) -> PathBuf { - self.root().join("package-lock.json") - } - fn lock_bytes(&self) -> Vec { - std::fs::read(self.lock_path()).expect("read package-lock.json") - } - fn manifest_path(&self) -> PathBuf { - self.root().join(".socket/manifest.json") - } - fn vendor_dir(&self) -> PathBuf { - self.root().join(".socket/vendor") - } - fn tgz_path(&self) -> PathBuf { - self.root().join(rel_tgz()) - } - fn state_path(&self) -> PathBuf { - self.root().join(".socket/vendor/state.json") - } - fn redirect_state_path(&self) -> PathBuf { - self.root().join(".socket/vendor/redirect-state.json") - } -} - -/// One manifest patch record (camelCase, the TS-compatible manifest shape). -fn patch_record(before_hash: &str, after_hash: &str) -> Value { - json!({ - "uuid": UUID, - "exportedAt": "2026-01-01T00:00:00Z", - "files": { - "package/index.js": { "beforeHash": before_hash, "afterHash": after_hash } - }, - "vulnerabilities": {}, - "description": "synthetic covgap vendor test patch", - "license": "MIT", - "tier": "free" - }) -} - -fn npm_fixture_with_purls(manifest_purls: &[&str]) -> NpmFixture { - let tmp = tempfile::tempdir().expect("tempdir"); - let root = tmp.path(); - - let pkg = root.join("node_modules/left-pad"); - std::fs::create_dir_all(&pkg).unwrap(); - std::fs::write( - pkg.join("package.json"), - br#"{"name":"left-pad","version":"1.3.0"}"#, - ) - .unwrap(); - std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); - - std::fs::write( - root.join("package.json"), - br#"{"name":"fixture","version":"1.0.0","private":true}"#, - ) - .unwrap(); - let lock = json!({ - "name": "fixture", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "fixture", - "version": "1.0.0", - "dependencies": { "left-pad": "^1.3.0" } - }, - "node_modules/left-pad": { - "version": "1.3.0", - "resolved": REG_RESOLVED, - "integrity": REG_INTEGRITY, - "license": "WTFPL" - } - } - }); - let mut original_lock = serde_json::to_vec_pretty(&lock).unwrap(); - original_lock.push(b'\n'); - std::fs::write(root.join("package-lock.json"), &original_lock).unwrap(); - - let before_hash = compute_git_sha256_from_bytes(ORIG_INDEX); - let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); - let mut patches = serde_json::Map::new(); - for purl in manifest_purls { - patches.insert(purl.to_string(), patch_record(&before_hash, &after_hash)); - } - let manifest = json!({ "patches": patches }); - let socket = root.join(".socket"); - std::fs::create_dir_all(socket.join("blobs")).unwrap(); - let mut manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap(); - manifest_bytes.push(b'\n'); - std::fs::write(socket.join("manifest.json"), &manifest_bytes).unwrap(); - std::fs::write(socket.join("blobs").join(&after_hash), PATCHED_INDEX).unwrap(); - - NpmFixture { tmp, original_lock } -} - -fn npm_fixture() -> NpmFixture { - npm_fixture_with_purls(&[PURL]) -} - -/// In-process `VendorArgs` (json+silent+offline), for staging a vendored -/// state a subsequent subprocess run asserts against. -fn vendor_args(cwd: &Path) -> VendorArgs { - VendorArgs { - common: GlobalArgs { - cwd: cwd.to_path_buf(), - json: true, - silent: true, - offline: false, - // See in_process_vendor.rs: absorbs the fork→exec fd window of - // concurrent tests in this binary. - lock_timeout: Some(5), - ..GlobalArgs::default() - }, - force: false, - revert: false, - check: false, - local_repo: None, - vex: Default::default(), - } -} - -// ───────────────────────── subprocess runner ───────────────────────── - -/// Run the built `socket-patch` binary in the hermetic env (every ambient -/// `SOCKET_*` env var scrubbed from the child, telemetry hard-disabled). -fn run_cli(cwd: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> (i32, String, String) { - let mut cmd = hermetic::binary_command(); - let fixture = (!args - .iter() - .any(|a| matches!(*a, "--api-url" | "--vendor-url")) - && !extra_env - .iter() - .any(|(k, _)| matches!(*k, "SOCKET_API_URL" | "SOCKET_VENDOR_URL"))) - .then(|| prebuilt_common::Server::project(cwd)); - let mut filtered = Vec::new(); - let mut args_iter = args.iter().copied(); - while let Some(arg) = args_iter.next() { - if fixture.is_some() && arg == "--patch-server-url" { - args_iter.next(); - } else { - filtered.push(arg); - } - } - cmd.args(&filtered).current_dir(cwd); - for (k, v) in extra_env { - cmd.env(k, v); - } - if let Some(fixture) = &fixture { - fixture.command(&mut cmd); - } - let out = cmd.output().expect("spawn socket-patch binary"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).into_owned(), - String::from_utf8_lossy(&out.stderr).into_owned(), - ) -} - -/// `vendor --json --offline --cwd ` through the binary. -fn vendor_cli(cwd: &Path, extra: &[&str]) -> (i32, Value) { - let mut args = vec!["vendor", "--json", "--cwd", cwd.to_str().unwrap()]; - args.extend_from_slice(extra); - let (code, stdout, stderr) = run_cli(cwd, &args, &[]); - let env: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { - panic!("vendor --json must emit an envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") - }); - (code, env) -} - -/// A synthetic ledger with one entry of `eco` for [`PURL`], written via the -/// real `save_state` serializer (what a tampered-but-parseable state.json -/// deserializes into). -async fn write_ledger_entry(root: &Path, eco: &str) { - let mut state = VendorState::default(); - state.entries.insert( - PURL.to_string(), - VendorEntry::new( - eco.into(), - PURL.into(), - UUID.into(), - VendorArtifact { - yarn_berry10c0: None, - path: format!(".socket/vendor/{eco}/{UUID}/left-pad-1.3.0.tgz"), - sha256: String::new(), - size: None, - platform_locked: None, - file_inventory: None, - }, - Vec::new(), - ), - ); - save_state(root, &state).await.unwrap(); -} +use crate::common::envelope::{events, find_event}; +use crate::npm_fixture::*; // ───────────────────────────────────────────────────────────────────── // 1. corrupt-ledger / corrupt-manifest fail-closed exit contracts @@ -416,81 +178,6 @@ fn missing_package_with_no_lock_and_no_ledger_is_calm_skip() { // ───────────────────────────────────────────────────────────────────── // 3. hosted-pin takeover guard (v5: lockfile pins, no redirect ledger) // ───────────────────────────────────────────────────────────────────── - -/// The mock patch-server origin the hosted pins below live on. Only -/// `https://patch.socket.dev` and the `--patch-server-url` origin count as -/// hosted, so every run over these pins passes that flag. -const HOSTED_ORIGIN: &str = "https://patch.socket.dev"; -const HOSTED_INTEGRITY: &str = "sha512-HOSTEDpatchedHOSTEDpatched=="; -/// What the mock npm registry's version document hands back for the -/// upstream restore. -const UPSTREAM_INTEGRITY: &str = "sha512-UPSTREAMupstreamUPSTREAM=="; - -fn hosted_url() -> String { - format!( - "{HOSTED_ORIGIN}/patch/npm/left-pad/1.3.0/55555555-5555-4555-8555-555555555555/{UUID}/left-pad-1.3.0.tgz" - ) -} - -/// Pin the fixture's left-pad entry to the hosted tarball (what `scan -/// --mode hosted` leaves in package-lock.json) and return the lock bytes. -fn pin_hosted(fx: &NpmFixture) -> Vec { - let mut lock: Value = serde_json::from_slice(&fx.lock_bytes()).unwrap(); - lock["packages"]["node_modules/left-pad"]["resolved"] = Value::String(hosted_url()); - lock["packages"]["node_modules/left-pad"]["integrity"] = - Value::String(HOSTED_INTEGRITY.to_string()); - let mut bytes = serde_json::to_vec_pretty(&lock).unwrap(); - bytes.push(b'\n'); - std::fs::write(fx.lock_path(), &bytes).unwrap(); - bytes -} - -/// A wiremock npm registry answering left-pad@1.3.0's version document. -async fn mock_npm_registry() -> MockServer { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/left-pad/1.3.0")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "name": "left-pad", - "version": "1.3.0", - "dist": { - "tarball": format!("{}/left-pad/-/left-pad-1.3.0.tgz", server.uri()), - "integrity": UPSTREAM_INTEGRITY, - "shasum": "0000000000000000000000000000000000000000" - } - }))) - .mount(&server) - .await; - server -} - -/// `vendor --json --patch-server-url ` through the binary, -/// ONLINE against the mock registry (the upstream restore needs it) but -/// anonymous, so no other network path opens. -fn vendor_online(fx: &NpmFixture, registry: &str, extra: &[&str]) -> (i32, Value) { - let root = fx.root().to_str().unwrap(); - let mut args = vec![ - "vendor", - "--json", - "--patch-server-url", - HOSTED_ORIGIN, - "--cwd", - root, - ]; - args.extend_from_slice(extra); - let (code, stdout, stderr) = run_cli( - fx.root(), - &args, - &[ - ("SOCKET_NO_API_TOKEN", "1"), - ("SOCKET_NPM_REGISTRY", registry), - ], - ); - let env: Value = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}")); - (code, env) -} - /// A pre-v5 redirect ledger on disk (even a malformed one) is IGNORED: /// the vendor run neither reads nor rewrites it, vendors the purl as /// usual, and leaves the stale file byte-identical. @@ -568,9 +255,9 @@ async fn vendor_over_hosted_pin_restores_upstream_then_revert_returns_to_registr find_event(&env, "applied", None); let warned = find_event(&env, "skipped", Some("vendor_takeover_reverted_redirect")); assert!( - warned - .to_string() - .contains("restored its upstream registry entry (package-lock.json)"), + warned["reason"] + .as_str() + .is_some_and(|m| m.contains("package-lock.json")), "the advisory names the restored lockfile: {warned:#}" ); let lock: Value = serde_json::from_slice(&fx.lock_bytes()).unwrap(); @@ -618,9 +305,8 @@ fn unrestorable_hosted_pin_fails_closed() { assert_eq!(failed["purl"], PURL); let detail = failed["error"].as_str().unwrap_or_default(); assert!( - detail.contains("cannot vendor over the live hosted pin") - && detail.contains("git checkout -- package-lock.json"), - "{env:#}" + detail.contains("git checkout") && detail.contains("package-lock.json"), + "the refusal names the checkout remedy for the lockfile: {env:#}" ); assert_eq!( fx.lock_bytes(), @@ -672,7 +358,7 @@ async fn revert_unknown_ecosystem_entry_fails_closed_and_keeps_entry() { assert!( failed["error"] .as_str() - .is_some_and(|d| d.contains("no vendor backend for ecosystem `frobnicate`")), + .is_some_and(|d| d.contains("`frobnicate`")), "{env:#}" ); let state: Value = serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap(); @@ -700,341 +386,33 @@ async fn reconcile_unknown_ecosystem_entry_fails_closed_and_keeps_entry() { ); } -// ───────────────────────────────────────────────────────────────────── -// 5. human-mode output surface (no --json, no --silent) -// ───────────────────────────────────────────────────────────────────── - -fn human_vendor(fx: &NpmFixture, extra: &[&str]) -> (i32, String, String) { - let mut args = vec!["vendor", "--cwd", fx.root().to_str().unwrap()]; - args.extend_from_slice(extra); - run_cli(fx.root(), &args, &[]) -} - -/// Human happy path: the summary line, the committables hint, and the -/// npm reinstall hint (package-lock flavor). -#[test] -fn human_vendor_prints_summary_committables_and_reinstall_hint() { - let fx = npm_fixture(); - let (code, stdout, stderr) = human_vendor(&fx, &[]); - assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stdout.contains("Vendored 1 package."), - "summary line: {stdout}" - ); - assert!( - stdout.contains("Commit .socket/vendor/ and the updated lockfiles"), - "committables hint: {stdout}" - ); - assert!( - stdout.contains("Run `npm install`"), - "package-lock reinstall hint: {stdout}" - ); -} - -/// An npm-only run never opens the project's composer.lock for the -/// Composer reinstall hint: a FIFO planted there would block the open(2) -/// after the lock and ledger are written, and a lock naming vendored -/// composer copies this run did not wire gets no composer hint. -#[cfg(unix)] +/// A manifest record with a patch-target file ABSENT from the installed +/// copy (non-empty beforeHash, no --force) still vendors the package, and +/// the installed tree is never written to. #[test] -fn npm_only_human_vendor_does_not_read_composer_lock() { - use std::time::{Duration, Instant}; - let fx = npm_fixture(); - let fifo = fx.root().join("composer.lock"); - assert!(Command::new("mkfifo") - .arg(&fifo) - .status() - .unwrap() - .success()); - let fixture = prebuilt_common::Server::project(fx.root()); - let mut child = hermetic::binary_command() - .env("SOCKET_VENDOR_URL", &fixture.uri) - .args(["vendor", "--cwd", fx.root().to_str().unwrap()]) - .current_dir(fx.root()) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .expect("spawn socket-patch binary"); - let started = Instant::now(); - while child.try_wait().unwrap().is_none() { - if started.elapsed() > Duration::from_secs(60) { - let _ = child.kill(); - panic!("vendor blocked opening the composer.lock FIFO"); - } - std::thread::sleep(Duration::from_millis(25)); - } - let out = child.wait_with_output().unwrap(); - let stdout = String::from_utf8_lossy(&out.stdout); - assert_eq!(out.status.code(), Some(0), "{stdout}"); - assert!(stdout.contains("Vendored 1 package."), "{stdout}"); - +fn patch_target_absent_from_the_install_still_vendors() { let fx = npm_fixture(); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + let elsewhere = compute_git_sha256_from_bytes(b"bytes the fixture never installed\n"); + let mut manifest: Value = + serde_json::from_slice(&std::fs::read(fx.manifest_path()).unwrap()).unwrap(); + manifest["patches"][PURL]["files"]["package/absent.js"] = + json!({ "beforeHash": elsewhere, "afterHash": after_hash }); std::fs::write( - fx.root().join("composer.lock"), - json!({"packages": [{"name": "psr/log", "version": "3.0.2", "dist": { - "type": "path", - "url": format!(".socket/vendor/composer/{UUID}/psr/log@3.0.2"), - "reference": UUID}}]}) - .to_string(), + fx.manifest_path(), + serde_json::to_vec_pretty(&manifest).unwrap(), ) .unwrap(); - let (code, stdout, stderr) = human_vendor(&fx, &[]); - assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!(stdout.contains("Run `npm install`"), "{stdout}"); - assert!(!stdout.contains("composer install"), "{stdout}"); -} - -/// Human `--dry-run`: the `Would vendor` verb and NO commit/reinstall -/// hints (nothing was written). A dry-run success is translated to a -/// `Verified` event (counted under `summary.verified`, not `applied`); the -/// human line counts it as a would-vendor package, and the JSON -/// cross-check below anchors where the package actually lands. -#[test] -fn human_dry_run_prints_would_vendor_and_no_commit_hints() { - let fx = npm_fixture(); - let (code, stdout, stderr) = human_vendor(&fx, &["--dry-run"]); - assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stdout.contains("Would vendor 1 package."), - "dry-run verb, counting the Verified preview event: {stdout}" - ); - let (json_code, env) = vendor_cli(fx.root(), &["--dry-run"]); - assert_eq!(json_code, 0, "{env:#}"); - assert_eq!( - env["summary"]["verified"], 1, - "the dry-run success is counted as verified: {env:#}" - ); - assert!( - !stdout.contains("Commit .socket/vendor/"), - "a dry run has nothing to commit: {stdout}" - ); - assert!( - !stdout.contains("Run `"), - "a dry run needs no reinstall: {stdout}" - ); - assert!(!fx.vendor_dir().exists(), "dry run writes nothing"); -} - -/// Human not-installed skip: the `Cannot vendor …` stderr line with the -/// on-disk cause, while the installed package still vendors (and the -/// summary counts both honestly). -#[test] -fn human_not_installed_prints_cannot_vendor_to_stderr() { - let fx = npm_fixture_with_purls(&[PURL, "pkg:npm/right-pad@9.9.9"]); - let (code, stdout, stderr) = human_vendor(&fx, &[]); - assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stderr.contains("Cannot vendor"), - "stderr names the skip: {stderr}" - ); - assert!( - stderr.contains("lock"), - "stderr carries the on-disk cause: {stderr}" - ); - assert!( - stdout.contains("Vendored 1 package; 1 failed."), - "summary counts the skip: {stdout}" - ); -} - -/// Human `--revert` summary after a completed revert. -#[tokio::test] -async fn human_revert_prints_reverted_summary() { - let fx = npm_fixture(); - assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "stage vendor"); - - let (code, stdout, stderr) = human_vendor(&fx, &["--revert"]); - assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stdout.contains("Reverted 1 vendored package."), - "revert summary: {stdout}" - ); - assert!( - !stdout.contains("Kept"), - "nothing drifted, so no drift explainer: {stdout}" - ); - assert_eq!(fx.lock_bytes(), fx.original_lock, "lock restored"); -} - -/// Human `--revert` over a drifted lock: no `Reverted …` line, only the -/// `Kept 1 drifted package` explainer (counts come from the drift-skip -/// keep, not advisory warnings). -#[tokio::test] -async fn human_revert_drift_keep_prints_kept_explainer() { - let fx = npm_fixture(); - assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "stage vendor"); - // Third-party drift: neither ours nor the recorded pre-vendor original. - let mut drifted: Value = serde_json::from_slice(&fx.lock_bytes()).unwrap(); - drifted["packages"]["node_modules/left-pad"]["resolved"] = - Value::String("https://example.com/their-fork.tgz".to_string()); - let mut drifted_lock = serde_json::to_vec_pretty(&drifted).unwrap(); - drifted_lock.push(b'\n'); - std::fs::write(fx.lock_path(), &drifted_lock).unwrap(); - - let (code, stdout, stderr) = human_vendor(&fx, &["--revert"]); - assert_eq!(code, 0, "a drift keep is not an error:\n{stdout}\n{stderr}"); - assert!( - !stdout.contains("Reverted"), - "nothing reverted, so no package line: {stdout}" - ); - assert!( - stdout.contains("Kept 1 drifted package:"), - "the drift-keep explainer: {stdout}" - ); - assert!(fx.tgz_path().is_file(), "kept artifacts survive"); - assert_eq!(fx.lock_bytes(), drifted_lock, "drifted lock left alone"); -} - -/// Human `--revert` with a `.socket/` present but an empty ledger: the -/// calm no-op line (complements the no-`.socket`-dir no-op pinned in -/// in_process_vendor.rs). -#[test] -fn human_revert_empty_ledger_prints_nothing_to_revert() { - let fx = npm_fixture(); - assert!(!fx.state_path().exists(), "fixture starts unvendored"); - - let (code, stdout, stderr) = human_vendor(&fx, &["--revert"]); - assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stdout.contains("Nothing vendored to revert."), - "the empty-ledger no-op line: {stdout}" - ); -} - -/// Human plain vendor with no manifest at all: the clean no-op message, -/// exit 0 (same contract as apply). The line names the MANIFEST — the -/// fixture's `.socket/` (blobs) very much exists, as it does on every -/// hosted-only or vendored-mode project. -#[test] -fn human_missing_manifest_prints_nothing_to_vendor() { - let fx = npm_fixture(); - std::fs::remove_file(fx.manifest_path()).unwrap(); - - let (code, stdout, stderr) = human_vendor(&fx, &[]); - assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - 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" - ); -} - -// ───────────────────────────────────────────────────────────────────── -// 6. pnpm committables hint (pnpm >=11 workspace-file portability) -// ───────────────────────────────────────────────────────────────────── - -/// A pnpm project (lockfileVersion 9.0): package.json + pnpm-lock.yaml + -/// installed node_modules/left-pad + the same staged `.socket` blob as the -/// npm fixture. The lock grammar mirrors core's pnpm_lock spike fixtures. -fn pnpm_fixture() -> NpmFixture { - let fx = npm_fixture(); - std::fs::remove_file(fx.lock_path()).unwrap(); - let lock = "lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - left-pad: - specifier: 1.3.0 - version: 1.3.0 - -packages: - - left-pad@1.3.0: - resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} - -snapshots: - left-pad@1.3.0: {} -"; - std::fs::write(fx.root().join("pnpm-lock.yaml"), lock).unwrap(); - fx -} - -/// pnpm-wired human run: the committables line must name -/// pnpm-workspace.yaml (pnpm >=11 reads overrides only from there — losing -/// it silently unvendors on the next install) and the reinstall hint must -/// say `pnpm install`. -#[test] -fn human_pnpm_vendor_names_workspace_committable_and_pnpm_install() { - let fx = pnpm_fixture(); - let (code, stdout, stderr) = human_vendor(&fx, &[]); - assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stdout.contains("pnpm-workspace.yaml"), - "the pnpm committables line names the workspace file: {stdout}" - ); - assert!( - stdout.contains("pnpm >=11"), - "…and says why (pnpm >=11 override source): {stdout}" - ); - assert!( - stdout.contains("Run `pnpm install`"), - "the pnpm reinstall hint: {stdout}" - ); - assert!( - !stdout.contains("Run `npm install`"), - "only the wired flavor's install is suggested: {stdout}" - ); + let (code, env) = vendor_cli(fx.root(), &[]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(find_event(&env, "applied", None)["purl"], PURL, "{env:#}"); + assert!(fx.tgz_path().is_file()); + assert!(!fx.root().join("node_modules/left-pad/absent.js").exists()); } // ───────────────────────────────────────────────────────────────────── -// 7. unix fault injection: state-write failures +// 5. unix fault injection: state-write failures // ───────────────────────────────────────────────────────────────────── #[cfg(unix)] @@ -1338,10 +716,10 @@ async fn vendor_refuses_a_symlinked_lock_instead_of_replacing_it() { assert_eq!(code, 0, "{linked}: {env:#}"); let warning = find_event(&env, "skipped", Some("vendor_would_refuse_symlinked_file")); assert!( - warning - .to_string() - .contains(&format!("{linked} is a symbolic link")), - "{linked}: {env:#}" + warning["reason"] + .as_str() + .is_some_and(|m| m.contains(linked)), + "{linked}: the advisory names the link: {env:#}" ); let (code, env) = vendor_cli(fx.root(), &[]); @@ -1353,8 +731,8 @@ async fn vendor_refuses_a_symlinked_lock_instead_of_replacing_it() { assert!( env["error"]["message"] .as_str() - .is_some_and(|m| m.starts_with(&format!("{linked} is a symbolic link"))), - "{linked}: {env:#}" + .is_some_and(|m| m.contains(linked)), + "{linked}: the refusal names the link: {env:#}" ); assert!( std::fs::symlink_metadata(&link) @@ -1469,328 +847,7 @@ async fn in_sync_vendor_over_a_symlinked_lock_neither_warns_nor_refuses() { assert_eq!(std::fs::read(&target).unwrap(), wired); } -// ───────────────────────────────────────────────────────────────────── -// 8. human-mode error/refusal stderr surfaces (no --json, no --silent) -// -// Section 5 covered the human happy paths; every test here pins one of -// the human-only eprintln lines that ride beside an already-pinned JSON -// contract (same fixture shapes as sections 1–4, human runner). -// ───────────────────────────────────────────────────────────────────── - -/// `sha512-…` SRI of `bytes` (same helper shape as the sibling repair and -/// scan-vendor suites): a syntactically VALID integrity so the pristine -/// ladder proceeds past the no-integrity `Unverifiable` refusal and -/// actually attempts the fetch. -fn sri_of(bytes: &[u8]) -> String { - use base64::Engine as _; - use sha2::{Digest as _, Sha512}; - format!( - "sha512-{}", - base64::engine::general_purpose::STANDARD.encode(Sha512::digest(bytes)) - ) -} - -/// Human corrupt-manifest surface: the `Error: could not read manifest` -/// stderr line beside the `manifest_invalid` exit contract section 1 pins -/// under --json. -#[test] -fn human_corrupt_manifest_prints_could_not_read() { - let fx = npm_fixture(); - std::fs::write(fx.manifest_path(), b"{broken").unwrap(); - - let (code, stdout, stderr) = human_vendor(&fx, &[]); - assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stderr.contains("Error: Could not read manifest:"), - "the human explanation for the flipped exit code: {stderr}" - ); - assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); -} - -/// Human corrupt-committed-artifact surface (fresh clone, ledger sha -/// mismatch): the `Cannot vendor …` stderr line still carries the -/// `socket-patch repair` remedy. -#[tokio::test] -async fn human_corrupt_committed_artifact_prints_repair_hint() { - let fx = npm_fixture(); - assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "stage vendor"); - std::fs::remove_dir_all(fx.root().join("node_modules")).unwrap(); - std::fs::write(fx.tgz_path(), b"corrupt bytes").unwrap(); - - let (code, stdout, stderr) = human_vendor(&fx, &["--offline"]); - assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stderr.contains("pkg:npm/left-pad@1.3.0"), - "stderr names the purl: {stderr}" - ); - assert!( - stderr.contains("offline"), - "the human line must carry the repair remedy: {stderr}" - ); - assert!( - stdout.contains("Vendored 0 packages; 1 failed."), - "the summary counts the failure: {stdout}" - ); -} - -/// Human registry-fetch-failure surface: a lockfile-resolved missing -/// package whose registry serves a 500 prints the -/// `Cannot vendor …: fetch failed: …` stderr line (the human twin of the -/// `vendor_fetch_failed` event scan_vendor_e2e pins under --json). -#[tokio::test] -async fn human_fetch_failure_prints_fetch_failed() { - let fx = npm_fixture(); - std::fs::remove_dir_all(fx.root().join("node_modules")).unwrap(); - let mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/left-pad/-/left-pad-1.3.0.tgz")) - .respond_with(ResponseTemplate::new(500)) - .mount(&mock) - .await; - // Re-point the lock at the mock registry with a VALID SRI: the ladder - // reaches the download (an SRI-less entry would stop at Unverifiable) - // and the 500 lands in `PristineFetch::Failed`. - let mut lock: Value = serde_json::from_slice(&fx.lock_bytes()).unwrap(); - lock["packages"]["node_modules/left-pad"]["resolved"] = - Value::String(format!("{}/left-pad/-/left-pad-1.3.0.tgz", mock.uri())); - lock["packages"]["node_modules/left-pad"]["integrity"] = - Value::String(sri_of(b"pristine bytes the mock never serves")); - std::fs::write(fx.lock_path(), serde_json::to_vec_pretty(&lock).unwrap()).unwrap(); - - // Non-offline (the fetch must actually run), anonymous (no other - // network path opens), human mode (no --json). - let (code, stdout, stderr) = run_cli( - fx.root(), - &[ - "vendor", - "--vendor-url", - &mock.uri(), - "--cwd", - fx.root().to_str().unwrap(), - ], - &[("SOCKET_NO_API_TOKEN", "1")], - ); - assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stderr.contains("pkg:npm/left-pad@1.3.0") && stderr.contains("request"), - "the human fetch-failure line: {stderr}" - ); - assert!( - stdout.contains("Vendored 0 packages; 1 failed."), - "a fetch failure is counted as failed, not skipped: {stdout}" - ); - assert!( - !fx.vendor_dir().exists(), - "nothing may be vendored from a failed fetch" - ); -} -/// Human stale-ledger surface: a malformed pre-v5 redirect ledger is -/// ignored — the run vendors normally and prints no refusal. -#[test] -fn human_corrupt_pre_v5_redirect_ledger_vendors_normally() { - let fx = npm_fixture(); - std::fs::create_dir_all(fx.vendor_dir()).unwrap(); - std::fs::write(fx.redirect_state_path(), b"garbage").unwrap(); - - let (code, stdout, stderr) = human_vendor(&fx, &[]); - assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stdout.contains("Vendored 1 package."), - "the stale ledger does not block the run: {stdout}" - ); - assert!( - !stderr.contains("Cannot vendor"), - "no refusal is printed: {stderr}" - ); - assert_eq!( - std::fs::read(fx.redirect_state_path()).unwrap(), - b"garbage", - "the pre-v5 ledger is left untouched" - ); -} - -/// Human unrestorable-hosted-pin surface: an offline run over a hosted pin -/// prints the `Cannot vendor …: cannot restore the upstream entry` line and -/// counts the refusal. -#[test] -fn human_unrestorable_hosted_pin_prints_cannot_restore() { - let fx = npm_fixture(); - let hosted_lock = pin_hosted(&fx); - - let (code, stdout, stderr) = - human_vendor(&fx, &["--offline", "--patch-server-url", HOSTED_ORIGIN]); - assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stderr.contains("Cannot vendor pkg:npm/left-pad@1.3.0:") - && stderr.contains("cannot restore the upstream entry"), - "the human takeover-refusal line: {stderr}" - ); - assert!( - stdout.contains("Vendored 0 packages; 1 failed."), - "the fail-closed refusal is counted: {stdout}" - ); - assert_eq!(fx.lock_bytes(), hosted_lock, "lock untouched"); -} -/// Human backend-refusal surface: an installed package with NO lockfile of -/// any flavor is a non-benign `vendor_lockfile_missing` refusal — the -/// `Cannot vendor …` stderr line carries the backend's remedy. -#[test] -fn human_lockfile_missing_refusal_prints_cannot_vendor() { - let fx = npm_fixture(); - std::fs::remove_file(fx.lock_path()).unwrap(); - - let (code, stdout, stderr) = human_vendor(&fx, &[]); - assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stderr.contains("Cannot vendor pkg:npm/left-pad@1.3.0:") - && stderr.contains("vendoring rewires the lockfile"), - "the refusal detail surfaces verbatim: {stderr}" - ); - assert!( - stdout.contains("Vendored 0 packages; 1 failed."), - "a non-benign refusal is counted as failed: {stdout}" - ); -} - -/// Human patch-failure surface: a manifest record with a patch-target file -/// ABSENT from the installed copy (non-empty beforeHash, no --force) fails -/// the staged apply closed; the `Failed to vendor …` stderr line carries -/// the apply diagnostic. -#[test] -fn human_patch_failure_prints_failed_to_vendor() { - let fx = npm_fixture(); - let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); - let elsewhere = compute_git_sha256_from_bytes(b"bytes the fixture never installed\n"); - let mut manifest: Value = - serde_json::from_slice(&std::fs::read(fx.manifest_path()).unwrap()).unwrap(); - manifest["patches"][PURL]["files"]["package/absent.js"] = - json!({ "beforeHash": elsewhere, "afterHash": after_hash }); - std::fs::write( - fx.manifest_path(), - serde_json::to_vec_pretty(&manifest).unwrap(), - ) - .unwrap(); - - let (code, stdout, stderr) = human_vendor(&fx, &[]); - assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); - assert!(fx.tgz_path().is_file()); - assert!(!fx.root().join("node_modules/left-pad/absent.js").exists()); -} - -/// Human corrupt-ledger `--revert` surface: the -/// `Error: Could not read the vendor ledger` stderr line beside -/// the `vendor_state_unreadable` exit contract section 1 pins under --json. -#[test] -fn human_corrupt_state_revert_prints_could_not_read() { - let fx = npm_fixture(); - std::fs::create_dir_all(fx.vendor_dir()).unwrap(); - std::fs::write(fx.state_path(), b"not json{").unwrap(); - - let (code, stdout, stderr) = human_vendor(&fx, &["--revert"]); - assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stderr.contains("Error: Could not read the vendor ledger"), - "the human explanation for the flipped exit code: {stderr}" - ); -} - -/// Human `--revert` failure surface: a ledger entry with no revert backend -/// prints the `Failed to revert ` stderr line, and the summary -/// counts it. -#[tokio::test] -async fn human_revert_failure_prints_failed_to_revert() { - let fx = npm_fixture(); - write_ledger_entry(fx.root(), "frobnicate").await; - - let (code, stdout, stderr) = human_vendor(&fx, &["--revert"]); - assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stderr.contains("Failed to revert pkg:npm/left-pad@1.3.0"), - "stderr names the failed purl: {stderr}" - ); - assert!( - stdout.contains("Reverted 0 vendored packages; 1 failed."), - "the summary counts the failure: {stdout}" - ); -} - -/// Human `--revert --dry-run`: the `Would revert` verb, and NOTHING is -/// mutated — the ledger keeps its entry, the wired lock and the artifact -/// stay exactly as vendored. -#[tokio::test] -async fn human_revert_dry_run_prints_would_revert_and_mutates_nothing() { - let fx = npm_fixture(); - assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "stage vendor"); - let state_before = std::fs::read(fx.state_path()).unwrap(); - let wired_lock = fx.lock_bytes(); - - let (code, stdout, stderr) = human_vendor(&fx, &["--revert", "--dry-run"]); - assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stdout.contains("Would revert 1 vendored package."), - "the dry-run verb and count: {stdout}" - ); - assert_eq!( - std::fs::read(fx.state_path()).unwrap(), - state_before, - "a dry revert must not touch the ledger" - ); - assert_eq!( - fx.lock_bytes(), - wired_lock, - "a dry revert must not touch the wired lock" - ); - assert!(fx.tgz_path().is_file(), "a dry revert keeps the artifact"); -} - -/// Human `--dry-run --vex`: the VEX skip line — a dry run vendors nothing, -/// so generating (and verifying the untouched tree) would spuriously fail; -/// the file must NOT be written. -#[test] -fn human_dry_run_with_vex_prints_skip_and_writes_no_vex() { - let fx = npm_fixture(); - let vex_path = fx.root().join("attestation.vex.json"); - - let (code, stdout, stderr) = - human_vendor(&fx, &["--dry-run", "--vex", vex_path.to_str().unwrap()]); - assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stdout.contains("Skipping VEX generation (--dry-run: nothing was vendored)."), - "the human VEX-skip explanation: {stdout}" - ); - assert!( - !vex_path.exists(), - "no attestation may be written during --dry-run" - ); -} - -/// Human run-level classic→berry migration advisory: a classic `yarn.lock` -/// carrying vendored wiring (and no yarn@1 corepack pin) warns on stderr at -/// envelope-finalize time — pinned through the `--revert` no-op path, which -/// exercises the state-based probe without any vendoring in the run itself. -#[test] -fn human_classic_migration_risk_prints_stderr_warning() { - let fx = npm_fixture(); - std::fs::write( - fx.root().join("yarn.lock"), - "# yarn lockfile v1\n\nleft-pad@^1.3.0:\n version \"1.3.0\"\n \ - resolved \"file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz\"\n", - ) - .unwrap(); - - let (code, stdout, stderr) = human_vendor(&fx, &["--revert"]); - assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stdout.contains("Nothing vendored to revert."), - "the revert itself is the calm no-op: {stdout}" - ); - assert!( - stderr.contains("Warning: yarn.lock is yarn-classic"), - "the run-level advisory prints for humans: {stderr}" - ); -} - -// ─────────────── service outage / source-flip idempotence ─────────────── +// ───────────── 6. service outage / source-flip idempotence ───────────── // // A re-run whose committed artifact the ledger vouches for is // `already_vendored` whichever source built it and whatever the service @@ -2193,38 +1250,3 @@ async fn pdm_relock_rescan_under_outage_rewires_the_committed_wheel() { "rollback restores the relocked bytes" ); } - -#[path = "vlt_hosted_common/mod.rs"] -mod vlt_hosted_common; -#[path = "vlt_hosted_common/vendored.rs"] -mod vlt_vendored; - -/// The vlt twin of the pnpm committables line: a vlt-wired run names -/// package.json (and workspace package.json files), vlt-lock.json and -/// `.socket/vendor/`, points CI at `vlt ci`, and names `vlt install` as -/// the reinstall; `vendor --revert` names `vlt install` too. -#[test] -fn human_vlt_vendor_names_vlt_committables_and_vlt_install() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - vlt_vendored::write_project(root); - vlt_vendored::seed_manifest(root); - let cwd = root.to_str().unwrap(); - let (code, stdout, stderr) = run_cli(root, &["vendor", "--cwd", cwd], &[]); - assert_eq!(code, 0, "{stdout}\n{stderr}"); - assert!( - stdout.contains( - "Commit package.json (and workspace package.json files), vlt-lock.json and \ - .socket/vendor/ (the .gitignore there re-includes the payload and keeps vlt's \ - node_modules links out of git); CI: `vlt ci`." - ), - "{stdout}" - ); - assert!( - stdout.contains("Run `vlt install` to update the installed tree"), - "{stdout}" - ); - let (code, stdout, stderr) = run_cli(root, &["vendor", "--revert", "--cwd", cwd], &[]); - assert_eq!(code, 0, "{stdout}\n{stderr}"); - assert!(stdout.contains("Run `vlt install` to resync"), "{stdout}"); -} diff --git a/crates/socket-patch-cli/tests/vendor/vendor_render.rs b/crates/socket-patch-cli/tests/vendor/vendor_render.rs new file mode 100644 index 000000000..7a45b5b06 --- /dev/null +++ b/crates/socket-patch-cli/tests/vendor/vendor_render.rs @@ -0,0 +1,631 @@ +//! `vendor`'s render suite (#1089): the human-mode (no `--json`, no +//! `--silent`) output of `commands/vendor.rs`. Behavior is asserted +//! through `--json` codes in `vendor_failure_paths.rs`; the sentence +//! checks here are the render checks, one per message family. + +use std::process::Command; + +use serde_json::{json, Value}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::common::hermetic; +use crate::npm_fixture::*; +use crate::prebuilt_common; +use crate::vlt_vendored; + +// ───────────────────────────────────────────────────────────────────── +// 1. human-mode output surface (no --json, no --silent) +// ───────────────────────────────────────────────────────────────────── + +/// Human happy path: the summary line, the committables hint, and the +/// npm reinstall hint (package-lock flavor). +#[test] +fn human_vendor_prints_summary_committables_and_reinstall_hint() { + let fx = npm_fixture(); + let (code, stdout, stderr) = human_vendor(&fx, &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("Vendored 1 package."), + "summary line: {stdout}" + ); + assert!( + stdout.contains("Commit .socket/vendor/ and the updated lockfiles"), + "committables hint: {stdout}" + ); + assert!( + stdout.contains("Run `npm install`"), + "package-lock reinstall hint: {stdout}" + ); +} + +/// An npm-only run never opens the project's composer.lock for the +/// Composer reinstall hint: a FIFO planted there would block the open(2) +/// after the lock and ledger are written, and a lock naming vendored +/// composer copies this run did not wire gets no composer hint. +#[cfg(unix)] +#[test] +fn npm_only_human_vendor_does_not_read_composer_lock() { + use std::time::{Duration, Instant}; + let fx = npm_fixture(); + let fifo = fx.root().join("composer.lock"); + assert!(Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap() + .success()); + let fixture = prebuilt_common::Server::project(fx.root()); + let mut child = hermetic::binary_command() + .env("SOCKET_VENDOR_URL", &fixture.uri) + .args(["vendor", "--cwd", fx.root().to_str().unwrap()]) + .current_dir(fx.root()) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn socket-patch binary"); + let started = Instant::now(); + while child.try_wait().unwrap().is_none() { + if started.elapsed() > Duration::from_secs(60) { + let _ = child.kill(); + panic!("vendor blocked opening the composer.lock FIFO"); + } + std::thread::sleep(Duration::from_millis(25)); + } + let out = child.wait_with_output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(out.status.code(), Some(0), "{stdout}"); + assert!(stdout.contains("Vendored 1 package."), "{stdout}"); + + let fx = npm_fixture(); + std::fs::write( + fx.root().join("composer.lock"), + json!({"packages": [{"name": "psr/log", "version": "3.0.2", "dist": { + "type": "path", + "url": format!(".socket/vendor/composer/{UUID}/psr/log@3.0.2"), + "reference": UUID}}]}) + .to_string(), + ) + .unwrap(); + let (code, stdout, stderr) = human_vendor(&fx, &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!(stdout.contains("Run `npm install`"), "{stdout}"); + assert!(!stdout.contains("composer install"), "{stdout}"); +} + +/// Human `--dry-run`: the `Would vendor` verb and NO commit/reinstall +/// hints (nothing was written). A dry-run success is translated to a +/// `Verified` event (counted under `summary.verified`, not `applied`); the +/// human line counts it as a would-vendor package, and the JSON +/// cross-check below anchors where the package actually lands. +#[test] +fn human_dry_run_prints_would_vendor_and_no_commit_hints() { + let fx = npm_fixture(); + let (code, stdout, stderr) = human_vendor(&fx, &["--dry-run"]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("Would vendor 1 package."), + "dry-run verb, counting the Verified preview event: {stdout}" + ); + let (json_code, env) = vendor_cli(fx.root(), &["--dry-run"]); + assert_eq!(json_code, 0, "{env:#}"); + assert_eq!( + env["summary"]["verified"], 1, + "the dry-run success is counted as verified: {env:#}" + ); + assert!( + !stdout.contains("Commit .socket/vendor/"), + "a dry run has nothing to commit: {stdout}" + ); + assert!( + !stdout.contains("Run `"), + "a dry run needs no reinstall: {stdout}" + ); + assert!(!fx.vendor_dir().exists(), "dry run writes nothing"); +} + +/// Human not-installed skip: the `Cannot vendor …` stderr line with the +/// on-disk cause, while the installed package still vendors (and the +/// summary counts both honestly). +#[test] +fn human_not_installed_prints_cannot_vendor_to_stderr() { + let fx = npm_fixture_with_purls(&[PURL, "pkg:npm/right-pad@9.9.9"]); + let (code, stdout, stderr) = human_vendor(&fx, &[]); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stderr.contains("Cannot vendor"), + "stderr names the skip: {stderr}" + ); + assert!( + stderr.contains("lock"), + "stderr carries the on-disk cause: {stderr}" + ); + assert!( + stdout.contains("Vendored 1 package; 1 failed."), + "summary counts the skip: {stdout}" + ); +} + +/// Human `--revert` summary after a completed revert. +#[tokio::test] +async fn human_revert_prints_reverted_summary() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "stage vendor"); + + let (code, stdout, stderr) = human_vendor(&fx, &["--revert"]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("Reverted 1 vendored package."), + "revert summary: {stdout}" + ); + assert!( + !stdout.contains("Kept"), + "nothing drifted, so no drift explainer: {stdout}" + ); + assert_eq!(fx.lock_bytes(), fx.original_lock, "lock restored"); +} + +/// Human `--revert` over a drifted lock: no `Reverted …` line, only the +/// `Kept 1 drifted package` explainer (counts come from the drift-skip +/// keep, not advisory warnings). +#[tokio::test] +async fn human_revert_drift_keep_prints_kept_explainer() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "stage vendor"); + // Third-party drift: neither ours nor the recorded pre-vendor original. + let mut drifted: Value = serde_json::from_slice(&fx.lock_bytes()).unwrap(); + drifted["packages"]["node_modules/left-pad"]["resolved"] = + Value::String("https://example.com/their-fork.tgz".to_string()); + let mut drifted_lock = serde_json::to_vec_pretty(&drifted).unwrap(); + drifted_lock.push(b'\n'); + std::fs::write(fx.lock_path(), &drifted_lock).unwrap(); + + let (code, stdout, stderr) = human_vendor(&fx, &["--revert"]); + assert_eq!(code, 0, "a drift keep is not an error:\n{stdout}\n{stderr}"); + assert!( + !stdout.contains("Reverted"), + "nothing reverted, so no package line: {stdout}" + ); + assert!( + stdout.contains("Kept 1 drifted package:"), + "the drift-keep explainer: {stdout}" + ); + assert!(fx.tgz_path().is_file(), "kept artifacts survive"); + assert_eq!(fx.lock_bytes(), drifted_lock, "drifted lock left alone"); +} + +/// Human `--revert` with a `.socket/` present but an empty ledger: the +/// calm no-op line (complements the no-`.socket`-dir no-op pinned in +/// in_process_vendor.rs). +#[test] +fn human_revert_empty_ledger_prints_nothing_to_revert() { + let fx = npm_fixture(); + assert!(!fx.state_path().exists(), "fixture starts unvendored"); + + let (code, stdout, stderr) = human_vendor(&fx, &["--revert"]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("Nothing vendored to revert."), + "the empty-ledger no-op line: {stdout}" + ); +} + +/// Human plain vendor with no manifest at all: the clean no-op message, +/// exit 0 (same contract as apply). The line names the MANIFEST — the +/// fixture's `.socket/` (blobs) very much exists, as it does on every +/// hosted-only or vendored-mode project. +#[test] +fn human_missing_manifest_prints_nothing_to_vendor() { + let fx = npm_fixture(); + std::fs::remove_file(fx.manifest_path()).unwrap(); + + let (code, stdout, stderr) = human_vendor(&fx, &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + 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" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// 2. pnpm committables hint (pnpm >=11 workspace-file portability) +// ───────────────────────────────────────────────────────────────────── + +/// A pnpm project (lockfileVersion 9.0): package.json + pnpm-lock.yaml + +/// installed node_modules/left-pad + the same staged `.socket` blob as the +/// npm fixture. The lock grammar mirrors core's pnpm_lock spike fixtures. +fn pnpm_fixture() -> NpmFixture { + let fx = npm_fixture(); + std::fs::remove_file(fx.lock_path()).unwrap(); + let lock = "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + left-pad: + specifier: 1.3.0 + version: 1.3.0 + +packages: + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + +snapshots: + + left-pad@1.3.0: {} +"; + std::fs::write(fx.root().join("pnpm-lock.yaml"), lock).unwrap(); + fx +} + +/// pnpm-wired human run: the committables line must name +/// pnpm-workspace.yaml (pnpm >=11 reads overrides only from there — losing +/// it silently unvendors on the next install) and the reinstall hint must +/// say `pnpm install`. +#[test] +fn human_pnpm_vendor_names_workspace_committable_and_pnpm_install() { + let fx = pnpm_fixture(); + let (code, stdout, stderr) = human_vendor(&fx, &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("pnpm-workspace.yaml"), + "the pnpm committables line names the workspace file: {stdout}" + ); + assert!( + stdout.contains("pnpm >=11"), + "…and says why (pnpm >=11 override source): {stdout}" + ); + assert!( + stdout.contains("Run `pnpm install`"), + "the pnpm reinstall hint: {stdout}" + ); + assert!( + !stdout.contains("Run `npm install`"), + "only the wired flavor's install is suggested: {stdout}" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// 3. human-mode error/refusal stderr surfaces (no --json, no --silent) +// +// Section 1 covered the human happy paths; every test here pins one of +// the human-only eprintln lines that ride beside a JSON contract +// `vendor_failure_paths.rs` pins (same fixture shapes, human runner). +// ───────────────────────────────────────────────────────────────────── + +/// Human corrupt-manifest surface: the `Error: could not read manifest` +/// stderr line beside the `manifest_invalid` exit contract `vendor_failure_paths.rs` pins +/// under --json. +#[test] +fn human_corrupt_manifest_prints_could_not_read() { + let fx = npm_fixture(); + std::fs::write(fx.manifest_path(), b"{broken").unwrap(); + + let (code, stdout, stderr) = human_vendor(&fx, &[]); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stderr.contains("Error: Could not read manifest:"), + "the human explanation for the flipped exit code: {stderr}" + ); + assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); +} + +/// Human corrupt-committed-artifact surface (fresh clone, ledger sha +/// mismatch): the `Cannot vendor …` stderr line still carries the +/// `socket-patch repair` remedy. +#[tokio::test] +async fn human_corrupt_committed_artifact_prints_repair_hint() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "stage vendor"); + std::fs::remove_dir_all(fx.root().join("node_modules")).unwrap(); + std::fs::write(fx.tgz_path(), b"corrupt bytes").unwrap(); + + let (code, stdout, stderr) = human_vendor(&fx, &["--offline"]); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stderr.contains("pkg:npm/left-pad@1.3.0"), + "stderr names the purl: {stderr}" + ); + assert!( + stderr.contains("offline"), + "the human line must carry the repair remedy: {stderr}" + ); + assert!( + stdout.contains("Vendored 0 packages; 1 failed."), + "the summary counts the failure: {stdout}" + ); +} + +/// Human registry-fetch-failure surface: a lockfile-resolved missing +/// package whose registry serves a 500 prints the +/// `Cannot vendor …: fetch failed: …` stderr line (the human twin of the +/// `vendor_fetch_failed` event scan_vendor_e2e pins under --json). +#[tokio::test] +async fn human_fetch_failure_prints_fetch_failed() { + let fx = npm_fixture(); + std::fs::remove_dir_all(fx.root().join("node_modules")).unwrap(); + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/left-pad/-/left-pad-1.3.0.tgz")) + .respond_with(ResponseTemplate::new(500)) + .mount(&mock) + .await; + // Re-point the lock at the mock registry with a VALID SRI: the ladder + // reaches the download (an SRI-less entry would stop at Unverifiable) + // and the 500 lands in `PristineFetch::Failed`. + let mut lock: Value = serde_json::from_slice(&fx.lock_bytes()).unwrap(); + lock["packages"]["node_modules/left-pad"]["resolved"] = + Value::String(format!("{}/left-pad/-/left-pad-1.3.0.tgz", mock.uri())); + lock["packages"]["node_modules/left-pad"]["integrity"] = + Value::String(sri_of(b"pristine bytes the mock never serves")); + std::fs::write(fx.lock_path(), serde_json::to_vec_pretty(&lock).unwrap()).unwrap(); + + // Non-offline (the fetch must actually run), anonymous (no other + // network path opens), human mode (no --json). + let (code, stdout, stderr) = run_cli( + fx.root(), + &[ + "vendor", + "--vendor-url", + &mock.uri(), + "--cwd", + fx.root().to_str().unwrap(), + ], + &[("SOCKET_NO_API_TOKEN", "1")], + ); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stderr.contains("pkg:npm/left-pad@1.3.0") && stderr.contains("request"), + "the human fetch-failure line: {stderr}" + ); + assert!( + stdout.contains("Vendored 0 packages; 1 failed."), + "a fetch failure is counted as failed, not skipped: {stdout}" + ); + assert!( + !fx.vendor_dir().exists(), + "nothing may be vendored from a failed fetch" + ); +} + +/// Human unrestorable-hosted-pin surface: an offline run over a hosted pin +/// prints the `Cannot vendor …: cannot restore the upstream entry` line and +/// counts the refusal. +#[test] +fn human_unrestorable_hosted_pin_prints_cannot_restore() { + let fx = npm_fixture(); + let hosted_lock = pin_hosted(&fx); + + let (code, stdout, stderr) = + human_vendor(&fx, &["--offline", "--patch-server-url", HOSTED_ORIGIN]); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stderr.contains("Cannot vendor pkg:npm/left-pad@1.3.0:") + && stderr.contains("cannot restore the upstream entry"), + "the human takeover-refusal line: {stderr}" + ); + assert!( + stdout.contains("Vendored 0 packages; 1 failed."), + "the fail-closed refusal is counted: {stdout}" + ); + assert_eq!(fx.lock_bytes(), hosted_lock, "lock untouched"); +} +/// Human backend-refusal surface: an installed package with NO lockfile of +/// any flavor is a non-benign `vendor_lockfile_missing` refusal — the +/// `Cannot vendor …` stderr line carries the backend's remedy. +#[test] +fn human_lockfile_missing_refusal_prints_cannot_vendor() { + let fx = npm_fixture(); + std::fs::remove_file(fx.lock_path()).unwrap(); + + let (code, stdout, stderr) = human_vendor(&fx, &[]); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stderr.contains("Cannot vendor pkg:npm/left-pad@1.3.0:") + && stderr.contains("vendoring rewires the lockfile"), + "the refusal detail surfaces verbatim: {stderr}" + ); + assert!( + stdout.contains("Vendored 0 packages; 1 failed."), + "a non-benign refusal is counted as failed: {stdout}" + ); +} + +/// Human corrupt-ledger `--revert` surface: the +/// `Error: Could not read the vendor ledger` stderr line beside +/// the `vendor_state_unreadable` exit contract `vendor_failure_paths.rs` pins under --json. +#[test] +fn human_corrupt_state_revert_prints_could_not_read() { + let fx = npm_fixture(); + std::fs::create_dir_all(fx.vendor_dir()).unwrap(); + std::fs::write(fx.state_path(), b"not json{").unwrap(); + + let (code, stdout, stderr) = human_vendor(&fx, &["--revert"]); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stderr.contains("Error: Could not read the vendor ledger"), + "the human explanation for the flipped exit code: {stderr}" + ); +} + +/// Human `--revert` failure surface: a ledger entry with no revert backend +/// prints the `Failed to revert ` stderr line, and the summary +/// counts it. +#[tokio::test] +async fn human_revert_failure_prints_failed_to_revert() { + let fx = npm_fixture(); + write_ledger_entry(fx.root(), "frobnicate").await; + + let (code, stdout, stderr) = human_vendor(&fx, &["--revert"]); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stderr.contains("Failed to revert pkg:npm/left-pad@1.3.0"), + "stderr names the failed purl: {stderr}" + ); + assert!( + stdout.contains("Reverted 0 vendored packages; 1 failed."), + "the summary counts the failure: {stdout}" + ); +} + +/// Human `--revert --dry-run`: the `Would revert` verb, and NOTHING is +/// mutated — the ledger keeps its entry, the wired lock and the artifact +/// stay exactly as vendored. +#[tokio::test] +async fn human_revert_dry_run_prints_would_revert_and_mutates_nothing() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "stage vendor"); + let state_before = std::fs::read(fx.state_path()).unwrap(); + let wired_lock = fx.lock_bytes(); + + let (code, stdout, stderr) = human_vendor(&fx, &["--revert", "--dry-run"]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("Would revert 1 vendored package."), + "the dry-run verb and count: {stdout}" + ); + assert_eq!( + std::fs::read(fx.state_path()).unwrap(), + state_before, + "a dry revert must not touch the ledger" + ); + assert_eq!( + fx.lock_bytes(), + wired_lock, + "a dry revert must not touch the wired lock" + ); + assert!(fx.tgz_path().is_file(), "a dry revert keeps the artifact"); +} + +/// Human `--dry-run --vex`: the VEX skip line — a dry run vendors nothing, +/// so generating (and verifying the untouched tree) would spuriously fail; +/// the file must NOT be written. +#[test] +fn human_dry_run_with_vex_prints_skip_and_writes_no_vex() { + let fx = npm_fixture(); + let vex_path = fx.root().join("attestation.vex.json"); + + let (code, stdout, stderr) = + human_vendor(&fx, &["--dry-run", "--vex", vex_path.to_str().unwrap()]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("Skipping VEX generation (--dry-run: nothing was vendored)."), + "the human VEX-skip explanation: {stdout}" + ); + assert!( + !vex_path.exists(), + "no attestation may be written during --dry-run" + ); +} + +/// Human run-level classic→berry migration advisory: a classic `yarn.lock` +/// carrying vendored wiring (and no yarn@1 corepack pin) warns on stderr at +/// envelope-finalize time — pinned through the `--revert` no-op path, which +/// exercises the state-based probe without any vendoring in the run itself. +#[test] +fn human_classic_migration_risk_prints_stderr_warning() { + let fx = npm_fixture(); + std::fs::write( + fx.root().join("yarn.lock"), + "# yarn lockfile v1\n\nleft-pad@^1.3.0:\n version \"1.3.0\"\n \ + resolved \"file:.socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz\"\n", + ) + .unwrap(); + + let (code, stdout, stderr) = human_vendor(&fx, &["--revert"]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("Nothing vendored to revert."), + "the revert itself is the calm no-op: {stdout}" + ); + assert!( + stderr.contains("Warning: yarn.lock is yarn-classic"), + "the run-level advisory prints for humans: {stderr}" + ); +} + +/// The vlt twin of the pnpm committables line: a vlt-wired run names +/// package.json (and workspace package.json files), vlt-lock.json and +/// `.socket/vendor/`, points CI at `vlt ci`, and names `vlt install` as +/// the reinstall; `vendor --revert` names `vlt install` too. +#[test] +fn human_vlt_vendor_names_vlt_committables_and_vlt_install() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vlt_vendored::write_project(root); + vlt_vendored::seed_manifest(root); + let cwd = root.to_str().unwrap(); + let (code, stdout, stderr) = run_cli(root, &["vendor", "--cwd", cwd], &[]); + assert_eq!(code, 0, "{stdout}\n{stderr}"); + assert!( + stdout.contains( + "Commit package.json (and workspace package.json files), vlt-lock.json and \ + .socket/vendor/ (the .gitignore there re-includes the payload and keeps vlt's \ + node_modules links out of git); CI: `vlt ci`." + ), + "{stdout}" + ); + assert!( + stdout.contains("Run `vlt install` to update the installed tree"), + "{stdout}" + ); + let (code, stdout, stderr) = run_cli(root, &["vendor", "--revert", "--cwd", cwd], &[]); + assert_eq!(code, 0, "{stdout}\n{stderr}"); + assert!(stdout.contains("Run `vlt install` to resync"), "{stdout}"); +} diff --git a/scripts/ci-test-durations.json b/scripts/ci-test-durations.json index 61e0ab69c..78cead71e 100644 --- a/scripts/ci-test-durations.json +++ b/scripts/ci-test-durations.json @@ -45,7 +45,6 @@ "covgap_commands_rollback": 1.86, "covgap_commands_scan_hosted": 1.46, "covgap_commands_scan_mod": 1.81, - "covgap_commands_vendor": 3.75, "covgap_commands_vex": 0.2, "covgap_crawlers_composer_crawler": 0.01, "covgap_crawlers_npm_crawler": 0.04, @@ -238,7 +237,7 @@ "update": 16.45, "upstream_restore_golden": 8.1, "uv_hosted": 0.01, - "vendor": 7.77, + "vendor": 11.52, "vendor_crash_safety_e2e": 0.67, "vendor_eject": 0.35, "vendor_eject_bun_lockb": 2.2, From cdd3949959351bf85cea7d946333c17051a33330 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:12:45 +0000 Subject: [PATCH 3/3] Fix vendor suite review findings The read-only-directory tests skip themselves under root, as the repair suite's do: chmod cannot stop root writing, so they failed the whole vendor binary in root containers. Test names and docs now say what the tests check. The corrupt artifact runs fail the redownload on the --offline conflict (there is no repair hint), the legacy-ledger and no-lockfile runs fail rather than skip, and the missing-patch-target run checks that the service's artifact carries the patched file. The yarn check reads the failed event by its code, not by stringifying it. The human stale-ledger render check is back. Refs #1089. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/vendor/npm_fixture.rs | 5 +- .../tests/vendor/vendor_failure_paths.rs | 111 ++++++++++++------ .../tests/vendor/vendor_render.rs | 30 ++++- 3 files changed, 106 insertions(+), 40 deletions(-) diff --git a/crates/socket-patch-cli/tests/vendor/npm_fixture.rs b/crates/socket-patch-cli/tests/vendor/npm_fixture.rs index 6e79a6240..9b7924e47 100644 --- a/crates/socket-patch-cli/tests/vendor/npm_fixture.rs +++ b/crates/socket-patch-cli/tests/vendor/npm_fixture.rs @@ -147,7 +147,7 @@ pub(crate) fn npm_fixture() -> NpmFixture { npm_fixture_with_purls(&[PURL]) } -/// In-process `VendorArgs` (json+silent+offline), for staging a vendored +/// In-process `VendorArgs` (json+silent), for staging a vendored /// state a subsequent subprocess run asserts against. pub(crate) fn vendor_args(cwd: &Path) -> VendorArgs { VendorArgs { @@ -210,7 +210,8 @@ pub(crate) fn run_cli( ) } -/// `vendor --json --offline --cwd ` through the binary. +/// `vendor --json --cwd ` through the binary, served by +/// the fixture patch service (pass `--offline` in `extra` for an offline run). pub(crate) fn vendor_cli(cwd: &Path, extra: &[&str]) -> (i32, Value) { let mut args = vec!["vendor", "--json", "--cwd", cwd.to_str().unwrap()]; args.extend_from_slice(extra); diff --git a/crates/socket-patch-cli/tests/vendor/vendor_failure_paths.rs b/crates/socket-patch-cli/tests/vendor/vendor_failure_paths.rs index 857c84280..2530d937e 100644 --- a/crates/socket-patch-cli/tests/vendor/vendor_failure_paths.rs +++ b/crates/socket-patch-cli/tests/vendor/vendor_failure_paths.rs @@ -5,7 +5,8 @@ //! unix fault-injection paths for the state-write failure events, and the //! service-outage re-runs. A message is read only for what it must name //! (a lockfile, a remedy command, an ecosystem). The human output lives in -//! `vendor_render.rs`. +//! `vendor_render.rs`; the refusal tests here also run the human mode once, +//! to check it claims no vendored package. use std::path::{Path, PathBuf}; @@ -87,11 +88,11 @@ fn corrupt_manifest_fails_closed() { // ───────────────────────────────────────────────────────────────────── /// Fresh-clone re-vendor over a PRESENT-but-corrupt committed artifact -/// (ledger sha mismatch): a loud `vendor_fetch_failed` failure carrying the -/// `socket-patch repair` hint — silently re-vendoring over it would mask -/// the corruption. +/// (ledger sha mismatch) under `--offline`: a loud `vendor_redownload_failed` +/// failure naming the `--offline` conflict (the redownload needs the patch +/// service) — silently re-vendoring over it would mask the corruption. #[tokio::test] -async fn corrupt_committed_artifact_fails_with_repair_hint() { +async fn corrupt_committed_artifact_offline_fails_the_redownload() { let fx = npm_fixture(); assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "stage vendor"); // Fresh-clone shape: no installed tree, only the committed artifacts. @@ -105,17 +106,17 @@ async fn corrupt_committed_artifact_fails_with_repair_hint() { assert!( failed["error"] .as_str() - .is_some_and(|d| d.contains("offline")), - "the failure must advise `socket-patch repair`: {env:#}" + .is_some_and(|d| d.contains("--offline")), + "the failure names the --offline conflict: {env:#}" ); } /// A legacy ledger with NO recorded artifact sha cannot verify the -/// committed artifact (`Unverifiable`) and must fall through to the -/// registry ladder — under `--offline` that lands in the calm -/// `package_not_installed` skip, never a loud `vendor_fetch_failed`. +/// committed artifact (`Unverifiable`), so the run redownloads it: with no +/// installed copy that is a `vendor_redownload_failed` failure (exit 1), +/// never a `vendor_fetch_failed`, and the committed artifact is left alone. #[tokio::test] -async fn legacy_ledger_without_sha_falls_through_to_calm_offline_skip() { +async fn legacy_ledger_without_sha_fails_the_redownload_not_a_fetch() { let fx = npm_fixture(); assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "stage vendor"); std::fs::remove_dir_all(fx.root().join("node_modules")).unwrap(); @@ -127,8 +128,8 @@ async fn legacy_ledger_without_sha_falls_through_to_calm_offline_skip() { let (code, env) = vendor_cli(fx.root(), &[]); assert_eq!(code, 1, "{env:#}"); - let skipped = find_event(&env, "failed", Some("vendor_redownload_failed")); - assert_eq!(skipped["purl"], PURL); + let failed = find_event(&env, "failed", Some("vendor_redownload_failed")); + assert_eq!(failed["purl"], PURL); assert!( events(&env) .iter() @@ -142,12 +143,12 @@ async fn legacy_ledger_without_sha_falls_through_to_calm_offline_skip() { } /// `PristineFetch::NoSource` in the auto-fetch loop: manifest purl, nothing -/// installed, no lockfile, no ledger — a NON-offline run still skips calmly -/// with the plain not-installed detail (the ladder returns before any -/// network I/O; `SOCKET_NO_API_TOKEN` keeps the run anonymous so no other -/// network path opens either). +/// installed, no lockfile, no ledger — a NON-offline run fails the purl +/// with `vendor_lockfile_missing` before any network I/O, never a +/// `vendor_fetch_failed` (`SOCKET_NO_API_TOKEN` keeps the run anonymous so +/// no other network path opens either). #[test] -fn missing_package_with_no_lock_and_no_ledger_is_calm_skip() { +fn missing_package_with_no_lock_and_no_ledger_fails_before_any_fetch() { let fx = npm_fixture(); std::fs::remove_dir_all(fx.root().join("node_modules")).unwrap(); std::fs::remove_file(fx.lock_path()).unwrap(); @@ -164,14 +165,14 @@ fn missing_package_with_no_lock_and_no_ledger_is_calm_skip() { code, 1, "an unvendorable manifest purl fails the run: {env:#}" ); - let skipped = find_event(&env, "failed", Some("vendor_lockfile_missing")); - assert_eq!(skipped["purl"], PURL); + let failed = find_event(&env, "failed", Some("vendor_lockfile_missing")); + assert_eq!(failed["purl"], PURL); assert!( events(&env) .iter() .all(|e| e["errorCode"] != "vendor_fetch_failed"), - "NoSource is a calm skip, not a fetch failure: {env:#}" + "NoSource is not a fetch failure: {env:#}" ); } @@ -387,8 +388,9 @@ async fn reconcile_unknown_ecosystem_entry_fails_closed_and_keeps_entry() { } /// A manifest record with a patch-target file ABSENT from the installed -/// copy (non-empty beforeHash, no --force) still vendors the package, and -/// the installed tree is never written to. +/// copy (non-empty beforeHash, no --force) still vendors the package: the +/// artifact is the patch service's build, which carries the patched file, +/// and the installed tree is never written to. #[test] fn patch_target_absent_from_the_install_still_vendors() { let fx = npm_fixture(); @@ -407,7 +409,23 @@ fn patch_target_absent_from_the_install_still_vendors() { let (code, env) = vendor_cli(fx.root(), &[]); assert_eq!(code, 0, "{env:#}"); assert_eq!(find_event(&env, "applied", None)["purl"], PURL, "{env:#}"); - assert!(fx.tgz_path().is_file()); + let tgz = std::fs::read(fx.tgz_path()).unwrap(); + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(&tgz[..])); + let absent = archive + .entries() + .unwrap() + .map(Result::unwrap) + .find(|e| e.path().unwrap().as_os_str() == "package/absent.js") + .map(|mut e| { + let mut bytes = Vec::new(); + std::io::Read::read_to_end(&mut e, &mut bytes).unwrap(); + bytes + }); + assert_eq!( + absent.as_deref(), + Some(PATCHED_INDEX), + "the artifact carries the patched file" + ); assert!(!fx.root().join("node_modules/left-pad/absent.js").exists()); } @@ -425,6 +443,17 @@ fn chmod(path: &Path, mode: u32) { /// an assertion panics first. #[cfg(unix)] struct RestorePerms(PathBuf); +/// Is the test process running as root? Read-only directories are inert +/// under euid 0, so the fault-injection tests skip themselves. +#[cfg(unix)] +fn is_root() -> bool { + std::process::Command::new("id") + .arg("-u") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "0") + .unwrap_or(false) +} + #[cfg(unix)] impl Drop for RestorePerms { fn drop(&mut self) { @@ -441,6 +470,10 @@ impl Drop for RestorePerms { #[cfg(unix)] #[tokio::test] async fn revert_state_write_failure_reports_failed_after_removal() { + if is_root() { + eprintln!("skipped: chmod cannot make a directory read-only for root"); + return; + } let fx = npm_fixture(); assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "stage vendor"); chmod(&fx.vendor_dir(), 0o555); @@ -472,6 +505,10 @@ async fn revert_state_write_failure_reports_failed_after_removal() { #[cfg(unix)] #[tokio::test] async fn reconcile_state_write_failure_reports_failed_after_removal() { + if is_root() { + eprintln!("skipped: chmod cannot make a directory read-only for root"); + return; + } 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(); @@ -511,6 +548,10 @@ async fn reconcile_state_write_failure_reports_failed_after_removal() { #[cfg(unix)] #[tokio::test] async fn vendor_state_write_failure_reports_failed_event() { + if is_root() { + eprintln!("skipped: chmod cannot make a directory read-only for root"); + return; + } let fx = npm_fixture(); // The backend's artifact home stays writable; only the ledger's own // directory refuses writes. @@ -645,10 +686,12 @@ fn yarn_classic_check_and_repair_flag_a_dangling_dependency_descriptor() { let (code, env) = vendor_cli(fx.root(), &["--check"]); assert_eq!(code, 1, "{env:#}"); - let failed = find_event(&env, "failed", None).to_string(); + let failed = find_event(&env, "failed", Some("vendor_check_failed")); assert!( - failed.contains("vendor_check_failed") && failed.contains("is-number@^7.0.0"), - "{env:#}" + failed["reason"] + .as_str() + .is_some_and(|m| m.contains("is-number@^7.0.0")), + "the check names the dangling descriptor: {env:#}" ); let cwd = fx.root().to_str().unwrap(); @@ -659,8 +702,13 @@ fn yarn_classic_check_and_repair_flag_a_dangling_dependency_descriptor() { ); assert_ne!(code, 0, "{stdout}\n{stderr}"); let env: Value = serde_json::from_str(&stdout).unwrap(); - let failed = find_event(&env, "failed", Some("vendor_dep_manifest_unlocked")).to_string(); - assert!(failed.contains("is-number@^7.0.0"), "{env:#}"); + let failed = find_event(&env, "failed", Some("vendor_dep_manifest_unlocked")); + assert!( + failed["error"] + .as_str() + .is_some_and(|m| m.contains("is-number@^7.0.0")), + "{env:#}" + ); assert_eq!( std::fs::read_to_string(&yarn_lock).unwrap(), dangling, @@ -765,10 +813,7 @@ async fn vendor_refuses_a_symlinked_lock_instead_of_replacing_it() { &[], ); assert_eq!(code, 1, "{linked}: {stdout}\n{stderr}"); - assert!( - stderr.contains(&format!("{linked} is a symbolic link")), - "{linked}: {stdout}\n{stderr}" - ); + assert!(stderr.contains(linked), "{linked}: {stdout}\n{stderr}"); assert!( !stdout.contains("Vendored 1 package") && !stdout.contains("Next steps"), "{linked}: {stdout}\n{stderr}" diff --git a/crates/socket-patch-cli/tests/vendor/vendor_render.rs b/crates/socket-patch-cli/tests/vendor/vendor_render.rs index 7a45b5b06..471f9871a 100644 --- a/crates/socket-patch-cli/tests/vendor/vendor_render.rs +++ b/crates/socket-patch-cli/tests/vendor/vendor_render.rs @@ -367,10 +367,10 @@ fn human_corrupt_manifest_prints_could_not_read() { } /// Human corrupt-committed-artifact surface (fresh clone, ledger sha -/// mismatch): the `Cannot vendor …` stderr line still carries the -/// `socket-patch repair` remedy. +/// mismatch, `--offline`): the stderr line names the purl and the +/// `--offline` conflict that stops the redownload. #[tokio::test] -async fn human_corrupt_committed_artifact_prints_repair_hint() { +async fn human_corrupt_committed_artifact_prints_the_offline_conflict() { let fx = npm_fixture(); assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "stage vendor"); std::fs::remove_dir_all(fx.root().join("node_modules")).unwrap(); @@ -383,8 +383,8 @@ async fn human_corrupt_committed_artifact_prints_repair_hint() { "stderr names the purl: {stderr}" ); assert!( - stderr.contains("offline"), - "the human line must carry the repair remedy: {stderr}" + stderr.contains("--vendor-source=service needs the network"), + "the human line names the --offline conflict: {stderr}" ); assert!( stdout.contains("Vendored 0 packages; 1 failed."), @@ -444,6 +444,26 @@ async fn human_fetch_failure_prints_fetch_failed() { ); } +/// Human stale-ledger surface: a malformed pre-v5 redirect ledger is +/// ignored — the run vendors normally and prints no refusal. +#[test] +fn human_corrupt_pre_v5_redirect_ledger_vendors_normally() { + let fx = npm_fixture(); + std::fs::create_dir_all(fx.vendor_dir()).unwrap(); + std::fs::write(fx.redirect_state_path(), b"garbage").unwrap(); + + let (code, stdout, stderr) = human_vendor(&fx, &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("Vendored 1 package."), + "the stale ledger does not block the run: {stdout}" + ); + assert!( + !stderr.contains("Cannot vendor"), + "no refusal is printed: {stderr}" + ); +} + /// Human unrestorable-hosted-pin surface: an offline run over a hosted pin /// prints the `Cannot vendor …: cannot restore the upstream entry` line and /// counts the refusal.