use {
crate::{package_metadata::PythonPackageMetadata, resource::PythonResource},
anyhow::{Context, Result},
std::{
cmp::Ordering,
collections::{BTreeMap, BTreeSet},
convert::TryInto,
},
tugger_licensing::{ComponentFlavor, LicensedComponent},
};
pub const SAFE_SYSTEM_LIBRARIES: &[&str] = &[
"cabinet", "iphlpapi", "msi", "rpcrt4", "rt", "winmm", "ws2_32",
];
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PackageLicenseInfo {
pub package: String,
pub version: String,
pub metadata_licenses: Vec<String>,
pub classifier_licenses: Vec<String>,
pub license_texts: Vec<String>,
pub notice_texts: Vec<String>,
pub is_public_domain: bool,
}
impl TryInto<LicensedComponent> for PackageLicenseInfo {
type Error = anyhow::Error;
fn try_into(self) -> Result<LicensedComponent, Self::Error> {
let mut component = if self.is_public_domain {
LicensedComponent::new_public_domain(&self.package)
} else if !self.metadata_licenses.is_empty() || !self.classifier_licenses.is_empty() {
let mut spdx_license_ids = BTreeSet::new();
let mut non_spdx_licenses = BTreeSet::new();
for s in self
.metadata_licenses
.into_iter()
.chain(self.classifier_licenses.into_iter())
{
if let Some(lid) = spdx::license_id(&s) {
spdx_license_ids.insert(format!("({})", lid.name));
} else if spdx::Expression::parse(&s).is_ok() {
spdx_license_ids.insert(format!("({})", s));
} else if let Some(name) = spdx::identifiers::LICENSES
.iter()
.find_map(|(name, full, _)| if &s == full { Some(name) } else { None })
{
spdx_license_ids.insert(name.to_string());
} else {
non_spdx_licenses.insert(s);
}
}
if non_spdx_licenses.is_empty() {
let expression = spdx_license_ids
.into_iter()
.collect::<Vec<_>>()
.join(" OR ");
LicensedComponent::new_spdx(&self.package, &expression)?
} else {
LicensedComponent::new_unknown(
&self.package,
non_spdx_licenses.into_iter().collect::<Vec<_>>(),
)
}
} else {
LicensedComponent::new_none(&self.package)
};
component.set_flavor(ComponentFlavor::PythonPackage);
for text in self
.license_texts
.into_iter()
.chain(self.notice_texts.into_iter())
{
component.add_license_text(text);
}
Ok(component)
}
}
impl PartialOrd for PackageLicenseInfo {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
if self.package == other.package {
self.version.partial_cmp(&other.version)
} else {
self.package.partial_cmp(&other.package)
}
}
}
impl Ord for PackageLicenseInfo {
fn cmp(&self, other: &Self) -> Ordering {
if self.package == other.package {
self.version.cmp(&other.version)
} else {
self.package.cmp(&other.package)
}
}
}
pub fn derive_package_license_infos<'a>(
resources: impl Iterator<Item = &'a PythonResource<'a>>,
) -> Result<Vec<PackageLicenseInfo>> {
let mut packages = BTreeMap::new();
let resources = resources.filter_map(|resource| {
if let PythonResource::PackageDistributionResource(resource) = resource {
Some(resource)
} else {
None
}
});
for resource in resources {
let key = (resource.package.clone(), resource.version.clone());
let entry = packages.entry(key).or_insert(PackageLicenseInfo {
package: resource.package.clone(),
version: resource.version.clone(),
..Default::default()
});
if resource.name == "METADATA" || resource.name == "PKG-INFO" {
let metadata = PythonPackageMetadata::from_metadata(&resource.data.resolve()?)
.context("parsing package metadata")?;
for value in metadata.find_all_headers("License") {
entry.metadata_licenses.push(value.to_string());
}
for value in metadata.find_all_headers("Classifier") {
if value.starts_with("License ") {
if let Some(license) = value.split(" :: ").last() {
if license != "OSI Approved" {
entry.classifier_licenses.push(license.to_string());
}
}
}
}
}
else if resource.name.starts_with("LICENSE")
|| resource.name.starts_with("LICENSE")
|| resource.name.starts_with("COPYING")
{
let data = resource.data.resolve()?;
let license_text = String::from_utf8_lossy(&data);
entry.license_texts.push(license_text.to_string());
}
else if resource.name.starts_with("NOTICE") {
let data = resource.data.resolve()?;
let notice_text = String::from_utf8_lossy(&data);
entry.notice_texts.push(notice_text.to_string());
}
}
Ok(packages.into_iter().map(|(_, v)| v).collect::<Vec<_>>())
}
#[cfg(test)]
mod tests {
use {
super::*,
crate::resource::{
PythonPackageDistributionResource, PythonPackageDistributionResourceFlavor,
},
std::borrow::Cow,
tugger_file_manifest::FileData,
};
#[test]
fn test_derive_package_license_infos_empty() -> Result<()> {
let infos = derive_package_license_infos(vec![].iter())?;
assert!(infos.is_empty());
Ok(())
}
#[test]
fn test_derive_package_license_infos_license_file() -> Result<()> {
let resources = vec![PythonResource::PackageDistributionResource(Cow::Owned(
PythonPackageDistributionResource {
location: PythonPackageDistributionResourceFlavor::DistInfo,
package: "foo".to_string(),
version: "1.0".to_string(),
name: "LICENSE".to_string(),
data: FileData::Memory(vec![42]),
},
))];
let infos = derive_package_license_infos(resources.iter())?;
assert_eq!(infos.len(), 1);
assert_eq!(
infos[0],
PackageLicenseInfo {
package: "foo".to_string(),
version: "1.0".to_string(),
license_texts: vec!["*".to_string()],
..Default::default()
}
);
Ok(())
}
#[test]
fn test_derive_package_license_infos_metadata_licenses() -> Result<()> {
let resources = vec![PythonResource::PackageDistributionResource(Cow::Owned(
PythonPackageDistributionResource {
location: PythonPackageDistributionResourceFlavor::DistInfo,
package: "foo".to_string(),
version: "1.0".to_string(),
name: "METADATA".to_string(),
data: FileData::Memory(
"Name: foo\nLicense: BSD-1-Clause\nLicense: BSD-2-Clause\n"
.as_bytes()
.to_vec(),
),
},
))];
let infos = derive_package_license_infos(resources.iter())?;
assert_eq!(infos.len(), 1);
assert_eq!(
infos[0],
PackageLicenseInfo {
package: "foo".to_string(),
version: "1.0".to_string(),
metadata_licenses: vec!["BSD-1-Clause".to_string(), "BSD-2-Clause".to_string()],
..Default::default()
}
);
Ok(())
}
#[test]
fn test_derive_package_license_infos_metadata_classifiers() -> Result<()> {
let resources = vec![PythonResource::PackageDistributionResource(Cow::Owned(
PythonPackageDistributionResource {
location: PythonPackageDistributionResourceFlavor::DistInfo,
package: "foo".to_string(),
version: "1.0".to_string(),
name: "METADATA".to_string(),
data: FileData::Memory(
"Name: foo\nClassifier: License :: OSI Approved\nClassifier: License :: OSI Approved :: BSD-1-Clause\n"
.as_bytes()
.to_vec(),
),
},
))];
let infos = derive_package_license_infos(resources.iter())?;
assert_eq!(infos.len(), 1);
assert_eq!(
infos[0],
PackageLicenseInfo {
package: "foo".to_string(),
version: "1.0".to_string(),
classifier_licenses: vec!["BSD-1-Clause".to_string()],
..Default::default()
}
);
Ok(())
}
#[test]
fn license_info_to_component_empty() -> Result<()> {
let li = PackageLicenseInfo {
package: "foo".to_string(),
version: "0.1".to_string(),
..Default::default()
};
let c: LicensedComponent = li.try_into()?;
let mut wanted = LicensedComponent::new_none("foo");
wanted.set_flavor(ComponentFlavor::PythonPackage);
assert_eq!(c, wanted);
Ok(())
}
#[test]
fn license_info_to_component_single_metadata_spdx() -> Result<()> {
let li = PackageLicenseInfo {
package: "foo".to_string(),
version: "0.1".to_string(),
metadata_licenses: vec!["MIT".to_string()],
..Default::default()
};
let c: LicensedComponent = li.try_into()?;
let mut wanted = LicensedComponent::new_spdx("foo", "MIT")?;
wanted.set_flavor(ComponentFlavor::PythonPackage);
assert_eq!(c, wanted);
Ok(())
}
#[test]
fn license_info_to_component_single_classifier_spdx() -> Result<()> {
let li = PackageLicenseInfo {
package: "foo".to_string(),
version: "0.1".to_string(),
classifier_licenses: vec!["Apache-2.0".to_string()],
..Default::default()
};
let c: LicensedComponent = li.try_into()?;
let mut wanted = LicensedComponent::new_spdx("foo", "Apache-2.0")?;
wanted.set_flavor(ComponentFlavor::PythonPackage);
assert_eq!(c, wanted);
Ok(())
}
#[test]
fn license_info_to_component_multiple_metadata_spdx() -> Result<()> {
let li = PackageLicenseInfo {
package: "foo".to_string(),
version: "0.1".to_string(),
metadata_licenses: vec!["MIT".to_string(), "Apache-2.0".to_string()],
..Default::default()
};
let c: LicensedComponent = li.try_into()?;
let mut wanted = LicensedComponent::new_spdx("foo", "Apache-2.0 OR MIT")?;
wanted.set_flavor(ComponentFlavor::PythonPackage);
assert_eq!(c, wanted);
Ok(())
}
#[test]
fn license_info_to_component_multiple_classifier_spdx() -> Result<()> {
let li = PackageLicenseInfo {
package: "foo".to_string(),
version: "0.1".to_string(),
classifier_licenses: vec!["Apache-2.0".to_string(), "MIT".to_string()],
..Default::default()
};
let c: LicensedComponent = li.try_into()?;
let mut wanted = LicensedComponent::new_spdx("foo", "Apache-2.0 OR MIT")?;
wanted.set_flavor(ComponentFlavor::PythonPackage);
assert_eq!(c, wanted);
Ok(())
}
#[test]
fn license_info_to_component_spdx_expression() -> Result<()> {
let li = PackageLicenseInfo {
package: "foo".to_string(),
version: "0.1".to_string(),
metadata_licenses: vec!["MIT OR Apache-2.0".to_string()],
..Default::default()
};
let c: LicensedComponent = li.try_into()?;
let mut wanted = LicensedComponent::new_spdx("foo", "MIT OR Apache-2.0")?;
wanted.set_flavor(ComponentFlavor::PythonPackage);
assert_eq!(c, wanted);
Ok(())
}
#[test]
fn license_info_to_component_spdx_fullname() -> Result<()> {
let li = PackageLicenseInfo {
package: "foo".to_string(),
version: "0.1".to_string(),
metadata_licenses: vec!["MIT License".to_string()],
..Default::default()
};
let c: LicensedComponent = li.try_into()?;
let mut wanted = LicensedComponent::new_spdx("foo", "MIT")?;
wanted.set_flavor(ComponentFlavor::PythonPackage);
assert_eq!(c, wanted);
Ok(())
}
#[test]
fn license_info_to_component_unknown() -> Result<()> {
let terms = vec!["Unknown".to_string(), "Unknown 2".to_string()];
let li = PackageLicenseInfo {
package: "foo".to_string(),
version: "0.1".to_string(),
metadata_licenses: terms.clone(),
..Default::default()
};
let c: LicensedComponent = li.try_into()?;
let mut wanted = LicensedComponent::new_unknown("foo", terms);
wanted.set_flavor(ComponentFlavor::PythonPackage);
assert_eq!(c, wanted);
Ok(())
}
}