From dbb2ef8b1c9dfe31c8acd9c9492d9be09901758d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 14:24:09 +0000 Subject: [PATCH 1/3] Start fix for #1547 Assisted-by: Claude Code:claude-opus-5-5 From 65f02549f03aeb3b0218264a278d52b91a1e5dbf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 14:34:19 +0000 Subject: [PATCH 2/3] Use the canonical pypi name in VEX products A Python project named `My_Service` got the VEX product `pkg:pypi/My_Service@1.2.0`. SBOMs, and the pypi components in the same document, use the PEP 503 name (`my-service`). OpenVEX consumers match products by exact purl, so they silently ignored every statement in the document. The pyproject.toml product (PEP 621 `[project]`, `[tool.poetry]` and legacy `[tool.pdm]`) is now built through `utils::purl::pypi_purl`, which normalizes the name. Poetry's two name fields are now compared after normalization: `My_Service` and `my-service` name the same distribution, so they no longer count as a conflict. Fixes #1547 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/tests/e2e_vex.rs | 44 +++++++++++++++ crates/socket-patch-core/src/vex/product.rs | 62 ++++++++++++++++++++- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_vex.rs b/crates/socket-patch-cli/tests/e2e_vex.rs index 8d83f74c9..f8ba60bc8 100644 --- a/crates/socket-patch-cli/tests/e2e_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_vex.rs @@ -601,6 +601,50 @@ fn auto_detect_uses_package_json() { ); } +/// #1547: a `pyproject.toml` product carries the PEP 503 name, as SBOMs +/// and this document's own pypi components do; OpenVEX consumers match +/// products by exact purl, so `pkg:pypi/My_Service@…` matched nothing. +#[test] +fn auto_detect_pyproject_uses_canonical_pypi_name() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + + std::fs::write( + cwd.join("pyproject.toml"), + "[project]\nname = \"My_Service\"\nversion = \"1.2.0\"\n\n[tool.poetry]\npackage-mode = false\n", + ) + .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"], + "pkg:pypi/my-service@1.2.0" + ); +} + // ────────────────────────────────────────────────────────────────────── // empty overrides mean "unset" // diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index 66a37de42..7c1111119 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -8,7 +8,7 @@ //! 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) @@ -34,6 +34,8 @@ use std::path::Path; +use crate::crawlers::python_crawler::canonicalize_pypi_name; + // npm/Node strip a BOM from package.json, cargo accepts one in Cargo.toml, and // git reads a BOM'd `.git/config`, but serde_json and the git config scanner // reject it — without this, files the user's own toolchain accepts yield no @@ -164,7 +166,10 @@ fn parse_pyproject(content: &str) -> Option { } let doc = content.parse::().ok()?; let (name, version) = pyproject_name_version(&doc)?; - Some(format!("pkg:pypi/{name}@{version}")) + // The purl carries the PEP 503 name, like every pypi component purl: + // OpenVEX consumers match `My_Service` and `my-service` by exact purl + // (#1547). + crate::utils::purl::pypi_purl(&name, &version) } /// The project's name and version, read the way its build backend reads @@ -190,7 +195,7 @@ fn pyproject_name_version(doc: &toml_edit::DocumentMut) -> Option<(String, Strin // and builds `[tool.poetry].name`: when they differ, the product // depends on the Poetry line, so name none rather than guess. if let (Some(a), Some(b)) = (project_name, poetry_name) { - if a != b { + if canonicalize_pypi_name(a) != canonicalize_pypi_name(b) { return None; } } @@ -1399,6 +1404,57 @@ mod tests { ); } + // ── Regression #1547: the pypi product name is the canonical one ── + // + // OpenVEX consumers match `products[].@id` by exact purl, and SBOMs + // (and this document's own components) carry the PEP 503 name, so a + // raw `My_Service` product never matched anything. + + #[tokio::test] + async fn detect_pyproject_canonicalizes_pep621_name() { + let dir = tempfile::tempdir().unwrap(); + let content = "[project]\nname = \"My_Service\"\nversion = \"1.2.0\"\n\n[tool.poetry]\npackage-mode = false\n"; + tokio::fs::write(dir.path().join("pyproject.toml"), content) + .await + .unwrap(); + let r = detect_product(dir.path()).await; + assert_eq!(r.purl.as_deref(), Some("pkg:pypi/my-service@1.2.0")); + } + + #[test] + fn pyproject_canonicalizes_tool_poetry_name() { + let legacy = "[tool.poetry]\nname = \"My_Service\"\nversion = \"0.1.0\"\n"; + assert_eq!( + parse_pyproject(legacy).as_deref(), + Some("pkg:pypi/my-service@0.1.0") + ); + let dotted = "[tool.poetry]\nname = \"Demo_Pkg.Core\"\nversion = \"0.1.0\"\n"; + assert_eq!( + parse_pyproject(dotted).as_deref(), + Some("pkg:pypi/demo-pkg-core@0.1.0") + ); + } + + #[test] + fn pyproject_canonicalizes_tool_pdm_name() { + let content = "[tool.pdm]\nname = \"Legacy__PDM.App\"\nversion = \"0.0.1\"\n"; + assert_eq!( + parse_pyproject(content).as_deref(), + Some("pkg:pypi/legacy-pdm-app@0.0.1") + ); + } + + /// Poetry 1.x and 2 build the same distribution when the two names + /// differ only in spelling, so that is not a conflict. + #[test] + fn pyproject_poetry_names_equal_after_canonicalization() { + let content = "[project]\nname = \"My_Service\"\nversion = \"1.0\"\n\n[tool.poetry]\nname = \"my-service\"\n"; + assert_eq!( + parse_pyproject(content).as_deref(), + Some("pkg:pypi/my-service@1.0") + ); + } + /// Multi-manifest combo: pyproject + Cargo.toml present, no /// package.json. pyproject wins per the priority list. #[tokio::test] From 4a442ecc7138403278d12170bb077cc752896eeb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:00:39 +0000 Subject: [PATCH 3/3] Trim the pyproject version in VEX products A pyproject.toml version written with surrounding spaces ended up inside the product purl, so the purl was invalid and matched nothing. The name was already trimmed when it was canonicalized. Refs #1547 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vex/product.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/vex/product.rs b/crates/socket-patch-core/src/vex/product.rs index 7c1111119..8a8cc9a4e 100644 --- a/crates/socket-patch-core/src/vex/product.rs +++ b/crates/socket-patch-core/src/vex/product.rs @@ -168,8 +168,8 @@ 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). - crate::utils::purl::pypi_purl(&name, &version) + // (#1547). `packaging` strips a version's surrounding whitespace too. + crate::utils::purl::pypi_purl(&name, version.trim()) } /// The project's name and version, read the way its build backend reads @@ -1444,6 +1444,15 @@ mod tests { ); } + #[test] + fn pyproject_trims_name_and_version() { + let content = "[project]\nname = \" My_Service \"\nversion = \" 1.2.0 \"\n"; + assert_eq!( + parse_pyproject(content).as_deref(), + Some("pkg:pypi/my-service@1.2.0") + ); + } + /// Poetry 1.x and 2 build the same distribution when the two names /// differ only in spelling, so that is not a conflict. #[test]