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