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
39 changes: 25 additions & 14 deletions crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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?))
Expand Down Expand Up @@ -358,10 +364,11 @@ pub(super) fn python_lock_inventory(text: &str) -> Option<Vec<LockfileEntry>> {
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,
Expand All @@ -377,7 +384,7 @@ pub(super) fn python_lock_inventory(text: &str) -> Option<Vec<LockfileEntry>> {

/// 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
Expand All @@ -390,7 +397,7 @@ async fn inventory_poetry_lock(view: &ProjectView<'_>) -> Option<Vec<LockfileEnt
let pure_wheel_sha = |files: Vec<&dyn TableLike>| {
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()?)
Expand Down Expand Up @@ -753,3 +760,7 @@ async fn requirements_tree(view: &ProjectView<'_>) -> Option<Vec<String>> {
}
Some(files)
}

#[cfg(test)]
#[path = "pypi_wheel_tests.rs"]
mod wheel_tests;
101 changes: 101 additions & 0 deletions crates/socket-patch-core/src/vendor/lock_inventory/pypi_wheel_tests.rs
Original file line number Diff line number Diff line change
@@ -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}"
);
}
}
23 changes: 5 additions & 18 deletions crates/socket-patch-core/src/vendor/lock_inventory/recover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────
Expand Down Expand Up @@ -456,26 +454,15 @@ pub(super) fn inline_yaml_field(line: &str, field: &str) -> Option<String> {

/// 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();
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;
}
Some((http_url(url)?, artifact.sha256?))
})
portable_wheel_artifact(package, &["archive", "wheels", "wheel", "files"])
}
Loading