1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::Path;
4
5use log::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 package_type: DenoLockParser::PACKAGE_TYPE,
130 namespace: String::new(),
131 name: name.clone(),
132 version: String::new(),
133 primary_language: Some("TypeScript".to_string()),
134 download_url: Some(target_url.to_string()),
135 sha1: None,
136 sha256: hash,
137 sha512: None,
138 md5: None,
139 is_virtual: true,
140 extra_data: Some(HashMap::from([(
141 "redirect_source".to_string(),
142 Value::String(source.to_string()),
143 )])),
144 dependencies: Vec::new(),
145 repository_homepage_url: None,
146 repository_download_url: None,
147 api_data_url: None,
148 datasource_id: Some(DatasourceId::DenoLock),
149 purl: purl.clone(),
150 };
151
152 dependencies.push(Dependency {
153 purl,
154 extracted_requirement: Some(source.to_string()),
155 scope: Some("imports".to_string()),
156 is_runtime: Some(true),
157 is_optional: Some(false),
158 is_pinned: Some(true),
159 is_direct: Some(true),
160 resolved_package: Some(Box::new(resolved_package)),
161 extra_data: None,
162 });
163 }
164 }
165
166 let mut extra_data = HashMap::new();
167 extra_data.insert(FIELD_VERSION.to_string(), Value::String("5".to_string()));
168 if !workspace_direct.is_empty() {
169 extra_data.insert(
170 "workspace_dependencies".to_string(),
171 Value::Array(
172 workspace_direct
173 .iter()
174 .cloned()
175 .map(Value::String)
176 .collect(),
177 ),
178 );
179 }
180
181 PackageData {
182 package_type: Some(DenoLockParser::PACKAGE_TYPE),
183 primary_language: Some("TypeScript".to_string()),
184 dependencies,
185 extra_data: Some(extra_data),
186 datasource_id: Some(DatasourceId::DenoLock),
187 ..Default::default()
188 }
189}
190
191fn extract_workspace_dependencies(json: &Value) -> Vec<String> {
192 json.get(FIELD_WORKSPACE)
193 .and_then(Value::as_object)
194 .and_then(|workspace| workspace.get(FIELD_DEPENDENCIES))
195 .and_then(Value::as_array)
196 .into_iter()
197 .flatten()
198 .filter_map(Value::as_str)
199 .map(|value| value.to_string())
200 .collect()
201}
202
203fn build_jsr_dependency(
204 resolved_key: &str,
205 is_direct: bool,
206 jsr_section: &Value,
207 extracted_requirement: Option<&str>,
208) -> Option<Dependency> {
209 let jsr_entry = jsr_section.get(resolved_key)?;
210 let jsr_object = jsr_entry.as_object()?;
211 let (namespace, name, version) = parse_jsr_key(resolved_key)?;
212 let purl = create_generic_purl(Some(&format!("jsr.io/{}", namespace)), &name, Some(version));
213
214 Some(Dependency {
215 purl: purl.clone(),
216 extracted_requirement: extracted_requirement.map(|value| value.to_string()),
217 scope: Some("imports".to_string()),
218 is_runtime: Some(true),
219 is_optional: Some(false),
220 is_pinned: Some(true),
221 is_direct: Some(is_direct),
222 resolved_package: Some(Box::new(ResolvedPackage {
223 package_type: DenoLockParser::PACKAGE_TYPE,
224 namespace,
225 name,
226 version: version.to_string(),
227 primary_language: Some("TypeScript".to_string()),
228 download_url: None,
229 sha1: None,
230 sha256: jsr_object
231 .get("integrity")
232 .and_then(Value::as_str)
233 .map(|value| value.to_string()),
234 sha512: None,
235 md5: None,
236 is_virtual: true,
237 extra_data: None,
238 dependencies: extract_jsr_resolved_dependencies(jsr_object),
239 repository_homepage_url: None,
240 repository_download_url: None,
241 api_data_url: None,
242 datasource_id: Some(DatasourceId::DenoLock),
243 purl,
244 })),
245 extra_data: None,
246 })
247}
248
249fn build_npm_dependency(
250 resolved_key: &str,
251 is_direct: bool,
252 npm_section: &Value,
253 extracted_requirement: Option<&str>,
254) -> Option<Dependency> {
255 let npm_entry = npm_section.get(resolved_key)?;
256 let npm_object = npm_entry.as_object()?;
257 let (namespace, name, version) = parse_npm_key(resolved_key)?;
258 let purl = create_npm_purl(namespace.as_deref(), &name, Some(version));
259
260 Some(Dependency {
261 purl: purl.clone(),
262 extracted_requirement: extracted_requirement.map(|value| value.to_string()),
263 scope: Some("imports".to_string()),
264 is_runtime: Some(true),
265 is_optional: Some(false),
266 is_pinned: Some(true),
267 is_direct: Some(is_direct),
268 resolved_package: Some(Box::new(ResolvedPackage {
269 package_type: PackageType::Npm,
270 namespace: namespace.unwrap_or_default(),
271 name,
272 version: version.to_string(),
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 })),
314 extra_data: None,
315 })
316}
317
318fn extract_jsr_resolved_dependencies(
319 jsr_object: &serde_json::Map<String, Value>,
320) -> Vec<Dependency> {
321 jsr_object
322 .get(FIELD_DEPENDENCIES)
323 .and_then(Value::as_array)
324 .into_iter()
325 .flatten()
326 .filter_map(Value::as_str)
327 .filter_map(|value| {
328 let (namespace, name, version) = parse_jsr_dependency_reference(value)?;
329 Some(Dependency {
330 purl: create_generic_purl(Some(&format!("jsr.io/{}", namespace)), &name, version),
331 extracted_requirement: Some(value.to_string()),
332 scope: Some("dependencies".to_string()),
333 is_runtime: Some(true),
334 is_optional: Some(false),
335 is_pinned: Some(version.is_some_and(is_exact_version)),
336 is_direct: Some(true),
337 resolved_package: None,
338 extra_data: None,
339 })
340 })
341 .collect()
342}
343
344fn parse_jsr_key(key: &str) -> Option<(String, String, &str)> {
345 let scoped = key.strip_prefix('@')?;
346 let slash_index = scoped.find('/')?;
347 let namespace = format!("@{}", &scoped[..slash_index]);
348 let name_and_version = &scoped[slash_index + 1..];
349 let at_index = name_and_version.rfind('@')?;
350 let name = name_and_version[..at_index].to_string();
351 let version = &name_and_version[at_index + 1..];
352 Some((namespace, name, version))
353}
354
355fn parse_jsr_dependency_reference(value: &str) -> Option<(String, String, Option<&str>)> {
356 let rest = value.strip_prefix("jsr:")?;
357 let slash_index = rest.find('/')?;
358 let namespace = format!("@{}", &rest[1..slash_index]);
359 let name_and_version = &rest[slash_index + 1..];
360 let (name, version) = split_name_and_version(name_and_version);
361 Some((namespace, name.to_string(), version))
362}
363
364fn resolve_jsr_full_key(specifier: &str, resolved_version: &str) -> Option<String> {
365 let (namespace, name, _) = parse_jsr_dependency_reference(specifier)?;
366 Some(format!("{}/{}@{}", namespace, name, resolved_version))
367}
368
369fn parse_npm_key(key: &str) -> Option<(Option<String>, String, &str)> {
370 if let Some(scoped) = key.strip_prefix('@') {
371 let slash_index = scoped.find('/')?;
372 let namespace = format!("@{}", &scoped[..slash_index]);
373 let name_and_version = &scoped[slash_index + 1..];
374 let at_index = name_and_version.rfind('@')?;
375 let name = name_and_version[..at_index].to_string();
376 let version = &name_and_version[at_index + 1..];
377 Some((Some(namespace), name, version))
378 } else {
379 let at_index = key.rfind('@')?;
380 let name = key[..at_index].to_string();
381 let version = &key[at_index + 1..];
382 Some((None, name, version))
383 }
384}
385
386fn resolve_npm_full_key(specifier: &str, resolved_version: &str) -> Option<String> {
387 let (namespace, name, _) = parse_npm_specifier(specifier)?;
388 Some(match namespace {
389 Some(namespace) => format!("{}/{}@{}", namespace, name, resolved_version),
390 None => format!("{}@{}", name, resolved_version),
391 })
392}
393
394fn parse_npm_specifier(specifier: &str) -> Option<(Option<String>, String, Option<&str>)> {
395 let rest = specifier.strip_prefix("npm:")?;
396 let (name_part, version) = split_name_and_version(rest);
397 if let Some(scoped) = name_part.strip_prefix('@') {
398 let slash_index = scoped.find('/')?;
399 let namespace = format!("@{}", &scoped[..slash_index]);
400 let name = scoped[slash_index + 1..].to_string();
401 Some((Some(namespace), name, version))
402 } else {
403 Some((None, name_part.to_string(), version))
404 }
405}
406
407fn split_name_and_version(input: &str) -> (&str, Option<&str>) {
408 if let Some(index) = input.rfind('@') {
409 let (name, version) = input.split_at(index);
410 if !name.is_empty() {
411 return (name, Some(&version[1..]));
412 }
413 }
414 (input, None)
415}
416
417fn create_npm_purl(namespace: Option<&str>, name: &str, version: Option<&str>) -> Option<String> {
418 let mut purl = PackageUrl::new("npm", name).ok()?;
419 if let Some(namespace) = namespace {
420 purl.with_namespace(namespace).ok()?;
421 }
422 if let Some(version) = version {
423 purl.with_version(version).ok()?;
424 }
425 Some(purl.to_string())
426}
427
428fn create_generic_purl(
429 namespace: Option<&str>,
430 name: &str,
431 version: Option<&str>,
432) -> Option<String> {
433 let mut purl = PackageUrl::new("generic", name).ok()?;
434 if let Some(namespace) = namespace {
435 purl.with_namespace(namespace).ok()?;
436 }
437 if let Some(version) = version {
438 purl.with_version(version).ok()?;
439 }
440 Some(purl.to_string())
441}
442
443fn create_remote_purl(specifier: &str) -> Option<String> {
444 let url = Url::parse(specifier).ok()?;
445 let segments: Vec<&str> = url.path_segments()?.collect();
446 let name = segments.last()?.to_string();
447 let namespace = if segments.len() > 1 {
448 Some(format!(
449 "{}/{}",
450 url.host_str()?,
451 segments[..segments.len() - 1].join("/")
452 ))
453 } else {
454 url.host_str().map(|host| host.to_string())
455 };
456 create_generic_purl(namespace.as_deref(), &name, None)
457}
458
459fn remote_name(url: &str) -> Option<String> {
460 let url = Url::parse(url).ok()?;
461 url.path_segments()?
462 .next_back()
463 .map(|value| value.to_string())
464}
465
466fn is_exact_version(version: &str) -> bool {
467 !version.contains('^')
468 && !version.contains('~')
469 && !version.contains('*')
470 && !version.contains('>')
471 && !version.contains('<')
472 && !version.contains('|')
473 && !version.contains(' ')
474}
475
476fn default_package_data() -> PackageData {
477 PackageData {
478 package_type: Some(DenoLockParser::PACKAGE_TYPE),
479 primary_language: Some("TypeScript".to_string()),
480 datasource_id: Some(DatasourceId::DenoLock),
481 ..Default::default()
482 }
483}
484
485crate::register_parser!(
486 "Deno lockfile",
487 &["**/deno.lock"],
488 "deno",
489 "TypeScript",
490 Some("https://docs.deno.com/runtime/fundamentals/modules/"),
491);