Skip to main content

provenant/parsers/
deno_lock.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use 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
72    // deno.lock has gone through several incompatible layouts; dispatch on the declared
73    // version so older lockfiles still in the wild are not silently dropped:
74    //   * v1 (Deno 1.0-1.17): remote ESM URL imports only, no registry dependencies.
75    //   * v2/v3 (Deno 1.18-1.40): npm dependencies nested under npm.{specifiers,packages};
76    //     no jsr or workspace sections.
77    //   * v4 (Deno 1.45+) and v5 (current): flat top-level specifiers/npm/jsr/workspace.
78    // Unknown/newer versions fall back to the latest (flat) layout rather than dropping.
79    let (mut dependencies, workspace_direct) = match lock_version {
80        Some("1") => (Vec::new(), Vec::new()),
81        Some("2") | Some("3") => (extract_nested_npm_dependencies(json), Vec::new()),
82        Some("4") | Some("5") => extract_flat_dependencies(json),
83        other => {
84            warn!(
85                "Unrecognized deno.lock version {:?}; parsing with the latest known layout",
86                other
87            );
88            extract_flat_dependencies(json)
89        }
90    };
91    dependencies.extend(extract_redirect_dependencies(json));
92
93    let mut extra_data = HashMap::new();
94    if let Some(version) = lock_version {
95        extra_data.insert(
96            FIELD_VERSION.to_string(),
97            Value::String(version.to_string()),
98        );
99    }
100    if !workspace_direct.is_empty() {
101        extra_data.insert(
102            "workspace_dependencies".to_string(),
103            Value::Array(workspace_direct.into_iter().map(Value::String).collect()),
104        );
105    }
106
107    PackageData {
108        package_type: Some(DenoLockParser::PACKAGE_TYPE),
109        primary_language: Some("TypeScript".to_string()),
110        dependencies,
111        extra_data: (!extra_data.is_empty()).then_some(extra_data),
112        datasource_id: Some(DatasourceId::DenoLock),
113        ..Default::default()
114    }
115}
116
117/// Flat top-level layout used by deno.lock v4 and v5: `specifiers`, `npm`, `jsr`, and
118/// `workspace` sections. Returns the resolved dependencies plus the workspace's direct
119/// specifier list (recorded in extra_data by the caller).
120fn extract_flat_dependencies(json: &Value) -> (Vec<Dependency>, Vec<String>) {
121    let specifiers = json
122        .get(FIELD_SPECIFIERS)
123        .and_then(Value::as_object)
124        .cloned()
125        .unwrap_or_default();
126    let workspace_direct = extract_workspace_dependencies(json);
127
128    let mut dependencies = Vec::new();
129    let mut direct_jsr_keys = HashSet::new();
130    let mut direct_npm_keys = HashSet::new();
131
132    for specifier in workspace_direct.iter().take(MAX_ITERATION_COUNT) {
133        if let Some(resolved_key) = specifiers.get(specifier).and_then(Value::as_str) {
134            if specifier.starts_with("jsr:") {
135                if let Some(full_key) = resolve_jsr_full_key(specifier, resolved_key)
136                    && let Some(dep) =
137                        build_jsr_dependency(&full_key, true, &json[FIELD_JSR], Some(specifier))
138                {
139                    direct_jsr_keys.insert(full_key);
140                    dependencies.push(dep);
141                }
142            } else if specifier.starts_with("npm:")
143                && let Some(full_key) = resolve_npm_full_key(specifier, resolved_key)
144                && let Some(dep) =
145                    build_npm_dependency(&full_key, true, &json[FIELD_NPM], Some(specifier))
146            {
147                direct_npm_keys.insert(full_key);
148                dependencies.push(dep);
149            }
150        }
151    }
152
153    if let Some(jsr_map) = json.get(FIELD_JSR).and_then(Value::as_object) {
154        for key in jsr_map.keys().take(MAX_ITERATION_COUNT) {
155            if direct_jsr_keys.contains(key) {
156                continue;
157            }
158            if let Some(dep) = build_jsr_dependency(key, false, &json[FIELD_JSR], None) {
159                dependencies.push(dep);
160            }
161        }
162    }
163
164    if let Some(npm_map) = json.get(FIELD_NPM).and_then(Value::as_object) {
165        for key in npm_map.keys().take(MAX_ITERATION_COUNT) {
166            if direct_npm_keys.contains(key) {
167                continue;
168            }
169            if let Some(dep) = build_npm_dependency(key, false, &json[FIELD_NPM], None) {
170                dependencies.push(dep);
171            }
172        }
173    }
174
175    (dependencies, workspace_direct)
176}
177
178/// Nested layout used by deno.lock v2 and v3: npm dependencies live under `npm.packages`
179/// (the resolved graph), with `npm.specifiers` mapping requested ranges to resolved
180/// `name@version` keys. There are no jsr or workspace sections in these versions.
181fn extract_nested_npm_dependencies(json: &Value) -> Vec<Dependency> {
182    let Some(npm) = json.get(FIELD_NPM) else {
183        return Vec::new();
184    };
185    let Some(packages) = npm.get("packages") else {
186        return Vec::new();
187    };
188    let Some(packages_map) = packages.as_object() else {
189        return Vec::new();
190    };
191
192    // `npm.specifiers` maps each requested specifier (e.g. "chalk@5") to its resolved
193    // `name@version` key (e.g. "chalk@5.3.0"). Invert it so resolved packages referenced
194    // by a top-level specifier can be marked direct and keep their requested range; the
195    // remaining npm.packages entries are transitive.
196    let requested_by_resolved: HashMap<String, String> = npm
197        .get("specifiers")
198        .and_then(Value::as_object)
199        .map(|specifiers| {
200            specifiers
201                .iter()
202                .filter_map(|(specifier, resolved)| {
203                    resolved.as_str().map(|resolved| {
204                        (
205                            resolved.trim_start_matches("npm:").to_string(),
206                            specifier.clone(),
207                        )
208                    })
209                })
210                .collect()
211        })
212        .unwrap_or_default();
213
214    let mut dependencies = Vec::new();
215    for key in packages_map.keys().take(MAX_ITERATION_COUNT) {
216        let requested = requested_by_resolved.get(key).map(String::as_str);
217        if let Some(dep) = build_npm_dependency(key, requested.is_some(), packages, requested) {
218            dependencies.push(dep);
219        }
220    }
221    dependencies
222}
223
224/// Module redirects, shared across all lockfile versions: each redirect target is a
225/// remote module whose integrity hash lives in the `remote` section.
226fn extract_redirect_dependencies(json: &Value) -> Vec<Dependency> {
227    let mut dependencies = Vec::new();
228    if let Some(redirects) = json.get(FIELD_REDIRECTS).and_then(Value::as_object) {
229        for (source, target) in redirects.iter().take(MAX_ITERATION_COUNT) {
230            let Some(target_url) = target.as_str() else {
231                continue;
232            };
233            let hash = json
234                .get(FIELD_REMOTE)
235                .and_then(Value::as_object)
236                .and_then(|remote| remote.get(target_url))
237                .and_then(Value::as_str)
238                .and_then(|value| Sha256Digest::from_hex(value).ok());
239
240            let name =
241                truncate_field(remote_name(target_url).unwrap_or_else(|| source.to_string()));
242            let purl = create_remote_purl(target_url).map(truncate_field);
243            let resolved_package = ResolvedPackage {
244                primary_language: Some("TypeScript".to_string()),
245                download_url: Some(truncate_field(target_url.to_string())),
246                sha1: None,
247                sha256: hash,
248                sha512: None,
249                md5: None,
250                is_virtual: true,
251                extra_data: Some(HashMap::from([(
252                    "redirect_source".to_string(),
253                    Value::String(truncate_field(source.to_string())),
254                )])),
255                dependencies: Vec::new(),
256                repository_homepage_url: None,
257                repository_download_url: None,
258                api_data_url: None,
259                datasource_id: Some(DatasourceId::DenoLock),
260                purl: purl.clone(),
261                ..ResolvedPackage::new(
262                    DenoLockParser::PACKAGE_TYPE,
263                    String::new(),
264                    name.clone(),
265                    String::new(),
266                )
267            };
268
269            dependencies.push(Dependency {
270                purl,
271                extracted_requirement: Some(truncate_field(source.to_string())),
272                scope: Some("imports".to_string()),
273                is_runtime: Some(true),
274                is_optional: Some(false),
275                is_pinned: Some(true),
276                is_direct: Some(true),
277                resolved_package: Some(Box::new(resolved_package)),
278                extra_data: None,
279            });
280        }
281    }
282    dependencies
283}
284
285fn extract_workspace_dependencies(json: &Value) -> Vec<String> {
286    json.get(FIELD_WORKSPACE)
287        .and_then(Value::as_object)
288        .and_then(|workspace| workspace.get(FIELD_DEPENDENCIES))
289        .and_then(Value::as_array)
290        .into_iter()
291        .flatten()
292        .filter_map(Value::as_str)
293        .map(|value| truncate_field(value.to_string()))
294        .collect()
295}
296
297fn build_jsr_dependency(
298    resolved_key: &str,
299    is_direct: bool,
300    jsr_section: &Value,
301    extracted_requirement: Option<&str>,
302) -> Option<Dependency> {
303    let jsr_entry = jsr_section.get(resolved_key)?;
304    let jsr_object = jsr_entry.as_object()?;
305    let (namespace, name, version) = parse_jsr_key(resolved_key)?;
306    let namespace = truncate_field(namespace);
307    let name = truncate_field(name);
308    let version_str = truncate_field(version.to_string());
309    let purl = create_generic_purl(
310        Some(&format!("jsr.io/{}", namespace)),
311        &name,
312        Some(&version_str),
313    )
314    .map(truncate_field);
315
316    Some(Dependency {
317        purl: purl.clone(),
318        extracted_requirement: extracted_requirement.map(|value| truncate_field(value.to_string())),
319        scope: Some("imports".to_string()),
320        is_runtime: Some(true),
321        is_optional: Some(false),
322        is_pinned: Some(true),
323        is_direct: Some(is_direct),
324        resolved_package: Some(Box::new(ResolvedPackage {
325            primary_language: Some("TypeScript".to_string()),
326            download_url: None,
327            sha1: None,
328            sha256: jsr_object
329                .get("integrity")
330                .and_then(Value::as_str)
331                .and_then(|value| {
332                    parse_sri(value)
333                        .and_then(|(algo, hex)| {
334                            (algo == "sha256").then(|| Sha256Digest::from_hex(&hex).ok())
335                        })
336                        .flatten()
337                        .or_else(|| Sha256Digest::from_hex(value).ok())
338                }),
339            sha512: None,
340            md5: None,
341            is_virtual: true,
342            extra_data: None,
343            dependencies: extract_jsr_resolved_dependencies(jsr_object),
344            repository_homepage_url: None,
345            repository_download_url: None,
346            api_data_url: None,
347            datasource_id: Some(DatasourceId::DenoLock),
348            purl,
349            ..ResolvedPackage::new(DenoLockParser::PACKAGE_TYPE, namespace, name, version_str)
350        })),
351        extra_data: None,
352    })
353}
354
355/// A package's nested `dependencies` field is shaped differently across lockfile
356/// versions: v4/v5 `npm[key].dependencies` is an array of resolved `name@version`
357/// keys (`["ansi-styles@6.2.1"]`), while v2/v3 `npm.packages[key].dependencies` is an
358/// object mapping bare names to those resolved keys (`{"ansi-styles":"ansi-styles@6.2.1"}`).
359/// Both encode the same resolved keys, so collect them uniformly from either shape.
360fn npm_dependency_keys(value: Option<&Value>) -> Vec<String> {
361    match value {
362        Some(Value::Array(items)) => items
363            .iter()
364            .filter_map(Value::as_str)
365            .map(str::to_string)
366            .collect(),
367        Some(Value::Object(map)) => map
368            .values()
369            .filter_map(Value::as_str)
370            .map(str::to_string)
371            .collect(),
372        _ => Vec::new(),
373    }
374}
375
376fn build_npm_dependency(
377    resolved_key: &str,
378    is_direct: bool,
379    npm_section: &Value,
380    extracted_requirement: Option<&str>,
381) -> Option<Dependency> {
382    let npm_entry = npm_section.get(resolved_key)?;
383    let npm_object = npm_entry.as_object()?;
384    let (namespace, name, version) = parse_npm_key(resolved_key)?;
385    let namespace = namespace.map(truncate_field);
386    let name = truncate_field(name);
387    let version_str = truncate_field(version.to_string());
388    let purl = create_npm_purl(namespace.as_deref(), &name, Some(&version_str)).map(truncate_field);
389
390    Some(Dependency {
391        purl: purl.clone(),
392        extracted_requirement: extracted_requirement.map(|value| truncate_field(value.to_string())),
393        scope: Some("imports".to_string()),
394        is_runtime: Some(true),
395        is_optional: Some(false),
396        is_pinned: Some(true),
397        is_direct: Some(is_direct),
398        resolved_package: Some(Box::new(ResolvedPackage {
399            primary_language: Some("JavaScript".to_string()),
400            download_url: npm_object
401                .get("tarball")
402                .and_then(Value::as_str)
403                .map(|value| truncate_field(value.to_string())),
404            sha1: None,
405            sha256: None,
406            sha512: npm_object
407                .get("integrity")
408                .and_then(Value::as_str)
409                .and_then(|value| {
410                    parse_sri(value)
411                        .and_then(|(algo, hex)| {
412                            (algo == "sha512").then(|| Sha512Digest::from_hex(&hex).ok())
413                        })
414                        .flatten()
415                }),
416            md5: None,
417            is_virtual: true,
418            extra_data: None,
419            dependencies: npm_dependency_keys(npm_object.get(FIELD_DEPENDENCIES))
420                .into_iter()
421                .take(MAX_ITERATION_COUNT)
422                .filter_map(|value| {
423                    let (namespace, name, version) = parse_npm_key(&value)?;
424                    Some(Dependency {
425                        purl: create_npm_purl(namespace.as_deref(), &name, Some(version))
426                            .map(truncate_field),
427                        extracted_requirement: Some(truncate_field(value.clone())),
428                        scope: Some("dependencies".to_string()),
429                        is_runtime: Some(true),
430                        is_optional: Some(false),
431                        is_pinned: Some(true),
432                        is_direct: Some(true),
433                        resolved_package: None,
434                        extra_data: None,
435                    })
436                })
437                .collect(),
438            repository_homepage_url: None,
439            repository_download_url: None,
440            api_data_url: None,
441            datasource_id: Some(DatasourceId::DenoLock),
442            purl,
443            ..ResolvedPackage::new(
444                PackageType::Npm,
445                namespace.unwrap_or_default(),
446                name,
447                version_str,
448            )
449        })),
450        extra_data: None,
451    })
452}
453
454fn extract_jsr_resolved_dependencies(
455    jsr_object: &serde_json::Map<String, Value>,
456) -> Vec<Dependency> {
457    jsr_object
458        .get(FIELD_DEPENDENCIES)
459        .and_then(Value::as_array)
460        .into_iter()
461        .flatten()
462        .filter_map(Value::as_str)
463        .take(MAX_ITERATION_COUNT)
464        .filter_map(|value| {
465            let (namespace, name, version) = parse_jsr_dependency_reference(value)?;
466            Some(Dependency {
467                purl: create_generic_purl(Some(&format!("jsr.io/{}", namespace)), &name, version)
468                    .map(truncate_field),
469                extracted_requirement: Some(truncate_field(value.to_string())),
470                scope: Some("dependencies".to_string()),
471                is_runtime: Some(true),
472                is_optional: Some(false),
473                is_pinned: Some(version.is_some_and(is_exact_version)),
474                is_direct: Some(true),
475                resolved_package: None,
476                extra_data: None,
477            })
478        })
479        .collect()
480}
481
482fn parse_jsr_key(key: &str) -> Option<(String, String, &str)> {
483    let scoped = key.strip_prefix('@')?;
484    let slash_index = scoped.find('/')?;
485    let namespace = format!("@{}", &scoped[..slash_index]);
486    let name_and_version = &scoped[slash_index + 1..];
487    let at_index = name_and_version.rfind('@')?;
488    let name = name_and_version[..at_index].to_string();
489    let version = &name_and_version[at_index + 1..];
490    Some((namespace, name, version))
491}
492
493fn parse_jsr_dependency_reference(value: &str) -> Option<(String, String, Option<&str>)> {
494    let rest = value.strip_prefix("jsr:")?;
495    let slash_index = rest.find('/')?;
496    let namespace = format!("@{}", &rest[1..slash_index]);
497    let name_and_version = &rest[slash_index + 1..];
498    let (name, version) = split_name_and_version(name_and_version);
499    Some((namespace, name.to_string(), version))
500}
501
502fn resolve_jsr_full_key(specifier: &str, resolved_version: &str) -> Option<String> {
503    let (namespace, name, _) = parse_jsr_dependency_reference(specifier)?;
504    Some(format!("{}/{}@{}", namespace, name, resolved_version))
505}
506
507fn parse_npm_key(key: &str) -> Option<(Option<String>, String, &str)> {
508    if let Some(scoped) = key.strip_prefix('@') {
509        let slash_index = scoped.find('/')?;
510        let namespace = format!("@{}", &scoped[..slash_index]);
511        let name_and_version = &scoped[slash_index + 1..];
512        let at_index = name_and_version.rfind('@')?;
513        let name = name_and_version[..at_index].to_string();
514        let version = &name_and_version[at_index + 1..];
515        Some((Some(namespace), name, version))
516    } else {
517        let at_index = key.rfind('@')?;
518        let name = key[..at_index].to_string();
519        let version = &key[at_index + 1..];
520        Some((None, name, version))
521    }
522}
523
524fn resolve_npm_full_key(specifier: &str, resolved_version: &str) -> Option<String> {
525    let (namespace, name, _) = parse_npm_specifier(specifier)?;
526    Some(match namespace {
527        Some(namespace) => format!("{}/{}@{}", namespace, name, resolved_version),
528        None => format!("{}@{}", name, resolved_version),
529    })
530}
531
532fn parse_npm_specifier(specifier: &str) -> Option<(Option<String>, String, Option<&str>)> {
533    let rest = specifier.strip_prefix("npm:")?;
534    let (name_part, version) = split_name_and_version(rest);
535    if let Some(scoped) = name_part.strip_prefix('@') {
536        let slash_index = scoped.find('/')?;
537        let namespace = format!("@{}", &scoped[..slash_index]);
538        let name = scoped[slash_index + 1..].to_string();
539        Some((Some(namespace), name, version))
540    } else {
541        Some((None, name_part.to_string(), version))
542    }
543}
544
545fn split_name_and_version(input: &str) -> (&str, Option<&str>) {
546    if let Some(index) = input.rfind('@') {
547        let (name, version) = input.split_at(index);
548        if !name.is_empty() {
549            return (name, Some(&version[1..]));
550        }
551    }
552    (input, None)
553}
554
555fn create_npm_purl(namespace: Option<&str>, name: &str, version: Option<&str>) -> Option<String> {
556    let mut purl = PackageUrl::new("npm", name).ok()?;
557    if let Some(namespace) = namespace {
558        purl.with_namespace(namespace).ok()?;
559    }
560    if let Some(version) = version {
561        purl.with_version(version).ok()?;
562    }
563    Some(purl.to_string())
564}
565
566fn create_generic_purl(
567    namespace: Option<&str>,
568    name: &str,
569    version: Option<&str>,
570) -> Option<String> {
571    let mut purl = PackageUrl::new("generic", name).ok()?;
572    if let Some(namespace) = namespace {
573        purl.with_namespace(namespace).ok()?;
574    }
575    if let Some(version) = version {
576        purl.with_version(version).ok()?;
577    }
578    Some(purl.to_string())
579}
580
581fn create_remote_purl(specifier: &str) -> Option<String> {
582    let url = Url::parse(specifier).ok()?;
583    let segments: Vec<&str> = url.path_segments()?.collect();
584    let name = segments.last()?.to_string();
585    let namespace = if segments.len() > 1 {
586        Some(format!(
587            "{}/{}",
588            url.host_str()?,
589            segments[..segments.len() - 1].join("/")
590        ))
591    } else {
592        url.host_str().map(|host| host.to_string())
593    };
594    create_generic_purl(namespace.as_deref(), &name, None)
595}
596
597fn remote_name(url: &str) -> Option<String> {
598    let url = Url::parse(url).ok()?;
599    url.path_segments()?
600        .next_back()
601        .map(|value| value.to_string())
602}
603
604fn is_exact_version(version: &str) -> bool {
605    !version.contains('^')
606        && !version.contains('~')
607        && !version.contains('*')
608        && !version.contains('>')
609        && !version.contains('<')
610        && !version.contains('|')
611        && !version.contains(' ')
612}
613
614fn default_package_data() -> PackageData {
615    PackageData {
616        package_type: Some(DenoLockParser::PACKAGE_TYPE),
617        primary_language: Some("TypeScript".to_string()),
618        datasource_id: Some(DatasourceId::DenoLock),
619        ..Default::default()
620    }
621}