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 {