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
47 changes: 47 additions & 0 deletions crates/socket-patch-cli/tests/e2e_vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,53 @@ fn auto_detect_pyproject_uses_canonical_pypi_name() {
);
}

/// #1589: the product version is the PEP 440 normalized one that uv.lock,
/// the built wheel and SBOMs record, through `[project]` and
/// `[tool.poetry]` alike.
#[test]
fn auto_detect_pyproject_normalizes_pep440_version() {
for (pyproject, product) in [
(
"[project]\nname = \"My_Service\"\nversion = \"1.0.0-RC1\"\n",
"pkg:pypi/my-service@1.0.0rc1",
),
(
"[tool.poetry]\nname = \"demo-ver\"\nversion = \"1.0.0-beta.1\"\n\n[build-system]\nbuild-backend = \"poetry.core.masonry.api\"\n",
"pkg:pypi/demo-ver@1.0.0b1",
),
] {
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path();
std::fs::write(cwd.join("pyproject.toml"), pyproject).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"], product);
}
}

// ──────────────────────────────────────────────────────────────────────
// empty overrides mean "unset"
//
Expand Down
115 changes: 105 additions & 10 deletions crates/socket-patch-core/src/utils/pep440.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
//! PEP 440 version equality — the comparison pip, uv and Hatch apply to a
//! hand-written `name==X` pin.
//! hand-written `name==X` pin — and the normalized spelling
//! (`normalize`) that uv.lock, wheel METADATA and PyPI record.
//!
//! Only equality is implemented: the writers that rewrite a pin need to know
//! No ordering is implemented: the writers that rewrite a pin need to know
//! whether `==X` selects exactly the release being patched, and under PEP 440
//! that ignores case, leading zeros, trailing `.0` release segments and the
//! alternate pre/post/dev spellings (`1.16` == `1.16.0` == `01.16.0.0`,
Expand Down Expand Up @@ -40,11 +41,11 @@ enum LocalPart {
Text(String),
}

/// A parsed version in the form PEP 440 equality compares.
/// A parsed version with every number's leading zeros stripped.
#[derive(Debug, PartialEq, Eq)]
struct Version {
epoch: String,
/// Release segments with trailing zeros removed (`1.16.0` → `[1, 16]`).
/// Release segments as written (`01.16.0` → `[1, 16, 0]`).
release: Vec<String>,
pre: Option<(&'static str, String)>,
post: Option<String>,
Expand All @@ -55,10 +56,7 @@ struct Version {
fn parse(text: &str) -> Option<Version> {
let captures = VERSION.captures(text)?;
let digits = |name: &str| captures.name(name).map(|m| number(m.as_str()));
let mut release: Vec<String> = captures["release"].split('.').map(number).collect();
while release.len() > 1 && release.last().is_some_and(|segment| segment == "0") {
release.pop();
}
let release = captures["release"].split('.').map(number).collect();
let pre = captures.name("pre_l").map(|label| {
let label = match label.as_str().to_ascii_lowercase().as_str() {
"a" | "alpha" => "a",
Expand All @@ -85,7 +83,9 @@ fn parse(text: &str) -> Option<Version> {
if part.bytes().all(|byte| byte.is_ascii_digit()) {
LocalPart::Number(number(part))
} else {
LocalPart::Text(part.to_ascii_lowercase())
// `str.lower()`, as `packaging` does: `(?i)` lets
// `[a-z]` match U+212A KELVIN SIGN, which lowers to `k`.
LocalPart::Text(part.to_lowercase())
}
})
.collect()
Expand All @@ -105,7 +105,17 @@ fn parse(text: &str) -> Option<Version> {
/// version on either side is never equal to anything: callers fail closed.
pub(crate) fn versions_equal(a: &str, b: &str) -> bool {
match (parse(a), parse(b)) {
(Some(a), Some(b)) => a == b,
(Some(mut a), Some(mut b)) => {
// Equality ignores trailing `.0` release segments.
for version in [&mut a, &mut b] {
while version.release.len() > 1
&& version.release.last().is_some_and(|segment| segment == "0")
{
version.release.pop();
}
}
a == b
}
_ => false,
}
}
Expand All @@ -115,6 +125,47 @@ pub(crate) fn is_valid(text: &str) -> bool {
parse(text).is_some()
}

/// `text` in PEP 440's normalized form, as `str(packaging.version.Version)`
/// prints it and as uv.lock, wheel METADATA and PyPI record it: no leading
/// `v` or surrounding whitespace, a zero epoch dropped, leading zeros
/// dropped from every number (trailing `.0` release segments kept), the
/// canonical `a`/`b`/`rc`/`.post`/`.dev` spellings and a lowercased,
/// dot-separated local label (`1.0.0-RC1` → `1.0.0rc1`, `v01.02` → `1.2`,
/// `1.0+Local-01` → `1.0+local.1`). `None` when `text` is not valid PEP 440.
pub(crate) fn normalize(text: &str) -> Option<String> {
let parsed = parse(text)?;
let mut out = String::new();
if parsed.epoch != "0" {
out.push_str(&parsed.epoch);
out.push('!');
}
out.push_str(&parsed.release.join("."));
if let Some((label, n)) = &parsed.pre {
out.push_str(label);
out.push_str(n);
}
if let Some(n) = &parsed.post {
out.push_str(".post");
out.push_str(n);
}
if let Some(n) = &parsed.dev {
out.push_str(".dev");
out.push_str(n);
}
if !parsed.local.is_empty() {
let local: Vec<&str> = parsed
.local
.iter()
.map(|part| match part {
LocalPart::Number(n) | LocalPart::Text(n) => n.as_str(),
})
.collect();
out.push('+');
out.push_str(&local.join("."));
}
Some(out)
}

/// Whether a whitespace-free specifier is an exact `==X` pin of `version`
/// under PEP 440. Wildcards (`==1.*`), arbitrary equality (`===`) and every
/// other operator are not.
Expand Down Expand Up @@ -234,6 +285,50 @@ mod tests {
}
}

/// What `str(packaging.version.Version(v))` prints, which is what
/// uv.lock, wheel METADATA and PyPI record (#1589).
#[test]
fn normalize_matches_packaging() {
for (raw, normalized) in [
("1.0.0-RC1", "1.0.0rc1"),
("1.0.0RC1", "1.0.0rc1"),
("v1.2", "1.2"),
("V1.2", "1.2"),
("01.02.0", "1.2.0"),
("2026.01.05", "2026.1.5"),
("1.0.0+Local.1", "1.0.0+local.1"),
("1.0.0+Local.Build", "1.0.0+local.build"),
("1.0+Ubuntu-01", "1.0+ubuntu.1"),
("1.0+abc.007", "1.0+abc.7"),
("1.0+\u{212A}8s", "1.0+k8s"),
("1.0.post0", "1.0.post0"),
("1.0-post.1", "1.0.post1"),
("1.0-1", "1.0.post1"),
("1.0rev", "1.0.post0"),
("1.0_r2", "1.0.post2"),
("1.0.0-beta.1", "1.0.0b1"),
("1.0.0-rc.2", "1.0.0rc2"),
("1.0.0-dev.3", "1.0.0.dev3"),
("1.0DEV", "1.0.dev0"),
("1.0alpha", "1.0a0"),
("1.0-c1", "1.0rc1"),
("1.0.preview2", "1.0rc2"),
("1.0a1.post2.dev3", "1.0a1.post2.dev3"),
("0!1.0", "1.0"),
("00!1.0", "1.0"),
("2!1.0", "2!1.0"),
("1.16.0.0", "1.16.0.0"),
(" 1.2.0 ", "1.2.0"),
("99999999999999999999999.0", "99999999999999999999999.0"),
] {
assert_eq!(normalize(raw).as_deref(), Some(normalized), "{raw}");
assert!(versions_equal(raw, normalized), "{raw} == {normalized}");
}
for invalid in ["", "not-a-version", "1.0 extra", "1.*", "1..2"] {
assert_eq!(normalize(invalid), None, "{invalid}");
}
}

#[test]
fn exact_pin_spellings() {
assert!(is_exact_pin_of("==1.16", "1.16.0"));
Expand Down
64 changes: 61 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,8 @@
//! 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/<PEP 503 name>@<version>`
//! 3. `pyproject.toml` (PyPI) → `pkg:pypi/<PEP 503 name>@<PEP 440
//! normalized version>`
//! 4. `Cargo.toml` (Cargo) → `pkg:cargo/<name>@<version>`
//! 5. `go.mod` (Go) → `pkg:golang/<module>` (go.mod
//! records no version)
Expand Down Expand Up @@ -168,8 +169,13 @@ fn parse_pyproject(content: &str) -> Option<String> {
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). `packaging` strips a version's surrounding whitespace too.
crate::utils::purl::pypi_purl(&name, version.trim())
// (#1547). Its version is the PEP 440 normalized one uv.lock, the
// built wheel and SBOMs record (`1.0.0-RC1` → `1.0.0rc1`, #1589); one
// that isn't valid PEP 440 keeps its spelling, minus the surrounding
// whitespace `packaging` strips too.
let version =
crate::utils::pep440::normalize(&version).unwrap_or_else(|| version.trim().to_string());
crate::utils::purl::pypi_purl(&name, &version)
}

/// The project's name and version, read the way its build backend reads
Expand Down Expand Up @@ -1509,6 +1515,58 @@ mod tests {
);
}

/// The product carries the PEP 440 normalized version, the one
/// uv.lock, the built wheel and SBOMs record (#1589).
#[test]
fn pyproject_normalizes_pep621_version() {
for (raw, normalized) in [
("1.0.0-RC1", "1.0.0rc1"),
("v1.2", "1.2"),
("01.02.0", "1.2.0"),
("1.0.0+Local.1", "1.0.0+local.1"),
("1.0.post0", "1.0.post0"),
] {
let content = format!("[project]\nname = \"My_Service\"\nversion = \"{raw}\"\n");
assert_eq!(
parse_pyproject(&content),
Some(format!("pkg:pypi/my-service@{normalized}")),
"{raw}"
);
}
}

/// poetry-core builds the normalized version from `[tool.poetry]`
/// too, on Poetry 1.8 and 2 alike (#1589).
#[test]
fn pyproject_normalizes_tool_poetry_version() {
for (raw, normalized) in [
("1.0.0-beta.1", "1.0.0b1"),
("1.0.0-rc.2", "1.0.0rc2"),
("1.0.0RC1", "1.0.0rc1"),
("2026.01.05", "2026.1.5"),
("1.0-post.1", "1.0.post1"),
("1.0.0-dev.3", "1.0.0.dev3"),
("1.0.0+Local.Build", "1.0.0+local.build"),
] {
let content = format!("[tool.poetry]\nname = \"demo-ver\"\nversion = \"{raw}\"\n\n[build-system]\nbuild-backend = \"poetry.core.masonry.api\"\n");
assert_eq!(
parse_pyproject(&content),
Some(format!("pkg:pypi/demo-ver@{normalized}")),
"{raw}"
);
}
}

/// A version that isn't valid PEP 440 keeps its verbatim spelling.
#[test]
fn pyproject_keeps_non_pep440_version_verbatim() {
let content = "[project]\nname = \"demo\"\nversion = \"1.0-SNAPSHOT-x\"\n";
assert_eq!(
parse_pyproject(content).as_deref(),
Some("pkg:pypi/demo@1.0-SNAPSHOT-x")
);
}

/// Poetry 1.x and 2 build the same distribution when the two names
/// differ only in spelling, so that is not a conflict.
#[test]
Expand Down
Loading