1use std::collections::HashMap;
28use std::path::Path;
29
30use crate::parser_warn as warn;
31use packageurl::PackageUrl;
32use serde::Deserialize;
33
34use crate::models::{DatasourceId, PackageData, PackageType, Party, PartyType};
35use crate::parsers::utils::{read_file_to_string, truncate_field};
36use crate::utils::spdx::{ExpressionRelation, combine_license_expressions_with_relation};
37
38use super::PackageParser;
39use super::license_normalization::{
40 DeclaredLicenseMatchMetadata, NormalizedDeclaredLicense, build_declared_license_data,
41 combine_normalized_licenses, empty_declared_license_data, normalize_declared_license_key,
42};
43use super::metadata::ParserMetadata;
44
45const PACKAGE_TYPE: PackageType = PackageType::Freebsd;
46
47fn default_package_data() -> PackageData {
48 PackageData {
49 package_type: Some(PACKAGE_TYPE),
50 datasource_id: Some(DatasourceId::FreebsdCompactManifest),
51 ..Default::default()
52 }
53}
54
55pub struct FreebsdCompactManifestParser;
57
58impl PackageParser for FreebsdCompactManifestParser {
59 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
60
61 fn metadata() -> Vec<ParserMetadata> {
62 vec![ParserMetadata {
63 description: "FreeBSD +COMPACT_MANIFEST package manifest",
64 file_patterns: &["**/*COMPACT_MANIFEST"],
65 package_type: "freebsd",
66 primary_language: "",
67 documentation_url: Some("https://man.freebsd.org/cgi/man.cgi?query=pkg-create"),
68 }]
69 }
70
71 fn is_match(path: &Path) -> bool {
72 path.file_name()
73 .and_then(|name| name.to_str())
74 .map(|name| name == "+COMPACT_MANIFEST")
75 .unwrap_or(false)
76 }
77
78 fn extract_packages(path: &Path) -> Vec<PackageData> {
79 let content = match read_file_to_string(path, None) {
80 Ok(c) => c,
81 Err(e) => {
82 warn!("Failed to read FreeBSD manifest {:?}: {}", path, e);
83 return vec![default_package_data()];
84 }
85 };
86
87 vec![parse_freebsd_manifest(&content)]
88 }
89}
90
91#[derive(Debug, Deserialize)]
92struct FreebsdManifest {
93 name: Option<String>,
94 version: Option<String>,
95 #[serde(rename = "desc")]
96 description: Option<String>,
97 categories: Option<Vec<String>>,
98 www: Option<String>,
99 maintainer: Option<String>,
100 origin: Option<String>,
101 arch: Option<String>,
102 licenses: Option<Vec<String>>,
103 licenselogic: Option<String>,
104}
105
106pub(crate) fn parse_freebsd_manifest(content: &str) -> PackageData {
107 let manifest: FreebsdManifest = match yaml_serde::from_str(content) {
108 Ok(m) => m,
109 Err(e) => {
110 warn!("Failed to parse FreeBSD manifest: {}", e);
111 return default_package_data();
112 }
113 };
114
115 let name = manifest.name.map(truncate_field);
116 let version = manifest.version.map(truncate_field);
117 let description = manifest.description.map(truncate_field);
118 let homepage_url = manifest.www.map(truncate_field);
119 let keywords = manifest
120 .categories
121 .unwrap_or_default()
122 .into_iter()
123 .map(truncate_field)
124 .collect();
125
126 let mut qualifiers = HashMap::new();
128 if let Some(ref arch) = manifest.arch {
129 qualifiers.insert("arch".to_string(), truncate_field(arch.clone()));
130 }
131 if let Some(ref origin) = manifest.origin {
132 qualifiers.insert("origin".to_string(), truncate_field(origin.clone()));
133 }
134
135 let mut parties = Vec::new();
137 if let Some(maintainer_email) = manifest.maintainer {
138 parties.push(Party {
139 r#type: Some(PartyType::Person),
140 role: Some("maintainer".to_string()),
141 name: None,
142 email: Some(truncate_field(maintainer_email)),
143 url: None,
144 organization: None,
145 organization_url: None,
146 timezone: None,
147 });
148 }
149
150 let licenses = manifest
152 .licenses
153 .map(|lics| lics.into_iter().map(truncate_field).collect());
154 let licenselogic = manifest.licenselogic.map(truncate_field);
155 let extracted_license_statement = build_license_statement(&licenses, &licenselogic);
156 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
157 build_freebsd_license_data(
158 licenses.as_deref(),
159 licenselogic.as_deref(),
160 extracted_license_statement.as_deref(),
161 );
162
163 let code_view_url = manifest
165 .origin
166 .as_ref()
167 .map(|origin| truncate_field(format!("https://svnweb.freebsd.org/ports/head/{}", origin)));
168
169 let download_url = if let (Some(arch), Some(pkg_name), Some(pkg_version)) =
171 (&manifest.arch, &name, &version)
172 {
173 Some(truncate_field(format!(
174 "https://pkg.freebsd.org/{}/latest/All/{}-{}.txz",
175 arch, pkg_name, pkg_version
176 )))
177 } else {
178 None
179 };
180
181 let purl = name.as_ref().and_then(|pkg_name| {
182 build_freebsd_purl(
183 pkg_name,
184 version.as_deref(),
185 manifest.arch.as_deref(),
186 manifest.origin.as_deref(),
187 )
188 });
189 let purl = purl.map(truncate_field);
190
191 PackageData {
192 datasource_id: Some(DatasourceId::FreebsdCompactManifest),
193 package_type: Some(PACKAGE_TYPE),
194 name,
195 version,
196 description,
197 homepage_url,
198 keywords,
199 parties,
200 qualifiers: if qualifiers.is_empty() {
201 None
202 } else {
203 Some(qualifiers)
204 },
205 declared_license_expression,
206 declared_license_expression_spdx,
207 license_detections,
208 extracted_license_statement: extracted_license_statement.map(truncate_field),
209 code_view_url,
210 download_url,
211 purl,
212 ..Default::default()
213 }
214}
215
216pub(crate) fn build_freebsd_purl(
217 name: &str,
218 version: Option<&str>,
219 arch: Option<&str>,
220 origin: Option<&str>,
221) -> Option<String> {
222 let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
223
224 if let Some(version) = version {
225 purl.with_version(version).ok()?;
226 }
227
228 if let Some(arch) = arch {
229 purl.add_qualifier("arch", arch).ok()?;
230 }
231
232 if let Some(origin) = origin {
233 purl.add_qualifier("origin", origin).ok()?;
234 }
235
236 Some(purl.to_string())
237}
238
239pub(crate) fn build_freebsd_license_data(
240 licenses: Option<&[String]>,
241 licenselogic: Option<&str>,
242 matched_text: Option<&str>,
243) -> (
244 Option<String>,
245 Option<String>,
246 Vec<crate::models::LicenseDetection>,
247) {
248 let Some(licenses) = licenses else {
249 return empty_declared_license_data();
250 };
251
252 let normalized: Vec<_> = licenses
253 .iter()
254 .filter_map(|license| normalize_freebsd_license_name(license))
255 .collect();
256
257 if normalized.is_empty() {
258 return empty_declared_license_data();
259 }
260
261 let combined = match licenselogic.unwrap_or("and") {
262 "single" => normalized.into_iter().next(),
263 "or" | "dual" => combine_normalized_licenses(normalized, " OR "),
264 _ => combine_normalized_licenses(normalized, " AND "),
265 };
266
267 let Some(combined) = combined else {
268 return empty_declared_license_data();
269 };
270
271 build_declared_license_data(
272 combined,
273 DeclaredLicenseMatchMetadata::single_line(matched_text.unwrap_or_default()),
274 )
275}
276
277fn normalize_freebsd_license_name(license: &str) -> Option<NormalizedDeclaredLicense> {
278 match license.trim() {
279 "GPLv2" => Some(NormalizedDeclaredLicense::new("gpl-2.0", "GPL-2.0-only")),
280 "GPLv3" => Some(NormalizedDeclaredLicense::new("gpl-3.0", "GPL-3.0-only")),
281 "BSD2CLAUSE" => Some(NormalizedDeclaredLicense::new(
282 "bsd-simplified",
283 "BSD-2-Clause",
284 )),
285 "BSD3CLAUSE" => Some(NormalizedDeclaredLicense::new("bsd-new", "BSD-3-Clause")),
286 "PSFL" => Some(NormalizedDeclaredLicense::new("psf-2.0", "PSF-2.0")),
287 "RUBY" => Some(NormalizedDeclaredLicense::new("ruby", "Ruby")),
288 other => normalize_declared_license_key(other),
289 }
290}
291
292pub(crate) fn build_license_statement(
300 licenses: &Option<Vec<String>>,
301 licenselogic: &Option<String>,
302) -> Option<String> {
303 let license_list = licenses.as_ref()?;
304
305 if license_list.is_empty() {
306 return None;
307 }
308
309 let filtered_licenses: Vec<String> = license_list
311 .iter()
312 .filter_map(|lic| {
313 let trimmed = lic.trim();
314 if trimmed.is_empty() {
315 None
316 } else {
317 Some(trimmed.to_string())
318 }
319 })
320 .collect();
321
322 if filtered_licenses.is_empty() {
323 return None;
324 }
325
326 let logic = licenselogic.as_deref().unwrap_or("and");
327
328 match logic {
329 "single" => Some(filtered_licenses[0].clone()),
330 "or" | "dual" => {
331 combine_license_expressions_with_relation(filtered_licenses, ExpressionRelation::Or)
332 }
333 _ => combine_license_expressions_with_relation(filtered_licenses, ExpressionRelation::And),
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use std::path::PathBuf;
341
342 #[test]
343 fn test_is_match() {
344 assert!(FreebsdCompactManifestParser::is_match(&PathBuf::from(
345 "/path/to/+COMPACT_MANIFEST"
346 )));
347 assert!(FreebsdCompactManifestParser::is_match(&PathBuf::from(
348 "+COMPACT_MANIFEST"
349 )));
350 assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
351 "+MANIFEST"
352 )));
353 assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
354 "COMPACT_MANIFEST"
355 )));
356 assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
357 "package.json"
358 )));
359 }
360
361 #[test]
362 fn test_build_license_statement_single() {
363 let licenses = Some(vec!["GPLv2".to_string()]);
364 let logic = Some("single".to_string());
365 let result = build_license_statement(&licenses, &logic);
366 assert_eq!(result, Some("GPLv2".to_string()));
367 }
368
369 #[test]
370 fn test_build_license_statement_and() {
371 let licenses = Some(vec!["MIT".to_string(), "BSD-2-Clause".to_string()]);
372 let logic = Some("and".to_string());
373 let result = build_license_statement(&licenses, &logic);
374 assert_eq!(result, Some("BSD-2-Clause AND MIT".to_string()));
375 }
376
377 #[test]
378 fn test_build_license_statement_or() {
379 let licenses = Some(vec!["MIT".to_string(), "Apache-2.0".to_string()]);
380 let logic = Some("or".to_string());
381 let result = build_license_statement(&licenses, &logic);
382 assert_eq!(result, Some("Apache-2.0 OR MIT".to_string()));
383 }
384
385 #[test]
386 fn test_build_license_statement_dual() {
387 let licenses = Some(vec!["MIT".to_string(), "Apache-2.0".to_string()]);
388 let logic = Some("dual".to_string());
389 let result = build_license_statement(&licenses, &logic);
390 assert_eq!(result, Some("Apache-2.0 OR MIT".to_string()));
391 }
392
393 #[test]
394 fn test_build_license_statement_default_and() {
395 let licenses = Some(vec!["MIT".to_string(), "BSD".to_string()]);
396 let logic = None;
397 let result = build_license_statement(&licenses, &logic);
398 assert_eq!(result, Some("BSD AND MIT".to_string()));
399 }
400
401 #[test]
402 fn test_build_license_statement_unknown_defaults_to_and() {
403 let licenses = Some(vec!["MIT".to_string(), "BSD".to_string()]);
404 let logic = Some("unknown".to_string());
405 let result = build_license_statement(&licenses, &logic);
406 assert_eq!(result, Some("BSD AND MIT".to_string()));
407 }
408
409 #[test]
410 fn test_build_license_statement_empty_licenses() {
411 let licenses = Some(vec![]);
412 let logic = Some("and".to_string());
413 let result = build_license_statement(&licenses, &logic);
414 assert_eq!(result, None);
415 }
416
417 #[test]
418 fn test_build_license_statement_no_licenses() {
419 let licenses = None;
420 let logic = Some("and".to_string());
421 let result = build_license_statement(&licenses, &logic);
422 assert_eq!(result, None);
423 }
424
425 #[test]
426 fn test_build_license_statement_filters_empty() {
427 let licenses = Some(vec!["MIT".to_string(), "".to_string(), " ".to_string()]);
428 let logic = Some("and".to_string());
429 let result = build_license_statement(&licenses, &logic);
430 assert_eq!(result, Some("MIT".to_string()));
431 }
432
433 #[test]
434 fn test_build_license_statement_trims_whitespace() {
435 let licenses = Some(vec![" MIT ".to_string(), " Apache-2.0 ".to_string()]);
436 let logic = Some("or".to_string());
437 let result = build_license_statement(&licenses, &logic);
438 assert_eq!(result, Some("Apache-2.0 OR MIT".to_string()));
439 }
440
441 #[test]
442 fn test_normalize_freebsd_license_name_bsd2clause() {
443 assert_eq!(
444 normalize_freebsd_license_name("BSD2CLAUSE"),
445 Some(NormalizedDeclaredLicense::new(
446 "bsd-simplified",
447 "BSD-2-Clause",
448 ))
449 );
450 }
451}