From f1ce8b67de15ebaba4dd88993f590a556fd17e21 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 06:29:31 +0000 Subject: [PATCH 1/5] Start fix for #529, #501 Assisted-by: Claude Code:claude-opus-5-5 From 13cc82df6e32c5f3bdbf62c0fbb86e3c7de24a7d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 06:32:56 +0000 Subject: [PATCH 2/5] Test PyPI apply patches every installed copy Regression tests for #529 (Pipenv WORKON_HOME venv beside ./.venv) and #501 (user site beside a system dist-packages in global scope): agent apply must patch both copies, vex must then attest, and rollback must restore both. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_pypi_multi_copy.rs | 288 ++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 crates/socket-patch-cli/tests/e2e_pypi_multi_copy.rs diff --git a/crates/socket-patch-cli/tests/e2e_pypi_multi_copy.rs b/crates/socket-patch-cli/tests/e2e_pypi_multi_copy.rs new file mode 100644 index 000000000..9a1793c7e --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_pypi_multi_copy.rs @@ -0,0 +1,288 @@ +//! Agent-mode PyPI apply over SEVERAL installed copies of one release. +//! +//! The Python crawler can resolve one `name@version` in more than one +//! candidate site-packages dir: a Pipenv project with both a WORKON_HOME +//! venv and an auto-detected `./.venv` (#529), or the user site beside a +//! system `dist-packages` in global scope (#501). Which copy the +//! interpreter imports is not knowable without running the project's tool, +//! so `apply` must patch EVERY copy (as it does for npm and gem, and as +//! `rollback` already restores every copy). Patching only the first one +//! left the imported copy vulnerable while `vex` attested `not_affected`. +//! +//! Hand-built site-packages layouts, a staged manifest + blobs, `--offline`: +//! no Python, network or package manager needed for #529; #501 swaps in a +//! stub `python3` whose `site` answer lists a system dir and the user site. + +mod common; + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::{json, Value}; + +const PURL: &str = "pkg:pypi/dupkit@1.0.0"; +const UUID: &str = "52952952-9529-4529-8529-529529529529"; +const FILE: &str = "dupkit/__init__.py"; +const ORIGINAL: &[u8] = b"VERSION = 'original'\n"; +const PATCHED: &[u8] = b"VERSION = 'patched'\n"; + +/// Env vars that redirect Python/Pipenv discovery; removed from every +/// child so an activated shell or CI image cannot change which dirs the +/// crawler probes. +const DISCOVERY_VARS: &[&str] = &[ + "VIRTUAL_ENV", + "CONDA_PREFIX", + "WORKON_HOME", + "PIPENV_ACTIVE", + "PIPENV_IGNORE_VIRTUALENVS", + "PIPENV_NO_IGNORE_VIRTUALENVS", + "PIPENV_VENV_IN_PROJECT", + "PIPENV_NO_VENV_IN_PROJECT", + "PIPENV_CUSTOM_VENV_NAME", + "PIPENV_PIPFILE", + "PYTHONHOME", + "PYTHONPATH", + "PYTHONUSERBASE", +]; + +/// Run the binary in `cwd` with the `SOCKET_*` and discovery env scrubbed +/// and exactly `env` added. +fn run(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { + let mut cmd = Command::new(common::binary()); + cmd.args(args).current_dir(cwd); + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") { + cmd.env_remove(&key); + } + } + for name in DISCOVERY_VARS { + cmd.env_remove(name); + } + cmd.env("SOCKET_NO_CONFIG", "1") + .env("SOCKET_NO_UPDATE_CHECK", "1"); + for (k, v) in env { + cmd.env(k, v); + } + let out = cmd.output().expect("run socket-patch"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// `/lib/python3.12/site-packages` (`Lib\site-packages` on Windows), +/// the layout `find_site_packages_under` probes. +fn venv_site_packages(venv: &Path) -> PathBuf { + if cfg!(windows) { + venv.join("Lib").join("site-packages") + } else { + venv.join("lib").join("python3.12").join("site-packages") + } +} + +/// Install a pristine `dupkit 1.0.0` (dist-info + module) into `site`. +/// Returns the module file the patch rewrites. +fn install_dupkit(site: &Path) -> PathBuf { + let dist = site.join("dupkit-1.0.0.dist-info"); + std::fs::create_dir_all(&dist).unwrap(); + std::fs::write( + dist.join("METADATA"), + "Metadata-Version: 2.1\nName: dupkit\nVersion: 1.0.0\n", + ) + .unwrap(); + let module = site.join(FILE); + std::fs::create_dir_all(module.parent().unwrap()).unwrap(); + std::fs::write(&module, ORIGINAL).unwrap(); + module +} + +/// Stage the agent patch for `dupkit 1.0.0` (with one vulnerability so +/// `vex` has a statement to emit) and its before/after blobs. +fn stage_patch(project: &Path) { + let socket = project.join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + let manifest = json!({ + "patches": { + PURL: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { FILE: { + "beforeHash": common::git_sha256(ORIGINAL), + "afterHash": common::git_sha256(PATCHED), + }}, + "vulnerabilities": { "GHSA-dupk-dupk-dupk": { + "cves": ["CVE-2026-0529"], + "summary": "dupkit test vulnerability", + "severity": "high", + "description": "synthetic", + }}, + "description": "synthetic multi-copy test patch", + "license": "MIT", + "tier": "free", + } + } + }); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + common::write_blob(&socket, &common::git_sha256(ORIGINAL), ORIGINAL); + common::write_blob(&socket, &common::git_sha256(PATCHED), PATCHED); +} + +fn envelope(stdout: &str, stderr: &str) -> Value { + serde_json::from_str(stdout) + .unwrap_or_else(|e| panic!("not a JSON envelope ({e}):\n{stdout}\nstderr:\n{stderr}")) +} + +/// Apply, check every copy is patched, `vex` attests, then rollback +/// restores every copy. +fn assert_every_copy_patched( + cwd: &Path, + global: bool, + env: &[(&str, &str)], + copies: &[(&str, &Path)], +) { + let g: &[&str] = if global { &["-g"] } else { &[] }; + let with = |base: &[&'static str]| -> Vec<&str> { base.iter().chain(g).copied().collect() }; + + let (code, stdout, stderr) = run(cwd, &with(&["apply", "--offline", "--json"]), env); + assert_eq!(code, 0, "apply failed\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env_json = envelope(&stdout, &stderr); + for (label, module) in copies { + assert_eq!( + std::fs::read(module).unwrap(), + PATCHED, + "the {label} copy must be patched: apply has to patch EVERY \ + installed copy, the interpreter may import any of them\n\ + stdout:\n{stdout}\nstderr:\n{stderr}" + ); + } + let applied = env_json["results"] + .as_array() + .expect("results array") + .iter() + .filter(|r| r["purl"] == json!(PURL) && r["success"] == json!(true)) + .count(); + assert_eq!( + applied, + copies.len(), + "one successful result per patched copy\nstdout:\n{stdout}" + ); + + // A re-run sees every copy already patched: nothing fails. + let (code, stdout, stderr) = run(cwd, &with(&["apply", "--offline", "--json"]), env); + assert_eq!(code, 0, "re-apply\nstdout:\n{stdout}\nstderr:\n{stderr}"); + + let (code, stdout, stderr) = run( + cwd, + &with(&["vex", "--offline", "--product", "pkg:pypi/app@1.0.0"]), + env, + ); + assert_eq!(code, 0, "vex\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let doc = envelope(&stdout, &stderr); + let stmts = doc["statements"].as_array().expect("statements"); + assert_eq!(stmts.len(), 1, "vex doc:\n{stdout}"); + assert_eq!(stmts[0]["status"], json!("not_affected"), "vex doc:\n{stdout}"); + + let (code, stdout, stderr) = run( + cwd, + &with(&["rollback", "--offline", "--json", "--yes"]), + env, + ); + assert_eq!(code, 0, "rollback\nstdout:\n{stdout}\nstderr:\n{stderr}"); + for (label, module) in copies { + assert_eq!( + std::fs::read(module).unwrap(), + ORIGINAL, + "rollback must restore the {label} copy\nstdout:\n{stdout}" + ); + } +} + +/// #529: a Pipenv project with a WORKON_HOME venv AND an auto-detected +/// `./.venv` (nothing explicit about which Pipenv uses). Pipenv up to +/// 2026.1 runs `./.venv`, 2026.2+ the WORKON_HOME one; the crawler returns +/// both, WORKON_HOME first. Apply used to patch only the WORKON_HOME copy, +/// leaving the `./.venv` copy older Pipenv imports unpatched. +#[test] +fn pipenv_workon_home_and_dot_venv_copies_are_all_patched() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("proj"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("Pipfile"), "[packages]\ndupkit = \"==1.0.0\"\n").unwrap(); + let workon = tmp.path().join("wh"); + let workon_copy = install_dupkit(&venv_site_packages(&workon.join("proj-env"))); + let in_tree_copy = install_dupkit(&venv_site_packages(&project.join(".venv"))); + stage_patch(&project); + + let workon_str = workon.to_str().unwrap(); + assert_every_copy_patched( + &project, + false, + &[ + ("WORKON_HOME", workon_str), + ("PIPENV_CUSTOM_VENV_NAME", "proj-env"), + ("HOME", tmp.path().to_str().unwrap()), + ], + &[ + ("WORKON_HOME venv", workon_copy.as_path()), + ("./.venv", in_tree_copy.as_path()), + ], + ); +} + +/// #501: the same release in a system site dir and in the user site. The +/// interpreter's `site` answer lists the system dirs first and the user +/// site last, while `sys.path` imports the user site first. Apply used to +/// patch only the first (shadowed, system) copy, so the imported user-site +/// copy stayed vulnerable while `vex -g` attested it. +#[cfg(unix)] +#[test] +fn global_system_and_user_site_copies_are_all_patched() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let home = root.join("home"); + let system_site = root.join("usr/lib/python3/dist-packages"); + let user_site = home.join(".local/lib/python3.12/site-packages"); + let system_copy = install_dupkit(&system_site); + let user_copy = install_dupkit(&user_site); + + // Stub interpreter: `python3 --version` succeeds, and the `site` query + // prints `getsitepackages()` then `getusersitepackages()`, exactly the + // order the real query prints. + let bin = root.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let stub = bin.join("python3"); + std::fs::write( + &stub, + format!( + "#!/bin/sh\nif [ \"$1\" = --version ]; then echo 'Python 3.12.0'; exit 0; fi\n\ + printf '%s\\n%s\\n' '{}' '{}'\n", + system_site.display(), + user_site.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let project = root.join("work"); + std::fs::create_dir_all(&project).unwrap(); + stage_patch(&project); + + let path = format!("{}:/usr/bin:/bin", bin.display()); + assert_every_copy_patched( + &project, + true, + &[("PATH", path.as_str()), ("HOME", home.to_str().unwrap())], + &[ + ("system dist-packages", system_copy.as_path()), + ("user site", user_copy.as_path()), + ], + ); +} From 4e63fa8e53ca2e0e08d2dfeb96cb4b7a490504f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 06:32:56 +0000 Subject: [PATCH 3/5] Patch every PyPI copy in agent apply The Python crawler can find one release in several site-packages dirs, but apply patched only the first. The copy the interpreter actually imports could stay vulnerable while vex attested it. Apply now patches every distinct copy, like gem and npm; paths that alias one directory through a symlink count once. Fixes #529, #501 Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 5 ++ crates/socket-patch-cli/CLI_CONTRACT.md | 6 +- crates/socket-patch-cli/src/commands/apply.rs | 60 ++++++++++++++----- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b03e92f8d..d7c9cbed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,11 @@ limits, and required install commands. ### Fixed +- Agent-mode PyPI `apply` patches every installed copy of a release, not just + the first one found. A Pipenv project with both a WORKON_HOME venv and a + `./.venv`, or a global install with the same release in the user site and a + system dir, no longer keeps the copy Python imports unpatched while `vex` + attests it (#529, #501). - Gem hosted and vendored modes wire only the manifest Bundler loads. A `gems.rb` twin or a `BUNDLE_GEMFILE` setting (environment or `.bundle/config`) no longer leads to an edit of an ignored `Gemfile` that reports success and attests an diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 72bffac7a..139dbc937 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -502,7 +502,11 @@ the model is **not uniform** today: **coexisting physical copies of one `gem@version`** (bundler-2's scoped store beside bundler-1's flat store), `apply`/`rollback` patch/restore **every copy** — one summary event per copy, mirroring npm's multi-copy fan-out — while single-representative consumers (`get`, `vendor`, - `vex`) use the highest-precedence copy. + `vex`) use the highest-precedence copy. PyPI follows the same rule: when the crawler resolves + one release in several site-packages dirs (a Pipenv WORKON_HOME venv beside an auto-detected + `./.venv`, or the user site beside a system dir in global scope), agent `apply` patches **every** + copy, one summary event per copy, because any of them may be the one the interpreter imports. + Paths that resolve to the same directory (a symlinked site-packages) count as one copy. *Copy classes (additive to the multi-copy vocabulary):* a copy under a **bundle-path store** (config/env/default root) is PRIMARY — a variant mismatch or write failure there fails the run, diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index aa8aa695d..eb042903b 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -627,6 +627,25 @@ pub(crate) fn variant_matches_installed(first_file_status: Option<&VerifyStatus> } } +/// `paths` in order with every path that resolves to an already-listed +/// directory dropped: two discovered site-packages paths can name ONE +/// directory (a `lib64 -> lib` symlink, a symlinked venv), and patching it +/// twice would report the second pass `already_patched`. A path that can't +/// be canonicalized is kept as-is. +async fn distinct_install_dirs(paths: &[PathBuf]) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut out = Vec::with_capacity(paths.len()); + for path in paths { + let key = tokio::fs::canonicalize(path) + .await + .unwrap_or_else(|_| path.clone()); + if seen.insert(key) { + out.push(path.clone()); + } + } + out +} + /// The file whose verify status decides whether a release variant /// describes the installed distribution (fed to /// [`variant_matches_installed`]). @@ -1881,22 +1900,31 @@ async fn apply_patches_inner( continue; } - // Patch EVERY coexisting gem store copy (the npm multi-copy - // precedent): leaving the other store pristine is a silent - // false "applied" for whichever bundler loads it, and the - // per-copy results below make the JSON summary count each - // patched copy — the signal a second copy exists. PyPI/Maven - // keep the one-representative contract: their crawlers resolve - // one install dir per version, and a second path can only - // alias the same logical install (re-patching it would produce - // the spurious `already_patched` double-patch the nuget - // first-wins restoration fixed). - let copy_paths: &[PathBuf] = - if matches!(Ecosystem::from_purl(purl), Some(Ecosystem::Gem)) { - pkg_paths.as_slice() - } else { - std::slice::from_ref(pkg_path) - }; + // Patch EVERY coexisting gem store copy and every PyPI + // site-packages copy (the npm multi-copy precedent): leaving + // the other copy pristine is a silent false "applied" for + // whichever bundler / interpreter loads it, and the per-copy + // results below make the JSON summary count each patched copy + // — the signal a second copy exists. The Python crawler + // resolves one release in several candidate envs when it + // can't tell which one the project's tool runs (a Pipenv + // WORKON_HOME venv beside `./.venv`, #529) or which one + // `sys.path` shadows (the user site beside a system dir in + // global scope, #501), and rollback already restores every + // copy. A PyPI path that only ALIASES another (a symlinked + // site-packages) is collapsed by canonical path, so one + // install is never patched twice. Maven keeps the + // one-representative contract: its crawler resolves one + // install dir per version. + let pypi_copies: Vec; + let copy_paths: &[PathBuf] = match Ecosystem::from_purl(purl) { + Some(Ecosystem::Gem) => pkg_paths.as_slice(), + Some(Ecosystem::Pypi) => { + pypi_copies = distinct_install_dirs(pkg_paths).await; + pypi_copies.as_slice() + } + _ => std::slice::from_ref(pkg_path), + }; // Copy CLASS decides FAILURE semantics (never write scope — // patching a shared home's vulnerable copy is fine when it From 99881ad250e0bd62cbae64cb82b4060ffa6cd02d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 06:39:02 +0000 Subject: [PATCH 4/5] Assert per-copy applied events in multi-copy test The apply JSON envelope reports per-copy outcomes as events, not results; count the applied events for the patched release. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_pypi_multi_copy.rs | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_pypi_multi_copy.rs b/crates/socket-patch-cli/tests/e2e_pypi_multi_copy.rs index 9a1793c7e..eb46b7c96 100644 --- a/crates/socket-patch-cli/tests/e2e_pypi_multi_copy.rs +++ b/crates/socket-patch-cli/tests/e2e_pypi_multi_copy.rs @@ -150,7 +150,10 @@ fn assert_every_copy_patched( let with = |base: &[&'static str]| -> Vec<&str> { base.iter().chain(g).copied().collect() }; let (code, stdout, stderr) = run(cwd, &with(&["apply", "--offline", "--json"]), env); - assert_eq!(code, 0, "apply failed\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + code, 0, + "apply failed\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); let env_json = envelope(&stdout, &stderr); for (label, module) in copies { assert_eq!( @@ -161,16 +164,16 @@ fn assert_every_copy_patched( stdout:\n{stdout}\nstderr:\n{stderr}" ); } - let applied = env_json["results"] + let applied = env_json["events"] .as_array() - .expect("results array") + .expect("envelope events array") .iter() - .filter(|r| r["purl"] == json!(PURL) && r["success"] == json!(true)) + .filter(|e| e["purl"] == json!(PURL) && e["action"] == json!("applied")) .count(); assert_eq!( applied, copies.len(), - "one successful result per patched copy\nstdout:\n{stdout}" + "one `applied` event per patched copy\nstdout:\n{stdout}" ); // A re-run sees every copy already patched: nothing fails. @@ -186,7 +189,11 @@ fn assert_every_copy_patched( let doc = envelope(&stdout, &stderr); let stmts = doc["statements"].as_array().expect("statements"); assert_eq!(stmts.len(), 1, "vex doc:\n{stdout}"); - assert_eq!(stmts[0]["status"], json!("not_affected"), "vex doc:\n{stdout}"); + assert_eq!( + stmts[0]["status"], + json!("not_affected"), + "vex doc:\n{stdout}" + ); let (code, stdout, stderr) = run( cwd, @@ -213,7 +220,11 @@ fn pipenv_workon_home_and_dot_venv_copies_are_all_patched() { let tmp = tempfile::tempdir().unwrap(); let project = tmp.path().join("proj"); std::fs::create_dir_all(&project).unwrap(); - std::fs::write(project.join("Pipfile"), "[packages]\ndupkit = \"==1.0.0\"\n").unwrap(); + std::fs::write( + project.join("Pipfile"), + "[packages]\ndupkit = \"==1.0.0\"\n", + ) + .unwrap(); let workon = tmp.path().join("wh"); let workon_copy = install_dupkit(&venv_site_packages(&workon.join("proj-env"))); let in_tree_copy = install_dupkit(&venv_site_packages(&project.join(".venv"))); From d354e9bd390b25b5204ccc092d615a877730820f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:25:15 +0000 Subject: [PATCH 5/5] Prefetch blobs for each copy's PyPI variant Two environments can hold different wheels of one release. Apply gates each variant per copy, but the blob prefetch gated it against the first copy only, so a locally modified file in the second copy never got its full patched blob and its apply then failed. The prefetch now probes each variant on the copies it is applied to. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/commands/apply.rs | 129 ++++++++++++++++-- 1 file changed, 117 insertions(+), 12 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index eb042903b..731e40d03 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -207,10 +207,11 @@ fn format_mismatch_fetch_result(downloaded: usize, needed: usize) -> String { /// duplicates of one `name@version`, the apply loop patches each of them, /// and copies drift independently — a pristine (or already-patched) root /// copy says nothing about a locally-modified nested duplicate, whose -/// mismatched files still need their afterHash blobs. The variant gate, -/// by contrast, mirrors the apply loop's representative check against the -/// FIRST copy (release-variant ecosystems install one directory per -/// `package@version`). +/// mismatched files still need their afterHash blobs. The variant gate +/// mirrors the apply loop's representative check PER COPY for gem and +/// PyPI (which patch every copy, and two envs can hold different wheels +/// of one release), and against the FIRST copy for Maven: a variant's +/// files are probed only on the copies it is attempted on. /// /// Only a mismatched file whose afterHash blob is NOT staged can queue a /// fetch, so the probe first decides that with metadata probes alone and @@ -263,25 +264,45 @@ async fn mismatch_blob_gaps( || records .first() .is_some_and(|(key, _)| key.as_str() != stripped)); + // The copies the apply loop gates per copy: gem and PyPI patch + // every copy, each against its own representative check; Maven + // gates (and patches) only the first. + let gate_copies: &[PathBuf] = if matches!( + Ecosystem::from_purl(purl), + Some(Ecosystem::Gem | Ecosystem::Pypi) + ) { + pkg_paths.as_slice() + } else { + std::slice::from_ref(first_path) + }; for (_, record) in records { if !can_queue(record) { continue; } - if gated { - if let Some((file_name, file_info)) = representative_file(&record.files) { - let status = verify_file_patch(first_path, file_name, file_info) - .await - .status; - if !variant_matches_installed(Some(&status)) { - continue; + // Copies this variant is attempted on: a copy whose installed + // distribution is another variant (two envs can hold different + // wheels of one release) is skipped there by the apply loop. + let probe_copies: Vec<&PathBuf> = match representative_file(&record.files) { + Some((file_name, file_info)) if gated => { + let mut matched = Vec::new(); + for copy in gate_copies { + let status = verify_file_patch(copy, file_name, file_info).await.status; + if variant_matches_installed(Some(&status)) { + matched.push(copy); + } } + matched } + _ => pkg_paths.iter().collect(), + }; + if probe_copies.is_empty() { + continue; } for (file_name, info) in &record.files { if info.before_hash.is_empty() || !missing.contains(&info.after_hash) { continue; } - for pkg_path in pkg_paths { + for pkg_path in &probe_copies { let verify = verify_file_patch(pkg_path, file_name, info).await; if verify.status == VerifyStatus::HashMismatch { needed.insert(info.after_hash.clone()); @@ -2908,6 +2929,90 @@ mod tests { ); } + /// Regression (#538 review): two PyPI copies of one release holding + /// DIFFERENT wheels. The apply loop gates each variant per copy, so the + /// variant installed only in the SECOND copy is attempted there; its + /// locally-modified non-representative file needs the full afterHash + /// blob. Gating against the first copy alone skipped that variant and + /// left the blob unfetched, so warn-and-apply failed on the second copy. + #[tokio::test] + async fn mismatch_blob_gaps_gates_pypi_variants_per_copy() { + use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; + + let dir = tempfile::tempdir().unwrap(); + // Copy A: the wheel's distribution, pristine. + let copy_a = dir.path().join("a"); + tokio::fs::create_dir_all(©_a).await.unwrap(); + tokio::fs::write(copy_a.join("aaa.py"), b"wheel\n") + .await + .unwrap(); + // Copy B: the sdist's distribution, with a locally modified + // non-representative file. + let copy_b = dir.path().join("b"); + tokio::fs::create_dir_all(©_b).await.unwrap(); + tokio::fs::write(copy_b.join("aaa.py"), b"sdist\n") + .await + .unwrap(); + tokio::fs::write(copy_b.join("zzz.py"), b"locally modified\n") + .await + .unwrap(); + let blobs = dir.path().join("blobs"); + tokio::fs::create_dir_all(&blobs).await.unwrap(); + + let mut wheel_files = HashMap::new(); + wheel_files.insert( + "aaa.py".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(b"wheel\n"), + after_hash: "1".repeat(64), + }, + ); + let mut manifest = manifest_with_record( + "pkg:pypi/foo@1.0.0?artifact_id=foo-1.0.0-py3-none-any.whl", + wheel_files, + ); + let mut sdist_files = HashMap::new(); + sdist_files.insert( + "aaa.py".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(b"sdist\n"), + after_hash: "2".repeat(64), + }, + ); + sdist_files.insert( + "zzz.py".to_string(), + PatchFileInfo { + before_hash: "3".repeat(64), + after_hash: "4".repeat(64), + }, + ); + manifest.patches.insert( + "pkg:pypi/foo@1.0.0?artifact_id=foo-1.0.0.tar.gz".to_string(), + PatchRecord { + uuid: "22222222-2222-4222-8222-222222222222".to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files: sdist_files, + vulnerabilities: HashMap::new(), + description: "fixture".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }, + ); + let mut all_packages = HashMap::new(); + all_packages.insert( + "pkg:pypi/foo@1.0.0".to_string(), + vec![copy_a.clone(), copy_b.clone()], + ); + + let needed = + mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await; + assert_eq!( + needed, + HashSet::from(["4".repeat(64)]), + "the second copy's variant must queue its mismatched file's blob" + ); + } + /// A variant with no content-modifying files (only new files) has /// nothing to disqualify it: no representative, treated as a match — /// the same no-files contract as core's `select_installed_variants`.