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        "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
274/// Builds the extracted_license_statement string from licenses and licenselogic.
275///
276/// # Logic:
277/// - `licenselogic: "single"` → single license string (just the first license)
278/// - `licenselogic: "and"` → join licenses with " AND "
279/// - `licenselogic: "or"` or `"dual"` → join licenses with " OR "
280/// - If `licenselogic` is missing or unknown → join with " AND " (default)
281pub(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    // Filter out empty licenses and trim whitespace
292    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);