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    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}