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
16 changes: 9 additions & 7 deletions src/parsers/conan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -475,16 +475,18 @@ fn parse_conan_reference(ref_str: &str) -> Option<Dependency> {
}
});

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()
Expand Down
13 changes: 4 additions & 9 deletions src/parsers/poetry_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,16 +407,11 @@ fn create_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
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 {
Expand Down
20 changes: 4 additions & 16 deletions src/parsers/pylock_toml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -734,23 +734,11 @@ fn build_pypi_urls(
}

fn create_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
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 {
Expand Down
8 changes: 4 additions & 4 deletions src/parsers/python/pyproject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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),
Expand Down Expand Up @@ -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 {
Expand All @@ -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),
Expand Down
37 changes: 37 additions & 0 deletions src/parsers/python/scan_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
}
6 changes: 3 additions & 3 deletions src/parsers/python/setup_cfg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
36 changes: 36 additions & 0 deletions src/parsers/python/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
mstykow marked this conversation as resolved.
);
}

#[test]
fn test_setup_cfg_dependency_preserves_pinned_version_in_purl() {
let content = r#"
Expand Down
40 changes: 24 additions & 16 deletions src/parsers/python/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
};
Expand All @@ -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),
Expand All @@ -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<String> {
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 {
Expand Down Expand Up @@ -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),
Expand Down
13 changes: 4 additions & 9 deletions src/parsers/uv_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -935,16 +935,11 @@ fn create_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
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 {
Expand Down
Loading