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::{MAX_ITERATION_COUNT, 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 license_strings: Vec<String> = licenses
195                    .iter()
196                    .take(MAX_ITERATION_COUNT)
197                    .filter_map(|v| v.as_str())
198                    .map(|s| s.trim())
199                    .filter(|s| !s.is_empty())
200                    .map(String::from)
201                    .collect();
202
203                if license_strings.is_empty() {
204                    None
205                } else {
206                    Some(truncate_field(license_strings.join(" AND ")))
207                }
208            }
209            _ => None,
210        })
211}
212
213fn normalize_bower_declared_license(
214    json: &Value,
215    extracted_license_statement: Option<&str>,
216) -> (
217    Option<String>,
218    Option<String>,
219    Vec<crate::models::LicenseDetection>,
220) {
221    match json.get(FIELD_LICENSE) {
222        Some(Value::Array(licenses)) => {
223            let normalized = licenses
224                .iter()
225                .take(MAX_ITERATION_COUNT)
226                .filter_map(|value| value.as_str().map(str::trim))
227                .filter(|value| !value.is_empty())
228                .map(normalize_declared_license_key)
229                .collect::<Option<Vec<_>>>();
230
231            if let Some(normalized) = normalized
232                && let Some(combined) = combine_normalized_licenses(normalized, " AND ")
233            {
234                return build_declared_license_data(
235                    combined,
236                    DeclaredLicenseMatchMetadata::single_line(
237                        extracted_license_statement.unwrap_or_default(),
238                    ),
239                );
240            }
241
242            empty_declared_license_data()
243        }
244        _ => normalize_spdx_declared_license(extracted_license_statement),
245    }
246}
247
248/// Extracts keywords from the keywords field.
249fn extract_keywords(json: &Value) -> Vec<String> {
250    json.get(FIELD_KEYWORDS)
251        .and_then(|v| v.as_array())
252        .map(|arr| {
253            arr.iter()
254                .take(MAX_ITERATION_COUNT)
255                .filter_map(|v| v.as_str())
256                .map(|s| truncate_field(s.to_string()))
257                .collect()
258        })
259        .unwrap_or_default()
260}
261
262/// Extracts parties (authors) from the authors field.
263/// Authors can be strings or objects with name, email, and homepage fields.
264fn extract_parties(json: &Value) -> Vec<Party> {
265    let mut parties = Vec::new();
266
267    if let Some(authors) = json.get(FIELD_AUTHORS).and_then(|v| v.as_array()) {
268        for author in authors.iter().take(MAX_ITERATION_COUNT) {
269            if let Some(party) = extract_party_from_author(author) {
270                parties.push(party);
271            }
272        }
273    }
274
275    parties
276}
277
278/// Extracts a single party from an author value (string or object).
279fn extract_party_from_author(author: &Value) -> Option<Party> {
280    match author {
281        Value::String(s) => {
282            let (name, email) = parse_author_string(s);
283            Some(Party {
284                r#type: Some(PartyType::Person),
285                role: Some("author".to_string()),
286                name: name.map(truncate_field),
287                email: email.map(truncate_field),
288                url: None,
289                organization: None,
290                organization_url: None,
291                timezone: None,
292            })
293        }
294        Value::Object(obj) => {
295            let name = obj
296                .get("name")
297                .and_then(|v| v.as_str())
298                .map(|s| truncate_field(s.to_string()));
299            let email = obj
300                .get("email")
301                .and_then(|v| v.as_str())
302                .map(|s| truncate_field(s.to_string()));
303            let url = obj
304                .get("homepage")
305                .and_then(|v| v.as_str())
306                .map(|s| truncate_field(s.to_string()));
307
308            Some(Party {
309                r#type: Some(PartyType::Person),
310                role: Some("author".to_string()),
311                name,
312                email,
313                url,
314                organization: None,
315                organization_url: None,
316                timezone: None,
317            })
318        }
319        _ => Some(Party {
320            r#type: Some(PartyType::Person),
321            role: Some("author".to_string()),
322            name: Some(truncate_field(format!("{:?}", author))),
323            email: None,
324            url: None,
325            organization: None,
326            organization_url: None,
327            timezone: None,
328        }),
329    }
330}
331
332/// Parses author string in "Name <email>" format.
333/// Returns (name, email) tuple with both as Option<String>.
334fn parse_author_string(author_str: &str) -> (Option<String>, Option<String>) {
335    if let Some(email_start) = author_str.find('<')
336        && let Some(email_end) = author_str.find('>')
337        && email_start < email_end
338    {
339        let name = author_str[..email_start].trim();
340        let email = author_str[email_start + 1..email_end].trim();
341
342        let name = if name.is_empty() {
343            None
344        } else {
345            Some(truncate_field(name.to_string()))
346        };
347        let email = if email.is_empty() {
348            None
349        } else {
350            Some(truncate_field(email.to_string()))
351        };
352
353        return (name, email);
354    }
355
356    let trimmed = author_str.trim();
357    if trimmed.is_empty() {
358        (None, None)
359    } else {
360        (Some(truncate_field(trimmed.to_string())), None)
361    }
362}
363
364/// Extracts VCS URL from the repository field.
365/// Repository can be an object with type and url fields.
366fn extract_vcs_url(json: &Value) -> Option<String> {
367    json.get(FIELD_REPOSITORY).and_then(|repo| {
368        if let Some(repo_obj) = repo.as_object() {
369            let repo_type = repo_obj.get("type").and_then(|v| v.as_str());
370            let repo_url = repo_obj.get("url").and_then(|v| v.as_str());
371
372            match (repo_type, repo_url) {
373                (Some(t), Some(u)) if !t.is_empty() && !u.is_empty() => {
374                    Some(truncate_field(format!("{}+{}", t, u)))
375                }
376                _ => None,
377            }
378        } else {
379            None
380        }
381    })
382}
383
384/// Extracts dependencies from a dependency field.
385fn extract_dependencies(
386    json: &Value,
387    field: &str,
388    scope: &str,
389    is_runtime: bool,
390) -> Vec<Dependency> {
391    json.get(field)
392        .and_then(|deps| deps.as_object())
393        .map_or_else(Vec::new, |deps| {
394            deps.iter()
395                .take(MAX_ITERATION_COUNT)
396                .filter_map(|(name, requirement)| {
397                    let requirement_str = requirement.as_str()?;
398                    let package_url =
399                        PackageUrl::new(BowerJsonParser::PACKAGE_TYPE.as_str(), name).ok()?;
400
401                    Some(Dependency {
402                        purl: Some(truncate_field(package_url.to_string())),
403                        extracted_requirement: Some(truncate_field(requirement_str.to_string())),
404                        scope: Some(scope.to_string()),
405                        is_runtime: Some(is_runtime),
406                        is_optional: Some(!is_runtime),
407                        is_pinned: None,
408                        is_direct: Some(true),
409                        resolved_package: None,
410                        extra_data: None,
411                    })
412                })
413                .collect()
414        })
415}
416
417fn default_package_data() -> PackageData {
418    PackageData {
419        package_type: Some(BowerJsonParser::PACKAGE_TYPE),
420        primary_language: Some("JavaScript".to_string()),
421        datasource_id: Some(DatasourceId::BowerJson),
422        ..Default::default()
423    }
424}