Skip to main content

provenant/parsers/
deno_lock.rs

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