Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions crates/socket-patch-cli/tests/e2e_vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
//
Expand Down
71 changes: 68 additions & 3 deletions crates/socket-patch-core/src/vex/product.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//! is returned as the URL with any credentials (userinfo), query
//! and fragment removed.
//! 2. `package.json` (npm) → `pkg:npm/<name>@<version>`
//! 3. `pyproject.toml` (PyPI) → `pkg:pypi/<name>@<version>`
//! 3. `pyproject.toml` (PyPI) → `pkg:pypi/<PEP 503 name>@<version>`
//! 4. `Cargo.toml` (Cargo) → `pkg:cargo/<name>@<version>`
//! 5. `go.mod` (Go) → `pkg:golang/<module>` (go.mod
//! records no version)
Expand All @@ -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
Expand Down Expand Up @@ -164,7 +166,10 @@ fn parse_pyproject(content: &str) -> Option<String> {
}
let doc = content.parse::<toml_edit::DocumentMut>().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
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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]
Expand Down
Loading