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