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