Skip to main content

provenant/parsers/
freebsd.rs

1//! Parser for FreeBSD package manifest files.
2//!
3//! Extracts package metadata from FreeBSD compact manifest files (+COMPACT_MANIFEST)
4//! which are JSON/YAML format files containing package information.
5//!
6//! # Supported Formats
7//! - `+COMPACT_MANIFEST` (JSON/YAML format)
8//!
9//! # Key Features
10//! - Package metadata extraction (name, version, description, etc.)
11//! - Complex license logic handling (single/and/or/dual)
12//! - URL construction from origin and architecture fields
13//! - Qualifier extraction (arch, origin)
14//! - Maintainer information parsing
15//!
16//! # Implementation Notes
17//! - Uses `yaml_serde` for parsing (handles both JSON and YAML)
18//! - Implements FreeBSD-specific license logic combining
19//! - Graceful error handling with `warn!()` logs
20
21use 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
48/// Parser for FreeBSD +COMPACT_MANIFEST files
49pub 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    // Build qualifiers from arch and origin
105    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    // Build parties from maintainer (just an email address)
114    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    // Build extracted_license_statement from licenses and licenselogic
129    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    // Build code_view_url from origin
139    let code_view_url = manifest
140        .origin
141        .as_ref()
142        .map(|origin| format!("https://svnweb.freebsd.org/ports/head/{}", origin));
143
144    // Build download_url from arch, name, and version
145    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
262/// Builds the extracted_license_statement string from licenses and licenselogic.
263///
264/// # Logic:
265/// - `licenselogic: "single"` → single license string (just the first license)
266/// - `licenselogic: "and"` → join licenses with " AND "
267/// - `licenselogic: "or"` or `"dual"` → join licenses with " OR "
268/// - If `licenselogic` is missing or unknown → join with " AND " (default)
269pub(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    // Filter out empty licenses and trim whitespace
280    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);