Skip to main content

provenant/parsers/
hex_lock.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::path::Path;
6
7use crate::parser_warn as warn;
8use crate::parsers::utils::{
9    MAX_ITERATION_COUNT, RecursionGuard, read_file_to_string, truncate_field,
10};
11use packageurl::PackageUrl;
12use serde_json::Value as JsonValue;
13
14use crate::models::{
15    DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage, Sha256Digest,
16};
17
18use super::PackageParser;
19use super::metadata::ParserMetadata;
20
21pub struct HexLockParser;
22
23#[derive(Clone, Debug)]
24enum Term {
25    Map(Vec<(Term, Term)>),
26    Tuple(Vec<Term>),
27    List(Vec<Term>),
28    KeywordList(Vec<(String, Term)>),
29    String(String),
30    Atom(String),
31    Bool(bool),
32    Integer(i64),
33}
34
35struct Parser<'a> {
36    chars: Vec<char>,
37    pos: usize,
38    source: &'a str,
39    guard: RecursionGuard<()>,
40}
41
42impl PackageParser for HexLockParser {
43    const PACKAGE_TYPE: PackageType = PackageType::Hex;
44
45    fn metadata() -> Vec<ParserMetadata> {
46        vec![ParserMetadata {
47            description: "Hex mix.lock lockfile",
48            file_patterns: &["**/mix.lock"],
49            package_type: "hex",
50            primary_language: "Elixir",
51            documentation_url: Some("https://hexdocs.pm/mix/Mix.Tasks.Deps.html"),
52        }]
53    }
54
55    fn is_match(path: &Path) -> bool {
56        path.file_name().and_then(|name| name.to_str()) == Some("mix.lock")
57    }
58
59    fn extract_packages(path: &Path) -> Vec<PackageData> {
60        let content = match read_file_to_string(path, None) {
61            Ok(content) => content,
62            Err(e) => {
63                warn!("Failed to read mix.lock at {:?}: {}", path, e);
64                return vec![default_package_data()];
65            }
66        };
67
68        match parse_mix_lock(&content) {
69            Ok(package_data) => vec![package_data],
70            Err(e) => {
71                warn!("Failed to parse mix.lock at {:?}: {}", path, e);
72                vec![default_package_data()]
73            }
74        }
75    }
76}
77
78fn default_package_data() -> PackageData {
79    PackageData {
80        package_type: Some(PackageType::Hex),
81        primary_language: Some("Elixir".to_string()),
82        datasource_id: Some(DatasourceId::HexMixLock),
83        ..Default::default()
84    }
85}
86
87fn parse_mix_lock(content: &str) -> Result<PackageData, String> {
88    let mut parser = Parser::new(content);
89    let term = parser.parse_term()?;
90    parser.skip_ws();
91    if !parser.is_eof() {
92        return Err("Unexpected trailing content in mix.lock".to_string());
93    }
94
95    let entries = match term {
96        Term::Map(entries) => entries,
97        _ => return Err("mix.lock root must be a map".to_string()),
98    };
99
100    let mut dependencies = Vec::new();
101    for (key, value) in entries.into_iter().take(MAX_ITERATION_COUNT) {
102        if let Some(dep) = build_dependency_from_lock_entry(&key, &value)? {
103            dependencies.push(dep);
104        }
105    }
106
107    let mut package = default_package_data();
108    package.dependencies = dependencies;
109    Ok(package)
110}
111
112fn build_dependency_from_lock_entry(
113    key: &Term,
114    value: &Term,
115) -> Result<Option<Dependency>, String> {
116    let app_name = truncate_field(term_to_string(key)?);
117
118    let tuple = match value {
119        Term::Tuple(items) => items,
120        _ => return Ok(None),
121    };
122
123    // hex dependency tuples have grown over time; accept all historical shapes rather than
124    // dropping older lockfiles:
125    //   6 elements (legacy): {:hex, name, version, inner_checksum, managers, deps}
126    //   7 elements: + repo
127    //   8 elements (modern, Hex >= 0.20 / Elixir >= 1.7): + outer_checksum
128    if tuple.len() < 6 {
129        return Ok(None);
130    }
131
132    let kind = term_to_atom(&tuple[0])?;
133    if kind != "hex" {
134        return Ok(None);
135    }
136
137    let package_name = truncate_field(term_to_atom(&tuple[1])?);
138    let version = truncate_field(term_to_string(&tuple[2])?);
139    let inner_checksum = truncate_field(term_to_string(&tuple[3])?);
140    let managers = term_to_atom_list(&tuple[4])?;
141    let nested_dependencies = term_to_dependency_tuples(&tuple[5])?;
142    // repo (element 7) was added later; default to the public "hexpm" repository when absent.
143    let repo = match tuple.get(6) {
144        Some(term) => truncate_field(term_to_string(term)?),
145        None => "hexpm".to_string(),
146    };
147    // outer_checksum (element 8) is only present in modern lockfiles.
148    let outer_checksum = match tuple.get(7) {
149        Some(term) => Some(truncate_field(term_to_string(term)?)),
150        None => None,
151    };
152
153    let purl = build_hex_purl(&package_name, Some(&version), Some(&repo));
154    let resolved_package = ResolvedPackage {
155        primary_language: Some("Elixir".to_string()),
156        download_url: None,
157        sha1: None,
158        sha256: Sha256Digest::from_hex(&inner_checksum).ok(),
159        sha512: None,
160        md5: None,
161        is_virtual: true,
162        extra_data: Some({
163            let mut extra = HashMap::from([
164                (
165                    "repo".to_string(),
166                    JsonValue::String(truncate_field(repo.clone())),
167                ),
168                (
169                    "managers".to_string(),
170                    JsonValue::Array(
171                        managers
172                            .into_iter()
173                            .map(|m| JsonValue::String(truncate_field(m)))
174                            .collect(),
175                    ),
176                ),
177            ]);
178            if let Some(ref outer) = outer_checksum {
179                extra.insert(
180                    "outer_checksum".to_string(),
181                    JsonValue::String(truncate_field(outer.clone())),
182                );
183            }
184            extra
185        }),
186        dependencies: nested_dependencies
187            .into_iter()
188            .map(build_nested_dependency)
189            .collect::<Result<Vec<_>, _>>()?,
190        repository_homepage_url: Some(truncate_field(build_hexdocs_homepage(&package_name, &repo))),
191        repository_download_url: None,
192        api_data_url: Some(truncate_field(build_hex_api_url(&package_name, &repo))),
193        datasource_id: Some(DatasourceId::HexMixLock),
194        purl: build_hex_purl(&package_name, Some(&version), Some(&repo)).map(truncate_field),
195        ..ResolvedPackage::new(
196            PackageType::Hex,
197            if repo == "hexpm" {
198                String::new()
199            } else {
200                repo.clone()
201            },
202            package_name.clone(),
203            version.clone(),
204        )
205    };
206
207    Ok(Some(Dependency {
208        purl: purl.map(truncate_field),
209        extracted_requirement: Some(truncate_field(version)),
210        scope: Some("dependencies".to_string()),
211        is_runtime: None,
212        is_optional: None,
213        is_pinned: Some(true),
214        is_direct: None,
215        resolved_package: Some(Box::new(resolved_package)),
216        extra_data: Some(HashMap::from([(
217            "app".to_string(),
218            JsonValue::String(truncate_field(app_name)),
219        )])),
220    }))
221}
222
223fn build_nested_dependency(tuple: DependencyTuple) -> Result<Dependency, String> {
224    let package_name = truncate_field(
225        tuple
226            .hex_name
227            .clone()
228            .unwrap_or_else(|| tuple.app_name.clone()),
229    );
230    Ok(Dependency {
231        purl: build_hex_purl(&package_name, None, tuple.repo.as_deref()).map(truncate_field),
232        extracted_requirement: Some(truncate_field(tuple.requirement)),
233        scope: Some("dependencies".to_string()),
234        is_runtime: Some(!tuple.optional),
235        is_optional: Some(tuple.optional),
236        is_pinned: Some(false),
237        is_direct: Some(true),
238        resolved_package: None,
239        extra_data: None,
240    })
241}
242
243#[derive(Debug)]
244struct DependencyTuple {
245    app_name: String,
246    requirement: String,
247    hex_name: Option<String>,
248    repo: Option<String>,
249    optional: bool,
250}
251
252fn term_to_dependency_tuples(term: &Term) -> Result<Vec<DependencyTuple>, String> {
253    let items = match term {
254        Term::List(items) => items,
255        _ => return Ok(Vec::new()),
256    };
257
258    let mut result = Vec::new();
259    for item in items.iter().take(MAX_ITERATION_COUNT) {
260        let tuple = match item {
261            Term::Tuple(items) if items.len() == 3 => items,
262            _ => continue,
263        };
264
265        let app_name = truncate_field(term_to_atom(&tuple[0])?);
266        let requirement = truncate_field(term_to_string(&tuple[1])?);
267        let opts = term_to_keyword_map(&tuple[2])?;
268        let hex_name = opts
269            .get("hex")
270            .map(term_to_atom)
271            .transpose()?
272            .map(truncate_field);
273        let repo = opts
274            .get("repo")
275            .map(term_to_string)
276            .transpose()?
277            .map(truncate_field);
278        let optional = opts
279            .get("optional")
280            .and_then(|term| match term {
281                Term::Bool(value) => Some(*value),
282                _ => None,
283            })
284            .unwrap_or(false);
285
286        result.push(DependencyTuple {
287            app_name,
288            requirement,
289            hex_name,
290            repo,
291            optional,
292        });
293    }
294
295    Ok(result)
296}
297
298fn term_to_keyword_map(term: &Term) -> Result<HashMap<String, Term>, String> {
299    match term {
300        Term::KeywordList(entries) => Ok(entries.iter().cloned().collect()),
301        Term::List(entries) => {
302            let mut map = HashMap::new();
303            for entry in entries {
304                if let Term::Tuple(items) = entry
305                    && items.len() == 2
306                {
307                    map.insert(term_to_atom(&items[0])?, items[1].clone());
308                }
309            }
310            Ok(map)
311        }
312        _ => Ok(HashMap::new()),
313    }
314}
315
316fn build_hex_purl(name: &str, version: Option<&str>, repo: Option<&str>) -> Option<String> {
317    let mut purl = PackageUrl::new("hex", name).ok()?;
318    if let Some(repo) = repo
319        && repo != "hexpm"
320    {
321        purl.with_namespace(repo).ok()?;
322    }
323    if let Some(version) = version {
324        purl.with_version(version).ok()?;
325    }
326    Some(purl.to_string())
327}
328
329fn build_hexdocs_homepage(name: &str, repo: &str) -> String {
330    if repo == "hexpm" {
331        format!("https://hex.pm/packages/{}", name)
332    } else {
333        format!("https://hex.pm/packages/{}?repo={}", name, repo)
334    }
335}
336
337fn build_hex_api_url(name: &str, repo: &str) -> String {
338    if repo == "hexpm" {
339        format!("https://hex.pm/api/packages/{}", name)
340    } else {
341        format!("https://hex.pm/api/repos/{}/packages/{}", repo, name)
342    }
343}
344
345fn term_to_string(term: &Term) -> Result<String, String> {
346    match term {
347        Term::String(value) => Ok(value.clone()),
348        Term::Atom(value) => Ok(value.clone()),
349        Term::Integer(value) => Ok(value.to_string()),
350        _ => Err("Expected string-like term".to_string()),
351    }
352}
353
354fn term_to_atom(term: &Term) -> Result<String, String> {
355    match term {
356        Term::Atom(value) => Ok(value.clone()),
357        _ => Err("Expected atom".to_string()),
358    }
359}
360
361fn term_to_atom_list(term: &Term) -> Result<Vec<String>, String> {
362    let items = match term {
363        Term::List(items) => items,
364        _ => return Ok(Vec::new()),
365    };
366    items.iter().map(term_to_atom).collect()
367}
368
369impl<'a> Parser<'a> {
370    fn new(source: &'a str) -> Self {
371        Self {
372            chars: source.chars().collect(),
373            pos: 0,
374            source,
375            guard: RecursionGuard::depth_only(),
376        }
377    }
378
379    fn parse_term(&mut self) -> Result<Term, String> {
380        if self.guard.descend() {
381            return Err("recursion depth exceeded".to_string());
382        }
383        self.skip_ws();
384        let result = match self.peek() {
385            Some('%') => self.parse_map(),
386            Some('{') => self.parse_tuple(),
387            Some('[') => self.parse_list(),
388            Some('"') => self.parse_string().map(Term::String),
389            Some(':') => self.parse_atom().map(Term::Atom),
390            Some(c) if c.is_ascii_digit() || c == '-' => self.parse_integer().map(Term::Integer),
391            Some('t') | Some('f') => self.parse_bool().map(Term::Bool),
392            Some(other) => Err(format!("Unexpected character '{}' at {}", other, self.pos)),
393            None => Err("Unexpected end of mix.lock".to_string()),
394        };
395        self.guard.ascend();
396        result
397    }
398
399    fn parse_map(&mut self) -> Result<Term, String> {
400        self.expect('%')?;
401        self.expect('{')?;
402        let mut entries = Vec::new();
403        let mut count = 0usize;
404        loop {
405            self.skip_ws();
406            if self.peek() == Some('}') {
407                self.pos += 1;
408                break;
409            }
410            if count >= MAX_ITERATION_COUNT {
411                warn!("map entry count exceeded MAX_ITERATION_COUNT in mix.lock");
412                break;
413            }
414            let key = self.parse_term()?;
415            self.skip_ws();
416            if self.starts_with("=>") {
417                self.expect_sequence("=>")?;
418            } else {
419                self.expect(':')?;
420            }
421            let value = self.parse_term()?;
422            entries.push((key, value));
423            count += 1;
424            self.skip_ws();
425            if self.peek() == Some(',') {
426                self.pos += 1;
427            }
428        }
429        Ok(Term::Map(entries))
430    }
431
432    fn parse_tuple(&mut self) -> Result<Term, String> {
433        self.expect('{')?;
434        let mut items = Vec::new();
435        let mut count = 0usize;
436        loop {
437            self.skip_ws();
438            if self.peek() == Some('}') {
439                self.pos += 1;
440                break;
441            }
442            if count >= MAX_ITERATION_COUNT {
443                warn!("tuple item count exceeded MAX_ITERATION_COUNT in mix.lock");
444                break;
445            }
446            items.push(self.parse_term()?);
447            count += 1;
448            self.skip_ws();
449            if self.peek() == Some(',') {
450                self.pos += 1;
451            }
452        }
453        Ok(Term::Tuple(items))
454    }
455
456    fn parse_list(&mut self) -> Result<Term, String> {
457        self.expect('[')?;
458        let mut keyword_entries = Vec::new();
459        let mut items = Vec::new();
460        let mut saw_keyword = false;
461        let mut count = 0usize;
462
463        loop {
464            self.skip_ws();
465            if self.peek() == Some(']') {
466                self.pos += 1;
467                break;
468            }
469            if count >= MAX_ITERATION_COUNT {
470                warn!("list item count exceeded MAX_ITERATION_COUNT in mix.lock");
471                break;
472            }
473
474            if let Some(keyword) = self.try_parse_keyword_key() {
475                saw_keyword = true;
476                let value = self.parse_term()?;
477                keyword_entries.push((keyword, value));
478            } else {
479                items.push(self.parse_term()?);
480            }
481
482            count += 1;
483            self.skip_ws();
484            if self.peek() == Some(',') {
485                self.pos += 1;
486            }
487        }
488
489        if saw_keyword && items.is_empty() {
490            Ok(Term::KeywordList(keyword_entries))
491        } else if saw_keyword {
492            let mut merged = items;
493            merged.extend(
494                keyword_entries
495                    .into_iter()
496                    .map(|(k, v)| Term::Tuple(vec![Term::Atom(k), v])),
497            );
498            Ok(Term::List(merged))
499        } else {
500            Ok(Term::List(items))
501        }
502    }
503
504    fn try_parse_keyword_key(&mut self) -> Option<String> {
505        let saved = self.pos;
506        self.skip_ws();
507        let start = self.pos;
508        while let Some(c) = self.peek() {
509            if c.is_ascii_alphanumeric() || c == '_' || c == '?' || c == '!' {
510                self.pos += 1;
511            } else {
512                break;
513            }
514        }
515        if self.pos == start || self.peek() != Some(':') || self.peek_n(1) == Some(':') {
516            self.pos = saved;
517            return None;
518        }
519        let key: String = self.chars[start..self.pos].iter().collect();
520        self.pos += 1;
521        Some(key)
522    }
523
524    fn parse_string(&mut self) -> Result<String, String> {
525        self.expect('"')?;
526        let mut out = String::new();
527        while let Some(c) = self.peek() {
528            self.pos += 1;
529            match c {
530                '"' => return Ok(out),
531                '\\' => {
532                    let escaped = self
533                        .peek()
534                        .ok_or_else(|| "Unterminated string escape".to_string())?;
535                    self.pos += 1;
536                    out.push(match escaped {
537                        'n' => '\n',
538                        'r' => '\r',
539                        't' => '\t',
540                        '"' => '"',
541                        '\\' => '\\',
542                        other => other,
543                    });
544                }
545                other => out.push(other),
546            }
547        }
548        Err("Unterminated string literal".to_string())
549    }
550
551    fn parse_atom(&mut self) -> Result<String, String> {
552        self.expect(':')?;
553        let start = self.pos;
554        while let Some(c) = self.peek() {
555            if c.is_ascii_alphanumeric() || c == '_' || c == '?' || c == '!' || c == '@' {
556                self.pos += 1;
557            } else {
558                break;
559            }
560        }
561        if self.pos == start {
562            return Err("Expected atom after ':'".to_string());
563        }
564        Ok(self.chars[start..self.pos].iter().collect())
565    }
566
567    fn parse_integer(&mut self) -> Result<i64, String> {
568        let start = self.pos;
569        if self.peek() == Some('-') {
570            self.pos += 1;
571        }
572        while let Some(c) = self.peek() {
573            if c.is_ascii_digit() {
574                self.pos += 1;
575            } else {
576                break;
577            }
578        }
579        self.source[start..self.byte_index(self.pos)]
580            .parse::<i64>()
581            .map_err(|e| format!("Invalid integer: {}", e))
582    }
583
584    fn parse_bool(&mut self) -> Result<bool, String> {
585        if self.starts_with("true") {
586            self.pos += 4;
587            Ok(true)
588        } else if self.starts_with("false") {
589            self.pos += 5;
590            Ok(false)
591        } else {
592            Err("Invalid boolean".to_string())
593        }
594    }
595
596    fn skip_ws(&mut self) {
597        while let Some(c) = self.peek() {
598            if c.is_whitespace() {
599                self.pos += 1;
600            } else {
601                break;
602            }
603        }
604    }
605
606    fn expect(&mut self, expected: char) -> Result<(), String> {
607        match self.peek() {
608            Some(c) if c == expected => {
609                self.pos += 1;
610                Ok(())
611            }
612            Some(c) => Err(format!("Expected '{}' but found '{}'", expected, c)),
613            None => Err(format!("Expected '{}' but reached end of input", expected)),
614        }
615    }
616
617    fn expect_sequence(&mut self, expected: &str) -> Result<(), String> {
618        if self.starts_with(expected) {
619            self.pos += expected.chars().count();
620            Ok(())
621        } else {
622            Err(format!("Expected '{}' at {}", expected, self.pos))
623        }
624    }
625
626    fn starts_with(&self, s: &str) -> bool {
627        self.chars[self.pos..]
628            .iter()
629            .collect::<String>()
630            .starts_with(s)
631    }
632
633    fn peek(&self) -> Option<char> {
634        self.chars.get(self.pos).copied()
635    }
636
637    fn peek_n(&self, n: usize) -> Option<char> {
638        self.chars.get(self.pos + n).copied()
639    }
640
641    fn is_eof(&self) -> bool {
642        self.pos >= self.chars.len()
643    }
644
645    fn byte_index(&self, char_pos: usize) -> usize {
646        self.chars.iter().take(char_pos).map(|c| c.len_utf8()).sum()
647    }
648}