diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4f8067ea..cce660612 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1397,7 +1397,7 @@ jobs: # The composer capstones shell out to a real composer; `composer:` # pins the release line (1, 2.2 LTS, 2) so the composer.lock grammar # the edits assert stays stable across runners. - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' tools: composer:${{ matrix.composer }} 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 55933228e..bf72f1ec3 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs @@ -4,11 +4,14 @@ use std::path::Path; use serde_json::Value; +use toml_edit::{DocumentMut, Item, TableLike}; 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; @@ -451,29 +454,28 @@ pub(super) fn inline_yaml_field(line: &str, field: &str) -> Option { (!v.is_empty()).then_some(v) } -/// First `{ url = "…", hash = "sha256:…" }` wheel in a uv.lock `[[package]]` -/// unit whose filename is a PURE wheel (`-none-any.whl`). +/// 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. pub(super) fn pure_wheel_from_uv_unit(unit: &str) -> Option<(String, String)> { - let mut search = unit; - while let Some(uidx) = search.find("url = \"") { - let after = &search[uidx + 7..]; - let uend = after.find('"')?; - let url = &after[..uend]; - let rest = &after[uend..]; - let advance = uidx + 7 + uend; - if url.ends_with("-none-any.whl") { - if let Some(hidx) = rest.find("hash = \"sha256:") { - let hafter = &rest[hidx + 15..]; - let hend = hafter.find('"')?; - let sha = &hafter[..hend]; - if is_hex(sha, 64) { - if let Some(url) = http_url(url) { - return Some((url, sha.to_ascii_lowercase())); - } - } + let document: DocumentMut = unit.parse().ok()?; + let root = document.as_table(); + let package: &dyn TableLike = match root.get("package").and_then(Item::as_array_of_tables) { + 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; } - } - search = &search[advance..]; - } - None + Some((http_url(url)?, artifact.sha256?)) + }) } diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs index 04ed9056d..ebe3cad9d 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/recover_tests.rs @@ -661,3 +661,91 @@ async fn recover_present_but_invalid_npm_fragments_fail_closed() { "every invalid fragment must fall through to the fail-closed error: {err}" ); } + +/// #1079 repro: a uv unit whose pure wheel carries no hash, followed by a +/// hashed platform wheel. The old string scanner paired the pure wheel's +/// URL with the NEXT wheel's digest; recovery must instead report that no +/// hash-pinned pure wheel exists. +#[tokio::test] +async fn recover_uv_never_pairs_a_pure_wheel_with_another_wheels_hash() { + let tmp = tempfile::tempdir().unwrap(); + let unit = format!( + "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nsource = {{ registry = \"https://pypi.org/simple\" }}\nwheels = [\n {{ url = \"https://files.example/six-1.16.0-py3-none-any.whl\" }},\n {{ url = \"https://files.example/six-1.16.0-cp312-cp312-manylinux_2_17_x86_64.whl\", hash = \"sha256:{}\" }},\n]\n", + "b".repeat(64) + ); + assert_eq!(pure_wheel_from_uv_unit(&unit), None); + let uv = entry( + "pypi", + "pkg:pypi/six@1.16.0", + vec![rec("uv_lock_package", serde_json::json!(unit))], + ); + let err = recover_lock_entry(tmp.path(), &uv).await.unwrap_err(); + assert!(err.contains("no fetchable registry URL"), "{err}"); +} + +/// Recovery's pure-wheel pick agrees with the shared classifier that +/// vendored (`vendor_platform_locked`) and hosted +/// (`redirect_pypi_platform_wheel`) mode use, on every wheel tag where the +/// old `-none-any.whl` suffix rule disagreed with it or could misread the +/// URL. Each row runs through a uv `wheels` unit and a pdm `files` unit. +#[test] +fn recovery_pure_wheel_matches_the_shared_portability_rule() { + let sha = "a".repeat(64); + let rows: &[(&str, bool)] = &[ + ("https://h/x-1.0-py3-none-any.whl", true), + ("https://h/x-1.0-py2.py3-none-any.whl", true), + ("https://h/x-1.0-py310-none-any.whl", true), + ("https://h/x-1.0-1-py3-none-any.whl", true), + // Interpreter-bound / Python 2-only: non-portable since #1048. + ("https://h/x-1.0-cp311-none-any.whl", false), + ("https://h/x-1.0-pp310-none-any.whl", false), + ("https://h/x-1.0-py2-none-any.whl", false), + ("https://h/x-1.0-cp38-abi3-manylinux_2_17_x86_64.whl", false), + // Query / fragment are stripped before classifying; the recorded + // URL is kept whole. + ("https://h/x-1.0-py3-none-any.whl#sha256=abc", true), + ("https://h/x-1.0-py3-none-any.whl?raw=1", true), + ("https://h/x-1.0.tar.gz", false), + ]; + for (url, portable) in rows { + let file = url + .split(['?', '#']) + .next() + .unwrap() + .rsplit('/') + .next() + .unwrap(); + let shared = file.ends_with(".whl") + && !crate::vendor::pypi_distribution::wheel_platform_from_filename(file).0; + assert_eq!(shared, *portable, "shared classifier on {url}"); + for key in ["wheels", "files"] { + let unit = format!( + "[[package]]\nname = \"x\"\nversion = \"1.0\"\n{key} = [\n {{ url = \"{url}\", hash = \"sha256:{sha}\" }},\n]\n" + ); + let want = portable.then(|| (url.to_string(), sha.clone())); + assert_eq!(pure_wheel_from_uv_unit(&unit), want, "{key}: {url}"); + } + } +} + +/// Every artifact keeps its own hash: the first portable PINNED wheel +/// wins even when an unpinned portable wheel comes first, an uppercase +/// digest is lowercased, and a unit that is not TOML (a bare +/// `requirements_line`) recovers nothing. +#[test] +fn recovery_pure_wheel_pairs_each_url_with_its_own_hash() { + let upper = "C".repeat(64); + let unit = format!( + "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nsdist = {{ url = \"https://h/six-1.16.0.tar.gz\", hash = \"sha256:{}\" }}\nwheels = [\n {{ url = \"https://h/six-1.16.0-py3-none-any.whl\" }},\n {{ url = \"https://h/six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:{upper}\" }},\n]\n\n[package.metadata]\nrequires-dist = [{{ name = \"a\" }}]\n", + "d".repeat(64) + ); + assert_eq!( + pure_wheel_from_uv_unit(&unit), + Some(( + "https://h/six-1.16.0-py2.py3-none-any.whl".to_string(), + "c".repeat(64) + )) + ); + let line = format!("six==1.16.0 --hash=sha256:{}", "a".repeat(64)); + assert_eq!(pure_wheel_from_uv_unit(&line), None); +} diff --git a/crates/socket-patch-core/src/vendor/pypi_distribution.rs b/crates/socket-patch-core/src/vendor/pypi_distribution.rs index 12248196e..2fa651fea 100644 --- a/crates/socket-patch-core/src/vendor/pypi_distribution.rs +++ b/crates/socket-patch-core/src/vendor/pypi_distribution.rs @@ -108,6 +108,17 @@ pub(crate) fn wheel_platform_from_filename(wheel_name: &str) -> (bool, String) { } } +/// Whether a lock artifact URL (or bare filename) names a wheel every +/// Python 3 interpreter on every platform installs: `?query` / `#fragment` +/// stripped, the last path segment a `.whl` that +/// [`wheel_platform_from_filename`] calls portable. Ledger recovery's pick +/// of a "pure" wheel, so it agrees with vendored and hosted mode. +pub(crate) fn is_portable_wheel_url(url: &str) -> bool { + let path = url.split(['?', '#']).next().unwrap_or(url); + let file = path.rsplit('/').next().unwrap_or(path); + file.ends_with(".whl") && !wheel_platform_from_filename(file).0 +} + /// Platform-specific unless every Python 3 interpreter on every platform /// installs the wheel: the ABI must be `none`, the platform `any`, and the /// python tag set must hold a generic Python 3 tag. pip accepts `py3` and