Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1650,7 +1650,11 @@ See [the JVM design](../../docs/design/maven-vendoring.md) for supported shapes.

`vendor --check` is an offline, read-only audit. Healthy entries emit `verified`
with `vendor_check_ok`; drift emits `failed` with `vendor_check_failed`, a
`partialFailure` envelope and exit 1. For a package-lock entry, drift includes a
`partialFailure` envelope and exit 1. Drift covers the committed artifact and its
wiring: an entry whose lockfile or config no longer references its
`.socket/vendor/` artifact (for example after `pipenv lock`, `uv lock` or
`npm install` re-resolved it) fails by the same liveness rule as `vex`'s
`vendor_unwired`. For a package-lock entry, drift also includes a
`package-lock.json` / `npm-shrinkwrap.json` entry for the vendored `name@version`
that `vendor` would rewire but that does not resolve to the vendored artifact
(#588); the reason names that entry. Missing ledger entries fail with
Expand Down
29 changes: 29 additions & 0 deletions crates/socket-patch-cli/src/commands/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,17 @@ async fn run_check(args: &VendorArgs) -> i32 {
};
let mut entries: Vec<_> = state.entries.iter().collect();
entries.sort_by_key(|(key, _)| *key);
// The lockfile view `vex` and `scan` judge vendor-ledger liveness from;
// JVM entries are checked against their own layout instead.
let discovery = if state
.entries
.values()
.any(|e| !vendor::jvm::apply::is_jvm_entry(e))
{
Some(crate::commands::discover_wiring(&args.common, root).await)
} else {
None
};
for (key, entry) in entries {
let record = entry.record.as_ref().or_else(|| manifest.patches.get(key));
let mut failure = match record {
Expand All @@ -982,11 +993,29 @@ async fn run_check(args: &VendorArgs) -> i32 {
if failure.is_none() && vendor::jvm::apply::is_jvm_entry(entry) {
failure = vendor::jvm::apply::check_entry(root, entry, local_repo.as_deref()).err();
}
// The npm check names the exact unwired lock entry, so it runs
// before the generic liveness rule below.
if failure.is_none() && entry.ecosystem == "npm" {
failure = vendor::npm_flavor::check_npm_wiring(entry, root)
.await
.err();
}
if let (None, Some(discovery), false) = (
&failure,
&discovery,
vendor::jvm::apply::is_jvm_entry(entry),
) {
// A relock (`pipenv lock`, `npm install`, `uv lock`, …) can
// drop the `.socket/vendor/` reference while the artifact stays
// intact; a fresh install is then unpatched. Same rule as
// `vex`'s `vendor_unwired`.
if !discovery.vendor_entry_live(root, entry).await {
failure = Some(format!(
"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",
entry.ecosystem, entry.uuid
));
}
}
if vendor::jvm::apply::upstream_unverified(entry) {
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")});
}
Expand Down
27 changes: 27 additions & 0 deletions crates/socket-patch-cli/tests/in_process_vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3992,6 +3992,33 @@ async fn revert_completes_when_lock_already_matches_the_original() {
assert!(state_gone, "ledger entry pruned once the revert converges");
}

/// REGRESSION (#725): `vendor --check` is the CI gate for vendored wiring,
/// but it only audited the committed tarball, so after `npm install`
/// re-resolved the lock to the registry it still printed "committed
/// artifact and wiring verified" and exited 0 while `vex` refused the same
/// checkout (`vendor_unwired`). It must fail the unwired entry.
#[tokio::test]
async fn vendor_check_fails_when_lock_no_longer_wires_artifact() {
let fx = npm_fixture();
assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "vendor");
let (code, env) = vendor_cli(fx.root(), &["--check"]);
assert_eq!(code, 0, "{env:#}");
find_event(&env, "verified", Some("vendor_check_ok"));

// The lock re-resolved to the registry; the artifact is untouched.
std::fs::write(fx.lock_path(), &fx.original_lock).unwrap();
assert!(fx.tgz_path().is_file());
let (code, env) = vendor_cli(fx.root(), &["--check"]);
assert_eq!(code, 1, "{env:#}");
let event = find_event(&env, "failed", Some("vendor_check_failed"));
assert!(
event["reason"]
.as_str()
.is_some_and(|r| r.contains("wiring")),
"{env:#}"
);
}

/// Manifest-less VEX over the committed state of an in-process npm
/// `vendor` (the in-process twin of `e2e_vendor_npm_build`'s tail): the
/// committed tarball is the evidence, so the checkout attests `(vendored)`
Expand Down
140 changes: 124 additions & 16 deletions crates/socket-patch-cli/tests/mode_migration_pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,12 @@ python-versions = ">=3.9"
content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01"
"#;

/// A requirements.txt project; returns its wiring files.
fn stage_requirements(root: &Path) -> &'static [&'static str] {
std::fs::write(root.join("requirements.txt"), "idna==3.7\nsix==1.16.0\n").unwrap();
&["requirements.txt"]
}

/// #765: a vendored requirements.txt picks up a superseding patch. The
/// manifest moves `six` from patch A to patch B (different patched bytes);
/// the next `vendor` must re-wire the requirements line to B's wheel in
Expand Down Expand Up @@ -380,8 +386,8 @@ async fn requirements_vendored_revendors_superseding_patch() {
#[tokio::test]
async fn requirements_vendored_to_hosted() {
let (_tmp, root) = project();
std::fs::write(root.join("requirements.txt"), "idna==3.7\nsix==1.16.0\n").unwrap();
assert_vendored_to_hosted(&root, &["requirements.txt"]).await;
let files = stage_requirements(&root);
assert_vendored_to_hosted(&root, files).await;
}

#[tokio::test]
Expand All @@ -391,9 +397,8 @@ async fn requirements_sole_pin_vendored_to_hosted() {
assert_vendored_to_hosted(&root, &["requirements.txt"]).await;
}

#[tokio::test]
async fn poetry_vendored_to_hosted() {
let (_tmp, root) = project();
/// A Poetry project; returns its wiring files.
fn stage_poetry(root: &Path) -> &'static [&'static str] {
std::fs::write(
root.join("pyproject.toml"),
"[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",
Expand All @@ -406,14 +411,20 @@ async fn poetry_vendored_to_hosted() {
.replace("SDIST_SHA", SDIST_SHA),
)
.unwrap();
assert_vendored_to_hosted(&root, &["poetry.lock", "pyproject.toml"]).await;
&["poetry.lock", "pyproject.toml"]
}

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";

#[tokio::test]
async fn pipenv_vendored_to_hosted() {
async fn poetry_vendored_to_hosted() {
let (_tmp, root) = project();
let files = stage_poetry(&root);
assert_vendored_to_hosted(&root, files).await;
}

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";

/// A Pipenv project; returns its wiring files.
fn stage_pipenv(root: &Path) -> &'static [&'static str] {
std::fs::write(root.join("Pipfile"), PIPFILE).unwrap();
let lock = json!({
"_meta": {
Expand All @@ -435,7 +446,14 @@ async fn pipenv_vendored_to_hosted() {
let mut text = serde_json::to_string_pretty(&lock).unwrap();
text.push('\n');
std::fs::write(root.join("Pipfile.lock"), text).unwrap();
assert_vendored_to_hosted(&root, &["Pipfile.lock"]).await;
&["Pipfile.lock"]
}

#[tokio::test]
async fn pipenv_vendored_to_hosted() {
let (_tmp, root) = project();
let files = stage_pipenv(&root);
assert_vendored_to_hosted(&root, files).await;
}

const UV_LOCK: &str = r#"version = 1
Expand Down Expand Up @@ -463,9 +481,8 @@ wheels = [
]
"#;

#[tokio::test]
async fn uv_vendored_to_hosted() {
let (_tmp, root) = project();
/// A uv project; returns its wiring files.
fn stage_uv(root: &Path) -> &'static [&'static str] {
std::fs::write(
root.join("pyproject.toml"),
"[project]\nname = \"demo\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\"]\n",
Expand All @@ -478,18 +495,31 @@ async fn uv_vendored_to_hosted() {
.replace("SDIST_SHA", SDIST_SHA),
)
.unwrap();
assert_vendored_to_hosted(&root, &["uv.lock", "pyproject.toml"]).await;
&["uv.lock", "pyproject.toml"]
}

#[tokio::test]
async fn hatch_vendored_to_hosted() {
async fn uv_vendored_to_hosted() {
let (_tmp, root) = project();
let files = stage_uv(&root);
assert_vendored_to_hosted(&root, files).await;
}

/// A Hatch project; returns its wiring files.
fn stage_hatch(root: &Path) -> &'static [&'static str] {
std::fs::write(
root.join("pyproject.toml"),
"[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"demo\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n",
)
.unwrap();
assert_vendored_to_hosted(&root, &["pyproject.toml"]).await;
&["pyproject.toml"]
}

#[tokio::test]
async fn hatch_vendored_to_hosted() {
let (_tmp, root) = project();
let files = stage_hatch(&root);
assert_vendored_to_hosted(&root, files).await;
}

/// The uv lock rewrite needs the hosted wheel's METADATA, fetched only
Expand Down Expand Up @@ -683,6 +713,84 @@ async fn ledger_update_failure_after_revert_is_stranded() {
assert_eq!(code, 1, "{env:#}");
}

// ── `vendor --check` wiring audit (#725) ─────────────────────────────────

/// Vendor the staged project, confirm `vendor --check` passes, then put
/// the wiring files back to their pre-vendor bytes — what `pipenv lock`,
/// `poetry lock`, `uv lock` or a hand-edited requirements.txt leave behind —
/// and require `vendor --check` to fail: the committed wheel is intact, but
/// nothing installs it any more, so a fresh install is unpatched.
fn assert_check_catches_relock(root: &Path, files: &[&str]) {
let pristine: Vec<Vec<u8>> = files
.iter()
.map(|f| std::fs::read(root.join(f)).unwrap())
.collect();
vendor_project(root, files);

let (code, env) = run_cli(root, &["vendor", "--check"], &[]);
assert_eq!(code, 0, "wired project passes: {env:#}");
assert_eq!(env["events"][0]["errorCode"], "vendor_check_ok", "{env:#}");

for (f, bytes) in files.iter().zip(&pristine) {
std::fs::write(root.join(f), bytes).unwrap();
}
let (code, env) = run_cli(root, &["vendor", "--check"], &[]);
assert_eq!(code, 1, "{files:?} no longer wire the artifact: {env:#}");
let event = &env["events"][0];
assert_eq!(event["action"], "failed", "{env:#}");
assert_eq!(event["errorCode"], "vendor_check_failed", "{env:#}");
assert!(
event["reason"]
.as_str()
.is_some_and(|r| r.contains("wiring")),
"the failure names the missing wiring: {env:#}"
);
assert_eq!(env["summary"]["failed"], 1, "{env:#}");
}

#[tokio::test]
async fn vendor_check_fails_after_pipenv_relock() {
let (_tmp, root) = project();
let files = stage_pipenv(&root);
assert_check_catches_relock(&root, files);

// Human mode must not claim the wiring was verified.
let (code, stdout, stderr) = run_raw(&root, &["vendor", "--check"], &[]);
assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}");
assert!(
!stdout.contains("wiring verified"),
"stdout:\n{stdout}\nstderr:\n{stderr}"
);
}

#[tokio::test]
async fn vendor_check_fails_after_requirements_rewrite() {
let (_tmp, root) = project();
let files = stage_requirements(&root);
assert_check_catches_relock(&root, files);
}

#[tokio::test]
async fn vendor_check_fails_after_poetry_relock() {
let (_tmp, root) = project();
let files = stage_poetry(&root);
assert_check_catches_relock(&root, files);
}

#[tokio::test]
async fn vendor_check_fails_after_uv_relock() {
let (_tmp, root) = project();
let files = stage_uv(&root);
assert_check_catches_relock(&root, files);
}

#[tokio::test]
async fn vendor_check_fails_after_hatch_dependency_reset() {
let (_tmp, root) = project();
let files = stage_hatch(&root);
assert_check_catches_relock(&root, files);
}

/// #699: hosted mode rewrites only the ROOT `requirements.txt`, while
/// vendored mode also wires a pin in a `-r` include or appends a managed
/// `(transitive)` line. A vendored → hosted takeover of such a pin used to
Expand Down
Loading