diff --git a/crates/socket-patch-core/src/utils/pdm_lock.rs b/crates/socket-patch-core/src/utils/pdm_lock.rs index f063d8448..db6526f41 100644 --- a/crates/socket-patch-core/src/utils/pdm_lock.rs +++ b/crates/socket-patch-core/src/utils/pdm_lock.rs @@ -114,6 +114,16 @@ pub fn legacy_files_key(package: &Table) -> Option { Some(format!("{name}{suffix} {version}")) } +/// The package a `[metadata.files]` key is for: [`legacy_files_key`] read +/// back (` `, or `[] ` for a unit with +/// extras). A key that is a bare name, as Poetry's are, is its own answer. +pub fn files_key_package(key: &str) -> &str { + let unit = key.split_whitespace().next().unwrap_or_default(); + unit.strip_suffix(']') + .and_then(|open| open.split_once('[')) + .map_or(unit, |(name, _)| name) +} + pub fn files_for<'a>(lock: &'a Table, package: &'a Table) -> Option<&'a Array> { package.get("files").and_then(Item::as_array).or_else(|| { lock.get("metadata")? @@ -497,7 +507,20 @@ pub(crate) mod tests { const WHEEL: &str = "urllib3-1.26.18-py2.py3-none-any.whl"; const PATH: &str = "./.socket/vendor/pypi/e828efa5-5c6d-43f3-9909-03f5ac232b98/urllib3-1.26.18-py2.py3-none-any.whl"; - fn fixture(name: &str) -> String { + /// The native locks the rewriter takes (tests/fixtures/pdm-native). + pub(crate) const REWRITABLE: [&str; 9] = [ + "0.12.3", + "2.8.2", + "2.9.3", + "2.10.4", + "2.11.2", + "2.17.3", + "2.29.2", + "0.12.3-extras", + "2.29.2-extras", + ]; + + pub(crate) fn fixture(name: &str) -> String { std::fs::read_to_string( std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/pdm-native") @@ -508,17 +531,7 @@ pub(crate) mod tests { #[test] fn native_formats_rewrite_and_reverse_byte_exactly() { - for version in [ - "0.12.3", - "2.8.2", - "2.9.3", - "2.10.4", - "2.11.2", - "2.17.3", - "2.29.2", - "0.12.3-extras", - "2.29.2-extras", - ] { + for version in REWRITABLE { for ending in ["\n", "\r\n"] { let original = fixture(version).replace('\n', ending); for (kind, source) in [("path", PATH), ("path", &PATH.replace('/', "\\")), ("url", "https://patch.socket.dev/patch/pypi/urllib3/1.26.18/7e52b8b6-53f2-4dc8-860a-1ae7ebd8be0e/e828efa5-5c6d-43f3-9909-03f5ac232b98/urllib3-1.26.18-py2.py3-none-any.whl")] { @@ -545,6 +558,36 @@ pub(crate) mod tests { } } + /// The key the writer builds for a unit reads back as the unit's + /// package, with extras or without: the revert's confinement check + /// depends on it. + #[test] + fn files_key_package_reads_back_every_legacy_key() { + for version in ["0.12.3", "0.12.3-extras"] { + let lock: DocumentMut = fixture(version).parse().unwrap(); + let packages = lock["package"].as_array_of_tables().unwrap(); + assert!(!packages.is_empty(), "{version}"); + for package in packages { + let key = legacy_files_key(package).unwrap(); + assert!(lock["metadata"]["files"].get(&key).is_some(), "{key}"); + assert_eq!( + files_key_package(&key), + pdm_files_key_name(package["name"].as_str().unwrap()), + "{key}" + ); + } + } + for (key, package) in [ + ("six", "six"), + ("zope.interface 5.0", "zope.interface"), + ("urllib3[brotli,socks] 1.26.18", "urllib3"), + ("odd] 1.0", "odd]"), + ("", ""), + ] { + assert_eq!(files_key_package(key), package, "{key:?}"); + } + } + #[test] fn native_installers_that_lose_source_identity_refuse() { for version in ["1.15.5", "2.0.3", "2.1.5", "2.3.4", "2.6.1", "2.7.4"] { diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 531158e95..9e7acffb0 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -1209,7 +1209,8 @@ fn toml_json(item: &Item) -> serde_json::Value { /// of the key `parent`: array elements that name the package (a /// requirement string, a `{ name = … }` entry, a lock unit), its key in a /// `sources` table or in a lock's `files` / `hashes` table (Poetry's and -/// PDM's older formats, the latter keyed ` `), the Hatch +/// PDM's older formats, the latter keyed ` `, or +/// `[] ` for a unit with extras), the Hatch /// direct-reference permission, and then every table and array left /// empty. fn drop_package(value: &mut serde_json::Value, parent: &str, canon: &str) { @@ -1217,11 +1218,12 @@ fn drop_package(value: &mut serde_json::Value, parent: &str, canon: &str) { let names = |name: &str| canonicalize_pypi_name(name) == canon; match value { Json::Object(map) => { - let keyed = matches!(parent, "sources" | "files" | "hashes"); - map.retain(|key, _| { - key != "allow-direct-references" - && !(keyed && names(key.split_whitespace().next().unwrap_or_default())) - }); + let own = |key: &str| match parent { + "sources" => names(key.split_whitespace().next().unwrap_or_default()), + "files" | "hashes" => names(crate::utils::pdm_lock::files_key_package(key)), + _ => false, + }; + map.retain(|key, _| key != "allow-direct-references" && !own(key)); for (key, child) in map.iter_mut() { drop_package(child, key, canon); } @@ -3676,6 +3678,47 @@ mod tests { } } + /// A revert of what the PDM writer wrote is the package's own in every + /// native lock format. PDM 0.x keys an extras unit's files + /// `[] `: read as another package's key, it made + /// `rollback` refuse to undo its own vendoring of a dependency declared + /// with extras. + #[test] + fn confinement_allows_a_pdm_revert_in_every_native_format() { + use crate::utils::pdm_lock::tests::{fixture as native_lock, REWRITABLE}; + const WHEEL: &str = "urllib3-1.26.18-py2.py3-none-any.whl"; + for fixture in REWRITABLE { + let original = native_lock(fixture); + let vendored = crate::utils::pdm_lock::rewrite_pdm_lock( + &original, + "urllib3", + "1.26.18", + ("path", &format!("./.socket/vendor/pypi/u/{WHEEL}")), + WHEEL, + &"a".repeat(64), + ) + .unwrap(); + assert_ne!(vendored, original, "{fixture}"); + assert_eq!( + confinement_refusal(&vendored, &original, "urllib3"), + None, + "{fixture}" + ); + } + + // Another package's extras key is still not the package's own, and + // a `sources` key is a name: nothing is read off the end of one. + let wired = "[metadata]\ncontent_hash = \"h\"\n\n[metadata.files]\n\"six[socks] 1.16.0\" = [{ file = \"six.whl\", hash = \"sha256:cc\" }]\n\"idna[socks] 3.7\" = [{ file = \"idna.whl\", hash = \"sha256:dd\" }]\n"; + let restored = wired.replace("sha256:cc", "sha256:bb"); + assert_eq!(confinement_refusal(wired, &restored, "six"), None); + assert!( + confinement_refusal(wired, &restored.replace("sha256:dd", "sha256:ee"), "six") + .is_some() + ); + let sourced = "[tool.uv.sources]\n\"six[socks]\" = { path = \"a\" }\n"; + assert!(confinement_refusal(sourced, &sourced.replace("\"a\"", "\"b\""), "six").is_some()); + } + /// ...and what it may not: another package, a setting, or anything in a /// file it cannot read as TOML. #[test] diff --git a/crates/socket-patch-core/src/vendor/pypi_pdm.rs b/crates/socket-patch-core/src/vendor/pypi_pdm.rs index 76e90a797..8c6f6309d 100644 --- a/crates/socket-patch-core/src/vendor/pypi_pdm.rs +++ b/crates/socket-patch-core/src/vendor/pypi_pdm.rs @@ -1328,6 +1328,66 @@ distribution = false } } + /// PDM 0.x keeps an extras unit's file hashes under a key of its own, + /// `[] `. Vendoring rewrites both keys and the + /// revert takes both back (5.1.0 refused it as a change to another + /// package's entry). + #[tokio::test] + async fn revert_round_trip_restores_a_legacy_lock_with_extras() { + const WHEEL: &str = "urllib3-1.26.18-py2.py3-none-any.whl"; + let rel_wheel = format!(".socket/vendor/pypi/{UUID}/{WHEEL}"); + let before = crate::utils::pdm_lock::tests::fixture("0.12.3-extras"); + let tmp = write_project( + &before, + "[tool.pdm]\nname = \"legacy-extras\"\nversion = \"0.0.0\"\npython_requires = \">=3.8\"\n\n[tool.pdm.dependencies]\nurllib3 = {version = \"==1.26.18\", extras = [\"socks\"]}\n", + ) + .await; + let p = load_pdm_project(tmp.path()).await.unwrap(); + let (wiring, meta) = wire_pdm( + &p, + tmp.path(), + "urllib3", + "1.26.18", + &rel_wheel, + WHEEL, + WHEEL_SHA, + UUID, + &[WHEEL_SHA], + ) + .await + .unwrap(); + let vendored = read_lock(tmp.path()).await; + assert_eq!(vendored.matches(&rel_wheel).count(), 2, "{vendored}"); + assert_eq!( + vendored.matches(&format!("sha256:{WHEEL_SHA}")).count(), + 2, + "{vendored}" + ); + let entry = VendorEntry { + flavor: Some("pdm".into()), + pdm: Some(meta), + ..VendorEntry::new( + "pypi".into(), + "pkg:pypi/urllib3@1.26.18".into(), + UUID.into(), + VendorArtifact { + yarn_berry10c0: None, + path: rel_wheel.clone(), + sha256: WHEEL_SHA.into(), + size: Some(11053), + platform_locked: None, + file_inventory: None, + }, + wiring, + ) + }; + + let outcome = revert_pdm(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(read_lock(tmp.path()).await, before, "byte-identical revert"); + } + /// The lock file is user-owned: wiring the splice must not reset its /// permission bits (see `atomic_write_bytes_preserving_mode`; the revert /// leg is covered in common.rs).