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