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