Skip to main content

provenant/parsers/
deno_lock.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::Path;
4
5use log::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                package_type: DenoLockParser::PACKAGE_TYPE,
130                namespace: String::new(),
131                name: name.clone(),
132                version: String::new(),
133                primary_language: Some("TypeScript".to_string()),
134                download_url: Some(target_url.to_string()),
135                sha1: None,
136                sha256: hash,
137                sha512: None,
138                md5: None,
139                is_virtual: true,
140                extra_data: Some(HashMap::from([(
141                    "redirect_source".to_string(),
142                    Value::String(source.to_string()),
143                )])),
144                dependencies: Vec::new(),
145                repository_homepage_url: None,
146                repository_download_url: None,
147                api_data_url: None,
148                datasource_id: Some(DatasourceId::DenoLock),
149                purl: purl.clone(),
150            };
151
152            dependencies.push(Dependency {
153                purl,
154                extracted_requirement: Some(source.to_string()),
155                scope: Some("imports".to_string()),
156                is_runtime: Some(true),
157                is_optional: Some(false),
158                is_pinned: Some(true),
159                is_direct: Some(true),
160                resolved_package: Some(Box::new(resolved_package)),
161                extra_data: None,
162            });
163        }
164    }
165
166    let mut extra_data = HashMap::new();
167    extra_data.insert(FIELD_VERSION.to_string(), Value::String("5".to_string()));
168    if !workspace_direct.is_empty() {
169        extra_data.insert(
170            "workspace_dependencies".to_string(),
171            Value::Array(
172                workspace_direct
173                    .iter()
174                    .cloned()
175                    .map(Value::String)
176                    .collect(),
177            ),
178        );
179    }
180
181    PackageData {
182        package_type: Some(DenoLockParser::PACKAGE_TYPE),
183        primary_language: Some("TypeScript".to_string()),
184        dependencies,
185        extra_data: Some(extra_data),
186        datasource_id: Some(DatasourceId::DenoLock),
187        ..Default::default()
188    }
189}
190
191fn extract_workspace_dependencies(json: &Value) -> Vec<String> {
192    json.get(FIELD_WORKSPACE)
193        .and_then(Value::as_object)
194        .and_then(|workspace| workspace.get(FIELD_DEPENDENCIES))
195        .and_then(Value::as_array)
196        .into_iter()
197        .flatten()
198        .filter_map(Value::as_str)
199        .map(|value| value.to_string())
200        .collect()
201}
202
203fn build_jsr_dependency(
204    resolved_key: &str,
205    is_direct: bool,
206    jsr_section: &Value,
207    extracted_requirement: Option<&str>,
208) -> Option<Dependency> {
209    let jsr_entry = jsr_section.get(resolved_key)?;
210    let jsr_object = jsr_entry.as_object()?;
211    let (namespace, name, version) = parse_jsr_key(resolved_key)?;
212    let purl = create_generic_purl(Some(&format!("jsr.io/{}", namespace)), &name, Some(version));
213
214    Some(Dependency {
215        purl: purl.clone(),
216        extracted_requirement: extracted_requirement.map(|value| value.to_string()),
217        scope: Some("imports".to_string()),
218        is_runtime: Some(true),
219        is_optional: Some(false),
220        is_pinned: Some(true),
221        is_direct: Some(is_direct),
222        resolved_package: Some(Box::new(ResolvedPackage {
223            package_type: DenoLockParser::PACKAGE_TYPE,
224            namespace,
225            name,
226            version: version.to_string(),
227            primary_language: Some("TypeScript".to_string()),
228            download_url: None,
229            sha1: None,
230            sha256: jsr_object
231                .get("integrity")
232                .and_then(Value::as_str)
233                .map(|value| value.to_string()),
234            sha512: None,
235            md5: None,
236            is_virtual: true,
237            extra_data: None,
238            dependencies: extract_jsr_resolved_dependencies(jsr_object),
239            repository_homepage_url: None,
240            repository_download_url: None,
241            api_data_url: None,
242            datasource_id: Some(DatasourceId::DenoLock),
243            purl,
244        })),
245        extra_data: None,
246    })
247}
248
249fn build_npm_dependency(
250    resolved_key: &str,
251    is_direct: bool,
252    npm_section: &Value,
253    extracted_requirement: Option<&str>,
254) -> Option<Dependency> {
255    let npm_entry = npm_section.get(resolved_key)?;
256    let npm_object = npm_entry.as_object()?;
257    let (namespace, name, version) = parse_npm_key(resolved_key)?;
258    let purl = create_npm_purl(namespace.as_deref(), &name, Some(version));
259
260    Some(Dependency {
261        purl: purl.clone(),
262        extracted_requirement: extracted_requirement.map(|value| value.to_string()),
263        scope: Some("imports".to_string()),
264        is_runtime: Some(true),
265        is_optional: Some(false),
266        is_pinned: Some(true),
267        is_direct: Some(is_direct),
268        resolved_package: Some(Box::new(ResolvedPackage {
269            package_type: PackageType::Npm,
270            namespace: namespace.unwrap_or_default(),
271            name,
272            version: version.to_string(),
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        })),
314        extra_data: None,
315    })
316}
317
318fn extract_jsr_resolved_dependencies(
319    jsr_object: &serde_json::Map<String, Value>,
320) -> Vec<Dependency> {
321    jsr_object
322        .get(FIELD_DEPENDENCIES)
323        .and_then(Value::as_array)
324        .into_iter()
325        .flatten()
326        .filter_map(Value::as_str)
327        .filter_map(|value| {
328            let (namespace, name, version) = parse_jsr_dependency_reference(value)?;
329            Some(Dependency {
330                purl: create_generic_purl(Some(&format!("jsr.io/{}", namespace)), &name, version),
331                extracted_requirement: Some(value.to_string()),
332                scope: Some("dependencies".to_string()),
333                is_runtime: Some(true),
334                is_optional: Some(false),
335                is_pinned: Some(version.is_some_and(is_exact_version)),
336                is_direct: Some(true),
337                resolved_package: None,
338                extra_data: None,
339            })
340        })
341        .collect()
342}
343
344fn parse_jsr_key(key: &str) -> Option<(String, String, &str)> {
345    let scoped = key.strip_prefix('@')?;
346    let slash_index = scoped.find('/')?;
347    let namespace = format!("@{}", &scoped[..slash_index]);
348    let name_and_version = &scoped[slash_index + 1..];
349    let at_index = name_and_version.rfind('@')?;
350    let name = name_and_version[..at_index].to_string();
351    let version = &name_and_version[at_index + 1..];
352    Some((namespace, name, version))
353}
354
355fn parse_jsr_dependency_reference(value: &str) -> Option<(String, String, Option<&str>)> {
356    let rest = value.strip_prefix("jsr:")?;
357    let slash_index = rest.find('/')?;
358    let namespace = format!("@{}", &rest[1..slash_index]);
359    let name_and_version = &rest[slash_index + 1..];
360    let (name, version) = split_name_and_version(name_and_version);
361    Some((namespace, name.to_string(), version))
362}
363
364fn resolve_jsr_full_key(specifier: &str, resolved_version: &str) -> Option<String> {
365    let (namespace, name, _) = parse_jsr_dependency_reference(specifier)?;
366    Some(format!("{}/{}@{}", namespace, name, resolved_version))
367}
368
369fn parse_npm_key(key: &str) -> Option<(Option<String>, String, &str)> {
370    if let Some(scoped) = key.strip_prefix('@') {
371        let slash_index = scoped.find('/')?;
372        let namespace = format!("@{}", &scoped[..slash_index]);
373        let name_and_version = &scoped[slash_index + 1..];
374        let at_index = name_and_version.rfind('@')?;
375        let name = name_and_version[..at_index].to_string();
376        let version = &name_and_version[at_index + 1..];
377        Some((Some(namespace), name, version))
378    } else {
379        let at_index = key.rfind('@')?;
380        let name = key[..at_index].to_string();
381        let version = &key[at_index + 1..];
382        Some((None, name, version))
383    }
384}
385
386fn resolve_npm_full_key(specifier: &str, resolved_version: &str) -> Option<String> {
387    let (namespace, name, _) = parse_npm_specifier(specifier)?;
388    Some(match namespace {
389        Some(namespace) => format!("{}/{}@{}", namespace, name, resolved_version),
390        None => format!("{}@{}", name, resolved_version),
391    })
392}
393
394fn parse_npm_specifier(specifier: &str) -> Option<(Option<String>, String, Option<&str>)> {
395    let rest = specifier.strip_prefix("npm:")?;
396    let (name_part, version) = split_name_and_version(rest);
397    if let Some(scoped) = name_part.strip_prefix('@') {
398        let slash_index = scoped.find('/')?;
399        let namespace = format!("@{}", &scoped[..slash_index]);
400        let name = scoped[slash_index + 1..].to_string();
401        Some((Some(namespace), name, version))
402    } else {
403        Some((None, name_part.to_string(), version))
404    }
405}
406
407fn split_name_and_version(input: &str) -> (&str, Option<&str>) {
408    if let Some(index) = input.rfind('@') {
409        let (name, version) = input.split_at(index);
410        if !name.is_empty() {
411            return (name, Some(&version[1..]));
412        }
413    }
414    (input, None)
415}
416
417fn create_npm_purl(namespace: Option<&str>, name: &str, version: Option<&str>) -> Option<String> {
418    let mut purl = PackageUrl::new("npm", name).ok()?;
419    if let Some(namespace) = namespace {
420        purl.with_namespace(namespace).ok()?;
421    }
422    if let Some(version) = version {
423        purl.with_version(version).ok()?;
424    }
425    Some(purl.to_string())
426}
427
428fn create_generic_purl(
429    namespace: Option<&str>,
430    name: &str,
431    version: Option<&str>,
432) -> Option<String> {
433    let mut purl = PackageUrl::new("generic", name).ok()?;
434    if let Some(namespace) = namespace {
435        purl.with_namespace(namespace).ok()?;
436    }
437    if let Some(version) = version {
438        purl.with_version(version).ok()?;
439    }
440    Some(purl.to_string())
441}
442
443fn create_remote_purl(specifier: &str) -> Option<String> {
444    let url = Url::parse(specifier).ok()?;
445    let segments: Vec<&str> = url.path_segments()?.collect();
446    let name = segments.last()?.to_string();
447    let namespace = if segments.len() > 1 {
448        Some(format!(
449            "{}/{}",
450            url.host_str()?,
451            segments[..segments.len() - 1].join("/")
452        ))
453    } else {
454        url.host_str().map(|host| host.to_string())
455    };
456    create_generic_purl(namespace.as_deref(), &name, None)
457}
458
459fn remote_name(url: &str) -> Option<String> {
460    let url = Url::parse(url).ok()?;
461    url.path_segments()?
462        .next_back()
463        .map(|value| value.to_string())
464}
465
466fn is_exact_version(version: &str) -> bool {
467    !version.contains('^')
468        && !version.contains('~')
469        && !version.contains('*')
470        && !version.contains('>')
471        && !version.contains('<')
472        && !version.contains('|')
473        && !version.contains(' ')
474}
475
476fn default_package_data() -> PackageData {
477    PackageData {
478        package_type: Some(DenoLockParser::PACKAGE_TYPE),
479        primary_language: Some("TypeScript".to_string()),
480        datasource_id: Some(DatasourceId::DenoLock),
481        ..Default::default()
482    }
483}
484
485crate::register_parser!(
486    "Deno lockfile",
487    &["**/deno.lock"],
488    "deno",
489    "TypeScript",
490    Some("https://docs.deno.com/runtime/fundamentals/modules/"),
491);