From ff82eda1c7529128383690b1ceed271dbede2aba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 04:29:11 +0000 Subject: [PATCH 1/6] Start fix for #701, #932 Assisted-by: Claude Code:claude-opus-5-5 From e50feee02210af8938e37692e08b59591b6d7323 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 04:55:12 +0000 Subject: [PATCH 2/6] Stop hosted PyPI pinning platform-only wheels When the patch service granted a PyPI patch as a platform- or ABI-tagged wheel (for example cp311 manylinux), hosted scan pinned that one wheel into the project's cross-platform lock: uv.lock, PEP 723 script locks, pylock.toml, Pipfile.lock, poetry.lock, pdm.lock, requirements.txt or Hatch's pyproject. It reported success, but installs then failed on every other Python version, OS and architecture, and hosted rollback refused to undo it. Hosted mode now checks the granted wheel's tags once, where every PyPI lock writer is dispatched. A platform-specific wheel is withheld from all of them and reported with a redirect_pypi_platform_wheel warning, the same way hosted gem refuses platform gems. Nothing is written or attested for that patch; other patches in the run are unaffected. The tag rule is the one vendored mode already uses for vendor_platform_locked, now shared between both modes. Fixes #701, #932. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 1 + .../tests/in_process_redirect_pipenv.rs | 42 ++- .../patch/redirect/group_equivalence_tests.rs | 4 + .../src/patch/redirect/mod.rs | 52 +++ .../patch/redirect/platform_wheel_tests.rs | 311 ++++++++++++++++++ crates/socket-patch-core/src/vendor/pypi.rs | 26 +- .../src/vendor/pypi_distribution.rs | 28 ++ 7 files changed, 438 insertions(+), 26 deletions(-) create mode 100644 crates/socket-patch-core/src/patch/redirect/platform_wheel_tests.rs diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index dff38f2c8..2e38a8e11 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1256,6 +1256,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_pipenv_refused` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): the Pipfile.lock pins another version or a non-registry / foreign source for the package — refused atomically across categories, and the patch is vetoed from the sibling Python rewriters (see the "Pipenv hosted redirect" section). | | `redirect_pipenv_skipped` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): no entry for the package, pipfile-spec < 6, an unparseable lock or a digest-less patch — nothing rewritten here; the sibling rewriters proceed. | | `redirect_pipenv_installer_unknown` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): the lock was rewritten with the modern `file` reference because no `pipenv` answered on PATH; Pipenv 7–11 projects need `path` — put that pipenv on PATH or set `SOCKET_PIPENV_MAJOR`. | +| `redirect_pypi_platform_wheel` | `redirect.warnings[]` (warning) | scan / get `--mode hosted` (pypi, every lane: uv.lock, PEP 723 script locks, pylock.toml, Pipfile.lock, poetry.lock, pdm.lock, requirements.txt, Hatch): the patch service granted the patch as a platform- or ABI-tagged wheel (any tag triple other than `-none-any`, e.g. `cp311-cp311-manylinux…`). A hosted pin would narrow the cross-platform lock entry to that one wheel, so installs on any other interpreter, OS or architecture would fail and hosted rollback could not derive the upstream wheels to restore. The patch is withheld from every PyPI rewriter (nothing is written or confirmed for it, and a same-run `--vex` does not attest it); exit 0, like every hosted refusal. The tags are read as vendored mode reads them for `vendor_platform_locked`. | | `pypi_pipenv_installer_unsupported` | `failed` | vendor (pipenv): the installed Pipenv is older than 2018 and cannot consume vendored wheel references — upgrade Pipenv or use hosted mode. | | `pypi_pipenv_version_mismatch` | `failed` | vendor (pipenv): a category pins a different version than the patch — refused before any write. (`pypi_pipenv_invalid_wheel` retired in v5.0: the backend takes the orchestrator's resolved version instead of parsing the wheel filename.) | | `pypi_poetry_symlink_unsupported` / `pypi_pipenv_symlink_unsupported` / `pypi_requirements_symlink_unsupported` | `failed` | vendor (pypi, v5.0): a target file (`pyproject.toml` / `poetry.lock`, `Pipfile` / `Pipfile.lock`, or any planned `requirements*.txt`) is a symlink — refused before any write on wire AND on revert (the revert keeps the artifact, `kept_artifact`); the twins of the existing pdm/uv symlink refusals. | diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index 3c33af6d0..310881880 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -109,6 +109,11 @@ fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { } async fn mock_api(server: &MockServer) { + mock_api_serving(server, HOSTED_URL).await; +} + +/// [`mock_api`] with the grant serving `hosted_url` as the patched artifact. +async fn mock_api_serving(server: &MockServer, hosted_url: &str) { Mock::given(method("POST")) .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ @@ -143,11 +148,11 @@ async fn mock_api(server: &MockServer) { "results": { UUID: { "status": "granted", - "url": HOSTED_URL, + "url": hosted_url, "purl": PURL, "artifacts": [{ "kind": "tarball", - "url": HOSTED_URL, + "url": hosted_url, "integrity": { "sha256": sha256() } }], "registryOverride": null @@ -515,6 +520,39 @@ async fn live_pipfile_lock_conflict_vetoes_the_requirements_redirect() { assert_eq!(read(&tmp.path().join("Pipfile")), PIPFILE); } +/// #932: a patch granted as a platform-tagged wheel (cp311 manylinux) is +/// never pinned into the cross-platform Pipfile.lock, nor into the sibling +/// requirements.txt: the run leaves both files alone, exits 0 like every +/// hosted refusal (the `redirect_pypi_platform_wheel` warning says why), +/// and its same-run VEX never attests the unpinned patch. +#[tokio::test] +#[serial] +async fn platform_wheel_is_not_pinned_into_the_lock() { + let _major = MajorGuard::set("2026"); + let server = MockServer::start().await; + let platform_url = HOSTED_URL.replace( + "py2.py3-none-any", + "cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64", + ); + mock_api_serving(&server, &platform_url).await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + const REQS: &str = "urllib3==1.26.18\n"; + std::fs::write(tmp.path().join("requirements.txt"), REQS).unwrap(); + let vex_path = tmp.path().join("out.vex.json"); + + let code = run(hosted_args(tmp.path(), server.uri(), Some(&vex_path))).await; + assert_eq!(code, 0, "a hosted refusal exits 0 with a warning"); + assert_eq!(read(&tmp.path().join("Pipfile.lock")), LOCK); + assert_eq!(read(&tmp.path().join("requirements.txt")), REQS); + assert_eq!(read(&tmp.path().join("Pipfile")), PIPFILE); + assert_no_ledger(tmp.path()); + if vex_path.exists() { + let vex = read(&vex_path); + assert!(!vex.contains("not_affected"), "{vex}"); + } +} + /// The same conflict in an ABANDONED lock (no Pipfile beside it) says /// nothing about the project's install files: the sibling requirements.txt /// is still redirected. diff --git a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs index a70af68bc..d13effcb6 100644 --- a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs @@ -15,6 +15,8 @@ fn serial_oracle( bun_lockb_present: bool, ) -> RewriteResult { let mut result = RewriteResult::default(); + let overrides = withhold_pypi_platform_wheels(overrides, &mut result); + let overrides: &[DepOverride] = &overrides; if pdm_drives(files) { pdm::rewrite(files, overrides, &mut result); } @@ -118,6 +120,8 @@ fn assert_same_with_metadata( // And the parallel merge itself — not the serial fallback — is what // produced it: real rewriters only append, to files of their own. let mut prefix = RewriteResult::default(); + let overrides = withhold_pypi_platform_wheels(overrides, &mut prefix); + let overrides: &[DepOverride] = &overrides; if pdm_drives(files) { pdm::rewrite(files, overrides, &mut prefix); } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 72c8b2967..0ec9c1044 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -63,6 +63,8 @@ use crate::formats::pnpm::hosted::pnpm_unrewritten_instances; use crate::formats::yarn::berry_entry::{manifest_bin, render_pinned_entry, Pin}; #[cfg(test)] mod pnpm_equivalence_tests; +#[cfg(test)] +mod platform_wheel_tests; mod poetry; #[cfg(test)] mod python_lock_equivalence_tests; @@ -510,6 +512,54 @@ pub fn rewrite_registry_redirect_with_pipenv_version( ) } +/// #701 / #932: the patch service can grant a pypi patch as a platform- or +/// ABI-tagged wheel (`…-cp311-cp311-manylinux…whl`). Every hosted PyPI lock +/// (uv.lock, PEP 723 script locks, pylock.toml, Pipfile.lock, poetry.lock, +/// pdm.lock, requirements.txt, Hatch's pyproject) is meant to install on +/// any platform its markers allow, and a hosted pin narrows the entry to +/// that one wheel: installs on any other interpreter, OS or architecture +/// then fail, and hosted rollback cannot derive which upstream wheels to +/// put back. Fail closed like hosted gem (`redirect_gem_platform_unsupported`): +/// warn once per patch and withhold it from every PyPI rewriter. The tags +/// are read the way vendored mode reads them for `vendor_platform_locked`. +fn withhold_pypi_platform_wheels<'a>( + overrides: &'a [DepOverride], + result: &mut RewriteResult, +) -> Cow<'a, [DepOverride]> { + let mut refused = std::collections::BTreeSet::new(); + for dep in overrides { + if dep.ecosystem != "pypi" || refused.contains(&dep.patch_uuid) { + continue; + } + let path = dep + .artifact_url + .split(['?', '#']) + .next() + .unwrap_or_default(); + let file_name = path.rsplit('/').next().unwrap_or_default(); + // An sdist carries no platform tags. + if !file_name.ends_with(".whl") { + continue; + } + let (platform_locked, tags) = + crate::vendor::pypi_distribution::wheel_platform_from_filename(file_name); + if !platform_locked { + continue; + } + refused.insert(dep.patch_uuid.clone()); + result.warnings.push(RewriteWarning { + code: "redirect_pypi_platform_wheel".into(), + detail: format!( + "the patched wheel for {}=={} is platform-specific ({tags}); pinning it \ + would make the project's Python lockfiles install it on this platform \ + only, so the redirect is skipped and nothing was written for it", + dep.name, dep.version + ), + }); + } + withhold(overrides, &refused) +} + /// [`rewrite_registry_redirect_with_pipenv_version`] with the patch uuids /// in `vlt_withheld` kept out of the vlt rewrite only: their artifact /// failed vlt's preflight while another npm-family lock may be the one the @@ -526,6 +576,8 @@ pub fn rewrite_registry_redirect_withholding_vlt( gradle_unreadable: &std::collections::BTreeSet, ) -> RewriteResult { let mut result = RewriteResult::default(); + let overrides = withhold_pypi_platform_wheels(overrides, &mut result); + let overrides: &[DepOverride] = &overrides; // pdm runs FIRST, but only when `pdm.lock` is the project's PyPI install // driver (see [`pdm_drives`]). When it does, a patch it refuses is // withheld from every other pypi rewriter so a sibling `Pipfile.lock` / diff --git a/crates/socket-patch-core/src/patch/redirect/platform_wheel_tests.rs b/crates/socket-patch-core/src/patch/redirect/platform_wheel_tests.rs new file mode 100644 index 000000000..e18694bf8 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/platform_wheel_tests.rs @@ -0,0 +1,311 @@ +//! #701 / #932: a pypi patch granted as a platform- or ABI-tagged wheel is +//! never pinned into a cross-platform Python lock. Each lane first proves +//! its fixture redirects a pure wheel (the control), then that the same +//! project with a `cp311-cp311-manylinux` wheel is left untouched, warned +//! about once, and confirms nothing. + +use super::*; + +const PURE: &str = "urllib3-1.26.18-py2.py3-none-any.whl"; +const PLATFORM: &str = "urllib3-1.26.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"; +const UUID: &str = "aaaaaaaa-0000-4000-8000-000000000701"; +const HEX: &str = "34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07"; + +fn dep(wheel: &str) -> DepOverride { + DepOverride { + ecosystem: "pypi".into(), + name: "urllib3".into(), + namespace: None, + version: "1.26.18".into(), + token: "11111111-1111-4111-8111-111111111111".into(), + patch_uuid: UUID.into(), + artifact_url: format!( + "https://patch.socket.dev/patch/pypi/urllib3/1.26.18/11111111-1111-4111-8111-111111111111/{UUID}/{wheel}" + ), + registry_override: None, + integrity: Integrity { + sha256: Some(HEX.into()), + ..Default::default() + }, + } +} + +fn confirmed(result: &RewriteResult) -> bool { + [ + &result.confirmed_pipenv_uuids, + &result.confirmed_pdm_uuids, + &result.confirmed_python_lock_uuids, + &result.confirmed_hatch_uuids, + &result.confirmed_requirements_uuids, + ] + .iter() + .any(|set| set.contains(UUID)) +} + +fn platform_warnings(result: &RewriteResult) -> usize { + result + .warnings + .iter() + .filter(|w| w.code == "redirect_pypi_platform_wheel") + .count() +} + +/// The control redirects `lock`; the platform wheel leaves every file alone. +fn assert_lane(lane: &str, files: &[(&str, &str)], lock: &str) { + let files: BTreeMap = files + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + let control = rewrite_registry_redirect(&files, &[dep(PURE)]); + assert!( + control + .files + .get(lock) + .is_some_and(|text| text.contains(PURE)), + "{lane}: control did not pin the pure wheel into {lock}: {:?}", + control.warnings + ); + assert!(confirmed(&control), "{lane}: control not confirmed"); + assert_eq!(platform_warnings(&control), 0, "{lane}"); + + let result = rewrite_registry_redirect(&files, &[dep(PLATFORM)]); + assert!( + result.files.is_empty() && result.edits.is_empty(), + "{lane}: platform wheel was pinned: {:?}", + result.files.keys().collect::>() + ); + assert!(!confirmed(&result), "{lane}: platform wheel confirmed"); + assert_eq!( + platform_warnings(&result), + 1, + "{lane}: {:?}", + result.warnings + ); + let detail = &result.warnings[0].detail; + assert!( + detail.contains("urllib3==1.26.18") && detail.contains("cp311-cp311-manylinux"), + "{lane}: {detail}" + ); +} + +/// #701: a uv project's `uv.lock` (and its `[tool.uv.sources]`). +#[test] +fn uv_project_lock_refuses_a_platform_wheel() { + let project = "[project]\nname = \"app\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"urllib3==1.26.18\"]\n"; + let lock = format!("version = 1\nrevision = 3\nrequires-python = \">=3.9\"\n\n[[package]]\nname = \"app\"\nversion = \"0.1.0\"\nsource = {{ virtual = \".\" }}\ndependencies = [{{ name = \"urllib3\" }}]\n\n[package.metadata]\nrequires-dist = [{{ name = \"urllib3\", specifier = \"==1.26.18\" }}]\n\n[[package]]\nname = \"urllib3\"\nversion = \"1.26.18\"\nsource = {{ registry = \"https://pypi.org/simple\" }}\nwheels = [{{ url = \"https://files.pythonhosted.org/packages/{PURE}\", hash = \"sha256:{HEX}\" }}]\n"); + for newline in ["\n", "\r\n"] { + assert_lane( + "uv.lock", + &[ + ("pyproject.toml", &project.replace('\n', newline)), + ("uv.lock", &lock.replace('\n', newline)), + ], + "uv.lock", + ); + } +} + +/// #701 (comment): a PEP 723 script lock. +#[test] +fn uv_script_lock_refuses_a_platform_wheel() { + let script = + "# /// script\n# requires-python = \">=3.9\"\n# dependencies = [\"urllib3==1.26.18\"]\n# ///\nimport urllib3\n"; + let lock = format!("version = 1\nrevision = 3\nrequires-python = \">=3.9\"\n\n[manifest]\nrequirements = [{{ name = \"urllib3\", specifier = \"==1.26.18\" }}]\n\n[[package]]\nname = \"urllib3\"\nversion = \"1.26.18\"\nsource = {{ registry = \"https://pypi.org/simple\" }}\nwheels = [{{ url = \"https://files.pythonhosted.org/packages/{PURE}\", hash = \"sha256:{HEX}\" }}]\n"); + assert_lane( + "script lock", + &[("tool.py", script), ("tool.py.lock", &lock)], + "tool.py.lock", + ); +} + +/// #701: the PEP 751 lane (`uv export --format pylock.toml`). +#[test] +fn pylock_refuses_a_platform_wheel() { + let lock = format!("lock-version = \"1.0\"\ncreated-by = \"uv\"\nrequires-python = \">=3.9\"\n\n[[packages]]\nname = \"urllib3\"\nversion = \"1.26.18\"\nindex = \"https://pypi.org/simple\"\nwheels = [{{ url = \"https://files.pythonhosted.org/packages/{PURE}\", size = 1, hashes = {{ sha256 = \"{HEX}\" }} }}]\n"); + assert_lane("pylock", &[("pylock.toml", &lock)], "pylock.toml"); +} + +/// #932: Pipenv's `Pipfile.lock`. +#[test] +fn pipfile_lock_refuses_a_platform_wheel() { + let lock = format!( + "{{\n \"_meta\": {{\n \"hash\": {{\n \"sha256\": \"unchanged\"\n }},\n \"pipfile-spec\": 6,\n \"sources\": [\n {{\n \"name\": \"pypi\",\n \"url\": \"https://pypi.org/simple\",\n \"verify_ssl\": true\n }}\n ]\n }},\n \"default\": {{\n \"urllib3\": {{\n \"hashes\": [\n \"sha256:{HEX}\"\n ],\n \"index\": \"pypi\",\n \"markers\": \"python_version >= '3.7'\",\n \"version\": \"==1.26.18\"\n }}\n }},\n \"develop\": {{}}\n}}\n" + ); + let pipfile = "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\nurllib3 = \"==1.26.18\"\n"; + for major in [None, Some(11), Some(2026)] { + let files: BTreeMap = [ + ("Pipfile".to_string(), pipfile.to_string()), + ("Pipfile.lock".to_string(), lock.clone()), + ] + .into_iter() + .collect(); + let control = rewrite_registry_redirect_with_pipenv_version( + &files, + &[dep(PURE)], + &BTreeMap::new(), + major, + false, + ); + assert!( + control + .files + .get("Pipfile.lock") + .is_some_and(|t| t.contains(PURE)), + "pipenv {major:?}: control did not pin: {:?}", + control.warnings + ); + assert!(confirmed(&control)); + let result = rewrite_registry_redirect_with_pipenv_version( + &files, + &[dep(PLATFORM)], + &BTreeMap::new(), + major, + false, + ); + assert!( + result.files.is_empty(), + "pipenv {major:?}: {:?}", + result.files + ); + assert!(!confirmed(&result)); + assert_eq!(platform_warnings(&result), 1, "{:?}", result.warnings); + } + assert_lane( + "Pipfile.lock", + &[("Pipfile", pipfile), ("Pipfile.lock", &lock)], + "Pipfile.lock", + ); +} + +/// #701 (comment): a `requirements.txt` pin, CRLF and hashed spellings too. +#[test] +fn requirements_refuses_a_platform_wheel() { + for text in [ + "urllib3==1.26.18\n".to_string(), + "idna==3.7\r\nurllib3==1.26.18\r\n".to_string(), + format!("urllib3==1.26.18 \\\n --hash=sha256:{HEX}\n"), + ] { + assert_lane( + "requirements", + &[("requirements.txt", &text)], + "requirements.txt", + ); + } +} + +#[test] +fn poetry_lock_refuses_a_platform_wheel() { + let pyproject = "[tool.poetry]\nname = \"app\"\nversion = \"0.1.0\"\ndescription = \"\"\nauthors = []\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\nurllib3 = \"1.26.18\"\n"; + assert_lane( + "poetry", + &[ + ("pyproject.toml", pyproject), + ( + "poetry.lock", + include_str!("../../../tests/fixtures/poetry/1.0.10/poetry.lock"), + ), + ], + "poetry.lock", + ); +} + +#[test] +fn pdm_lock_refuses_a_platform_wheel() { + let pyproject = + "[project]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\"urllib3==1.26.18\"]\n"; + assert_lane( + "pdm", + &[ + ("pyproject.toml", pyproject), + ( + "pdm.lock", + include_str!("../../../tests/fixtures/pdm-native/2.29.2.lock"), + ), + ], + "pdm.lock", + ); +} + +/// Hatch's own pyproject environment pin (a lock-less Hatch project). +#[test] +fn hatch_refuses_a_platform_wheel() { + let pyproject = "[project]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\"urllib3==1.26.18\"]\n\n[tool.hatch.envs.default]\ndependencies = [\"urllib3==1.26.18\"]\n"; + assert_lane("hatch", &[("pyproject.toml", pyproject)], "pyproject.toml"); +} + +/// The tag rule matches vendored mode's: a version-bound `cp311-none-any` +/// wheel and an sdist stay redirectable, an `abi3` or platform-only tag +/// does not, and a query or fragment on the serve URL is ignored. +#[test] +fn only_platform_or_abi_tagged_wheels_are_withheld() { + let files: BTreeMap = [( + "requirements.txt".to_string(), + "urllib3==1.26.18\n".to_string(), + )] + .into_iter() + .collect(); + for (artifact, refused) in [ + (PURE.to_string(), false), + ("urllib3-1.26.18-cp311-none-any.whl".to_string(), false), + ("urllib3-1.26.18.tar.gz".to_string(), false), + (format!("{PURE}?token=x#sha256={HEX}"), false), + (PLATFORM.to_string(), true), + (format!("{PLATFORM}#sha256={HEX}"), true), + ( + "urllib3-1.26.18-cp38-abi3-macosx_11_0_arm64.whl".to_string(), + true, + ), + ("urllib3-1.26.18-py3-none-win_amd64.whl".to_string(), true), + ] { + let result = rewrite_registry_redirect(&files, &[dep(&artifact)]); + assert_eq!( + platform_warnings(&result), + usize::from(refused), + "{artifact}" + ); + assert_eq!( + result.files.is_empty(), + refused, + "{artifact}: {:?}", + result.warnings + ); + } +} + +/// Withholding is per patch: a portable sibling patch in the same run is +/// still pinned, and a non-pypi override is never inspected. +#[test] +fn a_platform_wheel_withholds_only_its_own_patch() { + let files: BTreeMap = [( + "requirements.txt".to_string(), + "idna==3.7\nurllib3==1.26.18\n".to_string(), + )] + .into_iter() + .collect(); + let idna = DepOverride { + name: "idna".into(), + version: "3.7".into(), + patch_uuid: "aaaaaaaa-0000-4000-8000-000000000702".into(), + artifact_url: "https://patch.socket.dev/patch/pypi/idna/3.7/t/u/idna-3.7-py3-none-any.whl" + .into(), + ..dep(PURE) + }; + let npm = DepOverride { + ecosystem: "npm".into(), + name: "left-pad".into(), + patch_uuid: "aaaaaaaa-0000-4000-8000-000000000703".into(), + artifact_url: format!("https://patch.socket.dev/{PLATFORM}"), + ..dep(PURE) + }; + let result = rewrite_registry_redirect(&files, &[dep(PLATFORM), idna.clone(), npm]); + assert_eq!(platform_warnings(&result), 1, "{:?}", result.warnings); + let text = &result.files["requirements.txt"]; + assert!(text.contains(&idna.artifact_url), "{text}"); + assert!(!text.contains(PLATFORM), "{text}"); + assert!(text.contains("urllib3==1.26.18"), "{text}"); + assert!(result + .confirmed_requirements_uuids + .contains(&idna.patch_uuid)); + assert!(!confirmed(&result)); +} diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 9ebf3be86..8443631de 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -25,6 +25,7 @@ use super::common::{ already_patched_result, done, prune_empty_vendor_levels, refused, service_offline_conflict, }; use super::path::vendor_uuid_dir_rel; +use super::pypi_distribution::wheel_platform_from_filename; use super::pypi_pdm::{PdmProject, PdmTarget}; use super::pypi_pipenv::{PipenvProject, PipenvTarget}; use super::pypi_poetry::{PoetryProject, PoetryTarget}; @@ -1959,35 +1960,12 @@ async fn try_pypi_service_wheel( })) } -fn wheel_platform_from_filename(wheel_name: &str) -> (bool, String) { - let stem = wheel_name.strip_suffix(".whl").unwrap_or(wheel_name); - let parts: Vec<&str> = stem.split('-').collect(); - if parts.len() >= 3 { - let triple = parts[parts.len() - 3..].join("-"); - (tag_is_platform_specific(&triple), triple) - } else { - // Unparseable → cannot prove portability. - (true, stem.to_string()) - } -} - -/// Platform-specific iff the tag triple binds an ABI or platform — `cp311- -/// none-any` is merely version-bound, `*-cp311-*` / `*-manylinux*` lock the -/// artifact to this machine's platform. -fn tag_is_platform_specific(tag: &str) -> bool { - let parts: Vec<&str> = tag.split('-').collect(); - match parts.as_slice() { - [_py, abi, plat] => *abi != "none" || *plat != "any", - // Malformed tags can't prove portability — claim platform-locked. - _ => true, - } -} - #[cfg(test)] mod tests { use super::*; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; + use crate::vendor::pypi_distribution::tag_is_platform_specific; use crate::vendor::state::VENDOR_MARKER_FILE; use std::collections::HashMap; use std::path::PathBuf; diff --git a/crates/socket-patch-core/src/vendor/pypi_distribution.rs b/crates/socket-patch-core/src/vendor/pypi_distribution.rs index 92738d7dc..022aac028 100644 --- a/crates/socket-patch-core/src/vendor/pypi_distribution.rs +++ b/crates/socket-patch-core/src/vendor/pypi_distribution.rs @@ -91,6 +91,34 @@ pub(crate) fn verify_members( Ok(()) } +/// Whether a wheel filename binds an ABI or platform, and its tag triple +/// for messages. Shared by vendored mode (`vendor_platform_locked`) and the +/// hosted redirect (`redirect_pypi_platform_wheel`), so both modes call the +/// same wheels portable. +pub(crate) fn wheel_platform_from_filename(wheel_name: &str) -> (bool, String) { + let stem = wheel_name.strip_suffix(".whl").unwrap_or(wheel_name); + let parts: Vec<&str> = stem.split('-').collect(); + if parts.len() >= 3 { + let triple = parts[parts.len() - 3..].join("-"); + (tag_is_platform_specific(&triple), triple) + } else { + // Unparseable → cannot prove portability. + (true, stem.to_string()) + } +} + +/// Platform-specific iff the tag triple binds an ABI or platform — `cp311- +/// none-any` is merely version-bound, `*-cp311-*` / `*-manylinux*` lock the +/// artifact to this machine's platform. +pub(crate) fn tag_is_platform_specific(tag: &str) -> bool { + let parts: Vec<&str> = tag.split('-').collect(); + match parts.as_slice() { + [_py, abi, plat] => *abi != "none" || *plat != "any", + // Malformed tags can't prove portability — claim platform-locked. + _ => true, + } +} + #[cfg(test)] mod tests { use super::*; From 139fa6511282f67650ebc5febd0dd958dfa46dee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 05:00:42 +0000 Subject: [PATCH 3/6] Keep vendored PyPI patch on a platform grant A vendored PyPI package that a hosted scan takes over is reverted to its registry entry first, and only then pinned to the hosted wheel. With platform-tagged hosted wheels now refused, that order would strip the live vendored patch and leave the package unpatched. The takeover now asks the same platform-wheel check before it reverts anything, so the package stays vendored and patched, and both the wet run and the dry run name redirect_pypi_platform_wheel as the reason. Refs #701, #932. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/scan/hosted.rs | 11 +++- .../tests/mode_migration_pypi.rs | 62 +++++++++++++++-- .../src/patch/redirect/mod.rs | 66 +++++++++++-------- 3 files changed, 101 insertions(+), 38 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index e6e48a140..d42cc24e4 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1824,8 +1824,9 @@ async fn vendored_takeover( }; // The takeover refusal (if any) for one candidate: bun gates every // npm purl, berry and vlt only their own vendored entries, Gradle each - // of its own purls, and a requirements.txt entry is gated on the hosted - // rewriter's reach (it pins only the root file, #699). Berry also runs + // of its own purls, a pypi purl on a platform-tagged grant (#701), and a + // requirements.txt entry on the hosted rewriter's reach (it pins only + // the root file, #699). Berry also runs // the rewriter's per-dep grant gate (a grant without the berry cache // checksum is skipped by the rewriter, so reverting first would leave // the package in neither mode). A refused purl is never dispatched (see @@ -1837,8 +1838,12 @@ async fn vendored_takeover( return gradle_takeover_refusals.get(&c.purl).cloned(); } if c.purl.starts_with("pkg:pypi/") { + // A platform-tagged grant is never pinned (#701 / #932): keep + // the vendored patch rather than revert it to nothing. return entry.and_then(|e| { - socket_patch_core::patch::redirect::preflight_requirements_takeover(e).err() + socket_patch_core::patch::redirect::pypi_platform_wheel_refusal(&c.dep).or_else( + || socket_patch_core::patch::redirect::preflight_requirements_takeover(e).err(), + ) }); } if !c.purl.starts_with("pkg:npm/") { diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index b8c92adbe..66c4679cd 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -143,10 +143,20 @@ fn run_raw(root: &Path, args: &[&str], extra: &[(&str, &str)]) -> (i32, String, /// rewriters) — or, with `wheel_served: false`, a 404 for it. Returns the /// hosted URL. async fn mount_hosted_api(server: &MockServer, wheel_served: bool) -> String { + mount_hosted_api_serving(server, wheel_served, WHEEL).await +} + +/// [`mount_hosted_api`] with the grant naming the wheel file `wheel_name`. +async fn mount_hosted_api_serving( + server: &MockServer, + wheel_served: bool, + wheel_name: &str, +) -> String { let wheel = hosted_wheel(); let sha = hex::encode(Sha256::digest(&wheel)); - let route = - format!("/patch/pypi/six/1.16.0/33333333-3333-4333-8333-333333333333/{UUID}/{WHEEL}"); + let route = format!( + "/patch/pypi/six/1.16.0/33333333-3333-4333-8333-333333333333/{UUID}/{wheel_name}" + ); let hosted_url = format!("{}{route}", server.uri()); Mock::given(method("POST")) .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) @@ -799,11 +809,30 @@ async fn vendor_check_fails_after_hatch_dependency_reset() { /// revert — the vendored patch, ledger entry and wheel are kept — and the /// dry run must predict that refusal instead of a clean takeover. async fn assert_unreachable_takeover_refused(root: &Path, wired: &str, dry_run: bool) { + assert_takeover_refused_before_revert( + root, + wired, + dry_run, + WHEEL, + "redirect_requirements_takeover_unreachable", + ) + .await; +} + +/// A takeover hosted mode cannot complete is refused before the revert: +/// the vendored line, ledger entry and wheel are kept and `code` names why. +async fn assert_takeover_refused_before_revert( + root: &Path, + wired: &str, + dry_run: bool, + wheel_name: &str, + code_name: &str, +) { let before = std::fs::read_to_string(root.join(wired)).unwrap(); let root_before = std::fs::read_to_string(root.join("requirements.txt")).unwrap(); let state = root.join(".socket/vendor/state.json"); let server = MockServer::start().await; - mount_hosted_api(&server, true).await; + mount_hosted_api_serving(&server, true, wheel_name).await; let uri = server.uri(); let mut args = hosted_scan_args(&uri); if dry_run { @@ -820,10 +849,7 @@ async fn assert_unreachable_takeover_refused(root: &Path, wired: &str, dry_run: !text.contains("redirect_takeover_unpatched"), "the package is never stranded: {env:#}" ); - assert!( - text.contains("redirect_requirements_takeover_unreachable"), - "the refusal is named: {env:#}" - ); + assert!(text.contains(code_name), "the refusal is named: {env:#}"); assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); assert_eq!( code, 0, @@ -915,3 +941,25 @@ async fn dry_run_previews_root_pin_takeover() { ); assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); } + +/// #701 / #932: a hosted grant that is a platform-tagged wheel is never +/// pinned, so a vendored → hosted takeover onto it must be refused BEFORE +/// the revert (keeping the vendored patch) instead of stranding the +/// package unpatched — on the dry run too. +#[tokio::test] +async fn platform_wheel_takeover_is_refused_before_revert() { + const PLATFORM: &str = "six-1.16.0-cp311-cp311-manylinux_2_17_x86_64.whl"; + for dry_run in [true, false] { + let (_tmp, root) = project(); + let files = stage_requirements(&root); + vendor_project(&root, files); + assert_takeover_refused_before_revert( + &root, + "requirements.txt", + dry_run, + PLATFORM, + "redirect_pypi_platform_wheel", + ) + .await; + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0ec9c1044..879898414 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -519,43 +519,53 @@ pub fn rewrite_registry_redirect_with_pipenv_version( /// any platform its markers allow, and a hosted pin narrows the entry to /// that one wheel: installs on any other interpreter, OS or architecture /// then fail, and hosted rollback cannot derive which upstream wheels to -/// put back. Fail closed like hosted gem (`redirect_gem_platform_unsupported`): -/// warn once per patch and withhold it from every PyPI rewriter. The tags -/// are read the way vendored mode reads them for `vendor_platform_locked`. +/// put back. Fail closed like hosted gem (`redirect_gem_platform_unsupported`). +/// The tags are read the way vendored mode reads them for +/// `vendor_platform_locked`. `None` for a portable wheel, an sdist, or a +/// non-pypi override. The vendored→hosted takeover asks this BEFORE it +/// reverts a vendored purl, so the refusal never strips a live patch. +pub fn pypi_platform_wheel_refusal(dep: &DepOverride) -> Option { + if dep.ecosystem != "pypi" { + return None; + } + let path = dep + .artifact_url + .split(['?', '#']) + .next() + .unwrap_or_default(); + let file_name = path.rsplit('/').next().unwrap_or_default(); + // An sdist carries no platform tags. + if !file_name.ends_with(".whl") { + return None; + } + let (platform_locked, tags) = + crate::vendor::pypi_distribution::wheel_platform_from_filename(file_name); + platform_locked.then(|| RewriteWarning { + code: "redirect_pypi_platform_wheel".into(), + detail: format!( + "the patched wheel for {}=={} is platform-specific ({tags}); pinning it \ + would make the project's Python lockfiles install it on this platform \ + only, so the redirect is skipped and nothing was written for it", + dep.name, dep.version + ), + }) +} + +/// Withhold every [`pypi_platform_wheel_refusal`] patch from all the PyPI +/// rewriters, warning once per patch. fn withhold_pypi_platform_wheels<'a>( overrides: &'a [DepOverride], result: &mut RewriteResult, ) -> Cow<'a, [DepOverride]> { let mut refused = std::collections::BTreeSet::new(); for dep in overrides { - if dep.ecosystem != "pypi" || refused.contains(&dep.patch_uuid) { + if refused.contains(&dep.patch_uuid) { continue; } - let path = dep - .artifact_url - .split(['?', '#']) - .next() - .unwrap_or_default(); - let file_name = path.rsplit('/').next().unwrap_or_default(); - // An sdist carries no platform tags. - if !file_name.ends_with(".whl") { - continue; - } - let (platform_locked, tags) = - crate::vendor::pypi_distribution::wheel_platform_from_filename(file_name); - if !platform_locked { - continue; + if let Some(warning) = pypi_platform_wheel_refusal(dep) { + refused.insert(dep.patch_uuid.clone()); + result.warnings.push(warning); } - refused.insert(dep.patch_uuid.clone()); - result.warnings.push(RewriteWarning { - code: "redirect_pypi_platform_wheel".into(), - detail: format!( - "the patched wheel for {}=={} is platform-specific ({tags}); pinning it \ - would make the project's Python lockfiles install it on this platform \ - only, so the redirect is skipped and nothing was written for it", - dep.name, dep.version - ), - }); } withhold(overrides, &refused) } From 041dc48fd8d0deef57c43122f06ef3a94d1a0071 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 05:01:09 +0000 Subject: [PATCH 4/6] Format the takeover test's hosted route Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/tests/mode_migration_pypi.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 66c4679cd..0180f576a 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -154,9 +154,8 @@ async fn mount_hosted_api_serving( ) -> String { let wheel = hosted_wheel(); let sha = hex::encode(Sha256::digest(&wheel)); - let route = format!( - "/patch/pypi/six/1.16.0/33333333-3333-4333-8333-333333333333/{UUID}/{wheel_name}" - ); + let route = + format!("/patch/pypi/six/1.16.0/33333333-3333-4333-8333-333333333333/{UUID}/{wheel_name}"); let hosted_url = format!("{}{route}", server.uri()); Mock::given(method("POST")) .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) From 3298ceef3dbbbdfe5f13a3505cfcdded0a486b41 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 18:24:21 +0000 Subject: [PATCH 5/6] Route Gradle digests through utils::digest main has failed socket-patch-core's lib tests since Gradle support (#646) and the digest helpers (#865) both landed. The guard test production_digests_go_through_the_helpers flags three files #646 added that still hash inline: crawlers/gradle_cache.rs, patch/jvm_jar.rs and patch/sidecars/maven.rs. That breaks test, test-release and coverage on every open PR. Each inline sha1/sha256 call now goes through sha1_hex_of or sha256_hex_of, which compute the same lowercase hex. Behaviour is unchanged. Assisted-by: Claude Code:claude-opus-5-5 (cherry picked from commit 659ac2c24e5c5904e743b4bc98ea4645da2ed6a1) --- crates/socket-patch-core/src/crawlers/gradle_cache.rs | 9 ++++----- crates/socket-patch-core/src/patch/jvm_jar.rs | 7 ++----- crates/socket-patch-core/src/patch/sidecars/maven.rs | 4 +--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } From 8725c55e234f33b7699674c4bb04e321ebc0ada2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 05:21:40 +0000 Subject: [PATCH 6/6] Check the Pipenv refusal exit code without VEX The new Pipenv platform-wheel test asserted exit 0 on a run that also asked for --vex. With nothing pinned, VEX correctly fails with manifest_not_found, so the run exits 1 and the coverage job failed. Assert the hosted refusal's exit 0 on a plain scan, then run --vex separately and check only that it attests nothing. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/in_process_redirect_pipenv.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index 310881880..ceeb2f111 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -522,9 +522,9 @@ async fn live_pipfile_lock_conflict_vetoes_the_requirements_redirect() { /// #932: a patch granted as a platform-tagged wheel (cp311 manylinux) is /// never pinned into the cross-platform Pipfile.lock, nor into the sibling -/// requirements.txt: the run leaves both files alone, exits 0 like every +/// requirements.txt: the run leaves both files alone and exits 0 like every /// hosted refusal (the `redirect_pypi_platform_wheel` warning says why), -/// and its same-run VEX never attests the unpinned patch. +/// and a same-run VEX never attests the unpinned patch. #[tokio::test] #[serial] async fn platform_wheel_is_not_pinned_into_the_lock() { @@ -539,14 +539,18 @@ async fn platform_wheel_is_not_pinned_into_the_lock() { write_project(tmp.path()); const REQS: &str = "urllib3==1.26.18\n"; std::fs::write(tmp.path().join("requirements.txt"), REQS).unwrap(); - let vex_path = tmp.path().join("out.vex.json"); - - let code = run(hosted_args(tmp.path(), server.uri(), Some(&vex_path))).await; + let code = run(hosted_args(tmp.path(), server.uri(), None)).await; assert_eq!(code, 0, "a hosted refusal exits 0 with a warning"); assert_eq!(read(&tmp.path().join("Pipfile.lock")), LOCK); assert_eq!(read(&tmp.path().join("requirements.txt")), REQS); assert_eq!(read(&tmp.path().join("Pipfile")), PIPFILE); assert_no_ledger(tmp.path()); + + // With nothing pinned, a same-run `--vex` has nothing to attest (it + // fails `manifest_not_found`) and never claims the patch. + let vex_path = tmp.path().join("out.vex.json"); + run(hosted_args(tmp.path(), server.uri(), Some(&vex_path))).await; + assert_eq!(read(&tmp.path().join("Pipfile.lock")), LOCK); if vex_path.exists() { let vex = read(&vex_path); assert!(!vex.contains("not_affected"), "{vex}");