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