Skip to main content

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