From 02ec3978fefba5858e4843680d752684ef65ad88 Mon Sep 17 00:00:00 2001 From: Maxim Stykow Date: Wed, 12 Aug 2026 01:44:00 +0200 Subject: [PATCH] fix(pypi): encode extras names fully, and mark the remaining raw sites safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extras path (`requests[socks]`) assembled its PURL by hand so the bracket escapes could be lowercase, matching what ScanCode emits. That left every *other* character raw, so a lockfile name carrying both a bracket and a reserved character kept the latter unencoded. Encode through the crate and lowercase only the two bracket escapes, which keeps the emitted spelling identical while closing the gap. The `format!("pkg:` sites that remain are all safe, and now say why — the whole reason this class of bug survived is that every such site looked alike under grep: - The pypi builders restrict the name to the PEP 508 charset before formatting, which needs no percent-encoding, and encode the version separately. - The CocoaPods ones are internal map keys that are never emitted; the emitted PURL comes from `create_cocoapods_purl`, which encodes through the crate. - Go's namespace splice is already documented: it preserves path-part case, which `normalize_purl` then lowercases host-only per purl-spec#308. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Maxim Stykow --- src/parsers/podfile_lock.rs | 5 +++++ src/parsers/poetry_lock.rs | 24 +++++++++--------------- src/parsers/python/utils.rs | 3 +++ src/parsers/requirements_txt.rs | 3 +++ src/parsers/uv_lock.rs | 20 +++++++++----------- 5 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/parsers/podfile_lock.rs b/src/parsers/podfile_lock.rs index 76a6062ed..adc592f0f 100644 --- a/src/parsers/podfile_lock.rs +++ b/src/parsers/podfile_lock.rs @@ -416,6 +416,11 @@ fn parse_dep_to_base_purl_and_version(dep: &str) -> (String, Option) { (base_purl, requirement) } +/// The internal key a pod is tracked by while parsing — never emitted. +/// +/// Emitted PURLs come from `create_cocoapods_purl`, which encodes through the +/// crate. This only has to be a stable key for the maps below, so it is not +/// encoded and must not start being used as an output value. fn make_base_purl(name: &str) -> String { format!("pkg:cocoapods/{}", name) } diff --git a/src/parsers/poetry_lock.rs b/src/parsers/poetry_lock.rs index 4a8a25302..e861f854f 100644 --- a/src/parsers/poetry_lock.rs +++ b/src/parsers/poetry_lock.rs @@ -404,7 +404,7 @@ fn normalize_pypi_name(name: &str) -> String { fn create_pypi_purl(name: &str, version: Option<&str>) -> Option { if name.contains('[') || name.contains(']') { - return Some(truncate_field(build_manual_pypi_purl(name, version))); + return build_manual_pypi_purl(name, version).map(truncate_field); } let mut purl = PackageUrl::new(PoetryLockParser::PACKAGE_TYPE.as_str(), name).ok()?; @@ -414,20 +414,14 @@ fn create_pypi_purl(name: &str, version: Option<&str>) -> Option { Some(truncate_field(purl.to_string())) } -fn build_manual_pypi_purl(name: &str, version: Option<&str>) -> String { - let encoded_name = encode_pypi_name(name); - let mut purl = format!("pkg:pypi/{}", encoded_name); - if let Some(version) = version - && !version.is_empty() - { - purl.push('@'); - purl.push_str(version); - } - purl -} - -fn encode_pypi_name(name: &str) -> String { - name.replace('[', "%5b").replace(']', "%5d") +/// Builds a PyPI PURL for a name carrying extras (`requests[socks]`). +/// +/// The crate encodes brackets as `%5B`/`%5D`; this lowercases those two escapes +/// to keep the spelling ScanCode emits. Everything else still goes through the +/// encoder, so a name that also contains a reserved character is not left raw. +fn build_manual_pypi_purl(name: &str, version: Option<&str>) -> Option { + crate::parsers::utils::simple_purl("pypi", name, version) + .map(|purl| purl.replace("%5B", "%5b").replace("%5D", "%5d")) } fn extract_sha256_from_files(package_table: &TomlMap) -> Option { diff --git a/src/parsers/python/utils.rs b/src/parsers/python/utils.rs index c8c8b5e84..e68b6502d 100644 --- a/src/parsers/python/utils.rs +++ b/src/parsers/python/utils.rs @@ -262,6 +262,9 @@ pub(super) fn build_python_dependency_purl(name: &str, version: Option<&str>) -> return None; } + // Safe to assemble by hand: the validation above restricts the name to the + // PEP 508 charset, which needs no percent-encoding, and the version is + // encoded by `encode_python_dependency_purl_version`. Some(match version { Some(version) => format!( "pkg:pypi/{normalized_name}@{}", diff --git a/src/parsers/requirements_txt.rs b/src/parsers/requirements_txt.rs index 9b30e4d65..109421d82 100644 --- a/src/parsers/requirements_txt.rs +++ b/src/parsers/requirements_txt.rs @@ -938,6 +938,9 @@ fn create_pypi_purl(name: &str, version: Option<&str>) -> Option { return None; } + // Safe to assemble by hand: `is_valid_distribution_name` above restricts the + // name to the PEP 508 charset, which needs no percent-encoding, and the + // version is encoded by `encode_pypi_purl_version`. PackageUrl::new(RequirementsTxtParser::PACKAGE_TYPE.as_str(), name) .ok() .map(|_| match version { diff --git a/src/parsers/uv_lock.rs b/src/parsers/uv_lock.rs index cb21bbe21..ead000abf 100644 --- a/src/parsers/uv_lock.rs +++ b/src/parsers/uv_lock.rs @@ -932,7 +932,7 @@ fn normalize_pypi_name(name: &str) -> String { fn create_pypi_purl(name: &str, version: Option<&str>) -> Option { if name.contains('[') || name.contains(']') { - return Some(truncate_field(build_manual_pypi_purl(name, version))); + return build_manual_pypi_purl(name, version).map(truncate_field); } let mut purl = PackageUrl::new(UvLockParser::PACKAGE_TYPE.as_str(), name).ok()?; @@ -942,16 +942,14 @@ fn create_pypi_purl(name: &str, version: Option<&str>) -> Option { Some(truncate_field(purl.to_string())) } -fn build_manual_pypi_purl(name: &str, version: Option<&str>) -> String { - let encoded_name = name.replace('[', "%5b").replace(']', "%5d"); - let mut purl = format!("pkg:pypi/{}", encoded_name); - if let Some(version) = version - && !version.is_empty() - { - purl.push('@'); - purl.push_str(version); - } - purl +/// Builds a PyPI PURL for a name carrying extras (`requests[socks]`). +/// +/// The crate encodes brackets as `%5B`/`%5D`; this lowercases those two escapes +/// to keep the spelling ScanCode emits. Everything else still goes through the +/// encoder, so a name that also contains a reserved character is not left raw. +fn build_manual_pypi_purl(name: &str, version: Option<&str>) -> Option { + crate::parsers::utils::simple_purl("pypi", name, version) + .map(|purl| purl.replace("%5B", "%5b").replace("%5D", "%5d")) } fn toml_value_to_json(value: &TomlValue) -> JsonValue {