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 {
283 purl,
284 extracted_requirement: Some(truncate_field(source.to_string())),
285 scope: Some("imports".to_string()),
286 is_runtime: None,
287 is_optional: None,
288 is_pinned: Some(true),
289 is_direct: None,
290 resolved_package: Some(Box::new(resolved_package)),
291 extra_data: None,
292 });
293 }
294 }
295 dependencies
296}
297
298fn extract_workspace_dependencies(json: &Value) -> Vec<String> {
299 json.get(FIELD_WORKSPACE)
300 .and_then(Value::as_object)
301 .and_then(|workspace| workspace.get(FIELD_DEPENDENCIES))
302 .and_then(Value::as_array)
303 .into_iter()
304 .flatten()
305 .filter_map(Value::as_str)
306 .map(|value| truncate_field(value.to_string()))
307 .collect()
308}
309
310fn build_jsr_dependency(
311 resolved_key: &str,
312 is_direct: bool,
313 jsr_section: &Value,
314 extracted_requirement: Option<&str>,
315) -> Option<Dependency> {
316 let jsr_entry = jsr_section.get(resolved_key)?;
317 let jsr_object = jsr_entry.as_object()?;
318 let (namespace, name, version) = parse_jsr_key(resolved_key)?;
319 let namespace = truncate_field(namespace);
320 let name = truncate_field(name);
321 let version_str = truncate_field(version.to_string());
322 let purl = create_generic_purl(
323 Some(&format!("jsr.io/{}", namespace)),
324 &name,
325 Some(&version_str),
326 )
327 .map(truncate_field);
328
329 Some(Dependency {
335 purl: purl.clone(),
336 extracted_requirement: extracted_requirement.map(|value| truncate_field(value.to_string())),
337 scope: Some("imports".to_string()),
338 is_runtime: None,
339 is_optional: None,
340 is_pinned: Some(true),
341 is_direct: Some(is_direct),
342 resolved_package: Some(Box::new(ResolvedPackage {
343 primary_language: Some("TypeScript".to_string()),
344 download_url: None,
345 sha1: None,
346 sha256: jsr_object
347 .get("integrity")
348 .and_then(Value::as_str)
349 .and_then(|value| {
350 parse_sri(value)
351 .and_then(|(algo, hex)| {
352 (algo == "sha256").then(|| Sha256Digest::from_hex(&hex).ok())
353 })
354 .flatten()
355 .or_else(|| Sha256Digest::from_hex(value).ok())
356 }),
357 sha512: None,
358 md5: None,
359 is_virtual: true,
360 extra_data: None,
361 dependencies: extract_jsr_resolved_dependencies(jsr_object),
362 repository_homepage_url: None,
363 repository_download_url: None,
364 api_data_url: None,
365 datasource_id: Some(DatasourceId::DenoLock),
366 purl,
367 ..ResolvedPackage::new(DenoLockParser::PACKAGE_TYPE, namespace, name, version_str)
368 })),
369 extra_data: None,
370 })
371}
372
373fn npm_dependency_keys(value: Option<&Value>) -> Vec<String> {
379 match value {
380 Some(Value::Array(items)) => items
381 .iter()
382 .filter_map(Value::as_str)
383 .map(str::to_string)
384 .collect(),
385 Some(Value::Object(map)) => map
386 .values()
387 .filter_map(Value::as_str)
388 .map(str::to_string)
389 .collect(),
390 _ => Vec::new(),
391 }
392}
393
394fn build_npm_dependency(
395 resolved_key: &str,
396 is_direct: bool,
397 npm_section: &Value,
398 extracted_requirement: Option<&str>,
399) -> Option<Dependency> {
400 let npm_entry = npm_section.get(resolved_key)?;
401 let npm_object = npm_entry.as_object()?;
402 let (namespace, name, version) = parse_npm_key(resolved_key)?;
403 let namespace = namespace.map(truncate_field);
404 let name = truncate_field(name);
405 let version_str = truncate_field(version.to_string());
406 let purl = create_npm_purl(namespace.as_deref(), &name, Some(&version_str)).map(truncate_field);
407
408 let resolved_dependency_keys = npm_dependency_keys(npm_object.get(FIELD_DEPENDENCIES));
409 let resolved_dependency_limit = capped_iteration_limit(
410 resolved_dependency_keys.len(),
411 "deno.lock npm resolved dependencies",
412 );
413
414 Some(Dependency {
419 purl: purl.clone(),
420 extracted_requirement: extracted_requirement.map(|value| truncate_field(value.to_string())),
421 scope: Some("imports".to_string()),
422 is_runtime: None,
423 is_optional: None,
424 is_pinned: Some(true),
425 is_direct: Some(is_direct),
426 resolved_package: Some(Box::new(ResolvedPackage {
427 primary_language: Some("JavaScript".to_string()),
428 download_url: npm_object
429 .get("tarball")
430 .and_then(Value::as_str)
431 .map(|value| truncate_field(value.to_string())),
432 sha1: None,
433 sha256: None,
434 sha512: npm_object
435 .get("integrity")
436 .and_then(Value::as_str)
437 .and_then(|value| {
438 parse_sri(value)
439 .and_then(|(algo, hex)| {
440 (algo == "sha512").then(|| Sha512Digest::from_hex(&hex).ok())
441 })
442 .flatten()
443 }),
444 md5: None,
445 is_virtual: true,
446 extra_data: None,
447 dependencies: resolved_dependency_keys
448 .into_iter()
449 .take(resolved_dependency_limit)
450 .filter_map(|value| {
451 let (namespace, name, version) = parse_npm_key(&value)?;
452 Some(Dependency {
456 purl: create_npm_purl(namespace.as_deref(), &name, Some(version))
457 .map(truncate_field),
458 extracted_requirement: Some(truncate_field(value.clone())),
459 scope: Some("dependencies".to_string()),
460 is_runtime: None,
461 is_optional: None,
462 is_pinned: Some(true),
463 is_direct: None,
464 resolved_package: None,
465 extra_data: None,
466 })
467 })
468 .collect(),
469 repository_homepage_url: None,
470 repository_download_url: None,
471 api_data_url: None,
472 datasource_id: Some(DatasourceId::DenoLock),
473 purl,
474 ..ResolvedPackage::new(
475 PackageType::Npm,
476 namespace.unwrap_or_default(),
477 name,
478 version_str,
479 )
480 })),
481 extra_data: None,
482 })
483}
484
485fn extract_jsr_resolved_dependencies(
486 jsr_object: &serde_json::Map<String, Value>,
487) -> Vec<Dependency> {
488 jsr_object
489 .get(FIELD_DEPENDENCIES)
490 .and_then(Value::as_array)
491 .into_iter()
492 .flatten()
493 .filter_map(Value::as_str)
494 .capped("deno.lock jsr resolved dependencies")
495 .filter_map(|value| {
496 let (namespace, name, version) = parse_jsr_dependency_reference(value)?;
497 Some(Dependency {
501 purl: create_generic_purl(Some(&format!("jsr.io/{}", namespace)), &name, version)
502 .map(truncate_field),
503 extracted_requirement: Some(truncate_field(value.to_string())),
504 scope: Some("dependencies".to_string()),
505 is_runtime: None,
506 is_optional: None,
507 is_pinned: Some(version.is_some_and(is_exact_version)),
508 is_direct: None,
509 resolved_package: None,
510 extra_data: None,
511 })
512 })
513 .collect()
514}
515
516fn parse_jsr_key(key: &str) -> Option<(String, String, &str)> {
517 let scoped = key.strip_prefix('@')?;
518 let slash_index = scoped.find('/')?;
519 let namespace = format!("@{}", &scoped[..slash_index]);
520 let name_and_version = &scoped[slash_index + 1..];
521 let at_index = name_and_version.rfind('@')?;
522 let name = name_and_version[..at_index].to_string();
523 let version = &name_and_version[at_index + 1..];
524 Some((namespace, name, version))
525}
526
527fn parse_jsr_dependency_reference(value: &str) -> Option<(String, String, Option<&str>)> {
528 let rest = value.strip_prefix("jsr:")?;
529 let slash_index = rest.find('/')?;
530 let namespace = format!("@{}", &rest[1..slash_index]);
531 let name_and_version = &rest[slash_index + 1..];
532 let (name, version) = split_name_and_version(name_and_version);
533 Some((namespace, name.to_string(), version))
534}
535
536fn resolve_jsr_full_key(specifier: &str, resolved_version: &str) -> Option<String> {
537 let (namespace, name, _) = parse_jsr_dependency_reference(specifier)?;
538 Some(format!("{}/{}@{}", namespace, name, resolved_version))
539}
540
541fn parse_npm_key(key: &str) -> Option<(Option<String>, String, &str)> {
542 if let Some(scoped) = key.strip_prefix('@') {
543 let slash_index = scoped.find('/')?;
544 let namespace = format!("@{}", &scoped[..slash_index]);
545 let name_and_version = &scoped[slash_index + 1..];
546 let at_index = name_and_version.rfind('@')?;
547 let name = name_and_version[..at_index].to_string();
548 let version = &name_and_version[at_index + 1..];
549 Some((Some(namespace), name, version))
550 } else {
551 let at_index = key.rfind('@')?;
552 let name = key[..at_index].to_string();
553 let version = &key[at_index + 1..];
554 Some((None, name, version))
555 }
556}
557
558fn resolve_npm_full_key(specifier: &str, resolved_version: &str) -> Option<String> {
559 let (namespace, name, _) = parse_npm_specifier(specifier)?;
560 Some(match namespace {
561 Some(namespace) => format!("{}/{}@{}", namespace, name, resolved_version),
562 None => format!("{}@{}", name, resolved_version),
563 })
564}
565
566fn parse_npm_specifier(specifier: &str) -> Option<(Option<String>, String, Option<&str>)> {
567 let rest = specifier.strip_prefix("npm:")?;
568 let (name_part, version) = split_name_and_version(rest);
569 if let Some(scoped) = name_part.strip_prefix('@') {
570 let slash_index = scoped.find('/')?;
571 let namespace = format!("@{}", &scoped[..slash_index]);
572 let name = scoped[slash_index + 1..].to_string();
573 Some((Some(namespace), name, version))
574 } else {
575 Some((None, name_part.to_string(), version))
576 }
577}
578
579fn split_name_and_version(input: &str) -> (&str, Option<&str>) {
580 if let Some(index) = input.rfind('@') {
581 let (name, version) = input.split_at(index);
582 if !name.is_empty() {
583 return (name, Some(&version[1..]));
584 }
585 }
586 (input, None)
587}
588
589fn create_npm_purl(namespace: Option<&str>, name: &str, version: Option<&str>) -> Option<String> {
590 let mut purl = PackageUrl::new("npm", name).ok()?;
591 if let Some(namespace) = namespace {
592 purl.with_namespace(namespace).ok()?;
593 }
594 if let Some(version) = version {
595 purl.with_version(version).ok()?;
596 }
597 Some(purl.to_string())
598}
599
600fn create_generic_purl(
601 namespace: Option<&str>,
602 name: &str,
603 version: Option<&str>,
604) -> Option<String> {
605 let mut purl = PackageUrl::new("generic", name).ok()?;
606 if let Some(namespace) = namespace {
607 purl.with_namespace(namespace).ok()?;
608 }
609 if let Some(version) = version {
610 purl.with_version(version).ok()?;
611 }
612 Some(purl.to_string())
613}
614
615fn create_remote_purl(specifier: &str) -> Option<String> {
616 let url = Url::parse(specifier).ok()?;
617 let segments: Vec<&str> = url.path_segments()?.collect();
618 let name = segments.last()?.to_string();
619 let namespace = if segments.len() > 1 {
620 Some(format!(
621 "{}/{}",
622 url.host_str()?,
623 segments[..segments.len() - 1].join("/")
624 ))
625 } else {
626 url.host_str().map(|host| host.to_string())
627 };
628 create_generic_purl(namespace.as_deref(), &name, None)
629}
630
631fn remote_name(url: &str) -> Option<String> {
632 let url = Url::parse(url).ok()?;
633 url.path_segments()?
634 .next_back()
635 .map(|value| value.to_string())
636}
637
638fn is_exact_version(version: &str) -> bool {
639 !version.contains('^')
640 && !version.contains('~')
641 && !version.contains('*')
642 && !version.contains('>')
643 && !version.contains('<')
644 && !version.contains('|')
645 && !version.contains(' ')
646}
647
648fn default_package_data() -> PackageData {
649 PackageData {
650 package_type: Some(DenoLockParser::PACKAGE_TYPE),
651 primary_language: Some("TypeScript".to_string()),
652 datasource_id: Some(DatasourceId::DenoLock),
653 ..Default::default()
654 }
655}