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