GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — Fix VEX product purl using raw PEP 440 version (#1589) (#1653) · SocketDev/socket-patch@b12ecc1

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
  • crates
    • socket-patch-cli/tests
      • e2e_vex.rs
    • socket-patch-core/src
      • utils
        • pep440.rs
      • vex
        • product.rs
Expand file treeCollapse file tree
Open diff view settings
Collapse file

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

Copy file name to clipboardExpand all lines: crates/socket-patch-cli/tests/e2e_vex.rs
+47Lines 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/[email protected]",
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/[email protected]",
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/[email protected]".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
//
Collapse file

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

Copy file name to clipboardExpand all lines: crates/socket-patch-core/src/utils/pep440.rs
+105-10Lines 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"));
Collapse file

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

Copy file name to clipboardExpand all lines: crates/socket-patch-core/src/vex/product.rs
+61-3Lines 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/[email protected]")
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 (0)
You are visitor 01402520 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.