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