GeezerVison is showing github.com — Fix VEX product purl using raw PEP 440 version (#1589) (#1653) · SocketDev/socket-patch@b12ecc1
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: crates/socket-patch-cli/tests/e2e_vex.rs +47Lines changed: 47 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -645,6 +645,53 @@ fn auto_detect_pyproject_uses_canonical_pypi_name() { | 645 | 645 | ); | 646 | 646 | } | 647 | 647 |
| | 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 | + | | 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 | + | | 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 | + | | 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 | + | 648 | 695 | // ────────────────────────────────────────────────────────────────────── | 649 | 696 | // empty overrides mean "unset" | 650 | 697 | // | |
Collapse file 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 number | Diff line number | Diff line change |
|---|
| 1 | 1 | //! 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. | 3 | 4 | //! | 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 | 5 | 6 | //! whether `==X` selects exactly the release being patched, and under PEP 440 | 6 | 7 | //! that ignores case, leading zeros, trailing `.0` release segments and the | 7 | 8 | //! alternate pre/post/dev spellings (`1.16` == `1.16.0` == `01.16.0.0`, | @@ -40,11 +41,11 @@ enum LocalPart { | 40 | 41 | Text(String), | 41 | 42 | } | 42 | 43 |
| 43 | | -/// A parsed version in the form PEP 440 equality compares. | | 44 | +/// A parsed version with every number's leading zeros stripped. | 44 | 45 | #[derive(Debug, PartialEq, Eq)] | 45 | 46 | struct Version { | 46 | 47 | 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]`). | 48 | 49 | release: Vec<String>, | 49 | 50 | pre: Option<(&'static str, String)>, | 50 | 51 | post: Option<String>, | @@ -55,10 +56,7 @@ struct Version { | 55 | 56 | fn parse(text: &str) -> Option<Version> { | 56 | 57 | let captures = VERSION.captures(text)?; | 57 | 58 | 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(); | 62 | 60 | let pre = captures.name("pre_l").map(|label| { | 63 | 61 | let label = match label.as_str().to_ascii_lowercase().as_str() { | 64 | 62 | "a" | "alpha" => "a", | @@ -85,7 +83,9 @@ fn parse(text: &str) -> Option<Version> { | 85 | 83 | if part.bytes().all(|byte| byte.is_ascii_digit()) { | 86 | 84 | LocalPart::Number(number(part)) | 87 | 85 | } 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()) | 89 | 89 | } | 90 | 90 | }) | 91 | 91 | .collect() | @@ -105,7 +105,17 @@ fn parse(text: &str) -> Option<Version> { | 105 | 105 | /// version on either side is never equal to anything: callers fail closed. | 106 | 106 | pub(crate) fn versions_equal(a: &str, b: &str) -> bool { | 107 | 107 | 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 | + } | 109 | 119 | _ => false, | 110 | 120 | } | 111 | 121 | } | @@ -115,6 +125,47 @@ pub(crate) fn is_valid(text: &str) -> bool { | 115 | 125 | parse(text).is_some() | 116 | 126 | } | 117 | 127 |
| | 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 | + | 118 | 169 | /// Whether a whitespace-free specifier is an exact `==X` pin of `version` | 119 | 170 | /// under PEP 440. Wildcards (`==1.*`), arbitrary equality (`===`) and every | 120 | 171 | /// other operator are not. | @@ -234,6 +285,50 @@ mod tests { | 234 | 285 | } | 235 | 286 | } | 236 | 287 |
| | 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 | + | 237 | 332 | #[test] | 238 | 333 | fn exact_pin_spellings() { | 239 | 334 | assert!(is_exact_pin_of("==1.16", "1.16.0")); | |
Collapse file 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 number | Diff line number | Diff line change |
|---|
| 8 | 8 | //! is returned as the URL with any credentials (userinfo), query | 9 | 9 | //! and fragment removed. | 10 | 10 | //! 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>` | 12 | 13 | //! 4. `Cargo.toml` (Cargo) → `pkg:cargo/<name>@<version>` | 13 | 14 | //! 5. `go.mod` (Go) → `pkg:golang/<module>` (go.mod | 14 | 15 | //! records no version) | @@ -168,8 +169,13 @@ fn parse_pyproject(content: &str) -> Option<String> { | 168 | 169 | let (name, version) = pyproject_name_version(&doc)?; | 169 | 170 | // The purl carries the PEP 503 name, like every pypi component purl: | 170 | 171 | // 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) | 173 | 179 | } | 174 | 180 |
| 175 | 181 | /// The project's name and version, read the way its build backend reads | @@ -1509,6 +1515,58 @@ mod tests { | 1509 | 1515 | ); | 1510 | 1516 | } | 1511 | 1517 |
| | 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 | + | | 1567 | + ); | | 1568 | + } | | 1569 | + | 1512 | 1570 | /// Poetry 1.x and 2 build the same distribution when the two names | 1513 | 1571 | /// differ only in spelling, so that is not a conflict. | 1514 | 1572 | #[test] | |
|