diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs b/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs index d21b6a87b..a159cdebc 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs @@ -14,6 +14,7 @@ use crate::utils::python_lock::{lock_package_collection, package_artifacts, UvSo use crate::utils::requirements::archive_filename_coords; use crate::utils::digest::{sha256_hex, sha256_prefixed}; +use crate::vendor::pypi_distribution::is_portable_wheel_url; use super::view::ProjectView; use super::{dedup_prefer_integrity, http_url, LockIntegrity, LockfileEntry, SourceKind}; @@ -205,7 +206,7 @@ pub(crate) fn replaceable_hosted_pin( } /// Inventory the pypi lock the project carries. Fetchable resolution -/// (URL + sha256 of a pure `-none-any` wheel) comes from `uv.lock` and +/// (URL + sha256 of a portable wheel) comes from `uv.lock` and /// PEP 751 / PEP 723 script locks; `poetry.lock` entries carry the pure /// wheel's sha256 when the lock lists one (resolved through PyPI's JSON API /// at fetch time), else stay discovery-only; exact `==` `requirements.txt` @@ -301,16 +302,21 @@ pub(super) async fn inventory_pypi_locks_raw_in( found.then_some(out) } -/// The first fetchable pure-Python wheel of a lock package — `archive`, -/// then `wheels[]` / `wheel` (read with the shared lock model, -/// [`crate::utils::python_lock::package_artifacts`]): an http(s) url ending -/// `-none-any.whl` with a sha256 pin, as `(url, sha256)`. -fn python_package_archive(package: &dyn TableLike) -> Option<(String, String)> { - package_artifacts(package, &["archive", "wheels", "wheel"]) +/// The first hash-pinned, portable, http(s) wheel among a lock package's +/// `keys` artifacts (read with the shared lock model, [`package_artifacts`]), +/// as `(url, sha256)`. Each url is paired with **that artifact's** hash, and +/// portability is the shared [`is_portable_wheel_url`] rule vendored and +/// hosted mode use. The one pure-wheel pick of the uv / PEP 751 inventory +/// and of ledger recovery. +pub(super) fn portable_wheel_artifact( + package: &dyn TableLike, + keys: &[&str], +) -> Option<(String, String)> { + package_artifacts(package, keys) .into_iter() .find_map(|artifact| { let url = artifact.url?; - if !url.split(['?', '#']).next()?.ends_with("-none-any.whl") { + if !is_portable_wheel_url(url) { return None; } Some((http_url(url)?, artifact.sha256?)) @@ -358,10 +364,11 @@ pub(super) fn python_lock_inventory(text: &str) -> Option> { if !remote { continue; } - let (resolved, integrity) = match python_package_archive(package) { - Some((url, sha)) => (Some(url), LockIntegrity::Sha256Hex(sha)), - None => (None, LockIntegrity::None), - }; + let (resolved, integrity) = + match portable_wheel_artifact(package, &["archive", "wheels", "wheel"]) { + Some((url, sha)) => (Some(url), LockIntegrity::Sha256Hex(sha)), + None => (None, LockIntegrity::None), + }; out.push(LockfileEntry { ecosystem: "pypi", source_kind: SourceKind::Unspecified, @@ -377,7 +384,7 @@ pub(super) fn python_lock_inventory(text: &str) -> Option> { /// poetry.lock: `[[package]]` tables with `name`/`version`. The lock records /// file hashes but no URLs and no platform choice, so an entry carries the -/// sha256 of the package's pure-Python (`-none-any.whl`) wheel when the lock +/// sha256 of the package's portable wheel ([`is_portable_wheel_url`]) when the lock /// lists one — its own `files = [...]` (lock 2.x) or its `[metadata.files]` /// entry (lock 1.0/1.1), read through the shared poetry lock helpers — and /// the pypi fetcher then resolves the matching file through PyPI's JSON @@ -390,7 +397,7 @@ async fn inventory_poetry_lock(view: &ProjectView<'_>) -> Option| { files.into_iter().find_map(|entry| { let file = entry.get("file")?.as_str()?; - if !file.ends_with("-none-any.whl") { + if !is_portable_wheel_url(file) { return None; } sha256_prefixed(entry.get("hash")?.as_str()?) @@ -753,3 +760,7 @@ async fn requirements_tree(view: &ProjectView<'_>) -> Option> { } Some(files) } + +#[cfg(test)] +#[path = "pypi_wheel_tests.rs"] +mod wheel_tests; diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/pypi_wheel_tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/pypi_wheel_tests.rs new file mode 100644 index 000000000..bc8226771 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/lock_inventory/pypi_wheel_tests.rs @@ -0,0 +1,101 @@ +//! Every lock reader that picks "the pure wheel" of a pypi package (uv / +//! PEP 751 inventory, poetry inventory, ledger recovery) goes through the +//! one shared portability rule, so they agree with vendored and hosted +//! mode on the wheels where the old `-none-any.whl` suffix check did not +//! (#1048: interpreter-bound and Python-2-only tags). + +use super::*; +use crate::vendor::lock_inventory::recover::pure_wheel_from_uv_unit; +use crate::vendor::pypi_distribution::wheel_platform_from_filename; + +const SHA: &str = "abababababababababababababababababababababababababababababababab"; + +/// `(wheel filename, portable)`: the cases where a suffix check and the +/// shared tag classifier used to differ, plus the ones they always agreed on. +const WHEELS: &[(&str, bool)] = &[ + ("six-1.16.0-py2.py3-none-any.whl", true), + ("six-1.16.0-py3-none-any.whl", true), + ("six-1.16.0-py38-none-any.whl", true), + ("six-1.16.0-cp311-none-any.whl", false), + ("six-1.16.0-pp310-none-any.whl", false), + ("six-1.16.0-py2-none-any.whl", false), + ("six-1.16.0-cp312-abi3-any.whl", false), + ("six-1.16.0-cp312-cp312-manylinux_2_17_x86_64.whl", false), +]; + +fn uv_unit(file: &str) -> String { + format!( + "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\n\ + source = {{ registry = \"https://pypi.org/simple\" }}\n\ + wheels = [{{ url = \"https://files.pythonhosted.org/packages/aa/{file}\", hash = \"sha256:{SHA}\" }}]\n" + ) +} + +#[test] +fn the_table_matches_the_shared_classifier() { + for &(file, portable) in WHEELS { + assert_eq!(!wheel_platform_from_filename(file).0, portable, "{file}"); + } +} + +#[test] +fn uv_inventory_and_ledger_recovery_pick_the_same_wheels() { + for &(file, portable) in WHEELS { + let unit = uv_unit(file); + let entries = python_lock_inventory(&format!("version = 1\n\n{unit}")).unwrap(); + let [six] = entries.as_slice() else { + panic!("{file}: {entries:?}") + }; + let expected = + portable.then(|| format!("https://files.pythonhosted.org/packages/aa/{file}")); + assert_eq!(six.resolved, expected, "inventory, {file}"); + assert_eq!( + six.integrity, + if portable { + LockIntegrity::Sha256Hex(SHA.into()) + } else { + LockIntegrity::None + }, + "inventory, {file}" + ); + assert_eq!( + pure_wheel_from_uv_unit(&unit).map(|(url, _)| url), + expected, + "recovery, {file}" + ); + } +} + +#[test] +fn uv_inventory_reads_the_wheel_name_before_a_query_or_fragment() { + for suffix in ["?download=1", "#sha256=00"] { + let unit = uv_unit(&format!("six-1.16.0-py3-none-any.whl{suffix}")); + let entries = python_lock_inventory(&format!("version = 1\n\n{unit}")).unwrap(); + assert!(entries[0].resolved.is_some(), "{suffix}: {entries:?}"); + assert!(pure_wheel_from_uv_unit(&unit).is_some(), "{suffix}"); + } +} + +#[tokio::test] +async fn poetry_inventory_pins_only_a_portable_wheel() { + for &(file, portable) in WHEELS { + let lock = format!( + "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nfiles = [\n {{file = \"{file}\", hash = \"sha256:{SHA}\"}},\n]\n\n[metadata]\nlock-version = \"2.1\"\n" + ); + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("poetry.lock"), lock) + .await + .unwrap(); + let entries = inventory_pypi_locks(tmp.path()).await.unwrap(); + let six = entries.iter().find(|e| e.name == "six").unwrap(); + assert_eq!( + six.integrity, + if portable { + LockIntegrity::Sha256Hex(SHA.into()) + } else { + LockIntegrity::None + }, + "poetry, {file}" + ); + } +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs b/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs index bf72f1ec3..5806fa995 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs @@ -10,11 +10,9 @@ use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::formats::composer::ComposerLockPackage; use crate::utils::digest::{is_hex, is_sri_pin, sha256_hex}; use crate::utils::purl::percent_decode_purl_component; -use crate::utils::python_lock::package_artifacts; -use crate::vendor::pypi_distribution::is_portable_wheel_url; use super::gem::{gem_download_url, gem_remotes}; -use super::pypi::python_lock_inventory; +use super::pypi::{portable_wheel_artifact, python_lock_inventory}; use super::{http_url, LockIntegrity, LockfileEntry, SourceKind}; // ──────────────── registry-fragment recovery from the ledger ──────────────── @@ -456,12 +454,9 @@ pub(super) fn inline_yaml_field(line: &str, field: &str) -> Option { /// The first hash-pinned, portable, http(s) wheel of a recorded uv / pdm /// `[[package]]` unit (or a bare artifact-array fragment), as -/// `(url, sha256)`. The unit is read as TOML through the shared lock model -/// ([`package_artifacts`]), so each artifact's url is paired with **that -/// artifact's** hash (#1079), and portability is the shared -/// [`is_portable_wheel_url`] rule vendored and hosted mode use. Anything -/// unparseable, unpinned or platform-bound yields `None`: fail-closed, -/// never a guessed pairing. +/// `(url, sha256)`, through the inventory's own pick +/// ([`portable_wheel_artifact`], #1079). Anything unparseable, unpinned or +/// platform-bound yields `None`: fail-closed, never a guessed pairing. pub(super) fn pure_wheel_from_uv_unit(unit: &str) -> Option<(String, String)> { let document: DocumentMut = unit.parse().ok()?; let root = document.as_table(); @@ -469,13 +464,5 @@ pub(super) fn pure_wheel_from_uv_unit(unit: &str) -> Option<(String, String)> { Some(units) => units.get(0)?, None => root, }; - package_artifacts(package, &["archive", "wheels", "wheel", "files"]) - .into_iter() - .find_map(|artifact| { - let url = artifact.url?; - if !is_portable_wheel_url(url) { - return None; - } - Some((http_url(url)?, artifact.sha256?)) - }) + portable_wheel_artifact(package, &["archive", "wheels", "wheel", "files"]) }