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