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