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