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