Skip to main content

provenant/parsers/
bun_lock.rs

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