Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
48 changes: 25 additions & 23 deletions crates/socket-patch-core/src/vendor/lock_inventory/recover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -451,29 +454,28 @@ pub(super) fn inline_yaml_field(line: &str, field: &str) -> Option<String> {
(!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?))
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
11 changes: 11 additions & 0 deletions crates/socket-patch-core/src/vendor/pypi_distribution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading