Skip to main content

provenant/parsers/
deno_lock.rs

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