Skip to main content

provenant/parsers/
bun_lock.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::path::Path;
6
7use crate::parser_warn as warn;
8use serde_json::{Map, Value as JsonValue};
9
10use crate::models::{
11    DatasourceId, Dependency, Md5Digest, PackageData, PackageType, ResolvedPackage, Sha1Digest,
12    Sha256Digest, Sha512Digest,
13};
14use crate::parsers::utils::{capped_iteration_limit, npm_purl, parse_sri, truncate_field};
15
16use super::PackageParser;
17
18pub struct BunLockParser;
19
20#[derive(Clone, Debug)]
21struct ManifestDependencyInfo {
22    scope: &'static str,
23    is_runtime: bool,
24    is_optional: bool,
25}
26
27struct WorkspaceContext {
28    root_name: Option<String>,
29    root_version: Option<String>,
30    direct_deps: HashMap<String, ManifestDependencyInfo>,
31    workspace_versions: HashMap<String, String>,
32    workspace_entries: HashMap<String, JsonValue>,
33}
34
35impl PackageParser for BunLockParser {
36    const PACKAGE_TYPE: PackageType = PackageType::Npm;
37
38    fn is_match(path: &Path) -> bool {
39        path.file_name()
40            .and_then(|name| name.to_str())
41            .is_some_and(|name| name == "bun.lock")
42    }
43
44    fn extract_packages(path: &Path) -> Vec<PackageData> {
45        let content = match crate::parsers::utils::read_file_to_string(path, None) {
46            Ok(content) => content,
47            Err(e) => {
48                warn!("Failed to read bun.lock at {:?}: {}", path, e);
49                return vec![default_package_data()];
50            }
51        };
52
53        let root: JsonValue = match json5::from_str(&content) {
54            Ok(root) => root,
55            Err(e) => {
56                warn!("Failed to parse bun.lock at {:?}: {}", path, e);
57                return vec![default_package_data()];
58            }
59        };
60
61        vec![parse_bun_lockfile(&root)]
62    }
63
64    fn metadata() -> Vec<super::metadata::ParserMetadata> {
65        vec![super::metadata::ParserMetadata {
66            description: "Bun lockfile",
67            file_patterns: &["**/bun.lock"],
68            package_type: "npm",
69            primary_language: "JavaScript",
70            documentation_url: Some("https://bun.sh/docs/pm/lockfile"),
71        }]
72    }
73}
74
75fn default_package_data() -> PackageData {
76    PackageData {
77        package_type: Some(BunLockParser::PACKAGE_TYPE),
78        primary_language: Some(truncate_field("JavaScript".to_string())),
79        datasource_id: Some(DatasourceId::BunLock),
80        extra_data: Some(HashMap::new()),
81        ..Default::default()
82    }
83}
84
85fn parse_bun_lockfile(root: &JsonValue) -> PackageData {
86    let mut result = default_package_data();
87
88    let workspace_context = extract_workspace_info(root);
89    let (namespace, name) = workspace_context
90        .root_name
91        .as_deref()
92        .map(split_namespace_name)
93        .unwrap_or((None, None));
94
95    result.namespace = namespace.map(truncate_field);
96    result.name = name.map(truncate_field);
97    result.version = workspace_context.root_version.clone().map(truncate_field);
98    result.purl = result
99        .name
100        .as_ref()
101        .map(|name| qualify_name(&result.namespace, name))
102        .and_then(|full_name| npm_purl(&full_name, workspace_context.root_version.as_deref()));
103
104    let extra_data = result.extra_data.get_or_insert_with(HashMap::new);
105    if let Some(lockfile_version) = root.get("lockfileVersion").and_then(|value| value.as_i64()) {
106        extra_data.insert(
107            "lockfileVersion".to_string(),
108            JsonValue::from(lockfile_version),
109        );
110    }
111    if let Some(config_version) = root.get("configVersion").and_then(|value| value.as_i64()) {
112        extra_data.insert("configVersion".to_string(), JsonValue::from(config_version));
113    }
114    if let Some(trusted) = root.get("trustedDependencies") {
115        extra_data.insert("trustedDependencies".to_string(), trusted.clone());
116    }
117
118    let Some(packages) = root.get("packages").and_then(|value| value.as_object()) else {
119        warn!("No packages field found in bun.lock");
120        if extra_data.is_empty() {
121            result.extra_data = None;
122        }
123        return result;
124    };
125
126    let mut dependencies = Vec::new();
127    let limit = capped_iteration_limit(packages.len(), "bun.lock packages");
128    for (key, value) in packages.iter().take(limit) {
129        if let Some(dependency) = parse_package_entry(
130            key,
131            value,
132            &workspace_context.direct_deps,
133            &workspace_context.workspace_versions,
134            &workspace_context.workspace_entries,
135        ) {
136            dependencies.push(dependency);
137        }
138    }
139
140    result.dependencies = dependencies;
141    if result
142        .extra_data
143        .as_ref()
144        .is_some_and(|data| data.is_empty())
145    {
146        result.extra_data = None;
147    }
148
149    result
150}
151
152fn extract_workspace_info(root: &JsonValue) -> WorkspaceContext {
153    let mut direct_deps = HashMap::new();
154    let mut workspace_versions = HashMap::new();
155    let mut workspace_entries = HashMap::new();
156
157    let workspaces = root.get("workspaces").and_then(|value| value.as_object());
158    let root_workspace = workspaces.and_then(|workspaces| workspaces.get(""));
159    let root_name = root_workspace
160        .and_then(|value| value.get("name"))
161        .and_then(|value| value.as_str())
162        .map(|s| truncate_field(s.to_owned()));
163    let root_version = root_workspace
164        .and_then(|value| value.get("version"))
165        .and_then(|value| value.as_str())
166        .map(|s| truncate_field(s.to_owned()));
167
168    if let Some(workspaces) = workspaces {
169        let limit = capped_iteration_limit(workspaces.len(), "bun.lock workspace versions");
170        for workspace in workspaces.values().take(limit) {
171            if let Some(name) = workspace.get("name").and_then(|value| value.as_str())
172                && let Some(version) = workspace.get("version").and_then(|value| value.as_str())
173            {
174                workspace_versions.insert(
175                    truncate_field(name.to_string()),
176                    truncate_field(version.to_string()),
177                );
178            }
179            if let Some(name) = workspace.get("name").and_then(|value| value.as_str()) {
180                workspace_entries.insert(truncate_field(name.to_string()), workspace.clone());
181            }
182        }
183    }
184
185    if let Some(workspaces) = workspaces {
186        let limit = capped_iteration_limit(workspaces.len(), "bun.lock workspace manifests");
187        for workspace in workspaces.values().take(limit) {
188            insert_manifest_dependency_info(
189                workspace.get("dependencies"),
190                "dependencies",
191                true,
192                false,
193                &mut direct_deps,
194            );
195            insert_manifest_dependency_info(
196                workspace.get("devDependencies"),
197                "devDependencies",
198                false,
199                true,
200                &mut direct_deps,
201            );
202            insert_manifest_dependency_info(
203                workspace.get("optionalDependencies"),
204                "optionalDependencies",
205                true,
206                true,
207                &mut direct_deps,
208            );
209            insert_manifest_dependency_info(
210                workspace.get("peerDependencies"),
211                "peerDependencies",
212                true,
213                false,
214                &mut direct_deps,
215            );
216        }
217    }
218
219    WorkspaceContext {
220        root_name,
221        root_version,
222        direct_deps,
223        workspace_versions,
224        workspace_entries,
225    }
226}
227
228fn insert_manifest_dependency_info(
229    value: Option<&JsonValue>,
230    scope: &'static str,
231    is_runtime: bool,
232    is_optional: bool,
233    out: &mut HashMap<String, ManifestDependencyInfo>,
234) {
235    let Some(map) = value.and_then(|value| value.as_object()) else {
236        return;
237    };
238
239    let limit = capped_iteration_limit(map.len(), "bun.lock manifest dependency names");
240    for name in map.keys().take(limit) {
241        out.insert(
242            truncate_field(name.clone()),
243            ManifestDependencyInfo {
244                scope,
245                is_runtime,
246                is_optional,
247            },
248        );
249    }
250}
251
252fn parse_package_entry(
253    key: &str,
254    value: &JsonValue,
255    direct_deps: &HashMap<String, ManifestDependencyInfo>,
256    workspace_versions: &HashMap<String, String>,
257    workspace_entries: &HashMap<String, JsonValue>,
258) -> Option<Dependency> {
259    let tuple = value.as_array()?;
260    let resolution = tuple.first()?.as_str()?;
261    let (package_name, locator) = split_locator(resolution)?;
262    let package_name = truncate_field(package_name);
263    let locator = truncate_field(locator);
264    let package_version = resolve_locator_version(&package_name, &locator, workspace_versions);
265
266    let manifest_info = direct_deps
267        .get(key)
268        .or_else(|| direct_deps.get(&package_name));
269    let (scope, is_runtime, is_optional, is_direct) = manifest_info
270        .map(|info| {
271            (
272                truncate_field(info.scope.to_string()),
273                info.is_runtime,
274                info.is_optional,
275                true,
276            )
277        })
278        .unwrap_or_else(|| {
279            (
280                truncate_field("dependencies".to_string()),
281                true,
282                false,
283                false,
284            )
285        });
286
287    let purl = npm_purl(&package_name, package_version.as_deref()).map(truncate_field);
288    let resolved_download_url =
289        resolved_download_url(&package_name, &locator, tuple, package_version.as_deref())
290            .map(truncate_field);
291    let (sha1, sha256, sha512, md5) = parse_integrity_tuple(tuple);
292    let nested_dependencies =
293        extract_nested_dependencies(&package_name, tuple, workspace_versions, workspace_entries);
294
295    let (namespace, name) = split_namespace_name(&package_name);
296    let namespace = namespace.map(truncate_field);
297    let name = name.map(truncate_field);
298    let resolved_package = ResolvedPackage {
299        primary_language: Some(truncate_field("JavaScript".to_string())),
300        download_url: resolved_download_url,
301        sha1: sha1.and_then(|h| Sha1Digest::from_hex(&h).ok()),
302        sha256: sha256.and_then(|h| Sha256Digest::from_hex(&h).ok()),
303        sha512: sha512.and_then(|h| Sha512Digest::from_hex(&h).ok()),
304        md5: md5.and_then(|h| Md5Digest::from_hex(&h).ok()),
305        is_virtual: true,
306        extra_data: None,
307        dependencies: nested_dependencies,
308        repository_homepage_url: None,
309        repository_download_url: None,
310        api_data_url: None,
311        datasource_id: Some(DatasourceId::BunLock),
312        purl: None,
313        ..ResolvedPackage::new(
314            BunLockParser::PACKAGE_TYPE,
315            namespace.unwrap_or_default(),
316            name.unwrap_or_else(|| package_name.clone()),
317            truncate_field(package_version.clone().unwrap_or_default()),
318        )
319    };
320
321    Some(Dependency {
322        purl,
323        extracted_requirement: Some(truncate_field(
324            package_version.clone().unwrap_or(locator.clone()),
325        )),
326        scope: Some(scope),
327        is_runtime: Some(is_runtime),
328        is_optional: Some(is_optional),
329        is_pinned: Some(true),
330        is_direct: Some(is_direct),
331        resolved_package: Some(Box::new(resolved_package)),
332        extra_data: None,
333    })
334}
335
336fn split_locator(resolution: &str) -> Option<(String, String)> {
337    let (name, locator) = resolution.rsplit_once('@')?;
338    if name.is_empty() || locator.is_empty() {
339        return None;
340    }
341    Some((
342        truncate_field(name.to_string()),
343        truncate_field(locator.to_string()),
344    ))
345}
346
347fn resolve_locator_version(
348    package_name: &str,
349    locator: &str,
350    workspace_versions: &HashMap<String, String>,
351) -> Option<String> {
352    if let Some(path) = locator.strip_prefix("workspace:") {
353        return workspace_versions
354            .get(package_name)
355            .cloned()
356            .or_else(|| workspace_versions.get(path).cloned());
357    }
358
359    if locator.starts_with("file:")
360        || locator.starts_with("link:")
361        || locator.starts_with("github:")
362        || locator.starts_with("git+")
363        || locator.starts_with("http://")
364        || locator.starts_with("https://")
365    {
366        return None;
367    }
368
369    Some(truncate_field(locator.to_string()))
370}
371
372fn resolved_download_url(
373    package_name: &str,
374    locator: &str,
375    tuple: &[JsonValue],
376    version: Option<&str>,
377) -> Option<String> {
378    if let Some(url) = tuple.get(1).and_then(|value| value.as_str())
379        && !url.is_empty()
380    {
381        return Some(truncate_field(url.to_string()));
382    }
383
384    if locator.starts_with("workspace:")
385        || locator.starts_with("file:")
386        || locator.starts_with("link:")
387    {
388        return None;
389    }
390
391    if locator.starts_with("http://")
392        || locator.starts_with("https://")
393        || locator.starts_with("git+")
394        || locator.starts_with("github:")
395    {
396        return Some(truncate_field(locator.to_string()));
397    }
398
399    version.and_then(|version| default_registry_download_url(package_name, version))
400}
401
402fn default_registry_download_url(package_name: &str, version: &str) -> Option<String> {
403    let (namespace, name) = split_namespace_name(package_name);
404    let name = name?;
405    let package_path = qualify_name(&namespace, &name);
406    Some(truncate_field(format!(
407        "https://registry.npmjs.org/{}/-/{}-{}.tgz",
408        package_path, name, version
409    )))
410}
411
412fn parse_integrity_tuple(
413    tuple: &[JsonValue],
414) -> (
415    Option<String>,
416    Option<String>,
417    Option<String>,
418    Option<String>,
419) {
420    let integrity = tuple.iter().rev().find_map(|value| {
421        value.as_str().filter(|value| {
422            value.starts_with("sha1-")
423                || value.starts_with("sha256-")
424                || value.starts_with("sha512-")
425                || value.starts_with("md5-")
426        })
427    });
428
429    let Some(integrity) = integrity else {
430        return (None, None, None, None);
431    };
432
433    match parse_sri(integrity) {
434        Some((algo, hash)) if algo == "sha1" => (Some(hash), None, None, None),
435        Some((algo, hash)) if algo == "sha256" => (None, Some(hash), None, None),
436        Some((algo, hash)) if algo == "sha512" => (None, None, Some(hash), None),
437        Some((algo, hash)) if algo == "md5" => (None, None, None, Some(hash)),
438        _ => (None, None, None, None),
439    }
440}
441
442fn extract_nested_dependencies(
443    package_name: &str,
444    tuple: &[JsonValue],
445    workspace_versions: &HashMap<String, String>,
446    workspace_entries: &HashMap<String, JsonValue>,
447) -> Vec<Dependency> {
448    let info = tuple
449        .iter()
450        .find_map(|value| value.as_object())
451        .or_else(|| {
452            workspace_entries
453                .get(package_name)
454                .and_then(|value| value.as_object())
455        });
456    let Some(info) = info else {
457        return Vec::new();
458    };
459
460    let mut dependencies = Vec::new();
461    dependencies.extend(build_nested_dependencies(
462        info.get("dependencies").and_then(|value| value.as_object()),
463        "dependencies",
464        true,
465        false,
466        workspace_versions,
467    ));
468    dependencies.extend(build_nested_dependencies(
469        info.get("optionalDependencies")
470            .and_then(|value| value.as_object()),
471        "optionalDependencies",
472        true,
473        true,
474        workspace_versions,
475    ));
476    dependencies.extend(build_nested_dependencies(
477        info.get("peerDependencies")
478            .and_then(|value| value.as_object()),
479        "peerDependencies",
480        true,
481        false,
482        workspace_versions,
483    ));
484    dependencies
485}
486
487fn build_nested_dependencies(
488    deps: Option<&Map<String, JsonValue>>,
489    scope: &str,
490    is_runtime: bool,
491    is_optional: bool,
492    workspace_versions: &HashMap<String, String>,
493) -> Vec<Dependency> {
494    let Some(deps) = deps else {
495        return Vec::new();
496    };
497
498    let limit = capped_iteration_limit(deps.len(), "bun.lock nested dependencies");
499    deps.iter()
500        .take(limit)
501        .filter_map(|(name, value)| {
502            let requirement = value.as_str()?;
503            let version = if requirement.starts_with("workspace:") {
504                workspace_versions.get(name).map(String::as_str)
505            } else {
506                None
507            };
508
509            Some(Dependency {
510                purl: npm_purl(name, version).map(truncate_field),
511                extracted_requirement: Some(truncate_field(requirement.to_string())),
512                scope: Some(truncate_field(scope.to_string())),
513                is_runtime: Some(is_runtime),
514                is_optional: Some(is_optional),
515                is_pinned: Some(false),
516                is_direct: Some(false),
517                resolved_package: None,
518                extra_data: None,
519            })
520        })
521        .collect()
522}
523
524fn split_namespace_name(full_name: &str) -> (Option<String>, Option<String>) {
525    if full_name.starts_with('@') {
526        let mut parts = full_name.splitn(2, '/');
527        let namespace = parts.next().map(|s| truncate_field(s.to_owned()));
528        let name = parts.next().map(|s| truncate_field(s.to_owned()));
529        (namespace, name)
530    } else {
531        (
532            Some(String::new()),
533            Some(truncate_field(full_name.to_string())),
534        )
535    }
536}
537
538fn qualify_name(namespace: &Option<String>, name: &str) -> String {
539    match namespace.as_deref() {
540        Some("") | None => name.to_string(),
541        Some(namespace) => format!("{}/{}", namespace, name),
542    }
543}