Skip to main content

provenant/parsers/
bower.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for Bower package manifests (bower.json).
5//!
6//! Extracts package metadata, dependencies, and license information from
7//! bower.json files used by the legacy Bower JavaScript package manager.
8//!
9//! # Supported Formats
10//! - bower.json (manifest)
11//! - .bower.json (alternative manifest)
12//!
13//! # Key Features
14//! - Dependency extraction (dependencies, devDependencies)
15//! - License extraction (string or array format)
16//! - Author parsing (string or object format)
17//! - VCS repository URL extraction
18//! - Private package detection
19//!
20//! # Implementation Notes
21//! - Uses serde_json for JSON parsing
22//! - Graceful error handling: logs warnings and returns default on parse failure
23//! - Authors field can be string, object, or array of either
24
25use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
26use crate::parser_warn as warn;
27use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
28use packageurl::PackageUrl;
29use serde_json::Value;
30use std::path::Path;
31
32use super::PackageParser;
33use super::license_normalization::{
34    DeclaredLicenseMatchMetadata, build_declared_license_data, combine_normalized_licenses,
35    empty_declared_license_data, normalize_declared_license_key, normalize_spdx_declared_license,
36};
37
38const FIELD_NAME: &str = "name";
39const FIELD_VERSION: &str = "version";
40const FIELD_DESCRIPTION: &str = "description";
41const FIELD_LICENSE: &str = "license";
42const FIELD_KEYWORDS: &str = "keywords";
43const FIELD_AUTHORS: &str = "authors";
44const FIELD_HOMEPAGE: &str = "homepage";
45const FIELD_REPOSITORY: &str = "repository";
46const FIELD_DEPENDENCIES: &str = "dependencies";
47const FIELD_DEV_DEPENDENCIES: &str = "devDependencies";
48const FIELD_PRIVATE: &str = "private";
49
50/// Bower package parser for bower.json manifests.
51///
52/// Supports legacy Bower JavaScript package manager format with all
53/// standard fields including dependencies, devDependencies, authors, and licenses.
54pub struct BowerJsonParser;
55
56impl PackageParser for BowerJsonParser {
57    const PACKAGE_TYPE: PackageType = PackageType::Bower;
58
59    fn extract_packages(path: &Path) -> Vec<PackageData> {
60        let json = match read_and_parse_json(path) {
61            Ok(json) => json,
62            Err(e) => {
63                warn!("Failed to read or parse bower.json at {:?}: {}", path, e);
64                return vec![default_package_data()];
65            }
66        };
67
68        let name = json
69            .get(FIELD_NAME)
70            .and_then(|v| v.as_str())
71            .map(|s| truncate_field(s.to_string()));
72
73        // If name is missing, the package is considered private
74        let is_private = if name.is_none() {
75            true
76        } else {
77            json.get(FIELD_PRIVATE)
78                .and_then(|v| v.as_bool())
79                .unwrap_or(false)
80        };
81
82        let version = json
83            .get(FIELD_VERSION)
84            .and_then(|v| v.as_str())
85            .map(|s| truncate_field(s.to_string()));
86
87        let description = json
88            .get(FIELD_DESCRIPTION)
89            .and_then(|v| v.as_str())
90            .map(|s| truncate_field(s.to_string()));
91
92        let extracted_license_statement = extract_license_statement(&json);
93        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
94            normalize_bower_declared_license(&json, extracted_license_statement.as_deref());
95        let declared_license_expression = declared_license_expression.map(truncate_field);
96        let declared_license_expression_spdx = declared_license_expression_spdx.map(truncate_field);
97        let keywords = extract_keywords(&json);
98        let parties = extract_parties(&json);
99        let homepage_url = json
100            .get(FIELD_HOMEPAGE)
101            .and_then(|v| v.as_str())
102            .map(|s| truncate_field(s.to_string()));
103
104        let vcs_url = extract_vcs_url(&json);
105        let dependencies = extract_dependencies(&json, FIELD_DEPENDENCIES, "dependencies", true);
106        let dev_dependencies =
107            extract_dependencies(&json, FIELD_DEV_DEPENDENCIES, "devDependencies", false);
108
109        vec![PackageData {
110            package_type: Some(Self::PACKAGE_TYPE),
111            namespace: None,
112            name,
113            version,
114            qualifiers: None,
115            subpath: None,
116            primary_language: Some("JavaScript".to_string()),
117            description,
118            release_date: None,
119            parties,
120            keywords,
121            homepage_url,
122            download_url: None,
123            size: None,
124            sha1: None,
125            md5: None,
126            sha256: None,
127            sha512: None,
128            bug_tracking_url: None,
129            code_view_url: None,
130            vcs_url,
131            copyright: None,
132            holder: None,
133            declared_license_expression,
134            declared_license_expression_spdx,
135            license_detections,
136            other_license_expression: None,
137            other_license_expression_spdx: None,
138            other_license_detections: Vec::new(),
139            extracted_license_statement,
140            notice_text: None,
141            source_packages: Vec::new(),
142            file_references: Vec::new(),
143            is_private,
144            is_virtual: false,
145            extra_data: None,
146            dependencies: [dependencies, dev_dependencies].concat(),
147            repository_homepage_url: None,
148            repository_download_url: None,
149            api_data_url: None,
150            datasource_id: Some(DatasourceId::BowerJson),
151            purl: None,
152        }]
153    }
154
155    fn is_match(path: &Path) -> bool {
156        path.file_name()
157            .is_some_and(|name| name == "bower.json" || name == ".bower.json")
158    }
159}
160
161/// Reads and parses a JSON file
162fn read_and_parse_json(path: &Path) -> Result<Value, String> {
163    let content =
164        read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
165    serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))
166}
167
168/// Extracts license statement from the license field.
169/// Can be a string or an array of strings.
170fn extract_license_statement(json: &Value) -> Option<String> {
171    json.get(FIELD_LICENSE)
172        .and_then(|license_value| match license_value {
173            Value::String(s) => {
174                let trimmed = s.trim();
175                if trimmed.is_empty() {
176                    None
177                } else {
178                    Some(truncate_field(trimmed.to_string()))
179                }
180            }
181            Value::Array(licenses) => {
182                let license_strings: Vec<String> = licenses
183                    .iter()
184                    .take(MAX_ITERATION_COUNT)
185                    .filter_map(|v| v.as_str())
186                    .map(|s| s.trim())
187                    .filter(|s| !s.is_empty())
188                    .map(String::from)
189                    .collect();
190
191                if license_strings.is_empty() {
192                    None
193                } else {
194                    Some(truncate_field(license_strings.join(" AND ")))
195                }
196            }
197            _ => None,
198        })
199}
200
201fn normalize_bower_declared_license(
202    json: &Value,
203    extracted_license_statement: Option<&str>,
204) -> (
205    Option<String>,
206    Option<String>,
207    Vec<crate::models::LicenseDetection>,
208) {
209    match json.get(FIELD_LICENSE) {
210        Some(Value::Array(licenses)) => {
211            let normalized = licenses
212                .iter()
213                .take(MAX_ITERATION_COUNT)
214                .filter_map(|value| value.as_str().map(str::trim))
215                .filter(|value| !value.is_empty())
216                .map(normalize_declared_license_key)
217                .collect::<Option<Vec<_>>>();
218
219            if let Some(normalized) = normalized
220                && let Some(combined) = combine_normalized_licenses(normalized, " AND ")
221            {
222                return build_declared_license_data(
223                    combined,
224                    DeclaredLicenseMatchMetadata::single_line(
225                        extracted_license_statement.unwrap_or_default(),
226                    ),
227                );
228            }
229
230            empty_declared_license_data()
231        }
232        _ => normalize_spdx_declared_license(extracted_license_statement),
233    }
234}
235
236/// Extracts keywords from the keywords field.
237fn extract_keywords(json: &Value) -> Vec<String> {
238    json.get(FIELD_KEYWORDS)
239        .and_then(|v| v.as_array())
240        .map(|arr| {
241            arr.iter()
242                .take(MAX_ITERATION_COUNT)
243                .filter_map(|v| v.as_str())
244                .map(|s| truncate_field(s.to_string()))
245                .collect()
246        })
247        .unwrap_or_default()
248}
249
250/// Extracts parties (authors) from the authors field.
251/// Authors can be strings or objects with name, email, and homepage fields.
252fn extract_parties(json: &Value) -> Vec<Party> {
253    let mut parties = Vec::new();
254
255    if let Some(authors) = json.get(FIELD_AUTHORS).and_then(|v| v.as_array()) {
256        for author in authors.iter().take(MAX_ITERATION_COUNT) {
257            if let Some(party) = extract_party_from_author(author) {
258                parties.push(party);
259            }
260        }
261    }
262
263    parties
264}
265
266/// Extracts a single party from an author value (string or object).
267fn extract_party_from_author(author: &Value) -> Option<Party> {
268    match author {
269        Value::String(s) => {
270            let (name, email) = parse_author_string(s);
271            Some(Party {
272                r#type: Some("person".to_string()),
273                role: Some("author".to_string()),
274                name: name.map(truncate_field),
275                email: email.map(truncate_field),
276                url: None,
277                organization: None,
278                organization_url: None,
279                timezone: None,
280            })
281        }
282        Value::Object(obj) => {
283            let name = obj
284                .get("name")
285                .and_then(|v| v.as_str())
286                .map(|s| truncate_field(s.to_string()));
287            let email = obj
288                .get("email")
289                .and_then(|v| v.as_str())
290                .map(|s| truncate_field(s.to_string()));
291            let url = obj
292                .get("homepage")
293                .and_then(|v| v.as_str())
294                .map(|s| truncate_field(s.to_string()));
295
296            Some(Party {
297                r#type: Some("person".to_string()),
298                role: Some("author".to_string()),
299                name,
300                email,
301                url,
302                organization: None,
303                organization_url: None,
304                timezone: None,
305            })
306        }
307        _ => Some(Party {
308            r#type: Some("person".to_string()),
309            role: Some("author".to_string()),
310            name: Some(truncate_field(format!("{:?}", author))),
311            email: None,
312            url: None,
313            organization: None,
314            organization_url: None,
315            timezone: None,
316        }),
317    }
318}
319
320/// Parses author string in "Name <email>" format.
321/// Returns (name, email) tuple with both as Option<String>.
322fn parse_author_string(author_str: &str) -> (Option<String>, Option<String>) {
323    if let Some(email_start) = author_str.find('<')
324        && let Some(email_end) = author_str.find('>')
325        && email_start < email_end
326    {
327        let name = author_str[..email_start].trim();
328        let email = author_str[email_start + 1..email_end].trim();
329
330        let name = if name.is_empty() {
331            None
332        } else {
333            Some(truncate_field(name.to_string()))
334        };
335        let email = if email.is_empty() {
336            None
337        } else {
338            Some(truncate_field(email.to_string()))
339        };
340
341        return (name, email);
342    }
343
344    let trimmed = author_str.trim();
345    if trimmed.is_empty() {
346        (None, None)
347    } else {
348        (Some(truncate_field(trimmed.to_string())), None)
349    }
350}
351
352/// Extracts VCS URL from the repository field.
353/// Repository can be an object with type and url fields.
354fn extract_vcs_url(json: &Value) -> Option<String> {
355    json.get(FIELD_REPOSITORY).and_then(|repo| {
356        if let Some(repo_obj) = repo.as_object() {
357            let repo_type = repo_obj.get("type").and_then(|v| v.as_str());
358            let repo_url = repo_obj.get("url").and_then(|v| v.as_str());
359
360            match (repo_type, repo_url) {
361                (Some(t), Some(u)) if !t.is_empty() && !u.is_empty() => {
362                    Some(truncate_field(format!("{}+{}", t, u)))
363                }
364                _ => None,
365            }
366        } else {
367            None
368        }
369    })
370}
371
372/// Extracts dependencies from a dependency field.
373fn extract_dependencies(
374    json: &Value,
375    field: &str,
376    scope: &str,
377    is_runtime: bool,
378) -> Vec<Dependency> {
379    json.get(field)
380        .and_then(|deps| deps.as_object())
381        .map_or_else(Vec::new, |deps| {
382            deps.iter()
383                .take(MAX_ITERATION_COUNT)
384                .filter_map(|(name, requirement)| {
385                    let requirement_str = requirement.as_str()?;
386                    let package_url =
387                        PackageUrl::new(BowerJsonParser::PACKAGE_TYPE.as_str(), name).ok()?;
388
389                    Some(Dependency {
390                        purl: Some(truncate_field(package_url.to_string())),
391                        extracted_requirement: Some(truncate_field(requirement_str.to_string())),
392                        scope: Some(scope.to_string()),
393                        is_runtime: Some(is_runtime),
394                        is_optional: Some(!is_runtime),
395                        is_pinned: None,
396                        is_direct: Some(true),
397                        resolved_package: None,
398                        extra_data: None,
399                    })
400                })
401                .collect()
402        })
403}
404
405fn default_package_data() -> PackageData {
406    PackageData {
407        package_type: Some(BowerJsonParser::PACKAGE_TYPE),
408        primary_language: Some("JavaScript".to_string()),
409        datasource_id: Some(DatasourceId::BowerJson),
410        ..Default::default()
411    }
412}
413
414crate::register_parser!(
415    "Bower package manifest",
416    &["**/bower.json", "**/.bower.json"],
417    "bower",
418    "JavaScript",
419    Some("https://bower.io"),
420);