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..8a8cc9a4e 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). `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 @@ -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,66 @@ 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") + ); + } + + #[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] + 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]