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