Skip to main content

provenant/parsers/
freebsd.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for FreeBSD package manifest files.
5//!
6//! Extracts package metadata from FreeBSD compact manifest files (+COMPACT_MANIFEST)
7//! which are JSON/YAML format files containing package information.
8//!
9//! # Supported Formats
10//! - `+COMPACT_MANIFEST` (JSON/YAML format)
11//!
12//! # Key Features
13//! - Package metadata extraction (name, version, description, etc.)
14//! - Complex license logic handling (single/and/or/dual)
15//! - URL construction from origin and architecture fields
16//! - Qualifier extraction (arch, origin)
17//! - Maintainer information parsing
18//!
19//! # Implementation Notes
20//! - Uses `yaml_serde` for parsing (handles both JSON and YAML)
21//! - Implements FreeBSD-specific license logic combining
22//! - Graceful error handling with `warn!()` logs
23
24use 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
51/// Parser for FreeBSD +COMPACT_MANIFEST files
52pub 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    // Build qualifiers from arch and origin
113    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    // Build parties from maintainer (just an email address)
122    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    // Build extracted_license_statement from licenses and licenselogic
137    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    // Build code_view_url from origin
150    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    // Build download_url from arch, name, and version
156    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        "BSD2CLAUSE" => Some(NormalizedDeclaredLicense::new(
268            "bsd-simplified",
269            "BSD-2-Clause",
270        )),
271        "BSD3CLAUSE" => Some(NormalizedDeclaredLicense::new("bsd-new", "BSD-3-Clause")),
272        "PSFL" => Some(NormalizedDeclaredLicense::new("psf-2.0", "PSF-2.0")),
273        "RUBY" => Some(NormalizedDeclaredLicense::new("ruby", "Ruby")),
274        other => normalize_declared_license_key(other),
275    }
276}
277
278/// Builds the extracted_license_statement string from licenses and licenselogic.
279///
280/// # Logic:
281/// - `licenselogic: "single"` → single license string (just the first license)
282/// - `licenselogic: "and"` → join licenses with " AND "
283/// - `licenselogic: "or"` or `"dual"` → join licenses with " OR "
284/// - If `licenselogic` is missing or unknown → join with " AND " (default)
285pub(crate) fn build_license_statement(
286    licenses: &Option<Vec<String>>,
287    licenselogic: &Option<String>,
288) -> Option<String> {
289    let license_list = licenses.as_ref()?;
290
291    if license_list.is_empty() {
292        return None;
293    }
294
295    // Filter out empty licenses and trim whitespace
296    let filtered_licenses: Vec<String> = license_list
297        .iter()
298        .filter_map(|lic| {
299            let trimmed = lic.trim();
300            if trimmed.is_empty() {
301                None
302            } else {
303                Some(trimmed.to_string())
304            }
305        })
306        .collect();
307
308    if filtered_licenses.is_empty() {
309        return None;
310    }
311
312    let logic = licenselogic.as_deref().unwrap_or("and");
313
314    match logic {
315        "single" => Some(filtered_licenses[0].clone()),
316        "or" | "dual" => {
317            combine_license_expressions_with_relation(filtered_licenses, ExpressionRelation::Or)
318        }
319        _ => combine_license_expressions_with_relation(filtered_licenses, ExpressionRelation::And),
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use std::path::PathBuf;
327
328    #[test]
329    fn test_is_match() {
330        assert!(FreebsdCompactManifestParser::is_match(&PathBuf::from(
331            "/path/to/+COMPACT_MANIFEST"
332        )));
333        assert!(FreebsdCompactManifestParser::is_match(&PathBuf::from(
334            "+COMPACT_MANIFEST"
335        )));
336        assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
337            "+MANIFEST"
338        )));
339        assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
340            "COMPACT_MANIFEST"
341        )));
342        assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
343            "package.json"
344        )));
345    }
346
347    #[test]
348    fn test_build_license_statement_single() {
349        let licenses = Some(vec!["GPLv2".to_string()]);
350        let logic = Some("single".to_string());
351        let result = build_license_statement(&licenses, &logic);
352        assert_eq!(result, Some("GPLv2".to_string()));
353    }
354
355    #[test]
356    fn test_build_license_statement_and() {
357        let licenses = Some(vec!["MIT".to_string(), "BSD-2-Clause".to_string()]);
358        let logic = Some("and".to_string());
359        let result = build_license_statement(&licenses, &logic);
360        assert_eq!(result, Some("BSD-2-Clause AND MIT".to_string()));
361    }
362
363    #[test]
364    fn test_build_license_statement_or() {
365        let licenses = Some(vec!["MIT".to_string(), "Apache-2.0".to_string()]);
366        let logic = Some("or".to_string());
367        let result = build_license_statement(&licenses, &logic);
368        assert_eq!(result, Some("Apache-2.0 OR MIT".to_string()));
369    }
370
371    #[test]
372    fn test_build_license_statement_dual() {
373        let licenses = Some(vec!["MIT".to_string(), "Apache-2.0".to_string()]);
374        let logic = Some("dual".to_string());
375        let result = build_license_statement(&licenses, &logic);
376        assert_eq!(result, Some("Apache-2.0 OR MIT".to_string()));
377    }
378
379    #[test]
380    fn test_build_license_statement_default_and() {
381        let licenses = Some(vec!["MIT".to_string(), "BSD".to_string()]);
382        let logic = None;
383        let result = build_license_statement(&licenses, &logic);
384        assert_eq!(result, Some("BSD AND MIT".to_string()));
385    }
386
387    #[test]
388    fn test_build_license_statement_unknown_defaults_to_and() {
389        let licenses = Some(vec!["MIT".to_string(), "BSD".to_string()]);
390        let logic = Some("unknown".to_string());
391        let result = build_license_statement(&licenses, &logic);
392        assert_eq!(result, Some("BSD AND MIT".to_string()));
393    }
394
395    #[test]
396    fn test_build_license_statement_empty_licenses() {
397        let licenses = Some(vec![]);
398        let logic = Some("and".to_string());
399        let result = build_license_statement(&licenses, &logic);
400        assert_eq!(result, None);
401    }
402
403    #[test]
404    fn test_build_license_statement_no_licenses() {
405        let licenses = None;
406        let logic = Some("and".to_string());
407        let result = build_license_statement(&licenses, &logic);
408        assert_eq!(result, None);
409    }
410
411    #[test]
412    fn test_build_license_statement_filters_empty() {
413        let licenses = Some(vec!["MIT".to_string(), "".to_string(), "  ".to_string()]);
414        let logic = Some("and".to_string());
415        let result = build_license_statement(&licenses, &logic);
416        assert_eq!(result, Some("MIT".to_string()));
417    }
418
419    #[test]
420    fn test_build_license_statement_trims_whitespace() {
421        let licenses = Some(vec!["  MIT  ".to_string(), " Apache-2.0 ".to_string()]);
422        let logic = Some("or".to_string());
423        let result = build_license_statement(&licenses, &logic);
424        assert_eq!(result, Some("Apache-2.0 OR MIT".to_string()));
425    }
426
427    #[test]
428    fn test_normalize_freebsd_license_name_bsd2clause() {
429        assert_eq!(
430            normalize_freebsd_license_name("BSD2CLAUSE"),
431            Some(NormalizedDeclaredLicense::new(
432                "bsd-simplified",
433                "BSD-2-Clause",
434            ))
435        );
436    }
437}
438
439crate::register_parser!(
440    "FreeBSD +COMPACT_MANIFEST package manifest",
441    &["**/*COMPACT_MANIFEST"],
442    "freebsd",
443    "",
444    Some("https://man.freebsd.org/cgi/man.cgi?query=pkg-create"),
445);