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, truncate_field};
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, None) {
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.map(truncate_field);
99 let version = manifest.version.map(truncate_field);
100 let description = manifest.description.map(truncate_field);
101 let homepage_url = manifest.www.map(truncate_field);
102 let keywords = manifest
103 .categories
104 .unwrap_or_default()
105 .into_iter()
106 .map(truncate_field)
107 .collect();
108
109 let mut qualifiers = HashMap::new();
111 if let Some(ref arch) = manifest.arch {
112 qualifiers.insert("arch".to_string(), truncate_field(arch.clone()));
113 }
114 if let Some(ref origin) = manifest.origin {
115 qualifiers.insert("origin".to_string(), truncate_field(origin.clone()));
116 }
117
118 let mut parties = Vec::new();
120 if let Some(maintainer_email) = manifest.maintainer {
121 parties.push(Party {
122 r#type: Some("person".to_string()),
123 role: Some("maintainer".to_string()),
124 name: None,
125 email: Some(truncate_field(maintainer_email)),
126 url: None,
127 organization: None,
128 organization_url: None,
129 timezone: None,
130 });
131 }
132
133 let licenses = manifest
135 .licenses
136 .map(|lics| lics.into_iter().map(truncate_field).collect());
137 let licenselogic = manifest.licenselogic.map(truncate_field);
138 let extracted_license_statement = build_license_statement(&licenses, &licenselogic);
139 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
140 build_freebsd_license_data(
141 licenses.as_deref(),
142 licenselogic.as_deref(),
143 extracted_license_statement.as_deref(),
144 );
145
146 let code_view_url = manifest
148 .origin
149 .as_ref()
150 .map(|origin| truncate_field(format!("https://svnweb.freebsd.org/ports/head/{}", origin)));
151
152 let download_url = if let (Some(arch), Some(pkg_name), Some(pkg_version)) =
154 (&manifest.arch, &name, &version)
155 {
156 Some(truncate_field(format!(
157 "https://pkg.freebsd.org/{}/latest/All/{}-{}.txz",
158 arch, pkg_name, pkg_version
159 )))
160 } else {
161 None
162 };
163
164 let purl = name.as_ref().and_then(|pkg_name| {
165 build_freebsd_purl(
166 pkg_name,
167 version.as_deref(),
168 manifest.arch.as_deref(),
169 manifest.origin.as_deref(),
170 )
171 });
172 let purl = purl.map(truncate_field);
173
174 PackageData {
175 datasource_id: Some(DatasourceId::FreebsdCompactManifest),
176 package_type: Some(PACKAGE_TYPE),
177 name,
178 version,
179 description,
180 homepage_url,
181 keywords,
182 parties,
183 qualifiers: if qualifiers.is_empty() {
184 None
185 } else {
186 Some(qualifiers)
187 },
188 declared_license_expression,
189 declared_license_expression_spdx,
190 license_detections,
191 extracted_license_statement: extracted_license_statement.map(truncate_field),
192 code_view_url,
193 download_url,
194 purl,
195 ..Default::default()
196 }
197}
198
199pub(crate) fn build_freebsd_purl(
200 name: &str,
201 version: Option<&str>,
202 arch: Option<&str>,
203 origin: Option<&str>,
204) -> Option<String> {
205 let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
206
207 if let Some(version) = version {
208 purl.with_version(version).ok()?;
209 }
210
211 if let Some(arch) = arch {
212 purl.add_qualifier("arch", arch).ok()?;
213 }
214
215 if let Some(origin) = origin {
216 purl.add_qualifier("origin", origin).ok()?;
217 }
218
219 Some(purl.to_string())
220}
221
222pub(crate) fn build_freebsd_license_data(
223 licenses: Option<&[String]>,
224 licenselogic: Option<&str>,
225 matched_text: Option<&str>,
226) -> (
227 Option<String>,
228 Option<String>,
229 Vec<crate::models::LicenseDetection>,
230) {
231 let Some(licenses) = licenses else {
232 return empty_declared_license_data();
233 };
234
235 let normalized: Vec<_> = licenses
236 .iter()
237 .filter_map(|license| normalize_freebsd_license_name(license))
238 .collect();
239
240 if normalized.is_empty() {
241 return empty_declared_license_data();
242 }
243
244 let combined = match licenselogic.unwrap_or("and") {
245 "single" => normalized.into_iter().next(),
246 "or" | "dual" => combine_normalized_licenses(normalized, " OR "),
247 _ => combine_normalized_licenses(normalized, " AND "),
248 };
249
250 let Some(combined) = combined else {
251 return empty_declared_license_data();
252 };
253
254 build_declared_license_data(
255 combined,
256 DeclaredLicenseMatchMetadata::single_line(matched_text.unwrap_or_default()),
257 )
258}
259
260fn normalize_freebsd_license_name(license: &str) -> Option<NormalizedDeclaredLicense> {
261 match license.trim() {
262 "GPLv2" => Some(NormalizedDeclaredLicense::new("gpl-2.0", "GPL-2.0-only")),
263 "GPLv3" => Some(NormalizedDeclaredLicense::new("gpl-3.0", "GPL-3.0-only")),
264 "BSD3CLAUSE" => Some(NormalizedDeclaredLicense::new("bsd-new", "BSD-3-Clause")),
265 "PSFL" => Some(NormalizedDeclaredLicense::new("psf-2.0", "PSF-2.0")),
266 "RUBY" => Some(NormalizedDeclaredLicense::new("ruby", "Ruby")),
267 other => normalize_declared_license_key(other),
268 }
269}
270
271pub(crate) fn build_license_statement(
279 licenses: &Option<Vec<String>>,
280 licenselogic: &Option<String>,
281) -> Option<String> {
282 let license_list = licenses.as_ref()?;
283
284 if license_list.is_empty() {
285 return None;
286 }
287
288 let filtered_licenses: Vec<String> = license_list
290 .iter()
291 .filter_map(|lic| {
292 let trimmed = lic.trim();
293 if trimmed.is_empty() {
294 None
295 } else {
296 Some(trimmed.to_string())
297 }
298 })
299 .collect();
300
301 if filtered_licenses.is_empty() {
302 return None;
303 }
304
305 let logic = licenselogic.as_deref().unwrap_or("and");
306
307 match logic {
308 "single" => Some(filtered_licenses[0].clone()),
309 "or" | "dual" => {
310 combine_license_expressions_with_relation(filtered_licenses, ExpressionRelation::Or)
311 }
312 _ => combine_license_expressions_with_relation(filtered_licenses, ExpressionRelation::And),
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use std::path::PathBuf;
320
321 #[test]
322 fn test_is_match() {
323 assert!(FreebsdCompactManifestParser::is_match(&PathBuf::from(
324 "/path/to/+COMPACT_MANIFEST"
325 )));
326 assert!(FreebsdCompactManifestParser::is_match(&PathBuf::from(
327 "+COMPACT_MANIFEST"
328 )));
329 assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
330 "+MANIFEST"
331 )));
332 assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
333 "COMPACT_MANIFEST"
334 )));
335 assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
336 "package.json"
337 )));
338 }
339
340 #[test]
341 fn test_build_license_statement_single() {
342 let licenses = Some(vec!["GPLv2".to_string()]);
343 let logic = Some("single".to_string());
344 let result = build_license_statement(&licenses, &logic);
345 assert_eq!(result, Some("GPLv2".to_string()));
346 }
347
348 #[test]
349 fn test_build_license_statement_and() {
350 let licenses = Some(vec!["MIT".to_string(), "BSD-2-Clause".to_string()]);
351 let logic = Some("and".to_string());
352 let result = build_license_statement(&licenses, &logic);
353 assert_eq!(result, Some("BSD-2-Clause AND MIT".to_string()));
354 }
355
356 #[test]
357 fn test_build_license_statement_or() {
358 let licenses = Some(vec!["MIT".to_string(), "Apache-2.0".to_string()]);
359 let logic = Some("or".to_string());
360 let result = build_license_statement(&licenses, &logic);
361 assert_eq!(result, Some("Apache-2.0 OR MIT".to_string()));
362 }
363
364 #[test]
365 fn test_build_license_statement_dual() {
366 let licenses = Some(vec!["MIT".to_string(), "Apache-2.0".to_string()]);
367 let logic = Some("dual".to_string());
368 let result = build_license_statement(&licenses, &logic);
369 assert_eq!(result, Some("Apache-2.0 OR MIT".to_string()));
370 }
371
372 #[test]
373 fn test_build_license_statement_default_and() {
374 let licenses = Some(vec!["MIT".to_string(), "BSD".to_string()]);
375 let logic = None;
376 let result = build_license_statement(&licenses, &logic);
377 assert_eq!(result, Some("BSD AND MIT".to_string()));
378 }
379
380 #[test]
381 fn test_build_license_statement_unknown_defaults_to_and() {
382 let licenses = Some(vec!["MIT".to_string(), "BSD".to_string()]);
383 let logic = Some("unknown".to_string());
384 let result = build_license_statement(&licenses, &logic);
385 assert_eq!(result, Some("BSD AND MIT".to_string()));
386 }
387
388 #[test]
389 fn test_build_license_statement_empty_licenses() {
390 let licenses = Some(vec![]);
391 let logic = Some("and".to_string());
392 let result = build_license_statement(&licenses, &logic);
393 assert_eq!(result, None);
394 }
395
396 #[test]
397 fn test_build_license_statement_no_licenses() {
398 let licenses = None;
399 let logic = Some("and".to_string());
400 let result = build_license_statement(&licenses, &logic);
401 assert_eq!(result, None);
402 }
403
404 #[test]
405 fn test_build_license_statement_filters_empty() {
406 let licenses = Some(vec!["MIT".to_string(), "".to_string(), " ".to_string()]);
407 let logic = Some("and".to_string());
408 let result = build_license_statement(&licenses, &logic);
409 assert_eq!(result, Some("MIT".to_string()));
410 }
411
412 #[test]
413 fn test_build_license_statement_trims_whitespace() {
414 let licenses = Some(vec![" MIT ".to_string(), " Apache-2.0 ".to_string()]);
415 let logic = Some("or".to_string());
416 let result = build_license_statement(&licenses, &logic);
417 assert_eq!(result, Some("Apache-2.0 OR MIT".to_string()));
418 }
419}
420
421crate::register_parser!(
422 "FreeBSD +COMPACT_MANIFEST package manifest",
423 &["**/*COMPACT_MANIFEST"],
424 "freebsd",
425 "",
426 Some("https://man.freebsd.org/cgi/man.cgi?query=pkg-create"),
427);