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..9b7924e47 --- /dev/null +++ b/crates/socket-patch-cli/tests/vendor/npm_fixture.rs @@ -0,0 +1,341 @@ +//! 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), 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 --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); + 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 53% 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..2530d937e 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,22 @@ -//! 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`; the refusal tests here also run the human mode once, +//! to check it claims no vendored package. 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 @@ -325,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. @@ -343,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(); @@ -365,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() @@ -380,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(); @@ -402,95 +165,20 @@ 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:#}" ); } // ───────────────────────────────────────────────────────────────────── // 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 +256,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 +306,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 +359,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 +387,50 @@ 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: the +/// artifact is the patch service's build, which carries the patched file, +/// 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" - ); + let (code, env) = vendor_cli(fx.root(), &[]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(find_event(&env, "applied", None)["purl"], PURL, "{env:#}"); + 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!( - 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}" + absent.as_deref(), + Some(PATCHED_INDEX), + "the artifact carries the patched 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)] @@ -1047,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) { @@ -1063,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); @@ -1094,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(); @@ -1133,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. @@ -1267,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(); @@ -1281,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, @@ -1338,10 +764,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 +779,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) @@ -1387,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}" @@ -1469,328 +892,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 +1295,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..471f9871a --- /dev/null +++ b/crates/socket-patch-cli/tests/vendor/vendor_render.rs @@ -0,0 +1,651 @@ +//! `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, `--offline`): the stderr line names the purl and the +/// `--offline` conflict that stops the redownload. +#[tokio::test] +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(); + 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("--vendor-source=service needs the network"), + "the human line names the --offline conflict: {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}" + ); +} + +/// 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,