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