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