1use std::collections::{HashMap, HashSet};
5use std::path::Path;
6
7use crate::parser_warn as warn;
8use packageurl::PackageUrl;
9use serde_json::Value;
10use url::Url;
11
12use crate::models::{
13 DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage, Sha256Digest, Sha512Digest,
14};
15
16use super::PackageParser;
17use super::metadata::ParserMetadata;
18use super::utils::{
19 CappedIterExt, capped_iteration_limit, parse_sri, read_file_to_string, truncate_field,
20};
21
22const FIELD_VERSION: &str = "version";
23const FIELD_SPECIFIERS: &str = "specifiers";
24const FIELD_JSR: &str = "jsr";
25const FIELD_NPM: &str = "npm";
26const FIELD_REMOTE: &str = "remote";
27const FIELD_REDIRECTS: &str = "redirects";
28const FIELD_WORKSPACE: &str = "workspace";
29const FIELD_DEPENDENCIES: &str = "dependencies";
30
31pub struct DenoLockParser;
32
33impl PackageParser for DenoLockParser {
34 const PACKAGE_TYPE: PackageType = PackageType::Deno;
35
36 fn metadata() -> Vec<ParserMetadata> {
37 vec![ParserMetadata {
38 description: "Deno lockfile",
39 file_patterns: &["**/deno.lock"],
40 package_type: "deno",
41 primary_language: "TypeScript",
42 documentation_url: Some("https://docs.deno.com/runtime/fundamentals/modules/"),
43 }]
44 }
45
46 fn is_match(path: &Path) -> bool {
47 path.file_name().and_then(|name| name.to_str()) == Some("deno.lock")
48 }
49
50 fn extract_packages(path: &Path) -> Vec<PackageData> {
51 let content = match read_file_to_string(path, None) {
52 Ok(content) => content,
53 Err(e) => {
54 warn!("Failed to read deno.lock at {:?}: {}", path, e);
55 return vec![default_package_data()];
56 }
57 };
58
59 let json: Value = match serde_json::from_str(&content) {
60 Ok(json) => json,
61 Err(e) => {
62 warn!("Failed to parse deno.lock at {:?}: {}", path, e);
63 return vec![default_package_data()];
64 }
65 };
66
67 vec![parse_deno_lock(&json)]
68 }
69}
70
71fn parse_deno_lock(json: &Value) -> PackageData {
72 let lock_version = json.get(FIELD_VERSION).and_then(Value::as_str);
73
74 let (mut dependencies, workspace_direct) = match lock_version {
82 Some("1") => (Vec::new(), Vec::new()),
83 Some("2") | Some("3") => (extract_nested_npm_dependencies(json), Vec::new()),
84 Some("4") | Some("5") => extract_flat_dependencies(json),
85 other => {
86 warn!(
87 "Unrecognized deno.lock version {:?}; parsing with the latest known layout",
88 other
89 );
90 extract_flat_dependencies(json)
91 }
92 };
93 dependencies.extend(extract_redirect_dependencies(json));
94
95 let mut extra_data = HashMap::new();
96 if let Some(version) = lock_version {
97 extra_data.insert(
98 FIELD_VERSION.to_string(),
99 Value::String(version.to_string()),
100 );
101 }
102 if !workspace_direct.is_empty() {
103 extra_data.insert(
104 "workspace_dependencies".to_string(),
105 Value::Array(workspace_direct.into_iter().map(Value::String).collect()),
106 );
107 }
108
109 PackageData {
110 package_type: Some(DenoLockParser::PACKAGE_TYPE),
111 primary_language: Some("TypeScript".to_string()),
112 dependencies,
113 extra_data: (!extra_data.is_empty()).then_some(extra_data),
114 datasource_id: Some(DatasourceId::DenoLock),
115 ..Default::default()
116 }
117}
118
119fn extract_flat_dependencies(json: &Value) -> (Vec<Dependency>, Vec<String>) {
123 let specifiers = json
124 .get(FIELD_SPECIFIERS)
125 .and_then(Value::as_object)
126 .cloned()
127 .unwrap_or_default();
128 let workspace_direct = extract_workspace_dependencies(json);
129
130 let mut dependencies = Vec::new();
131 let mut direct_jsr_keys = HashSet::new();
132 let mut direct_npm_keys = HashSet::new();
133
134 let workspace_limit =
135 capped_iteration_limit(workspace_direct.len(), "deno.lock workspace specifiers");
136 for specifier in workspace_direct.iter().take(workspace_limit) {
137 if let Some(resolved_key) = specifiers.get(specifier).and_then(Value::as_str) {
138 if specifier.starts_with("jsr:") {
139 if let Some(full_key) = resolve_jsr_full_key(specifier, resolved_key)
140 && let Some(dep) =
141 build_jsr_dependency(&full_key, true, &json[FIELD_JSR], Some(specifier))
142 {
143 direct_jsr_keys.insert(full_key);
144 dependencies.push(dep);
145 }
146 } else if specifier.starts_with("npm:")
147 && let Some(full_key) = resolve_npm_full_key(specifier, resolved_key)
148 && let Some(dep) =
149 build_npm_dependency(&full_key, true, &json[FIELD_NPM], Some(specifier))
150 {
151 direct_npm_keys.insert(full_key);
152 dependencies.push(dep);
153 }
154 }
155 }
156
157 if let Some(jsr_map) = json.get(FIELD_JSR).and_then(Value::as_object) {
158 let jsr_limit = capped_iteration_limit(jsr_map.len(), "deno.lock jsr packages");
159 for key in jsr_map.keys().take(jsr_limit) {
160 if direct_jsr_keys.contains(key) {
161 continue;
162 }
163 if let Some(dep) = build_jsr_dependency(key, false, &json[FIELD_JSR], None) {
164 dependencies.push(dep);
165 }
166 }
167 }
168
169 if let Some(npm_map) = json.get(FIELD_NPM).and_then(Value::as_object) {
170 let npm_limit = capped_iteration_limit(npm_map.len(), "deno.lock npm packages");
171 for key in npm_map.keys().take(npm_limit) {
172 if direct_npm_keys.contains(key) {
173 continue;
174 }
175 if let Some(dep) = build_npm_dependency(key, false, &json[FIELD_NPM], None) {
176 dependencies.push(dep);
177 }
178 }
179 }
180
181 (dependencies, workspace_direct)
182}
183
184fn extract_nested_npm_dependencies(json: &Value) -> Vec<Dependency> {
188 let Some(npm) = json.get(FIELD_NPM) else {
189 return Vec::new();
190 };
191 let Some(packages) = npm.get("packages") else {
192 return Vec::new();
193 };
194 let Some(packages_map) = packages.as_object() else {
195 return Vec::new();
196 };
197
198 let requested_by_resolved: HashMap<String, String> = npm
203 .get("specifiers")
204 .and_then(Value::as_object)
205 .map(|specifiers| {
206 specifiers
207 .iter()
208 .filter_map(|(specifier, resolved)| {
209 resolved.as_str().map(|resolved| {
210 (
211 resolved.trim_start_matches("npm:").to_string(),
212 specifier.clone(),
213 )
214 })
215 })
216 .collect()
217 })
218 .unwrap_or_default();
219
220 let mut dependencies = Vec::new();
221 let packages_limit =
222 capped_iteration_limit(packages_map.len(), "deno.lock nested npm packages");
223 for key in packages_map.keys().take(packages_limit) {
224 let requested = requested_by_resolved.get(key).map(String::as_str);
225 if let Some(dep) = build_npm_dependency(key, requested.is_some(), packages, requested) {
226 dependencies.push(dep);
227 }
228 }
229 dependencies
230}
231
232fn extract_redirect_dependencies(json: &Value) -> Vec<Dependency> {
235 let mut dependencies = Vec::new();
236 if let Some(redirects) = json.get(FIELD_REDIRECTS).and_then(Value::as_object) {
237 let redirects_limit = capped_iteration_limit(redirects.len(), "deno.lock redirects");
238 for (source, target) in redirects.iter().take(redirects_limit) {
239 let Some(target_url) = target.as_str() else {
240 continue;
241 };
242 let hash = json
243 .get(FIELD_REMOTE)
244 .and_then(Value::as_object)
245 .and_then(|remote| remote.get(target_url))
246 .and_then(Value::as_str)
247 .and_then(|value| Sha256Digest::from_hex(value).ok());
248
249 let name =
250 truncate_field(remote_name(target_url).unwrap_or_else(|| source.to_string()));
251 let purl = create_remote_purl(target_url).map(truncate_field);
252 let resolved_package = ResolvedPackage {
253 primary_language: Some("TypeScript".to_string()),
254 download_url: Some(truncate_field(target_url.to_string())),
255 sha1: None,
256 sha256: hash,
257 sha512: None,
258 md5: None,
259 is_virtual: true,
260 extra_data: Some(HashMap::from([(
261 "redirect_source".to_string(),
262 Value::String(truncate_field(source.to_string())),
263 )])),
264 dependencies: Vec::new(),
265 repository_homepage_url: None,
266 repository_download_url: None,
267 api_data_url: None,
268 datasource_id: Some(DatasourceId::DenoLock),
269 purl: purl.clone(),
270 ..ResolvedPackage::new(
271 DenoLockParser::PACKAGE_TYPE,
272 String::new(),
273 name.clone(),
274 String::new(),
275 )
276 };
277
278 dependencies.push(Dependency {
279 purl,
280 extracted_requirement: Some(truncate_field(source.to_string())),
281 scope: Some("imports".to_string()),
282 is_runtime: Some(true),
283 is_optional: Some(false),
284 is_pinned: Some(true),
285 is_direct: Some(true),
286 resolved_package: Some(Box::new(resolved_package)),
287 extra_data: None,
288 });
289 }
290 }
291 dependencies
292}
293
294fn extract_workspace_dependencies(json: &Value) -> Vec<String> {
295 json.get(FIELD_WORKSPACE)
296 .and_then(Value::as_object)
297 .and_then(|workspace| workspace.get(FIELD_DEPENDENCIES))
298 .and_then(Value::as_array)
299 .into_iter()
300 .flatten()
301 .filter_map(Value::as_str)
302 .map(|value| truncate_field(value.to_string()))
303 .collect()
304}
305
306fn build_jsr_dependency(
307 resolved_key: &str,
308 is_direct: bool,
309 jsr_section: &Value,
310 extracted_requirement: Option<&str>,
311) -> Option<Dependency> {
312 let jsr_entry = jsr_section.get(resolved_key)?;
313 let jsr_object = jsr_entry.as_object()?;
314 let (namespace, name, version) = parse_jsr_key(resolved_key)?;
315 let namespace = truncate_field(namespace);
316 let name = truncate_field(name);
317 let version_str = truncate_field(version.to_string());
318 let purl = create_generic_purl(
319 Some(&format!("jsr.io/{}", namespace)),
320 &name,
321 Some(&version_str),
322 )
323 .map(truncate_field);
324
325 Some(Dependency {
326 purl: purl.clone(),
327 extracted_requirement: extracted_requirement.map(|value| truncate_field(value.to_string())),
328 scope: Some("imports".to_string()),
329 is_runtime: Some(true),
330 is_optional: Some(false),
331 is_pinned: Some(true),
332 is_direct: Some(is_direct),
333 resolved_package: Some(Box::new(ResolvedPackage {
334 primary_language: Some("TypeScript".to_string()),
335 download_url: None,
336 sha1: None,
337 sha256: jsr_object
338 .get("integrity")
339 .and_then(Value::as_str)
340 .and_then(|value| {
341 parse_sri(value)
342 .and_then(|(algo, hex)| {
343 (algo == "sha256").then(|| Sha256Digest::from_hex(&hex).ok())
344 })
345 .flatten()
346 .or_else(|| Sha256Digest::from_hex(value).ok())
347 }),
348 sha512: None,
349 md5: None,
350 is_virtual: true,
351 extra_data: None,
352 dependencies: extract_jsr_resolved_dependencies(jsr_object),
353 repository_homepage_url: None,
354 repository_download_url: None,
355 api_data_url: None,
356 datasource_id: Some(DatasourceId::DenoLock),
357 purl,
358 ..ResolvedPackage::new(DenoLockParser::PACKAGE_TYPE, namespace, name, version_str)
359 })),
360 extra_data: None,
361 })
362}
363
364fn npm_dependency_keys(value: Option<&Value>) -> Vec<String> {
370 match value {
371 Some(Value::Array(items)) => items
372 .iter()
373 .filter_map(Value::as_str)
374 .map(str::to_string)
375 .collect(),
376 Some(Value::Object(map)) => map
377 .values()
378 .filter_map(Value::as_str)
379 .map(str::to_string)
380 .collect(),
381 _ => Vec::new(),
382 }
383}
384
385fn build_npm_dependency(
386 resolved_key: &str,
387 is_direct: bool,
388 npm_section: &Value,
389 extracted_requirement: Option<&str>,
390) -> Option<Dependency> {
391 let npm_entry = npm_section.get(resolved_key)?;
392 let npm_object = npm_entry.as_object()?;
393 let (namespace, name, version) = parse_npm_key(resolved_key)?;
394 let namespace = namespace.map(truncate_field);
395 let name = truncate_field(name);
396 let version_str = truncate_field(version.to_string());
397 let purl = create_npm_purl(namespace.as_deref(), &name, Some(&version_str)).map(truncate_field);
398
399 let resolved_dependency_keys = npm_dependency_keys(npm_object.get(FIELD_DEPENDENCIES));
400 let resolved_dependency_limit = capped_iteration_limit(
401 resolved_dependency_keys.len(),
402 "deno.lock npm resolved dependencies",
403 );
404
405 Some(Dependency {
406 purl: purl.clone(),
407 extracted_requirement: extracted_requirement.map(|value| truncate_field(value.to_string())),
408 scope: Some("imports".to_string()),
409 is_runtime: Some(true),
410 is_optional: Some(false),
411 is_pinned: Some(true),
412 is_direct: Some(is_direct),
413 resolved_package: Some(Box::new(ResolvedPackage {
414 primary_language: Some("JavaScript".to_string()),
415 download_url: npm_object
416 .get("tarball")
417 .and_then(Value::as_str)
418 .map(|value| truncate_field(value.to_string())),
419 sha1: None,
420 sha256: None,
421 sha512: npm_object
422 .get("integrity")
423 .and_then(Value::as_str)
424 .and_then(|value| {
425 parse_sri(value)
426 .and_then(|(algo, hex)| {
427 (algo == "sha512").then(|| Sha512Digest::from_hex(&hex).ok())
428 })
429 .flatten()
430 }),
431 md5: None,
432 is_virtual: true,
433 extra_data: None,
434 dependencies: resolved_dependency_keys
435 .into_iter()
436 .take(resolved_dependency_limit)
437 .filter_map(|value| {
438 let (namespace, name, version) = parse_npm_key(&value)?;
439 Some(Dependency {
440 purl: create_npm_purl(namespace.as_deref(), &name, Some(version))
441 .map(truncate_field),
442 extracted_requirement: Some(truncate_field(value.clone())),
443 scope: Some("dependencies".to_string()),
444 is_runtime: Some(true),
445 is_optional: Some(false),
446 is_pinned: Some(true),
447 is_direct: Some(true),
448 resolved_package: None,
449 extra_data: None,
450 })
451 })
452 .collect(),
453 repository_homepage_url: None,
454 repository_download_url: None,
455 api_data_url: None,
456 datasource_id: Some(DatasourceId::DenoLock),
457 purl,
458 ..ResolvedPackage::new(
459 PackageType::Npm,
460 namespace.unwrap_or_default(),
461 name,
462 version_str,
463 )
464 })),
465 extra_data: None,
466 })
467}
468
469fn extract_jsr_resolved_dependencies(
470 jsr_object: &serde_json::Map<String, Value>,
471) -> Vec<Dependency> {
472 jsr_object
473 .get(FIELD_DEPENDENCIES)
474 .and_then(Value::as_array)
475 .into_iter()
476 .flatten()
477 .filter_map(Value::as_str)
478 .capped("deno.lock jsr resolved dependencies")
479 .filter_map(|value| {
480 let (namespace, name, version) = parse_jsr_dependency_reference(value)?;
481 Some(Dependency {
482 purl: create_generic_purl(Some(&format!("jsr.io/{}", namespace)), &name, version)
483 .map(truncate_field),
484 extracted_requirement: Some(truncate_field(value.to_string())),
485 scope: Some("dependencies".to_string()),
486 is_runtime: Some(true),
487 is_optional: Some(false),
488 is_pinned: Some(version.is_some_and(is_exact_version)),
489 is_direct: Some(true),
490 resolved_package: None,
491 extra_data: None,
492 })
493 })
494 .collect()
495}
496
497fn parse_jsr_key(key: &str) -> Option<(String, String, &str)> {
498 let scoped = key.strip_prefix('@')?;
499 let slash_index = scoped.find('/')?;
500 let namespace = format!("@{}", &scoped[..slash_index]);
501 let name_and_version = &scoped[slash_index + 1..];
502 let at_index = name_and_version.rfind('@')?;
503 let name = name_and_version[..at_index].to_string();
504 let version = &name_and_version[at_index + 1..];
505 Some((namespace, name, version))
506}
507
508fn parse_jsr_dependency_reference(value: &str) -> Option<(String, String, Option<&str>)> {
509 let rest = value.strip_prefix("jsr:")?;
510 let slash_index = rest.find('/')?;
511 let namespace = format!("@{}", &rest[1..slash_index]);
512 let name_and_version = &rest[slash_index + 1..];
513 let (name, version) = split_name_and_version(name_and_version);
514 Some((namespace, name.to_string(), version))
515}
516
517fn resolve_jsr_full_key(specifier: &str, resolved_version: &str) -> Option<String> {
518 let (namespace, name, _) = parse_jsr_dependency_reference(specifier)?;
519 Some(format!("{}/{}@{}", namespace, name, resolved_version))
520}
521
522fn parse_npm_key(key: &str) -> Option<(Option<String>, String, &str)> {
523 if let Some(scoped) = key.strip_prefix('@') {
524 let slash_index = scoped.find('/')?;
525 let namespace = format!("@{}", &scoped[..slash_index]);
526 let name_and_version = &scoped[slash_index + 1..];
527 let at_index = name_and_version.rfind('@')?;
528 let name = name_and_version[..at_index].to_string();
529 let version = &name_and_version[at_index + 1..];
530 Some((Some(namespace), name, version))
531 } else {
532 let at_index = key.rfind('@')?;
533 let name = key[..at_index].to_string();
534 let version = &key[at_index + 1..];
535 Some((None, name, version))
536 }
537}
538
539fn resolve_npm_full_key(specifier: &str, resolved_version: &str) -> Option<String> {
540 let (namespace, name, _) = parse_npm_specifier(specifier)?;
541 Some(match namespace {
542 Some(namespace) => format!("{}/{}@{}", namespace, name, resolved_version),
543 None => format!("{}@{}", name, resolved_version),
544 })
545}
546
547fn parse_npm_specifier(specifier: &str) -> Option<(Option<String>, String, Option<&str>)> {
548 let rest = specifier.strip_prefix("npm:")?;
549 let (name_part, version) = split_name_and_version(rest);
550 if let Some(scoped) = name_part.strip_prefix('@') {
551 let slash_index = scoped.find('/')?;
552 let namespace = format!("@{}", &scoped[..slash_index]);
553 let name = scoped[slash_index + 1..].to_string();
554 Some((Some(namespace), name, version))
555 } else {
556 Some((None, name_part.to_string(), version))
557 }
558}
559
560fn split_name_and_version(input: &str) -> (&str, Option<&str>) {
561 if let Some(index) = input.rfind('@') {
562 let (name, version) = input.split_at(index);
563 if !name.is_empty() {
564 return (name, Some(&version[1..]));
565 }
566 }
567 (input, None)
568}
569
570fn create_npm_purl(namespace: Option<&str>, name: &str, version: Option<&str>) -> Option<String> {
571 let mut purl = PackageUrl::new("npm", name).ok()?;
572 if let Some(namespace) = namespace {
573 purl.with_namespace(namespace).ok()?;
574 }
575 if let Some(version) = version {
576 purl.with_version(version).ok()?;
577 }
578 Some(purl.to_string())
579}
580
581fn create_generic_purl(
582 namespace: Option<&str>,
583 name: &str,
584 version: Option<&str>,
585) -> Option<String> {
586 let mut purl = PackageUrl::new("generic", name).ok()?;
587 if let Some(namespace) = namespace {
588 purl.with_namespace(namespace).ok()?;
589 }
590 if let Some(version) = version {
591 purl.with_version(version).ok()?;
592 }
593 Some(purl.to_string())
594}
595
596fn create_remote_purl(specifier: &str) -> Option<String> {
597 let url = Url::parse(specifier).ok()?;
598 let segments: Vec<&str> = url.path_segments()?.collect();
599 let name = segments.last()?.to_string();
600 let namespace = if segments.len() > 1 {
601 Some(format!(
602 "{}/{}",
603 url.host_str()?,
604 segments[..segments.len() - 1].join("/")
605 ))
606 } else {
607 url.host_str().map(|host| host.to_string())
608 };
609 create_generic_purl(namespace.as_deref(), &name, None)
610}
611
612fn remote_name(url: &str) -> Option<String> {
613 let url = Url::parse(url).ok()?;
614 url.path_segments()?
615 .next_back()
616 .map(|value| value.to_string())
617}
618
619fn is_exact_version(version: &str) -> bool {
620 !version.contains('^')
621 && !version.contains('~')
622 && !version.contains('*')
623 && !version.contains('>')
624 && !version.contains('<')
625 && !version.contains('|')
626 && !version.contains(' ')
627}
628
629fn default_package_data() -> PackageData {
630 PackageData {
631 package_type: Some(DenoLockParser::PACKAGE_TYPE),
632 primary_language: Some("TypeScript".to_string()),
633 datasource_id: Some(DatasourceId::DenoLock),
634 ..Default::default()
635 }
636}