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);