Skip to content

Commit b70eb6e

Browse files
Fix VEX product purl using raw PEP 440 version (#1589) (#1653)
* Start fix for #1589 Assisted-by: Claude Code:claude-opus-5-5 * 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 * 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1fa04c7 commit b70eb6e

3 files changed

Lines changed: 213 additions & 13 deletions

File tree

‎crates/socket-patch-cli/tests/e2e_vex.rs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,53 @@ fn auto_detect_pyproject_uses_canonical_pypi_name() {
645645
);
646646
}
647647

648+
/// #1589: the product version is the PEP 440 normalized one that uv.lock,
649+
/// the built wheel and SBOMs record, through `[project]` and
650+
/// `[tool.poetry]` alike.
651+
#[test]
652+
fn auto_detect_pyproject_normalizes_pep440_version() {
653+
for (pyproject, product) in [
654+
(
655+
"[project]\nname = \"My_Service\"\nversion = \"1.0.0-RC1\"\n",
656+
"pkg:pypi/my-service@1.0.0rc1",
657+
),
658+
(
659+
"[tool.poetry]\nname = \"demo-ver\"\nversion = \"1.0.0-beta.1\"\n\n[build-system]\nbuild-backend = \"poetry.core.masonry.api\"\n",
660+
"pkg:pypi/demo-ver@1.0.0b1",
661+
),
662+
] {
663+
let tmp = tempfile::tempdir().unwrap();
664+
let cwd = tmp.path();
665+
std::fs::write(cwd.join("pyproject.toml"), pyproject).unwrap();
666+
667+
let mut manifest = PatchManifest::new();
668+
manifest.patches.insert(
669+
"pkg:pypi/six@1.16.0".to_string(),
670+
make_record(
671+
"11111111-1111-4111-8111-111111111111",
672+
"six.py",
673+
"a".repeat(64).as_str(),
674+
"b".repeat(64).as_str(),
675+
"GHSA-z",
676+
&["CVE-Z"],
677+
),
678+
);
679+
write_manifest(cwd, &manifest);
680+
681+
let out = cli()
682+
.args(["vex", "--cwd", cwd.to_str().unwrap(), "--no-verify"])
683+
.output()
684+
.expect("invoke vex");
685+
assert!(
686+
out.status.success(),
687+
"{}",
688+
String::from_utf8_lossy(&out.stderr)
689+
);
690+
let doc: Value = serde_json::from_slice(&out.stdout).unwrap();
691+
assert_eq!(doc["statements"][0]["products"][0]["@id"], product);
692+
}
693+
}
694+
648695
// ──────────────────────────────────────────────────────────────────────
649696
// empty overrides mean "unset"
650697
//

‎crates/socket-patch-core/src/utils/pep440.rs‎

Lines changed: 105 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
//! PEP 440 version equality — the comparison pip, uv and Hatch apply to a
2-
//! hand-written `name==X` pin.
2+
//! hand-written `name==X` pin — and the normalized spelling
3+
//! (`normalize`) that uv.lock, wheel METADATA and PyPI record.
34
//!
4-
//! Only equality is implemented: the writers that rewrite a pin need to know
5+
//! No ordering is implemented: the writers that rewrite a pin need to know
56
//! whether `==X` selects exactly the release being patched, and under PEP 440
67
//! that ignores case, leading zeros, trailing `.0` release segments and the
78
//! alternate pre/post/dev spellings (`1.16` == `1.16.0` == `01.16.0.0`,
@@ -40,11 +41,11 @@ enum LocalPart {
4041
Text(String),
4142
}
4243

43-
/// A parsed version in the form PEP 440 equality compares.
44+
/// A parsed version with every number's leading zeros stripped.
4445
#[derive(Debug, PartialEq, Eq)]
4546
struct Version {
4647
epoch: String,
47-
/// Release segments with trailing zeros removed (`1.16.0` → `[1, 16]`).
48+
/// Release segments as written (`01.16.0` → `[1, 16, 0]`).
4849
release: Vec<String>,
4950
pre: Option<(&'static str, String)>,
5051
post: Option<String>,
@@ -55,10 +56,7 @@ struct Version {
5556
fn parse(text: &str) -> Option<Version> {
5657
let captures = VERSION.captures(text)?;
5758
let digits = |name: &str| captures.name(name).map(|m| number(m.as_str()));
58-
let mut release: Vec<String> = captures["release"].split('.').map(number).collect();
59-
while release.len() > 1 && release.last().is_some_and(|segment| segment == "0") {
60-
release.pop();
61-
}
59+
let release = captures["release"].split('.').map(number).collect();
6260
let pre = captures.name("pre_l").map(|label| {
6361
let label = match label.as_str().to_ascii_lowercase().as_str() {
6462
"a" | "alpha" => "a",
@@ -85,7 +83,9 @@ fn parse(text: &str) -> Option<Version> {
8583
if part.bytes().all(|byte| byte.is_ascii_digit()) {
8684
LocalPart::Number(number(part))
8785
} else {
88-
LocalPart::Text(part.to_ascii_lowercase())
86+
// `str.lower()`, as `packaging` does: `(?i)` lets
87+
// `[a-z]` match U+212A KELVIN SIGN, which lowers to `k`.
88+
LocalPart::Text(part.to_lowercase())
8989
}
9090
})
9191
.collect()
@@ -105,7 +105,17 @@ fn parse(text: &str) -> Option<Version> {
105105
/// version on either side is never equal to anything: callers fail closed.
106106
pub(crate) fn versions_equal(a: &str, b: &str) -> bool {
107107
match (parse(a), parse(b)) {
108-
(Some(a), Some(b)) => a == b,
108+
(Some(mut a), Some(mut b)) => {
109+
// Equality ignores trailing `.0` release segments.
110+
for version in [&mut a, &mut b] {
111+
while version.release.len() > 1
112+
&& version.release.last().is_some_and(|segment| segment == "0")
113+
{
114+
version.release.pop();
115+
}
116+
}
117+
a == b
118+
}
109119
_ => false,
110120
}
111121
}
@@ -115,6 +125,47 @@ pub(crate) fn is_valid(text: &str) -> bool {
115125
parse(text).is_some()
116126
}
117127

128+
/// `text` in PEP 440's normalized form, as `str(packaging.version.Version)`
129+
/// prints it and as uv.lock, wheel METADATA and PyPI record it: no leading
130+
/// `v` or surrounding whitespace, a zero epoch dropped, leading zeros
131+
/// dropped from every number (trailing `.0` release segments kept), the
132+
/// canonical `a`/`b`/`rc`/`.post`/`.dev` spellings and a lowercased,
133+
/// dot-separated local label (`1.0.0-RC1` → `1.0.0rc1`, `v01.02` → `1.2`,
134+
/// `1.0+Local-01` → `1.0+local.1`). `None` when `text` is not valid PEP 440.
135+
pub(crate) fn normalize(text: &str) -> Option<String> {
136+
let parsed = parse(text)?;
137+
let mut out = String::new();
138+
if parsed.epoch != "0" {
139+
out.push_str(&parsed.epoch);
140+
out.push('!');
141+
}
142+
out.push_str(&parsed.release.join("."));
143+
if let Some((label, n)) = &parsed.pre {
144+
out.push_str(label);
145+
out.push_str(n);
146+
}
147+
if let Some(n) = &parsed.post {
148+
out.push_str(".post");
149+
out.push_str(n);
150+
}
151+
if let Some(n) = &parsed.dev {
152+
out.push_str(".dev");
153+
out.push_str(n);
154+
}
155+
if !parsed.local.is_empty() {
156+
let local: Vec<&str> = parsed
157+
.local
158+
.iter()
159+
.map(|part| match part {
160+
LocalPart::Number(n) | LocalPart::Text(n) => n.as_str(),
161+
})
162+
.collect();
163+
out.push('+');
164+
out.push_str(&local.join("."));
165+
}
166+
Some(out)
167+
}
168+
118169
/// Whether a whitespace-free specifier is an exact `==X` pin of `version`
119170
/// under PEP 440. Wildcards (`==1.*`), arbitrary equality (`===`) and every
120171
/// other operator are not.
@@ -234,6 +285,50 @@ mod tests {
234285
}
235286
}
236287

288+
/// What `str(packaging.version.Version(v))` prints, which is what
289+
/// uv.lock, wheel METADATA and PyPI record (#1589).
290+
#[test]
291+
fn normalize_matches_packaging() {
292+
for (raw, normalized) in [
293+
("1.0.0-RC1", "1.0.0rc1"),
294+
("1.0.0RC1", "1.0.0rc1"),
295+
("v1.2", "1.2"),
296+
("V1.2", "1.2"),
297+
("01.02.0", "1.2.0"),
298+
("2026.01.05", "2026.1.5"),
299+
("1.0.0+Local.1", "1.0.0+local.1"),
300+
("1.0.0+Local.Build", "1.0.0+local.build"),
301+
("1.0+Ubuntu-01", "1.0+ubuntu.1"),
302+
("1.0+abc.007", "1.0+abc.7"),
303+
("1.0+\u{212A}8s", "1.0+k8s"),
304+
("1.0.post0", "1.0.post0"),
305+
("1.0-post.1", "1.0.post1"),
306+
("1.0-1", "1.0.post1"),
307+
("1.0rev", "1.0.post0"),
308+
("1.0_r2", "1.0.post2"),
309+
("1.0.0-beta.1", "1.0.0b1"),
310+
("1.0.0-rc.2", "1.0.0rc2"),
311+
("1.0.0-dev.3", "1.0.0.dev3"),
312+
("1.0DEV", "1.0.dev0"),
313+
("1.0alpha", "1.0a0"),
314+
("1.0-c1", "1.0rc1"),
315+
("1.0.preview2", "1.0rc2"),
316+
("1.0a1.post2.dev3", "1.0a1.post2.dev3"),
317+
("0!1.0", "1.0"),
318+
("00!1.0", "1.0"),
319+
("2!1.0", "2!1.0"),
320+
("1.16.0.0", "1.16.0.0"),
321+
(" 1.2.0 ", "1.2.0"),
322+
("99999999999999999999999.0", "99999999999999999999999.0"),
323+
] {
324+
assert_eq!(normalize(raw).as_deref(), Some(normalized), "{raw}");
325+
assert!(versions_equal(raw, normalized), "{raw} == {normalized}");
326+
}
327+
for invalid in ["", "not-a-version", "1.0 extra", "1.*", "1..2"] {
328+
assert_eq!(normalize(invalid), None, "{invalid}");
329+
}
330+
}
331+
237332
#[test]
238333
fn exact_pin_spellings() {
239334
assert!(is_exact_pin_of("==1.16", "1.16.0"));

‎crates/socket-patch-core/src/vex/product.rs‎

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
//! is returned as the URL with any credentials (userinfo), query
99
//! and fragment removed.
1010
//! 2. `package.json` (npm) → `pkg:npm/<name>@<version>`
11-
//! 3. `pyproject.toml` (PyPI) → `pkg:pypi/<PEP 503 name>@<version>`
11+
//! 3. `pyproject.toml` (PyPI) → `pkg:pypi/<PEP 503 name>@<PEP 440
12+
//! normalized version>`
1213
//! 4. `Cargo.toml` (Cargo) → `pkg:cargo/<name>@<version>`
1314
//! 5. `go.mod` (Go) → `pkg:golang/<module>` (go.mod
1415
//! records no version)
@@ -168,8 +169,13 @@ fn parse_pyproject(content: &str) -> Option<String> {
168169
let (name, version) = pyproject_name_version(&doc)?;
169170
// The purl carries the PEP 503 name, like every pypi component purl:
170171
// OpenVEX consumers match `My_Service` and `my-service` by exact purl
171-
// (#1547). `packaging` strips a version's surrounding whitespace too.
172-
crate::utils::purl::pypi_purl(&name, version.trim())
172+
// (#1547). Its version is the PEP 440 normalized one uv.lock, the
173+
// built wheel and SBOMs record (`1.0.0-RC1` → `1.0.0rc1`, #1589); one
174+
// that isn't valid PEP 440 keeps its spelling, minus the surrounding
175+
// whitespace `packaging` strips too.
176+
let version =
177+
crate::utils::pep440::normalize(&version).unwrap_or_else(|| version.trim().to_string());
178+
crate::utils::purl::pypi_purl(&name, &version)
173179
}
174180

175181
/// The project's name and version, read the way its build backend reads
@@ -1509,6 +1515,58 @@ mod tests {
15091515
);
15101516
}
15111517

1518+
/// The product carries the PEP 440 normalized version, the one
1519+
/// uv.lock, the built wheel and SBOMs record (#1589).
1520+
#[test]
1521+
fn pyproject_normalizes_pep621_version() {
1522+
for (raw, normalized) in [
1523+
("1.0.0-RC1", "1.0.0rc1"),
1524+
("v1.2", "1.2"),
1525+
("01.02.0", "1.2.0"),
1526+
("1.0.0+Local.1", "1.0.0+local.1"),
1527+
("1.0.post0", "1.0.post0"),
1528+
] {
1529+
let content = format!("[project]\nname = \"My_Service\"\nversion = \"{raw}\"\n");
1530+
assert_eq!(
1531+
parse_pyproject(&content),
1532+
Some(format!("pkg:pypi/my-service@{normalized}")),
1533+
"{raw}"
1534+
);
1535+
}
1536+
}
1537+
1538+
/// poetry-core builds the normalized version from `[tool.poetry]`
1539+
/// too, on Poetry 1.8 and 2 alike (#1589).
1540+
#[test]
1541+
fn pyproject_normalizes_tool_poetry_version() {
1542+
for (raw, normalized) in [
1543+
("1.0.0-beta.1", "1.0.0b1"),
1544+
("1.0.0-rc.2", "1.0.0rc2"),
1545+
("1.0.0RC1", "1.0.0rc1"),
1546+
("2026.01.05", "2026.1.5"),
1547+
("1.0-post.1", "1.0.post1"),
1548+
("1.0.0-dev.3", "1.0.0.dev3"),
1549+
("1.0.0+Local.Build", "1.0.0+local.build"),
1550+
] {
1551+
let content = format!("[tool.poetry]\nname = \"demo-ver\"\nversion = \"{raw}\"\n\n[build-system]\nbuild-backend = \"poetry.core.masonry.api\"\n");
1552+
assert_eq!(
1553+
parse_pyproject(&content),
1554+
Some(format!("pkg:pypi/demo-ver@{normalized}")),
1555+
"{raw}"
1556+
);
1557+
}
1558+
}
1559+
1560+
/// A version that isn't valid PEP 440 keeps its verbatim spelling.
1561+
#[test]
1562+
fn pyproject_keeps_non_pep440_version_verbatim() {
1563+
let content = "[project]\nname = \"demo\"\nversion = \"1.0-SNAPSHOT-x\"\n";
1564+
assert_eq!(
1565+
parse_pyproject(content).as_deref(),
1566+
Some("pkg:pypi/demo@1.0-SNAPSHOT-x")
1567+
);
1568+
}
1569+
15121570
/// Poetry 1.x and 2 build the same distribution when the two names
15131571
/// differ only in spelling, so that is not a conflict.
15141572
#[test]

0 commit comments

Comments
 (0)