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