Skip to main content

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