diff --git a/crates/socket-patch-cli/tests/e2e_vex.rs b/crates/socket-patch-cli/tests/e2e_vex.rs index f8ba60bc8..26e39dd70 100644 --- a/crates/socket-patch-cli/tests/e2e_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_vex.rs @@ -645,6 +645,53 @@ fn auto_detect_pyproject_uses_canonical_pypi_name() { ); } +/// #1589: the product version is the PEP 440 normalized one that uv.lock, +/// the built wheel and SBOMs record, through `[project]` and +/// `[tool.poetry]` alike. +#[test] +fn auto_detect_pyproject_normalizes_pep440_version() { + for (pyproject, product) in [ + ( + "[project]\nname = \"My_Service\"\nversion = \"1.0.0-RC1\"\n", + "pkg:pypi/my-service@1.0.0rc1", + ), + ( + "[tool.poetry]\nname = \"demo-ver\"\nversion = \"1.0.0-beta.1\"\n\n[build-system]\nbuild-backend = \"poetry.core.masonry.api\"\n", + "pkg:pypi/demo-ver@1.0.0b1", + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + std::fs::write(cwd.join("pyproject.toml"), pyproject).unwrap(); + + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + "pkg:pypi/six@1.16.0".to_string(), + make_record( + "11111111-1111-4111-8111-111111111111", + "six.py", + "a".repeat(64).as_str(), + "b".repeat(64).as_str(), + "GHSA-z", + &["CVE-Z"], + ), + ); + write_manifest(cwd, &manifest); + + let out = cli() + .args(["vex", "--cwd", cwd.to_str().unwrap(), "--no-verify"]) + .output() + .expect("invoke vex"); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let doc: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(doc["statements"][0]["products"][0]["@id"], product); + } +} + // ────────────────────────────────────────────────────────────────────── // empty overrides mean "unset" // diff --git a/crates/socket-patch-core/src/utils/pep440.rs b/crates/socket-patch-core/src/utils/pep440.rs index 641e602c9..100e38a4f 100644 --- a/crates/socket-patch-core/src/utils/pep440.rs +++ b/crates/socket-patch-core/src/utils/pep440.rs @@ -1,7 +1,8 @@ //! PEP 440 version equality — the comparison pip, uv and Hatch apply to a -//! hand-written `name==X` pin. +//! hand-written `name==X` pin — and the normalized spelling +//! (`normalize`) that uv.lock, wheel METADATA and PyPI record. //! -//! Only equality is implemented: the writers that rewrite a pin need to know +//! No ordering is implemented: the writers that rewrite a pin need to know //! whether `==X` selects exactly the release being patched, and under PEP 440 //! that ignores case, leading zeros, trailing `.0` release segments and the //! alternate pre/post/dev spellings (`1.16` == `1.16.0` == `01.16.0.0`, @@ -40,11 +41,11 @@ enum LocalPart { Text(String), } -/// A parsed version in the form PEP 440 equality compares. +/// A parsed version with every number's leading zeros stripped. #[derive(Debug, PartialEq, Eq)] struct Version { epoch: String, - /// Release segments with trailing zeros removed (`1.16.0` → `[1, 16]`). + /// Release segments as written (`01.16.0` → `[1, 16, 0]`). release: Vec, pre: Option<(&'static str, String)>, post: Option, @@ -55,10 +56,7 @@ struct Version { fn parse(text: &str) -> Option { let captures = VERSION.captures(text)?; let digits = |name: &str| captures.name(name).map(|m| number(m.as_str())); - let mut release: Vec = captures["release"].split('.').map(number).collect(); - while release.len() > 1 && release.last().is_some_and(|segment| segment == "0") { - release.pop(); - } + let release = captures["release"].split('.').map(number).collect(); let pre = captures.name("pre_l").map(|label| { let label = match label.as_str().to_ascii_lowercase().as_str() { "a" | "alpha" => "a", @@ -85,7 +83,9 @@ fn parse(text: &str) -> Option { if part.bytes().all(|byte| byte.is_ascii_digit()) { LocalPart::Number(number(part)) } else { - LocalPart::Text(part.to_ascii_lowercase()) + // `str.lower()`, as `packaging` does: `(?i)` lets + // `[a-z]` match U+212A KELVIN SIGN, which lowers to `k`. + LocalPart::Text(part.to_lowercase()) } }) .collect() @@ -105,7 +105,17 @@ fn parse(text: &str) -> Option { /// version on either side is never equal to anything: callers fail closed. pub(crate) fn versions_equal(a: &str, b: &str) -> bool { match (parse(a), parse(b)) { - (Some(a), Some(b)) => a == b, + (Some(mut a), Some(mut b)) => { + // Equality ignores trailing `.0` release segments. + for version in [&mut a, &mut b] { + while version.release.len() > 1 + && version.release.last().is_some_and(|segment| segment == "0") + { + version.release.pop(); + } + } + a == b + } _ => false, } } @@ -115,6 +125,47 @@ pub(crate) fn is_valid(text: &str) -> bool { parse(text).is_some() } +/// `text` in PEP 440's normalized form, as `str(packaging.version.Version)` +/// prints it and as uv.lock, wheel METADATA and PyPI record it: no leading +/// `v` or surrounding whitespace, a zero epoch dropped, leading zeros +/// dropped from every number (trailing `.0` release segments kept), the +/// canonical `a`/`b`/`rc`/`.post`/`.dev` spellings and a lowercased, +/// dot-separated local label (`1.0.0-RC1` → `1.0.0rc1`, `v01.02` → `1.2`, +/// `1.0+Local-01` → `1.0+local.1`). `None` when `text` is not valid PEP 440. +pub(crate) fn normalize(text: &str) -> Option { + let parsed = parse(text)?; + let mut out = String::new(); + if parsed.epoch != "0" { + out.push_str(&parsed.epoch); + out.push('!'); + } + out.push_str(&parsed.release.join(".")); + if let Some((label, n)) = &parsed.pre { + out.push_str(label); + out.push_str(n); + } + if let Some(n) = &parsed.post { + out.push_str(".post"); + out.push_str(n); + } + if let Some(n) = &parsed.dev { + out.push_str(".dev"); + out.push_str(n); + } + if !parsed.local.is_empty() { + let local: Vec<&str> = parsed + .local + .iter() + .map(|part| match part { + LocalPart::Number(n) | LocalPart::Text(n) => n.as_str(), + }) + .collect(); + out.push('+'); + out.push_str(&local.join(".")); + } + Some(out) +} + /// Whether a whitespace-free specifier is an exact `==X` pin of `version` /// under PEP 440. Wildcards (`==1.*`), arbitrary equality (`===`) and every /// other operator are not. @@ -234,6 +285,50 @@ mod tests { } } + /// What `str(packaging.version.Version(v))` prints, which is what + /// uv.lock, wheel METADATA and PyPI record (#1589). + #[test] + fn normalize_matches_packaging() { + for (raw, normalized) in [ + ("1.0.0-RC1", "1.0.0rc1"), + ("1.0.0RC1", "1.0.0rc1"), + ("v1.2", "1.2"), + ("V1.2", "1.2"), + ("01.02.0", "1.2.0"), + ("2026.01.05", "2026.1.5"), + ("1.0.0+Local.1", "1.0.0+local.1"), + ("1.0.0+Local.Build", "1.0.0+local.build"), + ("1.0+Ubuntu-01", "1.0+ubuntu.1"), + ("1.0+abc.007", "1.0+abc.7"), + ("1.0+\u{212A}8s", "1.0+k8s"), + ("1.0.post0", "1.0.post0"), + ("1.0-post.1", "1.0.post1"), + ("1.0-1", "1.0.post1"), + ("1.0rev", "1.0.post0"), + ("1.0_r2", "1.0.post2"), + ("1.0.0-beta.1", "1.0.0b1"), + ("1.0.0-rc.2", "1.0.0rc2"), + ("1.0.0-dev.3", "1.0.0.dev3"), + ("1.0DEV", "1.0.dev0"), + ("1.0alpha", "1.0a0"), + ("1.0-c1", "1.0rc1"), + ("1.0.preview2", "1.0rc2"), + ("1.0a1.post2.dev3", "1.0a1.post2.dev3"), + ("0!1.0", "1.0"), + ("00!1.0", "1.0"), + ("2!1.0", "2!1.0"), + ("1.16.0.0", "1.16.0.0"), + (" 1.2.0 ", "1.2.0"), + ("99999999999999999999999.0", "99999999999999999999999.0"), + ] { + assert_eq!(normalize(raw).as_deref(), Some(normalized), "{raw}"); + assert!(versions_equal(raw, normalized), "{raw} == {normalized}"); + } + for invalid in ["", "not-a-version", "1.0 extra", "1.*", "1..2"] { + assert_eq!(normalize(invalid), None, "{invalid}"); + } + } + #[test] fn exact_pin_spellings() { assert!(is_exact_pin_of("==1.16", "1.16.0")); diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index be46a8011..15a7fe290 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -8,7 +8,8 @@ //! is returned as the URL with any credentials (userinfo), query //! and fragment removed. //! 2. `package.json` (npm) → `pkg:npm/@` -//! 3. `pyproject.toml` (PyPI) → `pkg:pypi/@` +//! 3. `pyproject.toml` (PyPI) → `pkg:pypi/@` //! 4. `Cargo.toml` (Cargo) → `pkg:cargo/@` //! 5. `go.mod` (Go) → `pkg:golang/` (go.mod //! records no version) @@ -168,8 +169,13 @@ fn parse_pyproject(content: &str) -> Option { let (name, version) = pyproject_name_version(&doc)?; // The purl carries the PEP 503 name, like every pypi component purl: // OpenVEX consumers match `My_Service` and `my-service` by exact purl - // (#1547). `packaging` strips a version's surrounding whitespace too. - crate::utils::purl::pypi_purl(&name, version.trim()) + // (#1547). Its version is the PEP 440 normalized one uv.lock, the + // built wheel and SBOMs record (`1.0.0-RC1` → `1.0.0rc1`, #1589); one + // that isn't valid PEP 440 keeps its spelling, minus the surrounding + // whitespace `packaging` strips too. + let version = + crate::utils::pep440::normalize(&version).unwrap_or_else(|| version.trim().to_string()); + crate::utils::purl::pypi_purl(&name, &version) } /// The project's name and version, read the way its build backend reads @@ -1509,6 +1515,58 @@ mod tests { ); } + /// The product carries the PEP 440 normalized version, the one + /// uv.lock, the built wheel and SBOMs record (#1589). + #[test] + fn pyproject_normalizes_pep621_version() { + for (raw, normalized) in [ + ("1.0.0-RC1", "1.0.0rc1"), + ("v1.2", "1.2"), + ("01.02.0", "1.2.0"), + ("1.0.0+Local.1", "1.0.0+local.1"), + ("1.0.post0", "1.0.post0"), + ] { + let content = format!("[project]\nname = \"My_Service\"\nversion = \"{raw}\"\n"); + assert_eq!( + parse_pyproject(&content), + Some(format!("pkg:pypi/my-service@{normalized}")), + "{raw}" + ); + } + } + + /// poetry-core builds the normalized version from `[tool.poetry]` + /// too, on Poetry 1.8 and 2 alike (#1589). + #[test] + fn pyproject_normalizes_tool_poetry_version() { + for (raw, normalized) in [ + ("1.0.0-beta.1", "1.0.0b1"), + ("1.0.0-rc.2", "1.0.0rc2"), + ("1.0.0RC1", "1.0.0rc1"), + ("2026.01.05", "2026.1.5"), + ("1.0-post.1", "1.0.post1"), + ("1.0.0-dev.3", "1.0.0.dev3"), + ("1.0.0+Local.Build", "1.0.0+local.build"), + ] { + let content = format!("[tool.poetry]\nname = \"demo-ver\"\nversion = \"{raw}\"\n\n[build-system]\nbuild-backend = \"poetry.core.masonry.api\"\n"); + assert_eq!( + parse_pyproject(&content), + Some(format!("pkg:pypi/demo-ver@{normalized}")), + "{raw}" + ); + } + } + + /// A version that isn't valid PEP 440 keeps its verbatim spelling. + #[test] + fn pyproject_keeps_non_pep440_version_verbatim() { + let content = "[project]\nname = \"demo\"\nversion = \"1.0-SNAPSHOT-x\"\n"; + assert_eq!( + parse_pyproject(content).as_deref(), + Some("pkg:pypi/demo@1.0-SNAPSHOT-x") + ); + } + /// Poetry 1.x and 2 build the same distribution when the two names /// differ only in spelling, so that is not a conflict. #[test]