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::{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        primary_language: Some("JavaScript".to_string()),
266        download_url: resolved_download_url,
267        sha1,
268        sha256,
269        sha512,
270        md5,
271        is_virtual: true,
272        extra_data: None,
273        dependencies: nested_dependencies,
274        repository_homepage_url: None,
275        repository_download_url: None,
276        api_data_url: None,
277        datasource_id: Some(DatasourceId::BunLock),
278        purl: None,
279        ..ResolvedPackage::new(
280            BunLockParser::PACKAGE_TYPE,
281            namespace.unwrap_or_default(),
282            name.unwrap_or_else(|| package_name.clone()),
283            package_version.clone().unwrap_or_default(),
284        )
285    };
286
287    Some(Dependency {
288        purl,
289        extracted_requirement: Some(package_version.clone().unwrap_or(locator.clone())),
290        scope: Some(scope),
291        is_runtime: Some(is_runtime),
292        is_optional: Some(is_optional),
293        is_pinned: Some(true),
294        is_direct: Some(is_direct),
295        resolved_package: Some(Box::new(resolved_package)),
296        extra_data: None,
297    })
298}
299
300fn split_locator(resolution: &str) -> Option<(String, String)> {
301    let (name, locator) = resolution.rsplit_once('@')?;
302    if name.is_empty() || locator.is_empty() {
303        return None;
304    }
305    Some((name.to_string(), locator.to_string()))
306}
307
308fn resolve_locator_version(
309    package_name: &str,
310    locator: &str,
311    workspace_versions: &HashMap<String, String>,
312) -> Option<String> {
313    if let Some(path) = locator.strip_prefix("workspace:") {
314        return workspace_versions
315            .get(package_name)
316            .cloned()
317            .or_else(|| workspace_versions.get(path).cloned());
318    }
319
320    if locator.starts_with("file:")
321        || locator.starts_with("link:")
322        || locator.starts_with("github:")
323        || locator.starts_with("git+")
324        || locator.starts_with("http://")
325        || locator.starts_with("https://")
326    {
327        return None;
328    }
329
330    Some(locator.to_string())
331}
332
333fn resolved_download_url(
334    package_name: &str,
335    locator: &str,
336    tuple: &[JsonValue],
337    version: Option<&str>,
338) -> Option<String> {
339    if let Some(url) = tuple.get(1).and_then(|value| value.as_str())
340        && !url.is_empty()
341    {
342        return Some(url.to_string());
343    }
344
345    if locator.starts_with("workspace:")
346        || locator.starts_with("file:")
347        || locator.starts_with("link:")
348    {
349        return None;
350    }
351
352    if locator.starts_with("http://")
353        || locator.starts_with("https://")
354        || locator.starts_with("git+")
355        || locator.starts_with("github:")
356    {
357        return Some(locator.to_string());
358    }
359
360    version.and_then(|version| default_registry_download_url(package_name, version))
361}
362
363fn default_registry_download_url(package_name: &str, version: &str) -> Option<String> {
364    let (namespace, name) = split_namespace_name(package_name);
365    let name = name?;
366    let package_path = qualify_name(&namespace, &name);
367    Some(format!(
368        "https://registry.npmjs.org/{}/-/{}-{}.tgz",
369        package_path, name, version
370    ))
371}
372
373fn parse_integrity_tuple(
374    tuple: &[JsonValue],
375) -> (
376    Option<String>,
377    Option<String>,
378    Option<String>,
379    Option<String>,
380) {
381    let integrity = tuple.iter().rev().find_map(|value| {
382        value.as_str().filter(|value| {
383            value.starts_with("sha1-")
384                || value.starts_with("sha256-")
385                || value.starts_with("sha512-")
386                || value.starts_with("md5-")
387        })
388    });
389
390    let Some(integrity) = integrity else {
391        return (None, None, None, None);
392    };
393
394    match parse_sri(integrity) {
395        Some((algo, hash)) if algo == "sha1" => (Some(hash), None, None, None),
396        Some((algo, hash)) if algo == "sha256" => (None, Some(hash), None, None),
397        Some((algo, hash)) if algo == "sha512" => (None, None, Some(hash), None),
398        Some((algo, hash)) if algo == "md5" => (None, None, None, Some(hash)),
399        _ => (None, None, None, None),
400    }
401}
402
403fn extract_nested_dependencies(
404    package_name: &str,
405    tuple: &[JsonValue],
406    workspace_versions: &HashMap<String, String>,
407    workspace_entries: &HashMap<String, JsonValue>,
408) -> Vec<Dependency> {
409    let info = tuple
410        .iter()
411        .find_map(|value| value.as_object())
412        .or_else(|| {
413            workspace_entries
414                .get(package_name)
415                .and_then(|value| value.as_object())
416        });
417    let Some(info) = info else {
418        return Vec::new();
419    };
420
421    let mut dependencies = Vec::new();
422    dependencies.extend(build_nested_dependencies(
423        info.get("dependencies").and_then(|value| value.as_object()),
424        "dependencies",
425        true,
426        false,
427        workspace_versions,
428    ));
429    dependencies.extend(build_nested_dependencies(
430        info.get("optionalDependencies")
431            .and_then(|value| value.as_object()),
432        "optionalDependencies",
433        true,
434        true,
435        workspace_versions,
436    ));
437    dependencies.extend(build_nested_dependencies(
438        info.get("peerDependencies")
439            .and_then(|value| value.as_object()),
440        "peerDependencies",
441        true,
442        false,
443        workspace_versions,
444    ));
445    dependencies
446}
447
448fn build_nested_dependencies(
449    deps: Option<&Map<String, JsonValue>>,
450    scope: &str,
451    is_runtime: bool,
452    is_optional: bool,
453    workspace_versions: &HashMap<String, String>,
454) -> Vec<Dependency> {
455    let Some(deps) = deps else {
456        return Vec::new();
457    };
458
459    deps.iter()
460        .filter_map(|(name, value)| {
461            let requirement = value.as_str()?;
462            let version = if requirement.starts_with("workspace:") {
463                workspace_versions.get(name).map(String::as_str)
464            } else {
465                None
466            };
467
468            Some(Dependency {
469                purl: npm_purl(name, version),
470                extracted_requirement: Some(requirement.to_string()),
471                scope: Some(scope.to_string()),
472                is_runtime: Some(is_runtime),
473                is_optional: Some(is_optional),
474                is_pinned: Some(false),
475                is_direct: Some(false),
476                resolved_package: None,
477                extra_data: None,
478            })
479        })
480        .collect()
481}
482
483fn split_namespace_name(full_name: &str) -> (Option<String>, Option<String>) {
484    if full_name.starts_with('@') {
485        let mut parts = full_name.splitn(2, '/');
486        let namespace = parts.next().map(ToOwned::to_owned);
487        let name = parts.next().map(ToOwned::to_owned);
488        (namespace, name)
489    } else {
490        (Some(String::new()), Some(full_name.to_string()))
491    }
492}
493
494fn qualify_name(namespace: &Option<String>, name: &str) -> String {
495    match namespace.as_deref() {
496        Some("") | None => name.to_string(),
497        Some(namespace) => format!("{}/{}", namespace, name),
498    }
499}
500
501crate::register_parser!(
502    "Bun lockfile",
503    &["**/bun.lock"],
504    "npm",
505    "JavaScript",
506    Some("https://bun.sh/docs/pm/lockfile"),
507);