Skip to main content

provenant/parsers/
bun_lock.rs

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