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