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 "BSD3CLAUSE" => Some(NormalizedDeclaredLicense::new("bsd-new", "BSD-3-Clause")),
268 "PSFL" => Some(NormalizedDeclaredLicense::new("psf-2.0", "PSF-2.0")),
269 "RUBY" => Some(NormalizedDeclaredLicense::new("ruby", "Ruby")),
270 other => normalize_declared_license_key(other),
271 }
272}
273
274pub(crate) fn build_license_statement(
282 licenses: &Option<Vec<String>>,
283 licenselogic: &Option<String>,
284) -> Option<String> {
285 let license_list = licenses.as_ref()?;
286
287 if license_list.is_empty() {
288 return None;
289 }
290
291 let filtered_licenses: Vec<String> = license_list
293 .iter()
294 .filter_map(|lic| {
295 let trimmed = lic.trim();
296 if trimmed.is_empty() {
297 None
298 } else {
299 Some(trimmed.to_string())
300 }
301 })
302 .collect();
303
304 if filtered_licenses.is_empty() {
305 return None;
306 }
307
308 let logic = licenselogic.as_deref().unwrap_or("and");
309
310 match logic {
311 "single" => Some(filtered_licenses[0].clone()),
312 "or" | "dual" => {
313 combine_license_expressions_with_relation(filtered_licenses, ExpressionRelation::Or)
314 }
315 _ => combine_license_expressions_with_relation(filtered_licenses, ExpressionRelation::And),
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use std::path::PathBuf;
323
324 #[test]
325 fn test_is_match() {
326 assert!(FreebsdCompactManifestParser::is_match(&PathBuf::from(
327 "/path/to/+COMPACT_MANIFEST"
328 )));
329 assert!(FreebsdCompactManifestParser::is_match(&PathBuf::from(
330 "+COMPACT_MANIFEST"
331 )));
332 assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
333 "+MANIFEST"
334 )));
335 assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
336 "COMPACT_MANIFEST"
337 )));
338 assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
339 "package.json"
340 )));
341 }
342
343 #[test]
344 fn test_build_license_statement_single() {
345 let licenses = Some(vec!["GPLv2".to_string()]);
346 let logic = Some("single".to_string());
347 let result = build_license_statement(&licenses, &logic);
348 assert_eq!(result, Some("GPLv2".to_string()));
349 }
350
351 #[test]
352 fn test_build_license_statement_and() {
353 let licenses = Some(vec!["MIT".to_string(), "BSD-2-Clause".to_string()]);
354 let logic = Some("and".to_string());
355 let result = build_license_statement(&licenses, &logic);
356 assert_eq!(result, Some("BSD-2-Clause AND MIT".to_string()));
357 }
358
359 #[test]
360 fn test_build_license_statement_or() {
361 let licenses = Some(vec!["MIT".to_string(), "Apache-2.0".to_string()]);
362 let logic = Some("or".to_string());
363 let result = build_license_statement(&licenses, &logic);
364 assert_eq!(result, Some("Apache-2.0 OR MIT".to_string()));
365 }
366
367 #[test]
368 fn test_build_license_statement_dual() {
369 let licenses = Some(vec!["MIT".to_string(), "Apache-2.0".to_string()]);
370 let logic = Some("dual".to_string());
371 let result = build_license_statement(&licenses, &logic);
372 assert_eq!(result, Some("Apache-2.0 OR MIT".to_string()));
373 }
374
375 #[test]
376 fn test_build_license_statement_default_and() {
377 let licenses = Some(vec!["MIT".to_string(), "BSD".to_string()]);
378 let logic = None;
379 let result = build_license_statement(&licenses, &logic);
380 assert_eq!(result, Some("BSD AND MIT".to_string()));
381 }
382
383 #[test]
384 fn test_build_license_statement_unknown_defaults_to_and() {
385 let licenses = Some(vec!["MIT".to_string(), "BSD".to_string()]);
386 let logic = Some("unknown".to_string());
387 let result = build_license_statement(&licenses, &logic);
388 assert_eq!(result, Some("BSD AND MIT".to_string()));
389 }
390
391 #[test]
392 fn test_build_license_statement_empty_licenses() {
393 let licenses = Some(vec![]);
394 let logic = Some("and".to_string());
395 let result = build_license_statement(&licenses, &logic);
396 assert_eq!(result, None);
397 }
398
399 #[test]
400 fn test_build_license_statement_no_licenses() {
401 let licenses = None;
402 let logic = Some("and".to_string());
403 let result = build_license_statement(&licenses, &logic);
404 assert_eq!(result, None);
405 }
406
407 #[test]
408 fn test_build_license_statement_filters_empty() {
409 let licenses = Some(vec!["MIT".to_string(), "".to_string(), " ".to_string()]);
410 let logic = Some("and".to_string());
411 let result = build_license_statement(&licenses, &logic);
412 assert_eq!(result, Some("MIT".to_string()));
413 }
414
415 #[test]
416 fn test_build_license_statement_trims_whitespace() {
417 let licenses = Some(vec![" MIT ".to_string(), " Apache-2.0 ".to_string()]);
418 let logic = Some("or".to_string());
419 let result = build_license_statement(&licenses, &logic);
420 assert_eq!(result, Some("Apache-2.0 OR MIT".to_string()));
421 }
422}
423
424crate::register_parser!(
425 "FreeBSD +COMPACT_MANIFEST package manifest",
426 &["**/*COMPACT_MANIFEST"],
427 "freebsd",
428 "",
429 Some("https://man.freebsd.org/cgi/man.cgi?query=pkg-create"),
430);