1use 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 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 let repo = match tuple.get(6) {
146 Some(term) => truncate_field(term_to_string(term)?),
147 None => "hexpm".to_string(),
148 };
149 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 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}