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