From 06391677e4baad3c69f491c608f8a00a720d3788 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 04:24:09 +0000 Subject: [PATCH 1/3] Start fix for #1589 Assisted-by: Claude Code:claude-opus-5-5 From 1b82c569c3058d1de480b202b1c4b3391ba78dbf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 04:42:21 +0000 Subject: [PATCH 2/3] Normalize the pyproject VEX product version A pyproject.toml version spelled 1.0.0-RC1, v1.2 or 01.02.0 went into the OpenVEX product purl verbatim, while uv.lock, the built wheel, PyPI and SBOMs all record the PEP 440 normalized form (1.0.0rc1, 1.2, 1.2.0). Consumers match products by exact purl, so every not_affected statement was silently ignored. The product now carries the normalized version for PEP 621, [tool.poetry] and [tool.pdm] projects; a version that isn't valid PEP 440 keeps its spelling. Fixes #1589 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/utils/pep440.rs | 85 ++++++++++++++++++++ crates/socket-patch-core/src/vex/product.rs | 61 +++++++++++++- 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/utils/pep440.rs b/crates/socket-patch-core/src/utils/pep440.rs index 641e602c9..ccc541f04 100644 --- a/crates/socket-patch-core/src/utils/pep440.rs +++ b/crates/socket-patch-core/src/utils/pep440.rs @@ -115,6 +115,49 @@ 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 captures = VERSION.captures(text)?; + let parsed = parse(text)?; + let mut out = String::new(); + if parsed.epoch != "0" { + out.push_str(&parsed.epoch); + out.push('!'); + } + let release: Vec = captures["release"].split('.').map(number).collect(); + out.push_str(&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 +277,48 @@ 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.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..cefccbed5 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -168,8 +168,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 +1514,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] From 9e4f1a7b9e588aca5e5e175f65ae8b9c8ad779ee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 05:05:11 +0000 Subject: [PATCH 3/3] Share PEP 440 parse between equal and normalize Parse each version once for normalize, keep trailing-zero trimming in the equality check, and lowercase local labels the way Python does so a non-ASCII letter the regex accepts prints as packaging prints it. Also documents the normalized product version and adds a CLI test for the vex command's detected product. Refs #1589 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/tests/e2e_vex.rs | 47 ++++++++++++++++++++ crates/socket-patch-core/src/utils/pep440.rs | 36 +++++++++------ crates/socket-patch-core/src/vex/product.rs | 3 +- 3 files changed, 72 insertions(+), 14 deletions(-) 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 ccc541f04..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, } } @@ -123,15 +133,13 @@ pub(crate) fn is_valid(text: &str) -> bool { /// 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 captures = VERSION.captures(text)?; let parsed = parse(text)?; let mut out = String::new(); if parsed.epoch != "0" { out.push_str(&parsed.epoch); out.push('!'); } - let release: Vec = captures["release"].split('.').map(number).collect(); - out.push_str(&release.join(".")); + out.push_str(&parsed.release.join(".")); if let Some((label, n)) = &parsed.pre { out.push_str(label); out.push_str(n); @@ -291,6 +299,8 @@ mod tests { ("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"), diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index cefccbed5..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)