Skip to main content

provenant/parsers/
bun_lock.rs

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