Skip to main content

provenant/parsers/
freebsd.rs

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