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