Skip to content

Commit 46e47d9

Browse files
committed
Fail vendor --check when lock drops vendored ref
`vendor --check` only verified wiring for Maven/Gradle entries. For every other ecosystem it reported "committed artifact and wiring verified" and exited 0 even after `pipenv lock`, `uv lock`, `npm install` or a hand edit pointed the lockfile back at the registry, so a CI gate stayed green while fresh installs got the unpatched package and `vex` refused the same checkout. Each non-JVM entry is now judged by the same vendor-ledger liveness rule `vex` and `scan` use; an unwired entry fails with `vendor_check_failed` and exit 1. Regression tests cover Pipenv, requirements.txt, Poetry, uv, Hatch and npm. Fixes #725 Assisted-by: Claude Code:claude-opus-5-5
1 parent b2f1877 commit 46e47d9

4 files changed

Lines changed: 178 additions & 17 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1633,7 +1633,11 @@ See [the JVM design](../../docs/design/maven-vendoring.md) for supported shapes.
16331633

16341634
`vendor --check` is an offline, read-only audit. Healthy entries emit `verified`
16351635
with `vendor_check_ok`; drift emits `failed` with `vendor_check_failed`, a
1636-
`partialFailure` envelope and exit 1. Missing ledger entries fail with
1636+
`partialFailure` envelope and exit 1. Drift covers the committed artifact and its
1637+
wiring: an entry whose lockfile or config no longer references its
1638+
`.socket/vendor/` artifact (for example after `pipenv lock`, `uv lock` or
1639+
`npm install` re-resolved it) fails by the same liveness rule as `vex`'s
1640+
`vendor_unwired`. Missing ledger entries fail with
16371641
`vendor_ledger_missing`. Offline upstream metadata is reported as the run warning
16381642
`vendor_jvm_upstream_unverified`. The check never starts an API client or writes
16391643
lock/recovery files. `--check` conflicts with `--revert`.

‎crates/socket-patch-cli/src/commands/vendor.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,17 @@ async fn run_check(args: &VendorArgs) -> i32 {
943943
};
944944
let mut entries: Vec<_> = state.entries.iter().collect();
945945
entries.sort_by_key(|(key, _)| *key);
946+
// The lockfile view `vex` and `scan` judge vendor-ledger liveness from;
947+
// JVM entries are checked against their own layout instead.
948+
let discovery = if state
949+
.entries
950+
.values()
951+
.any(|e| !vendor::jvm::apply::is_jvm_entry(e))
952+
{
953+
Some(crate::commands::discover_wiring(&args.common, root).await)
954+
} else {
955+
None
956+
};
946957
for (key, entry) in entries {
947958
let record = entry.record.as_ref().or_else(|| manifest.patches.get(key));
948959
let mut failure = match record {
@@ -954,6 +965,17 @@ async fn run_check(args: &VendorArgs) -> i32 {
954965
};
955966
if failure.is_none() && vendor::jvm::apply::is_jvm_entry(entry) {
956967
failure = vendor::jvm::apply::check_entry(root, entry, local_repo.as_deref()).err();
968+
} else if let (None, Some(discovery)) = (&failure, &discovery) {
969+
// A relock (`pipenv lock`, `npm install`, `uv lock`, …) can
970+
// drop the `.socket/vendor/` reference while the artifact stays
971+
// intact; a fresh install is then unpatched. Same rule as
972+
// `vex`'s `vendor_unwired`.
973+
if !discovery.vendor_entry_live(root, entry).await {
974+
failure = Some(format!(
975+
"wiring missing: no lockfile or config references .socket/vendor/{}/{} any more, so a fresh install gets the unpatched package; re-run `socket-patch vendor` to rewire it",
976+
entry.ecosystem, entry.uuid
977+
));
978+
}
957979
}
958980
if vendor::jvm::apply::upstream_unverified(entry) {
959981
env.warnings.push(RunWarning {code: "vendor_jvm_upstream_unverified".into(), detail: format!("{key}: upstream metadata was accepted offline; run vendor online to verify registry checksums")});

‎crates/socket-patch-cli/tests/in_process_vendor.rs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3889,6 +3889,33 @@ async fn revert_completes_when_lock_already_matches_the_original() {
38893889
assert!(state_gone, "ledger entry pruned once the revert converges");
38903890
}
38913891

3892+
/// REGRESSION (#725): `vendor --check` is the CI gate for vendored wiring,
3893+
/// but it only audited the committed tarball, so after `npm install`
3894+
/// re-resolved the lock to the registry it still printed "committed
3895+
/// artifact and wiring verified" and exited 0 while `vex` refused the same
3896+
/// checkout (`vendor_unwired`). It must fail the unwired entry.
3897+
#[tokio::test]
3898+
async fn vendor_check_fails_when_lock_no_longer_wires_artifact() {
3899+
let fx = npm_fixture();
3900+
assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "vendor");
3901+
let (code, env) = vendor_cli(fx.root(), &["--check"]);
3902+
assert_eq!(code, 0, "{env:#}");
3903+
find_event(&env, "verified", Some("vendor_check_ok"));
3904+
3905+
// The lock re-resolved to the registry; the artifact is untouched.
3906+
std::fs::write(fx.lock_path(), &fx.original_lock).unwrap();
3907+
assert!(fx.tgz_path().is_file());
3908+
let (code, env) = vendor_cli(fx.root(), &["--check"]);
3909+
assert_eq!(code, 1, "{env:#}");
3910+
let event = find_event(&env, "failed", Some("vendor_check_failed"));
3911+
assert!(
3912+
event["reason"]
3913+
.as_str()
3914+
.is_some_and(|r| r.contains("wiring")),
3915+
"{env:#}"
3916+
);
3917+
}
3918+
38923919
/// Manifest-less VEX over the committed state of an in-process npm
38933920
/// `vendor` (the in-process twin of `e2e_vendor_npm_build`'s tail): the
38943921
/// committed tarball is the evidence, so the checkout attests `(vendored)`

‎crates/socket-patch-cli/tests/mode_migration_pypi.rs‎

Lines changed: 124 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,17 @@ python-versions = ">=3.9"
292292
content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01"
293293
"#;
294294

295+
/// A requirements.txt project; returns its wiring files.
296+
fn stage_requirements(root: &Path) -> &'static [&'static str] {
297+
std::fs::write(root.join("requirements.txt"), "idna==3.7\nsix==1.16.0\n").unwrap();
298+
&["requirements.txt"]
299+
}
300+
295301
#[tokio::test]
296302
async fn requirements_vendored_to_hosted() {
297303
let (_tmp, root) = project();
298-
std::fs::write(root.join("requirements.txt"), "idna==3.7\nsix==1.16.0\n").unwrap();
299-
assert_vendored_to_hosted(&root, &["requirements.txt"]).await;
304+
let files = stage_requirements(&root);
305+
assert_vendored_to_hosted(&root, files).await;
300306
}
301307

302308
#[tokio::test]
@@ -306,9 +312,8 @@ async fn requirements_sole_pin_vendored_to_hosted() {
306312
assert_vendored_to_hosted(&root, &["requirements.txt"]).await;
307313
}
308314

309-
#[tokio::test]
310-
async fn poetry_vendored_to_hosted() {
311-
let (_tmp, root) = project();
315+
/// A Poetry project; returns its wiring files.
316+
fn stage_poetry(root: &Path) -> &'static [&'static str] {
312317
std::fs::write(
313318
root.join("pyproject.toml"),
314319
"[tool.poetry]\nname = \"demo\"\nversion = \"0.1.0\"\ndescription = \"\"\nauthors = [\"x <x@x>\"]\npackage-mode = false\n\n[tool.poetry.dependencies]\npython = \">=3.9\"\nsix = \"1.16.0\"\n",
@@ -321,14 +326,20 @@ async fn poetry_vendored_to_hosted() {
321326
.replace("SDIST_SHA", SDIST_SHA),
322327
)
323328
.unwrap();
324-
assert_vendored_to_hosted(&root, &["poetry.lock", "pyproject.toml"]).await;
329+
&["poetry.lock", "pyproject.toml"]
325330
}
326331

327-
const PIPFILE: &str = "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\nsix = \"==1.16.0\"\n\n[requires]\npython_version = \"3.11\"\n";
328-
329332
#[tokio::test]
330-
async fn pipenv_vendored_to_hosted() {
333+
async fn poetry_vendored_to_hosted() {
331334
let (_tmp, root) = project();
335+
let files = stage_poetry(&root);
336+
assert_vendored_to_hosted(&root, files).await;
337+
}
338+
339+
const PIPFILE: &str = "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\nsix = \"==1.16.0\"\n\n[requires]\npython_version = \"3.11\"\n";
340+
341+
/// A Pipenv project; returns its wiring files.
342+
fn stage_pipenv(root: &Path) -> &'static [&'static str] {
332343
std::fs::write(root.join("Pipfile"), PIPFILE).unwrap();
333344
let lock = json!({
334345
"_meta": {
@@ -350,7 +361,14 @@ async fn pipenv_vendored_to_hosted() {
350361
let mut text = serde_json::to_string_pretty(&lock).unwrap();
351362
text.push('\n');
352363
std::fs::write(root.join("Pipfile.lock"), text).unwrap();
353-
assert_vendored_to_hosted(&root, &["Pipfile.lock"]).await;
364+
&["Pipfile.lock"]
365+
}
366+
367+
#[tokio::test]
368+
async fn pipenv_vendored_to_hosted() {
369+
let (_tmp, root) = project();
370+
let files = stage_pipenv(&root);
371+
assert_vendored_to_hosted(&root, files).await;
354372
}
355373

356374
const UV_LOCK: &str = r#"version = 1
@@ -378,9 +396,8 @@ wheels = [
378396
]
379397
"#;
380398

381-
#[tokio::test]
382-
async fn uv_vendored_to_hosted() {
383-
let (_tmp, root) = project();
399+
/// A uv project; returns its wiring files.
400+
fn stage_uv(root: &Path) -> &'static [&'static str] {
384401
std::fs::write(
385402
root.join("pyproject.toml"),
386403
"[project]\nname = \"demo\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\"]\n",
@@ -393,18 +410,31 @@ async fn uv_vendored_to_hosted() {
393410
.replace("SDIST_SHA", SDIST_SHA),
394411
)
395412
.unwrap();
396-
assert_vendored_to_hosted(&root, &["uv.lock", "pyproject.toml"]).await;
413+
&["uv.lock", "pyproject.toml"]
397414
}
398415

399416
#[tokio::test]
400-
async fn hatch_vendored_to_hosted() {
417+
async fn uv_vendored_to_hosted() {
401418
let (_tmp, root) = project();
419+
let files = stage_uv(&root);
420+
assert_vendored_to_hosted(&root, files).await;
421+
}
422+
423+
/// A Hatch project; returns its wiring files.
424+
fn stage_hatch(root: &Path) -> &'static [&'static str] {
402425
std::fs::write(
403426
root.join("pyproject.toml"),
404427
"[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"demo\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n",
405428
)
406429
.unwrap();
407-
assert_vendored_to_hosted(&root, &["pyproject.toml"]).await;
430+
&["pyproject.toml"]
431+
}
432+
433+
#[tokio::test]
434+
async fn hatch_vendored_to_hosted() {
435+
let (_tmp, root) = project();
436+
let files = stage_hatch(&root);
437+
assert_vendored_to_hosted(&root, files).await;
408438
}
409439

410440
/// The uv lock rewrite needs the hosted wheel's METADATA, fetched only
@@ -597,3 +627,81 @@ async fn ledger_update_failure_after_revert_is_stranded() {
597627
assert_eq!(env["status"], "partial_failure", "{env:#}");
598628
assert_eq!(code, 1, "{env:#}");
599629
}
630+
631+
// ── `vendor --check` wiring audit (#725) ─────────────────────────────────
632+
633+
/// Vendor the staged project, confirm `vendor --check` passes, then put
634+
/// the wiring files back to their pre-vendor bytes — what `pipenv lock`,
635+
/// `poetry lock`, `uv lock` or a hand-edited requirements.txt leave behind —
636+
/// and require `vendor --check` to fail: the committed wheel is intact, but
637+
/// nothing installs it any more, so a fresh install is unpatched.
638+
fn assert_check_catches_relock(root: &Path, files: &[&str]) {
639+
let pristine: Vec<Vec<u8>> = files
640+
.iter()
641+
.map(|f| std::fs::read(root.join(f)).unwrap())
642+
.collect();
643+
vendor_project(root, files);
644+
645+
let (code, env) = run_cli(root, &["vendor", "--check"], &[]);
646+
assert_eq!(code, 0, "wired project passes: {env:#}");
647+
assert_eq!(env["events"][0]["errorCode"], "vendor_check_ok", "{env:#}");
648+
649+
for (f, bytes) in files.iter().zip(&pristine) {
650+
std::fs::write(root.join(f), bytes).unwrap();
651+
}
652+
let (code, env) = run_cli(root, &["vendor", "--check"], &[]);
653+
assert_eq!(code, 1, "{files:?} no longer wire the artifact: {env:#}");
654+
let event = &env["events"][0];
655+
assert_eq!(event["action"], "failed", "{env:#}");
656+
assert_eq!(event["errorCode"], "vendor_check_failed", "{env:#}");
657+
assert!(
658+
event["reason"]
659+
.as_str()
660+
.is_some_and(|r| r.contains("wiring")),
661+
"the failure names the missing wiring: {env:#}"
662+
);
663+
assert_eq!(env["summary"]["failed"], 1, "{env:#}");
664+
}
665+
666+
#[tokio::test]
667+
async fn vendor_check_fails_after_pipenv_relock() {
668+
let (_tmp, root) = project();
669+
let files = stage_pipenv(&root);
670+
assert_check_catches_relock(&root, files);
671+
672+
// Human mode must not claim the wiring was verified.
673+
let (code, stdout, stderr) = run_raw(&root, &["vendor", "--check"], &[]);
674+
assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}");
675+
assert!(
676+
!stdout.contains("wiring verified"),
677+
"stdout:\n{stdout}\nstderr:\n{stderr}"
678+
);
679+
}
680+
681+
#[tokio::test]
682+
async fn vendor_check_fails_after_requirements_rewrite() {
683+
let (_tmp, root) = project();
684+
let files = stage_requirements(&root);
685+
assert_check_catches_relock(&root, files);
686+
}
687+
688+
#[tokio::test]
689+
async fn vendor_check_fails_after_poetry_relock() {
690+
let (_tmp, root) = project();
691+
let files = stage_poetry(&root);
692+
assert_check_catches_relock(&root, files);
693+
}
694+
695+
#[tokio::test]
696+
async fn vendor_check_fails_after_uv_relock() {
697+
let (_tmp, root) = project();
698+
let files = stage_uv(&root);
699+
assert_check_catches_relock(&root, files);
700+
}
701+
702+
#[tokio::test]
703+
async fn vendor_check_fails_after_hatch_dependency_reset() {
704+
let (_tmp, root) = project();
705+
let files = stage_hatch(&root);
706+
assert_check_catches_relock(&root, files);
707+
}

0 commit comments

Comments
 (0)