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