Skip to main content

provenant/parsers/
freebsd.rs

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