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