diff --git a/src/parsers/conan.rs b/src/parsers/conan.rs index cb80b3abd..2f79d1184 100644 --- a/src/parsers/conan.rs +++ b/src/parsers/conan.rs @@ -475,16 +475,18 @@ fn parse_conan_reference(ref_str: &str) -> Option { } }); - let purl = if let Some(v) = version.as_deref() { - PackageUrl::new("conan", name) - .map(|mut p| { + // A range constraint is not a PURL version, so ranged and bare references + // both fall through to a name-only PURL and keep the constraint in + // `extracted_requirement`. + let purl = version + .as_deref() + .and_then(|v| { + PackageUrl::new("conan", name).ok().map(|mut p| { let _ = p.with_version(v); p.to_string() }) - .unwrap_or_else(|_| format!("pkg:conan/{}", name)) - } else { - format!("pkg:conan/{}", name) - }; + }) + .unwrap_or_else(|| format!("pkg:conan/{}", name)); let is_pinned = version_spec .as_ref() diff --git a/src/parsers/poetry_lock.rs b/src/parsers/poetry_lock.rs index facc2e6f7..4a8a25302 100644 --- a/src/parsers/poetry_lock.rs +++ b/src/parsers/poetry_lock.rs @@ -407,16 +407,11 @@ fn create_pypi_purl(name: &str, version: Option<&str>) -> Option { return Some(truncate_field(build_manual_pypi_purl(name, version))); } - if let Ok(mut purl) = PackageUrl::new(PoetryLockParser::PACKAGE_TYPE.as_str(), name) { - if let Some(version) = version - && purl.with_version(version).is_err() - { - return None; - } - return Some(truncate_field(purl.to_string())); + let mut purl = PackageUrl::new(PoetryLockParser::PACKAGE_TYPE.as_str(), name).ok()?; + if let Some(version) = version { + purl.with_version(version).ok()?; } - - Some(truncate_field(build_manual_pypi_purl(name, version))) + Some(truncate_field(purl.to_string())) } fn build_manual_pypi_purl(name: &str, version: Option<&str>) -> String { diff --git a/src/parsers/pylock_toml.rs b/src/parsers/pylock_toml.rs index c34d22238..a3777b7b8 100644 --- a/src/parsers/pylock_toml.rs +++ b/src/parsers/pylock_toml.rs @@ -734,23 +734,11 @@ fn build_pypi_urls( } fn create_pypi_purl(name: &str, version: Option<&str>) -> Option { - if let Ok(mut purl) = PackageUrl::new(PylockTomlParser::PACKAGE_TYPE.as_str(), name) { - if let Some(version) = version - && purl.with_version(version).is_err() - { - return None; - } - return Some(truncate_field(purl.to_string())); - } - - let mut purl = format!("pkg:pypi/{}", name); - if let Some(version) = version - && !version.is_empty() - { - purl.push('@'); - purl.push_str(version); + let mut purl = PackageUrl::new(PylockTomlParser::PACKAGE_TYPE.as_str(), name).ok()?; + if let Some(version) = version { + purl.with_version(version).ok()?; } - Some(truncate_field(purl)) + Some(truncate_field(purl.to_string())) } fn toml_value_to_json(value: &TomlValue) -> JsonValue { diff --git a/src/parsers/python/pyproject.rs b/src/parsers/python/pyproject.rs index cde2ee3c2..fb53b4fb5 100644 --- a/src/parsers/python/pyproject.rs +++ b/src/parsers/python/pyproject.rs @@ -723,7 +723,7 @@ fn build_poetry_group_dependency( let pinned_version = version_spec .as_deref() .and_then(extract_exact_pinned_version); - let purl = build_python_dependency_purl(&normalized_name, pinned_version.as_deref())?; + let purl = build_python_dependency_purl(&normalized_name, pinned_version.as_deref()); let mut extra_data = HashMap::new(); if let Some(marker) = marker { @@ -737,7 +737,7 @@ fn build_poetry_group_dependency( } Some(Dependency { - purl: Some(purl), + purl, extracted_requirement: version_spec, scope: scope.map(|value| value.to_string()), is_runtime: Some(!is_optional), @@ -779,7 +779,7 @@ fn build_pyproject_array_dependency( .as_deref() .and_then(extract_exact_pinned_version); - let purl = build_python_dependency_purl(&name, pinned_version.as_deref())?; + let purl = build_python_dependency_purl(&name, pinned_version.as_deref()); let mut extra_data = HashMap::new(); if let Some(marker) = parsed.marker { @@ -795,7 +795,7 @@ fn build_pyproject_array_dependency( let extracted_requirement = parsed.specifiers.or(parsed.url); Some(Dependency { - purl: Some(purl), + purl, extracted_requirement: extracted_requirement.clone(), scope: scope.map(|s| s.to_string()), is_runtime: Some(!is_optional), diff --git a/src/parsers/python/scan_test.rs b/src/parsers/python/scan_test.rs index f5907bc73..76a4aaea2 100644 --- a/src/parsers/python/scan_test.rs +++ b/src/parsers/python/scan_test.rs @@ -771,4 +771,41 @@ license = { file = "LICENSE.txt" } 1 ); } + + #[test] + fn test_setup_cfg_scan_keeps_a_purl_less_dependency_through_assembly() { + // A requirement whose name is not a distribution name yields no PURL, and + // assembly must still carry it to the top level rather than dropping the + // only record of it. `extracted_requirement` is where the raw text lives, + // so a purl-less entry is the honest result, not a lost one. + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + fs::write( + temp_dir.path().join("setup.cfg"), + "[metadata]\nname = demo\nversion = 1.0.0\n\n[options]\ninstall_requires =\n a/b==2.0\n requests>=2.0\n", + ) + .expect("write setup.cfg"); + + let (_files, result) = scan_and_assemble(temp_dir.path()); + + assert_dependency_present(&result.dependencies, "pkg:pypi/requests", "setup.cfg"); + + let purl_less = result + .dependencies + .iter() + .find(|dependency| dependency.extracted_requirement.as_deref() == Some("a/b==2.0")) + .expect("the purl-less dependency should survive assembly"); + assert_eq!(purl_less.purl, None); + assert!( + purl_less.datafile_path.ends_with("setup.cfg"), + "it should stay attributed to its datafile, got {}", + purl_less.datafile_path + ); + assert!( + result.dependencies.iter().all(|dependency| dependency + .purl + .as_deref() + .is_none_or(|purl| !purl.contains("a/b"))), + "the invalid name must not reappear as a purl anywhere" + ); + } } diff --git a/src/parsers/python/setup_cfg.rs b/src/parsers/python/setup_cfg.rs index aa8ad470d..9c531feff 100644 --- a/src/parsers/python/setup_cfg.rs +++ b/src/parsers/python/setup_cfg.rs @@ -254,13 +254,13 @@ fn build_setup_cfg_dependency(req: &str, scope: &str, is_optional: bool) -> Opti .as_deref() .map(|requirement| requirement.trim_start_matches('=')) .and_then(|version| build_python_dependency_purl(&name, Some(version))) - .or_else(|| build_python_dependency_purl(&name, None))? + .or_else(|| build_python_dependency_purl(&name, None)) } else { - build_python_dependency_purl(&name, None)? + build_python_dependency_purl(&name, None) }; Some(Dependency { - purl: Some(purl), + purl, extracted_requirement: Some(normalize_setup_cfg_requirement(trimmed)), scope: Some(scope.to_string()), is_runtime: Some(true), diff --git a/src/parsers/python/test.rs b/src/parsers/python/test.rs index 7f530470e..cfbd8a911 100644 --- a/src/parsers/python/test.rs +++ b/src/parsers/python/test.rs @@ -3139,6 +3139,42 @@ install_requires = })); } + #[test] + fn test_setup_cfg_dependency_name_that_is_not_a_distribution_name_yields_no_purl() { + // `install_requires` is free-form text, so a name containing `/` reached + // the PURL verbatim as `pkg:pypi/a/b@2.0` — which no PURL parser accepts, + // since pypi prohibits a namespace. The raw text stays in + // `extracted_requirement`, so nothing is lost by declining the PURL. + let content = r#" +[metadata] +name = test-package +version = 1.0.0 + +[options] +install_requires = + a/b==2.0 + requests>=2.0 +"#; + + let (_temp_dir, file_path) = create_temp_file(content, "setup.cfg"); + let package_data = PythonParser::extract_first_package(&file_path); + + let invalid = package_data + .dependencies + .iter() + .find(|dependency| dependency.extracted_requirement.as_deref() == Some("a/b==2.0")) + .expect("the unparsable requirement should still be reported"); + assert_eq!(invalid.purl, None); + + assert!( + package_data + .dependencies + .iter() + .any(|dependency| { dependency.purl.as_deref() == Some("pkg:pypi/requests") }), + "the valid requirement in the same file must be unaffected" + ); + } + #[test] fn test_setup_cfg_dependency_preserves_pinned_version_in_purl() { let content = r#" diff --git a/src/parsers/python/utils.rs b/src/parsers/python/utils.rs index 4fb918872..c8c8b5e84 100644 --- a/src/parsers/python/utils.rs +++ b/src/parsers/python/utils.rs @@ -205,7 +205,7 @@ pub(super) fn build_python_dependency( default_scope, default_optional, ); - let purl = build_python_dependency_purl(&name, None)?; + let purl = build_python_dependency_purl(&name, None); let is_pinned = requirement .as_deref() @@ -215,7 +215,7 @@ pub(super) fn build_python_dependency( .as_deref() .map(|req| req.trim_start_matches('=')) .and_then(|version| build_python_dependency_purl(&name, Some(version))) - .unwrap_or(purl) + .or(purl) } else { purl }; @@ -227,7 +227,7 @@ pub(super) fn build_python_dependency( } Some(Dependency { - purl: Some(purl), + purl, extracted_requirement: requirement, scope: Some(parsed.scope), is_runtime: Some(true), @@ -247,20 +247,28 @@ pub(super) fn normalize_python_dependency_name(name: &str) -> String { normalize_python_distribution_name(name) } +/// Builds the PyPI PURL for a dependency name, or `None` when the name is not a +/// distribution name. +/// +/// The check replaces a `PackageUrl::new(…).ok()` gate that never rejected +/// anything: that call validates the *type*, which is a constant here, and the +/// PURL was then assembled with `format!` anyway. Names reaching this from +/// `setup.cfg` are taken from free-form text, so `install_requires = a/b==2.0` +/// produced `pkg:pypi/a/b@2.0` — a PURL no parser accepts, since pypi prohibits a +/// namespace. pub(super) fn build_python_dependency_purl(name: &str, version: Option<&str>) -> Option { let normalized_name = normalize_python_dependency_name(name); + if !crate::parsers::pep508::is_valid_distribution_name(&normalized_name) { + return None; + } - PackageUrl::new(PythonParser::PACKAGE_TYPE.as_str(), &normalized_name) - .ok() - .map(|_| match version { - Some(version) => { - format!( - "pkg:pypi/{normalized_name}@{}", - encode_python_dependency_purl_version(version) - ) - } - None => format!("pkg:pypi/{normalized_name}"), - }) + Some(match version { + Some(version) => format!( + "pkg:pypi/{normalized_name}@{}", + encode_python_dependency_purl_version(version) + ), + None => format!("pkg:pypi/{normalized_name}"), + }) } fn encode_python_dependency_purl_version(version: &str) -> String { @@ -502,10 +510,10 @@ fn parse_setup_py_dep_list(deps_str: &str, scope: &str, is_optional: bool) -> Ve } let name = extract_setup_cfg_dependency_name(dep_str)?; - let purl = build_python_dependency_purl(&name, None)?; + let purl = build_python_dependency_purl(&name, None); Some(Dependency { - purl: Some(purl), + purl, extracted_requirement: Some(dep_str.to_string()), scope: Some(scope.to_string()), is_runtime: Some(true), diff --git a/src/parsers/uv_lock.rs b/src/parsers/uv_lock.rs index e0f7ad90a..cb21bbe21 100644 --- a/src/parsers/uv_lock.rs +++ b/src/parsers/uv_lock.rs @@ -935,16 +935,11 @@ fn create_pypi_purl(name: &str, version: Option<&str>) -> Option { return Some(truncate_field(build_manual_pypi_purl(name, version))); } - if let Ok(mut purl) = PackageUrl::new(UvLockParser::PACKAGE_TYPE.as_str(), name) { - if let Some(version) = version - && purl.with_version(version).is_err() - { - return None; - } - return Some(truncate_field(purl.to_string())); + let mut purl = PackageUrl::new(UvLockParser::PACKAGE_TYPE.as_str(), name).ok()?; + if let Some(version) = version { + purl.with_version(version).ok()?; } - - Some(truncate_field(build_manual_pypi_purl(name, version))) + Some(truncate_field(purl.to_string())) } fn build_manual_pypi_purl(name: &str, version: Option<&str>) -> String {