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