use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::debug::{log, FeludaError, FeludaResult, LogLevel};
mod spdx_charset {
pub const GLOBALLY_FORBIDDEN: &[char] = &['"', '\\', '\n', '\r', '\t'];
#[allow(dead_code)]
pub const LICENSE_VALID_CHARS: &str =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-+() ";
pub const SPDXID_VALID_CHARS: &str =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
pub const PROBLEMATIC_CHARS: &[char] = &[
'&', '|', '[', ']', '<', '>', '=', '*', '?', '^', '$', '%', '#', '@', '!', '~', '`', '{',
'}',
];
pub fn contains_forbidden_chars(s: &str) -> bool {
s.chars().any(|c| GLOBALLY_FORBIDDEN.contains(&c))
}
#[allow(dead_code)]
pub fn is_valid_ascii(s: &str) -> bool {
s.is_ascii()
}
pub fn contains_problematic_chars(s: &str) -> bool {
s.chars().any(|c| PROBLEMATIC_CHARS.contains(&c))
}
}
fn is_valid_spdx_license_format(license: &str) -> bool {
let allowed_chars = license
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+' | '(' | ')' | ' '));
if !allowed_chars {
return false;
}
let normalized = license.to_uppercase();
let has_invalid_operators = normalized.contains("&&")
|| normalized.contains("||")
|| normalized.contains("&")
|| normalized.contains("|");
if has_invalid_operators {
return false;
}
!license.contains("..") && !license.contains("--") && !license.trim().is_empty()
}
pub fn convert_to_spdx_license_expression(license: &str) -> String {
let force_noassertion = std::env::var("FELUDA_FORCE_NOASSERTION_LICENSES")
.map(|v| v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if force_noassertion {
log(
LogLevel::Info,
&format!("Force NOASSERTION mode: converting '{license}' to NOASSERTION"),
);
return "NOASSERTION".to_string();
}
let trimmed = license.trim();
if trimmed.is_empty()
|| trimmed.eq_ignore_ascii_case("null")
|| trimmed.eq_ignore_ascii_case("undefined")
|| trimmed.eq_ignore_ascii_case("none")
|| trimmed == "-"
|| trimmed == "n/a"
|| trimmed.eq_ignore_ascii_case("unlicensed")
|| trimmed.eq_ignore_ascii_case("proprietary")
|| !trimmed.is_ascii()
{
return "NOASSERTION".to_string();
}
let result = trimmed.replace(" / ", " OR ").replace("/", " OR ");
if spdx_charset::contains_forbidden_chars(&result) {
log(
LogLevel::Trace,
&format!("License '{license}' contains forbidden characters -> NOASSERTION"),
);
return "NOASSERTION".to_string();
}
if result.contains("{}") || result.contains("${") {
log(
LogLevel::Trace,
&format!("License '{license}' contains template patterns -> NOASSERTION"),
);
return "NOASSERTION".to_string();
}
if spdx_charset::contains_problematic_chars(&result) {
log(
LogLevel::Trace,
&format!("License '{license}' contains problematic characters -> NOASSERTION"),
);
return "NOASSERTION".to_string();
}
if result.len() > 100
|| !is_valid_spdx_license_format(&result)
|| result.is_empty()
|| result.trim() != result
|| result.contains(" ")
{
log(
LogLevel::Trace,
&format!(
"License '{license}' failed structural validation -> '{result}' -> NOASSERTION"
),
);
return "NOASSERTION".to_string();
}
let safe_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-+() ";
if !result.chars().all(|c| safe_chars.contains(c)) {
log(
LogLevel::Trace,
&format!("License '{license}' has invalid characters -> '{result}' -> NOASSERTION"),
);
return "NOASSERTION".to_string();
}
if license != result {
log(
LogLevel::Trace,
&format!("License conversion: '{license}' -> '{result}'"),
);
}
result
}
fn is_valid_spdx_id_format(spdx_id: &str) -> bool {
if !spdx_id.starts_with("SPDXRef-") {
return false;
}
if spdx_id.len() > 200 {
return false;
}
if spdx_charset::contains_forbidden_chars(spdx_id) {
return false;
}
if !spdx_id.is_ascii() {
return false;
}
let suffix = &spdx_id[8..];
if suffix.is_empty() {
return false;
}
suffix
.chars()
.all(|c| spdx_charset::SPDXID_VALID_CHARS.contains(c))
}
fn sanitize_spdx_identifier(input: &str) -> String {
if input.trim().is_empty() {
return String::new();
}
let sanitized = input
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect::<String>()
.split('_')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("_");
if sanitized.is_empty() {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
input.hash(&mut hasher);
let hash = hasher.finish();
format!("pkg_{hash:08x}").chars().take(12).collect()
} else {
sanitized
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpdxDocument {
pub spdx_version: String,
pub data_license: String,
#[serde(rename = "SPDXID")]
pub spdx_id: String,
pub name: String,
pub document_namespace: String,
pub creation_info: CreationInfo,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub packages: Vec<SpdxPackage>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub relationships: Vec<Relationship>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub annotations: Vec<Annotation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreationInfo {
pub created: DateTime<Utc>,
pub creators: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license_list_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpdxPackage {
pub name: String,
#[serde(rename = "SPDXID")]
pub spdx_id: String,
pub download_location: String,
pub files_analyzed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub version_info: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license_concluded: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license_declared: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license_comments: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub copyright_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub external_refs: Vec<ExternalReference>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalReference {
pub reference_category: String,
pub reference_type: String,
pub reference_locator: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Relationship {
#[serde(rename = "spdxElementId")]
pub spdx_element_id: String,
pub relationship_type: String,
pub related_spdx_element: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Annotation {
pub annotator: String,
pub annotation_date: DateTime<Utc>,
pub annotation_type: String,
#[serde(rename = "spdxIdentifierReference")]
pub spdx_identifier_reference: String,
pub comment: String,
}
impl SpdxDocument {
pub fn new(project_name: &str) -> Self {
let doc_id = Uuid::new_v4();
Self {
spdx_version: "SPDX-2.3".to_string(),
data_license: "CC0-1.0".to_string(),
spdx_id: "SPDXRef-DOCUMENT".to_string(),
name: format!("{}-{}", project_name, doc_id.simple()),
document_namespace: format!("https://anirudha.dev/feluda/spdx/{doc_id}"),
creation_info: CreationInfo {
created: Utc::now(),
creators: vec![format!("Tool: Feluda-{}", env!("CARGO_PKG_VERSION"))],
license_list_version: None,
},
packages: Vec::new(),
relationships: Vec::new(),
annotations: Vec::new(),
}
}
pub fn add_package(&mut self, package: SpdxPackage) {
let relationship = Relationship {
spdx_element_id: self.spdx_id.clone(),
relationship_type: "DESCRIBES".to_string(),
related_spdx_element: package.spdx_id.clone(),
comment: None,
};
self.packages.push(package);
self.relationships.push(relationship);
}
#[allow(dead_code)]
pub fn add_annotation(&mut self, spdx_ref: String, comment: String, annotation_type: String) {
let annotation = Annotation {
annotator: format!("Tool: Feluda-{}", env!("CARGO_PKG_VERSION")),
annotation_date: Utc::now(),
annotation_type,
spdx_identifier_reference: spdx_ref,
comment,
};
self.annotations.push(annotation);
}
}
impl SpdxPackage {
#[allow(dead_code)]
pub fn new(name: String, _document_namespace: &str) -> Self {
let sanitized_name = sanitize_spdx_identifier(&name);
Self {
name,
spdx_id: format!("SPDXRef-Package-{sanitized_name}"),
download_location: "NOASSERTION".to_string(),
files_analyzed: false,
version_info: None,
license_concluded: None,
license_declared: None,
license_comments: None,
copyright_text: Some("NOASSERTION".to_string()),
comment: None,
external_refs: Vec::new(),
}
}
pub fn with_version(mut self, version: String) -> Self {
self.version_info = Some(version.clone());
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let combined_input = format!("{}_{}", self.name, version);
let mut hasher = DefaultHasher::new();
combined_input.hash(&mut hasher);
let hash = hasher.finish();
self.spdx_id = format!("SPDXRef-Package-pkg{hash:016x}");
log(
LogLevel::Trace,
&format!(
"Generated SPDX ID '{}' for package '{}' version '{version}'",
self.spdx_id, self.name
),
);
self
}
pub fn with_license(mut self, license: String) -> Self {
let spdx_license = convert_to_spdx_license_expression(&license);
if license != spdx_license {
log(
LogLevel::Trace,
&format!("Converted license '{license}' to SPDX format: '{spdx_license}'"),
);
}
let final_license = if spdx_license.trim().is_empty() {
"NOASSERTION".to_string()
} else {
spdx_license
};
self.license_declared = Some(final_license.clone());
self.license_concluded = Some(final_license);
self
}
#[allow(dead_code)]
pub fn with_download_location(mut self, location: String) -> Self {
let validated = if location.trim().is_empty()
|| location.eq_ignore_ascii_case("noassertion")
|| location.eq_ignore_ascii_case("none")
{
location
} else if spdx_charset::contains_forbidden_chars(&location) || !location.is_ascii() {
log(
LogLevel::Warn,
&format!(
"Download location '{location}' contains invalid characters, using NOASSERTION"
),
);
"NOASSERTION".to_string()
} else {
location
};
self.download_location = validated;
self
}
#[allow(dead_code)]
pub fn with_copyright(mut self, copyright: String) -> Self {
let validated = if copyright.trim().is_empty() {
"NOASSERTION".to_string()
} else if spdx_charset::contains_forbidden_chars(©right) || !copyright.is_ascii() {
log(
LogLevel::Warn,
"Copyright text contains invalid characters, using NOASSERTION",
);
"NOASSERTION".to_string()
} else if copyright.len() > 1000 {
log(
LogLevel::Warn,
"Copyright text exceeds 1000 character limit",
);
"NOASSERTION".to_string()
} else {
copyright
};
self.copyright_text = Some(validated);
self
}
#[allow(dead_code)]
pub fn with_comment(mut self, comment: String) -> Self {
let validated = if comment.trim().is_empty() {
None
} else if spdx_charset::contains_forbidden_chars(&comment) || !comment.is_ascii() {
log(
LogLevel::Warn,
"Comment contains invalid characters, skipping",
);
None
} else if comment.len() > 500 {
log(LogLevel::Warn, "Comment exceeds 500 character limit");
None
} else {
Some(comment)
};
self.comment = validated;
self
}
#[allow(dead_code)]
pub fn add_external_ref(mut self, category: String, ref_type: String, locator: String) -> Self {
let is_valid = !spdx_charset::contains_forbidden_chars(&category)
&& category.is_ascii()
&& !spdx_charset::contains_forbidden_chars(&ref_type)
&& ref_type.is_ascii()
&& !spdx_charset::contains_forbidden_chars(&locator)
&& locator.is_ascii();
if is_valid && category.len() <= 100 && ref_type.len() <= 100 && locator.len() <= 500 {
let external_ref = ExternalReference {
reference_category: category,
reference_type: ref_type,
reference_locator: locator,
comment: None,
};
self.external_refs.push(external_ref);
} else {
log(
LogLevel::Warn,
"External reference validation failed, skipping",
);
}
self
}
}
impl Default for SpdxDocument {
fn default() -> Self {
Self::new("project")
}
}
fn validate_and_sanitize_spdx_package(package: &mut SpdxPackage) -> bool {
let mut needs_fix = false;
if !is_valid_spdx_id_format(&package.spdx_id) {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
package.name.hash(&mut hasher);
if let Some(ref version) = package.version_info {
version.hash(&mut hasher);
}
let hash = hasher.finish();
let old_id = package.spdx_id.clone();
package.spdx_id = format!("SPDXRef-Package-pkg{hash:016x}");
log(
LogLevel::Trace,
&format!(
"Regenerated invalid SPDX ID '{}' → '{}'",
old_id, package.spdx_id
),
);
needs_fix = true;
}
if package.name.is_empty()
|| spdx_charset::contains_forbidden_chars(&package.name)
|| !package.name.is_ascii()
|| package.name.len() > 500
{
let old_name = package.name.clone();
package.name = package
.name
.chars()
.filter(|&c| c.is_ascii_graphic() && !spdx_charset::GLOBALLY_FORBIDDEN.contains(&c))
.take(500)
.collect();
if package.name.is_empty() {
package.name = "unknown-package".to_string();
}
log(
LogLevel::Trace,
&format!("Sanitized package name '{}' → '{}'", old_name, package.name),
);
needs_fix = true;
}
if spdx_charset::contains_forbidden_chars(&package.download_location)
|| !package.download_location.is_ascii()
|| package.download_location.len() > 1000
{
package.download_location = "NOASSERTION".to_string();
needs_fix = true;
}
if let Some(ref mut version) = package.version_info {
if spdx_charset::contains_forbidden_chars(version)
|| !version.is_ascii()
|| version.len() > 200
{
let old_version = version.clone();
*version = version
.chars()
.filter(|&c| c.is_ascii_graphic() && !spdx_charset::GLOBALLY_FORBIDDEN.contains(&c))
.take(200)
.collect();
if version.is_empty() {
log(
LogLevel::Trace,
&format!(
"Removed invalid version '{old_version}' (became empty after sanitization)"
),
);
package.version_info = None;
} else {
log(
LogLevel::Trace,
&format!("Sanitized version '{old_version}' → '{version}'"),
);
}
needs_fix = true;
}
}
let validate_license = |license_opt: &mut Option<String>, _field_name: &str| -> bool {
if let Some(ref mut license) = license_opt {
if license.trim().is_empty()
|| spdx_charset::contains_forbidden_chars(license)
|| license.len() > 200
|| !license.is_ascii()
|| !is_valid_spdx_license_format(license)
{
*license = "NOASSERTION".to_string();
return true;
}
}
false
};
needs_fix |= validate_license(&mut package.license_declared, "license_declared");
needs_fix |= validate_license(&mut package.license_concluded, "license_concluded");
if package.license_declared.is_none() {
package.license_declared = Some("NOASSERTION".to_string());
needs_fix = true;
}
if package.license_concluded.is_none() {
package.license_concluded = Some("NOASSERTION".to_string());
needs_fix = true;
}
if let Some(ref mut copyright) = package.copyright_text {
if spdx_charset::contains_forbidden_chars(copyright)
|| !copyright.is_ascii()
|| copyright.len() > 1000
{
*copyright = "NOASSERTION".to_string();
needs_fix = true;
}
} else {
package.copyright_text = Some("NOASSERTION".to_string());
needs_fix = true;
}
needs_fix
}
pub fn generate_spdx_output(
spdx_doc: &SpdxDocument,
output_file: Option<String>,
) -> FeludaResult<()> {
log(LogLevel::Info, "Generating SPDX 2.3 compliant output");
let mut safe_doc = spdx_doc.clone();
let mut total_fixes = 0;
for package in &mut safe_doc.packages {
if validate_and_sanitize_spdx_package(package) {
total_fixes += 1;
}
}
if total_fixes > 0 {
log(
LogLevel::Warn,
&format!("Applied sanitization fixes to {total_fixes} packages"),
);
}
let json_output = serde_json::to_string_pretty(&safe_doc).map_err(|e| {
FeludaError::Serialization(format!("Failed to serialize SPDX document: {e}"))
})?;
if json_output.contains("\\n") || json_output.contains("\\r") {
return Err(FeludaError::InvalidData(
"SPDX JSON contains invalid escaped characters".to_string(),
));
}
if let Some(file_path) = output_file {
let spdx_file = if file_path.ends_with(".json") {
file_path
} else {
format!("{}.spdx.json", file_path.trim_end_matches(".spdx"))
};
std::fs::write(&spdx_file, &json_output)
.map_err(|e| FeludaError::FileWrite(format!("Failed to write SPDX file: {e}")))?;
println!("SPDX SBOM written to: {spdx_file}");
log(
LogLevel::Info,
&format!("SPDX SBOM written to: {spdx_file}"),
);
} else {
println!("=== SPDX SBOM ===");
println!("{json_output}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
#[serial]
fn test_convert_to_spdx_license_expression() {
std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES");
assert_eq!(convert_to_spdx_license_expression("MIT"), "MIT");
assert_eq!(
convert_to_spdx_license_expression("MIT/Apache-2.0"),
"MIT OR Apache-2.0"
);
assert_eq!(
convert_to_spdx_license_expression("Apache-2.0/MIT"),
"Apache-2.0 OR MIT"
);
assert_eq!(
convert_to_spdx_license_expression("MIT / Apache-2.0"),
"MIT OR Apache-2.0"
);
assert_eq!(
convert_to_spdx_license_expression("Apache-2.0 / MIT"),
"Apache-2.0 OR MIT"
);
assert_eq!(
convert_to_spdx_license_expression("Unlicense/MIT"),
"Unlicense OR MIT"
);
assert_eq!(
convert_to_spdx_license_expression("MIT OR Apache-2.0"),
"MIT OR Apache-2.0"
);
}
#[test]
fn test_spdx_package_unique_ids() {
let package1 = SpdxPackage::new("getrandom".to_string(), "https://example.com/test")
.with_version("0.2.16".to_string());
let package2 = SpdxPackage::new("getrandom".to_string(), "https://example.com/test")
.with_version("0.3.3".to_string());
assert!(package1.spdx_id.starts_with("SPDXRef-Package-pkg"));
assert!(package2.spdx_id.starts_with("SPDXRef-Package-pkg"));
assert_ne!(package1.spdx_id, package2.spdx_id);
let package3 = SpdxPackage::new("test-package".to_string(), "https://example.com/test")
.with_version("1.0.0-beta".to_string());
assert!(package3.spdx_id.starts_with("SPDXRef-Package-pkg"));
}
#[test]
fn test_sanitize_spdx_identifier() {
assert_eq!(sanitize_spdx_identifier("lodash"), "lodash");
assert_eq!(sanitize_spdx_identifier("lodash-utils"), "lodash_utils");
assert_eq!(
sanitize_spdx_identifier("lodash.castarray"),
"lodash_castarray"
);
assert_eq!(sanitize_spdx_identifier("@types/node"), "types_node");
assert_eq!(sanitize_spdx_identifier("package@1.2.3"), "package_1_2_3");
assert_eq!(sanitize_spdx_identifier("@babel/core"), "babel_core");
assert_eq!(
sanitize_spdx_identifier("package-name-with.dots"),
"package_name_with_dots"
);
assert_eq!(sanitize_spdx_identifier("123-package"), "123_package");
assert_eq!(sanitize_spdx_identifier(""), "");
assert_eq!(sanitize_spdx_identifier("a__b__c"), "a_b_c");
let special_only = sanitize_spdx_identifier("___");
assert!(!special_only.is_empty());
assert!(special_only.starts_with("pkg_"));
let symbols_only = sanitize_spdx_identifier("@#$%^&*()");
assert!(!symbols_only.is_empty());
assert!(symbols_only.starts_with("pkg_"));
let unicode_only = sanitize_spdx_identifier("ä½ å¥½ä¸–ç•Œ");
assert!(!unicode_only.is_empty());
assert!(unicode_only.starts_with("pkg_"));
let hash1 = sanitize_spdx_identifier("___");
let hash2 = sanitize_spdx_identifier("___");
assert_eq!(hash1, hash2);
}
#[test]
#[serial]
fn test_license_expression_edge_cases() {
std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES");
assert_eq!(convert_to_spdx_license_expression(""), "NOASSERTION");
assert_eq!(convert_to_spdx_license_expression(" "), "NOASSERTION");
assert_eq!(convert_to_spdx_license_expression("null"), "NOASSERTION");
assert_eq!(convert_to_spdx_license_expression("NULL"), "NOASSERTION");
assert_eq!(
convert_to_spdx_license_expression("undefined"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("UNDEFINED"),
"NOASSERTION"
);
assert_eq!(convert_to_spdx_license_expression("none"), "NOASSERTION");
assert_eq!(convert_to_spdx_license_expression("NONE"), "NOASSERTION");
assert_eq!(convert_to_spdx_license_expression("-"), "NOASSERTION");
assert_eq!(convert_to_spdx_license_expression("n/a"), "NOASSERTION");
assert_eq!(convert_to_spdx_license_expression("MIT{}"), "NOASSERTION");
assert_eq!(
convert_to_spdx_license_expression("${LICENSE}"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT\"quote"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT\\backslash"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT\nnewline"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT\ttab"),
"NOASSERTION"
);
assert_eq!(convert_to_spdx_license_expression("MIT&AND"), "NOASSERTION");
assert_eq!(convert_to_spdx_license_expression("MIT|OR"), "NOASSERTION");
assert_eq!(
convert_to_spdx_license_expression("MIT[bracket]"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT{brace}"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT<angle>"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("unlicensed"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("proprietary"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT--invalid"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT..invalid"),
"NOASSERTION"
);
let long_license = "A".repeat(250);
assert_eq!(
convert_to_spdx_license_expression(&long_license),
"NOASSERTION"
);
std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES");
assert_eq!(convert_to_spdx_license_expression("MIT"), "MIT");
assert_eq!(
convert_to_spdx_license_expression("MIT OR Apache-2.0"),
"MIT OR Apache-2.0"
);
assert_eq!(
convert_to_spdx_license_expression("MIT/Apache-2.0"),
"MIT OR Apache-2.0"
);
assert_eq!(
convert_to_spdx_license_expression("MIT / Apache-2.0"),
"MIT OR Apache-2.0"
);
}
#[test]
#[serial]
fn test_complex_package_names() {
std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES");
let package = SpdxPackage::new("lodash.castarray".to_string(), "https://example.com/test")
.with_version("4.4.0".to_string());
assert!(package.spdx_id.starts_with("SPDXRef-Package-pkg"));
let types_package = SpdxPackage::new("@types/node".to_string(), "https://example.com/test")
.with_version("18.15.0".to_string());
assert!(types_package.spdx_id.starts_with("SPDXRef-Package-pkg"));
let esbuild_package = SpdxPackage::new(
"esbuild-linux-ppc64".to_string(),
"https://example.com/test",
)
.with_version("0.19.4".to_string())
.with_license("MIT".to_string());
assert!(esbuild_package.spdx_id.starts_with("SPDXRef-Package-pkg"));
assert_eq!(esbuild_package.license_concluded, Some("MIT".to_string()));
let no_license_package =
SpdxPackage::new("some-package".to_string(), "https://example.com/test")
.with_version("1.0.0".to_string())
.with_license("".to_string());
assert_eq!(
no_license_package.license_concluded,
Some("NOASSERTION".to_string())
);
}
#[test]
#[serial]
fn test_extreme_edge_case_packages() {
std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES");
let special_package = SpdxPackage::new("@#$%^&*()".to_string(), "https://example.com/test")
.with_version("!!!".to_string())
.with_license("null".to_string());
assert!(!special_package.spdx_id.is_empty());
assert!(special_package.spdx_id.starts_with("SPDXRef-Package-pkg"));
assert!(!special_package.spdx_id.contains("SPDXRef-Package-947d52c7")); assert_eq!(
special_package.license_concluded,
Some("NOASSERTION".to_string())
);
let unicode_package = SpdxPackage::new("ä½ å¥½ä¸–ç•Œ".to_string(), "https://example.com/test")
.with_version("版本1.0".to_string())
.with_license("MIT".to_string());
assert!(!unicode_package.spdx_id.is_empty());
assert!(unicode_package.spdx_id.starts_with("SPDXRef-Package-pkg"));
assert_eq!(unicode_package.license_concluded, Some("MIT".to_string()));
}
#[test]
fn test_spdx_id_hash_fallback_logic() {
let normal_name_weird_version = SpdxPackage::new(
"micromark-util-symbol".to_string(),
"https://example.com/test",
)
.with_version("@#$%^&*()".to_string());
assert!(normal_name_weird_version
.spdx_id
.starts_with("SPDXRef-Package-pkg"));
assert!(normal_name_weird_version.spdx_id.len() <= 50);
let weird_name_normal_version =
SpdxPackage::new("@#$%^&*()".to_string(), "https://example.com/test")
.with_version("2.0.1".to_string());
assert!(weird_name_normal_version
.spdx_id
.starts_with("SPDXRef-Package-pkg"));
let weird_name_weird_version =
SpdxPackage::new("@#$%^&*()".to_string(), "https://example.com/test")
.with_version("!!!".to_string());
assert!(weird_name_weird_version
.spdx_id
.starts_with("SPDXRef-Package-pkg"));
let duplicate = SpdxPackage::new("@#$%^&*()".to_string(), "https://example.com/test")
.with_version("!!!".to_string());
assert_eq!(weird_name_weird_version.spdx_id, duplicate.spdx_id);
}
#[test]
#[serial]
fn test_micromark_util_symbol_case() {
std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES");
let micromark_package = SpdxPackage::new(
"micromark-util-symbol".to_string(),
"https://example.com/test",
)
.with_version("2.0.1".to_string())
.with_license("MIT".to_string());
assert!(micromark_package.spdx_id.starts_with("SPDXRef-Package-pkg"));
assert_eq!(micromark_package.license_concluded, Some("MIT".to_string()));
let micromark_with_weird_version = SpdxPackage::new(
"micromark-util-symbol".to_string(),
"https://example.com/test",
)
.with_version("!!!".to_string()) .with_license("MIT".to_string());
assert!(micromark_with_weird_version
.spdx_id
.starts_with("SPDXRef-Package-pkg"));
assert!(micromark_with_weird_version.spdx_id.len() <= 50); }
#[test]
#[serial]
fn test_license_concluded_vs_declared() {
std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES");
let mit_package = SpdxPackage::new("test-package".to_string(), "https://example.com/test")
.with_version("1.0.0".to_string())
.with_license("MIT".to_string());
assert_eq!(mit_package.license_declared, Some("MIT".to_string()));
assert_eq!(mit_package.license_concluded, Some("MIT".to_string()));
let no_license_package =
SpdxPackage::new("test-package".to_string(), "https://example.com/test")
.with_version("1.0.0".to_string())
.with_license("".to_string());
assert_eq!(
no_license_package.license_declared,
Some("NOASSERTION".to_string())
);
assert_eq!(
no_license_package.license_concluded,
Some("NOASSERTION".to_string())
);
let bad_license_package =
SpdxPackage::new("test-package".to_string(), "https://example.com/test")
.with_version("1.0.0".to_string())
.with_license("MIT\"with-quotes".to_string());
assert_eq!(
bad_license_package.license_declared,
Some("NOASSERTION".to_string())
);
assert_eq!(
bad_license_package.license_concluded,
Some("NOASSERTION".to_string())
);
let cargo_license_package =
SpdxPackage::new("test-package".to_string(), "https://example.com/test")
.with_version("1.0.0".to_string())
.with_license("MIT/Apache-2.0".to_string());
assert_eq!(
cargo_license_package.license_declared,
Some("MIT OR Apache-2.0".to_string())
);
assert_eq!(
cargo_license_package.license_concluded,
Some("MIT OR Apache-2.0".to_string())
);
}
#[test]
fn test_spdx_license_format_validation() {
assert!(super::is_valid_spdx_license_format("MIT"));
assert!(super::is_valid_spdx_license_format("Apache-2.0"));
assert!(super::is_valid_spdx_license_format("GPL-3.0+"));
assert!(super::is_valid_spdx_license_format("MIT OR Apache-2.0"));
assert!(super::is_valid_spdx_license_format(
"(MIT OR Apache-2.0) AND GPL-2.0"
));
assert!(super::is_valid_spdx_license_format("NOASSERTION"));
assert!(!super::is_valid_spdx_license_format("MIT&Apache"));
assert!(!super::is_valid_spdx_license_format("MIT|Apache"));
assert!(!super::is_valid_spdx_license_format("MIT&&Apache"));
assert!(!super::is_valid_spdx_license_format("MIT||Apache"));
assert!(!super::is_valid_spdx_license_format("MIT--invalid"));
assert!(!super::is_valid_spdx_license_format("MIT..invalid"));
assert!(!super::is_valid_spdx_license_format("MIT@invalid"));
assert!(!super::is_valid_spdx_license_format("MIT#invalid"));
assert!(!super::is_valid_spdx_license_format(""));
assert!(!super::is_valid_spdx_license_format(" "));
}
#[test]
#[serial]
fn test_ultra_conservative_license_validation() {
std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES");
assert_eq!(
convert_to_spdx_license_expression("MIT=invalid"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT*wildcard"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT?question"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT^caret"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT$dollar"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT%percent"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT#hash"),
"NOASSERTION"
);
assert_eq!(convert_to_spdx_license_expression("MIT@at"), "NOASSERTION");
assert_eq!(
convert_to_spdx_license_expression("MIT!exclaim"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT~tilde"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT`backtick"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT©copyright"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MITâ„¢trademark"),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT®registered"),
"NOASSERTION"
);
let long_license = "A".repeat(101);
assert_eq!(
convert_to_spdx_license_expression(&long_license),
"NOASSERTION"
);
assert_eq!(
convert_to_spdx_license_expression("MIT_underscore"),
"NOASSERTION"
); assert_eq!(
convert_to_spdx_license_expression("MIT:colon"),
"NOASSERTION"
); assert_eq!(
convert_to_spdx_license_expression("MIT;semicolon"),
"NOASSERTION"
); assert_eq!(
convert_to_spdx_license_expression("MIT,comma"),
"NOASSERTION"
); }
#[test]
#[serial]
fn test_force_noassertion_mode() {
std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES");
assert_eq!(convert_to_spdx_license_expression("MIT"), "MIT");
assert_eq!(
convert_to_spdx_license_expression("Apache-2.0"),
"Apache-2.0"
);
std::env::set_var("FELUDA_FORCE_NOASSERTION_LICENSES", "true");
assert_eq!(convert_to_spdx_license_expression("MIT"), "NOASSERTION");
assert_eq!(
convert_to_spdx_license_expression("Apache-2.0"),
"NOASSERTION"
);
assert_eq!(convert_to_spdx_license_expression("GPL-3.0"), "NOASSERTION");
assert_eq!(convert_to_spdx_license_expression(""), "NOASSERTION");
std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES");
}
#[test]
fn test_spdx_document_license_safety_net() {
let mut doc = SpdxDocument::new("test");
let mut package = SpdxPackage::new("test-package".to_string(), &doc.document_namespace);
package.license_declared = Some("MIT\"with-quotes".to_string()); package.license_concluded = Some("Apache\\with-backslash".to_string());
doc.add_package(package);
assert_eq!(doc.packages.len(), 1);
assert!(doc.packages[0].license_declared.is_some());
assert!(doc.packages[0].license_concluded.is_some());
}
#[test]
fn test_spdx_id_format_validation() {
assert!(super::is_valid_spdx_id_format("SPDXRef-DOCUMENT"));
assert!(super::is_valid_spdx_id_format("SPDXRef-Package-123"));
assert!(super::is_valid_spdx_id_format("SPDXRef-File-src.main.rs"));
assert!(super::is_valid_spdx_id_format(
"SPDXRef-Package-pkg0123456789abcdef"
));
assert!(!super::is_valid_spdx_id_format("")); assert!(!super::is_valid_spdx_id_format("SPDX-DOCUMENT")); assert!(!super::is_valid_spdx_id_format("SPDXRef-")); assert!(!super::is_valid_spdx_id_format("SPDXRef-Package\"quoted")); assert!(!super::is_valid_spdx_id_format(
"SPDXRef-Package\\backslash"
)); assert!(!super::is_valid_spdx_id_format(
"SPDXRef-Package\nwithNewline"
));
let valid_long = format!("SPDXRef-{}", "a".repeat(192)); assert!(super::is_valid_spdx_id_format(&valid_long));
let invalid_long = format!("SPDXRef-{}", "a".repeat(193)); assert!(!super::is_valid_spdx_id_format(&invalid_long));
}
#[test]
#[serial]
fn test_package_metadata_validation() {
std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES");
let package = SpdxPackage::new("valid-package".to_string(), "https://example.com/test")
.with_version("1.0.0".to_string())
.with_license("MIT".to_string())
.with_copyright("(C) 2024 Example Corp".to_string())
.with_download_location("https://github.com/example/repo".to_string())
.with_comment("A valid test package".to_string());
assert!(!package.name.is_empty());
assert_eq!(package.version_info, Some("1.0.0".to_string()));
assert_eq!(package.license_concluded, Some("MIT".to_string()));
assert!(package.copyright_text.is_some());
let mut package_with_issues =
SpdxPackage::new("test".to_string(), "https://example.com/test")
.with_version("1.0.0".to_string())
.with_license("MIT".to_string());
package_with_issues.name = "package\"with-quote".to_string();
package_with_issues.download_location = "https://example.com\nmalicious".to_string();
assert!(super::validate_and_sanitize_spdx_package(
&mut package_with_issues
));
assert!(!package_with_issues.name.contains('"'));
assert_eq!(package_with_issues.download_location, "NOASSERTION");
}
#[test]
#[serial]
fn test_download_location_validation() {
std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES");
let package1 = SpdxPackage::new("test".to_string(), "https://example.com/test")
.with_download_location("https://github.com/example/repo".to_string());
assert_eq!(
package1.download_location,
"https://github.com/example/repo"
);
let package2 = SpdxPackage::new("test".to_string(), "https://example.com/test")
.with_download_location("NOASSERTION".to_string());
assert_eq!(package2.download_location, "NOASSERTION");
let package3 = SpdxPackage::new("test".to_string(), "https://example.com/test")
.with_download_location("https://example.com/文件".to_string());
assert_eq!(package3.download_location, "NOASSERTION");
let mut package4 = SpdxPackage::new("test".to_string(), "https://example.com/test");
package4.download_location = format!("https://example.com/{}", "a".repeat(2000));
assert!(super::validate_and_sanitize_spdx_package(&mut package4));
assert_eq!(package4.download_location, "NOASSERTION");
}
#[test]
#[serial]
fn test_copyright_validation() {
std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES");
let package1 = SpdxPackage::new("test".to_string(), "https://example.com/test")
.with_copyright("(C) 2024 Example Corp".to_string());
assert_eq!(
package1.copyright_text,
Some("(C) 2024 Example Corp".to_string())
);
let package2 = SpdxPackage::new("test".to_string(), "https://example.com/test")
.with_copyright("".to_string());
assert_eq!(package2.copyright_text, Some("NOASSERTION".to_string()));
let package3 = SpdxPackage::new("test".to_string(), "https://example.com/test")
.with_copyright("(C) 2024 Corp\"with-quotes".to_string());
assert_eq!(package3.copyright_text, Some("NOASSERTION".to_string()));
}
#[test]
#[serial]
fn test_external_ref_validation() {
std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES");
let package = SpdxPackage::new("test".to_string(), "https://example.com/test")
.add_external_ref(
"PACKAGE_MANAGER".to_string(),
"npm".to_string(),
"lodash@4.17.21".to_string(),
);
assert_eq!(package.external_refs.len(), 1);
assert_eq!(
package.external_refs[0].reference_category,
"PACKAGE_MANAGER"
);
assert_eq!(package.external_refs[0].reference_type, "npm");
assert_eq!(package.external_refs[0].reference_locator, "lodash@4.17.21");
let package2 = SpdxPackage::new("test".to_string(), "https://example.com/test")
.add_external_ref(
"SECURITY_OTHER".to_string(),
"cpe23".to_string(),
"cpe:2.3:a:vendor:product:1.0\"malicious".to_string(),
);
assert_eq!(package2.external_refs.len(), 0);
}
#[test]
#[serial]
fn test_comment_validation() {
std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES");
let package1 = SpdxPackage::new("test".to_string(), "https://example.com/test")
.with_comment("This is a valid comment".to_string());
assert_eq!(
package1.comment,
Some("This is a valid comment".to_string())
);
let package2 = SpdxPackage::new("test".to_string(), "https://example.com/test")
.with_comment("".to_string());
assert_eq!(package2.comment, None);
let package3 = SpdxPackage::new("test".to_string(), "https://example.com/test")
.with_comment("Comment with\nnewline".to_string());
assert_eq!(package3.comment, None);
}
#[test]
fn test_charset_validation_helpers() {
assert!(spdx_charset::contains_forbidden_chars("test\"quote"));
assert!(spdx_charset::contains_forbidden_chars("test\\backslash"));
assert!(spdx_charset::contains_forbidden_chars("test\nnewline"));
assert!(!spdx_charset::contains_forbidden_chars("test-string"));
assert!(spdx_charset::is_valid_ascii("test"));
assert!(!spdx_charset::is_valid_ascii("testâ„¢"));
assert!(!spdx_charset::is_valid_ascii("test©"));
assert!(spdx_charset::contains_problematic_chars("test&symbol"));
assert!(spdx_charset::contains_problematic_chars("test|pipe"));
assert!(spdx_charset::contains_problematic_chars("test[bracket]"));
assert!(!spdx_charset::contains_problematic_chars("test-string"));
}
}