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/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/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index 3c33af6d0..ceeb2f111 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,43 @@ 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 and exits 0 like every +/// hosted refusal (the `redirect_pypi_platform_wheel` warning says why), +/// and a 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 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}"); + } +} + /// 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-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index b8c92adbe..0180f576a 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -143,10 +143,19 @@ 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}"); + 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 +808,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 +848,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 +940,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/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/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..879898414 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,64 @@ 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`). +/// 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 refused.contains(&dep.patch_uuid) { + continue; + } + if let Some(warning) = pypi_platform_wheel_refusal(dep) { + refused.insert(dep.patch_uuid.clone()); + result.warnings.push(warning); + } + } + 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 +586,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/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)), } } 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::*;