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 {
233 purl: build_hex_purl(&package_name, None, tuple.repo.as_deref()).map(truncate_field),
234 extracted_requirement: Some(truncate_field(tuple.requirement)),
235 scope: Some("dependencies".to_string()),
236 is_runtime: Some(!tuple.optional),
237 is_optional: Some(tuple.optional),
238 is_pinned: Some(false),
239 is_direct: Some(true),
240 resolved_package: None,
241 extra_data: None,
242 })
243}
244
245#[derive(Debug)]
246struct DependencyTuple {
247 app_name: String,
248 requirement: String,
249 hex_name: Option<String>,
250 repo: Option<String>,
251 optional: bool,
252}
253
254fn term_to_dependency_tuples(term: &Term) -> Result<Vec<DependencyTuple>, String> {
255 let items = match term {
256 Term::List(items) => items,
257 _ => return Ok(Vec::new()),
258 };
259
260 let mut result = Vec::new();
261 let limit = capped_iteration_limit(items.len(), "mix.lock dependency tuples");
262 for item in items.iter().take(limit) {
263 let tuple = match item {
264 Term::Tuple(items) if items.len() == 3 => items,
265 _ => continue,
266 };
267
268 let app_name = truncate_field(term_to_atom(&tuple[0])?);
269 let requirement = truncate_field(term_to_string(&tuple[1])?);
270 let opts = term_to_keyword_map(&tuple[2])?;
271 let hex_name = opts
272 .get("hex")
273 .map(term_to_atom)
274 .transpose()?
275 .map(truncate_field);
276 let repo = opts
277 .get("repo")
278 .map(term_to_string)
279 .transpose()?
280 .map(truncate_field);
281 let optional = opts
282 .get("optional")
283 .and_then(|term| match term {
284 Term::Bool(value) => Some(*value),
285 _ => None,
286 })
287 .unwrap_or(false);
288
289 result.push(DependencyTuple {
290 app_name,
291 requirement,
292 hex_name,
293 repo,
294 optional,
295 });
296 }
297
298 Ok(result)
299}
300
301fn term_to_keyword_map(term: &Term) -> Result<HashMap<String, Term>, String> {
302 match term {
303 Term::KeywordList(entries) => Ok(entries.iter().cloned().collect()),
304 Term::List(entries) => {
305 let mut map = HashMap::new();
306 for entry in entries {
307 if let Term::Tuple(items) = entry
308 && items.len() == 2
309 {
310 map.insert(term_to_atom(&items[0])?, items[1].clone());
311 }
312 }
313 Ok(map)
314 }
315 _ => Ok(HashMap::new()),
316 }
317}
318
319fn build_hex_purl(name: &str, version: Option<&str>, repo: Option<&str>) -> Option<String> {
320 let mut purl = PackageUrl::new("hex", name).ok()?;
321 if let Some(repo) = repo
322 && repo != "hexpm"
323 {
324 purl.with_namespace(repo).ok()?;
325 }
326 if let Some(version) = version {
327 purl.with_version(version).ok()?;
328 }
329 Some(purl.to_string())
330}
331
332fn build_hexdocs_homepage(name: &str, repo: &str) -> String {
333 if repo == "hexpm" {
334 format!("https://hex.pm/packages/{}", name)
335 } else {
336 format!("https://hex.pm/packages/{}?repo={}", name, repo)
337 }
338}
339
340fn build_hex_api_url(name: &str, repo: &str) -> String {
341 if repo == "hexpm" {
342 format!("https://hex.pm/api/packages/{}", name)
343 } else {
344 format!("https://hex.pm/api/repos/{}/packages/{}", repo, name)
345 }
346}
347
348fn term_to_string(term: &Term) -> Result<String, String> {
349 match term {
350 Term::String(value) => Ok(value.clone()),
351 Term::Atom(value) => Ok(value.clone()),
352 Term::Integer(value) => Ok(value.to_string()),
353 _ => Err("Expected string-like term".to_string()),
354 }
355}
356
357fn term_to_atom(term: &Term) -> Result<String, String> {
358 match term {
359 Term::Atom(value) => Ok(value.clone()),
360 _ => Err("Expected atom".to_string()),
361 }
362}
363
364fn term_to_atom_list(term: &Term) -> Result<Vec<String>, String> {
365 let items = match term {
366 Term::List(items) => items,
367 _ => return Ok(Vec::new()),
368 };
369 items.iter().map(term_to_atom).collect()
370}
371
372impl<'a> Parser<'a> {
373 fn new(source: &'a str) -> Self {
374 Self {
375 chars: source.chars().collect(),
376 pos: 0,
377 source,
378 guard: RecursionGuard::depth_only(),
379 }
380 }
381
382 fn parse_term(&mut self) -> Result<Term, String> {
383 if self.guard.descend() {
384 return Err("recursion depth exceeded".to_string());
385 }
386 self.skip_ws();
387 let result = match self.peek() {
388 Some('%') => self.parse_map(),
389 Some('{') => self.parse_tuple(),
390 Some('[') => self.parse_list(),
391 Some('"') => self.parse_string().map(Term::String),
392 Some(':') => self.parse_atom().map(Term::Atom),
393 Some(c) if c.is_ascii_digit() || c == '-' => self.parse_integer().map(Term::Integer),
394 Some('t') | Some('f') => self.parse_bool().map(Term::Bool),
395 Some(other) => Err(format!("Unexpected character '{}' at {}", other, self.pos)),
396 None => Err("Unexpected end of mix.lock".to_string()),
397 };
398 self.guard.ascend();
399 result
400 }
401
402 fn parse_map(&mut self) -> Result<Term, String> {
403 self.expect('%')?;
404 self.expect('{')?;
405 let mut entries = Vec::new();
406 let mut count = 0usize;
407 loop {
408 self.skip_ws();
409 if self.peek() == Some('}') {
410 self.pos += 1;
411 break;
412 }
413 if count >= MAX_ITERATION_COUNT {
414 warn!("map entry count exceeded MAX_ITERATION_COUNT in mix.lock");
415 break;
416 }
417 let key = self.parse_term()?;
418 self.skip_ws();
419 if self.starts_with("=>") {
420 self.expect_sequence("=>")?;
421 } else {
422 self.expect(':')?;
423 }
424 let value = self.parse_term()?;
425 entries.push((key, value));
426 count += 1;
427 self.skip_ws();
428 if self.peek() == Some(',') {
429 self.pos += 1;
430 }
431 }
432 Ok(Term::Map(entries))
433 }
434
435 fn parse_tuple(&mut self) -> Result<Term, String> {
436 self.expect('{')?;
437 let mut items = Vec::new();
438 let mut count = 0usize;
439 loop {
440 self.skip_ws();
441 if self.peek() == Some('}') {
442 self.pos += 1;
443 break;
444 }
445 if count >= MAX_ITERATION_COUNT {
446 warn!("tuple item count exceeded MAX_ITERATION_COUNT in mix.lock");
447 break;
448 }
449 items.push(self.parse_term()?);
450 count += 1;
451 self.skip_ws();
452 if self.peek() == Some(',') {
453 self.pos += 1;
454 }
455 }
456 Ok(Term::Tuple(items))
457 }
458
459 fn parse_list(&mut self) -> Result<Term, String> {
460 self.expect('[')?;
461 let mut keyword_entries = Vec::new();
462 let mut items = Vec::new();
463 let mut saw_keyword = false;
464 let mut count = 0usize;
465
466 loop {
467 self.skip_ws();
468 if self.peek() == Some(']') {
469 self.pos += 1;
470 break;
471 }
472 if count >= MAX_ITERATION_COUNT {
473 warn!("list item count exceeded MAX_ITERATION_COUNT in mix.lock");
474 break;
475 }
476
477 if let Some(keyword) = self.try_parse_keyword_key() {
478 saw_keyword = true;
479 let value = self.parse_term()?;
480 keyword_entries.push((keyword, value));
481 } else {
482 items.push(self.parse_term()?);
483 }
484
485 count += 1;
486 self.skip_ws();
487 if self.peek() == Some(',') {
488 self.pos += 1;
489 }
490 }
491
492 if saw_keyword && items.is_empty() {
493 Ok(Term::KeywordList(keyword_entries))
494 } else if saw_keyword {
495 let mut merged = items;
496 merged.extend(
497 keyword_entries
498 .into_iter()
499 .map(|(k, v)| Term::Tuple(vec![Term::Atom(k), v])),
500 );
501 Ok(Term::List(merged))
502 } else {
503 Ok(Term::List(items))
504 }
505 }
506
507 fn try_parse_keyword_key(&mut self) -> Option<String> {
508 let saved = self.pos;
509 self.skip_ws();
510 let start = self.pos;
511 while let Some(c) = self.peek() {
512 if c.is_ascii_alphanumeric() || c == '_' || c == '?' || c == '!' {
513 self.pos += 1;
514 } else {
515 break;
516 }
517 }
518 if self.pos == start || self.peek() != Some(':') || self.peek_n(1) == Some(':') {
519 self.pos = saved;
520 return None;
521 }
522 let key: String = self.chars[start..self.pos].iter().collect();
523 self.pos += 1;
524 Some(key)
525 }
526
527 fn parse_string(&mut self) -> Result<String, String> {
528 self.expect('"')?;
529 let mut out = String::new();
530 while let Some(c) = self.peek() {
531 self.pos += 1;
532 match c {
533 '"' => return Ok(out),
534 '\\' => {
535 let escaped = self
536 .peek()
537 .ok_or_else(|| "Unterminated string escape".to_string())?;
538 self.pos += 1;
539 out.push(match escaped {
540 'n' => '\n',
541 'r' => '\r',
542 't' => '\t',
543 '"' => '"',
544 '\\' => '\\',
545 other => other,
546 });
547 }
548 other => out.push(other),
549 }
550 }
551 Err("Unterminated string literal".to_string())
552 }
553
554 fn parse_atom(&mut self) -> Result<String, String> {
555 self.expect(':')?;
556 let start = self.pos;
557 while let Some(c) = self.peek() {
558 if c.is_ascii_alphanumeric() || c == '_' || c == '?' || c == '!' || c == '@' {
559 self.pos += 1;
560 } else {
561 break;
562 }
563 }
564 if self.pos == start {
565 return Err("Expected atom after ':'".to_string());
566 }
567 Ok(self.chars[start..self.pos].iter().collect())
568 }
569
570 fn parse_integer(&mut self) -> Result<i64, String> {
571 let start = self.pos;
572 if self.peek() == Some('-') {
573 self.pos += 1;
574 }
575 while let Some(c) = self.peek() {
576 if c.is_ascii_digit() {
577 self.pos += 1;
578 } else {
579 break;
580 }
581 }
582 self.source[start..self.byte_index(self.pos)]
583 .parse::<i64>()
584 .map_err(|e| format!("Invalid integer: {}", e))
585 }
586
587 fn parse_bool(&mut self) -> Result<bool, String> {
588 if self.starts_with("true") {
589 self.pos += 4;
590 Ok(true)
591 } else if self.starts_with("false") {
592 self.pos += 5;
593 Ok(false)
594 } else {
595 Err("Invalid boolean".to_string())
596 }
597 }
598
599 fn skip_ws(&mut self) {
600 while let Some(c) = self.peek() {
601 if c.is_whitespace() {
602 self.pos += 1;
603 } else {
604 break;
605 }
606 }
607 }
608
609 fn expect(&mut self, expected: char) -> Result<(), String> {
610 match self.peek() {
611 Some(c) if c == expected => {
612 self.pos += 1;
613 Ok(())
614 }
615 Some(c) => Err(format!("Expected '{}' but found '{}'", expected, c)),
616 None => Err(format!("Expected '{}' but reached end of input", expected)),
617 }
618 }
619
620 fn expect_sequence(&mut self, expected: &str) -> Result<(), String> {
621 if self.starts_with(expected) {
622 self.pos += expected.chars().count();
623 Ok(())
624 } else {
625 Err(format!("Expected '{}' at {}", expected, self.pos))
626 }
627 }
628
629 fn starts_with(&self, s: &str) -> bool {
630 self.chars[self.pos..]
631 .iter()
632 .collect::<String>()
633 .starts_with(s)
634 }
635
636 fn peek(&self) -> Option<char> {
637 self.chars.get(self.pos).copied()
638 }
639
640 fn peek_n(&self, n: usize) -> Option<char> {
641 self.chars.get(self.pos + n).copied()
642 }
643
644 fn is_eof(&self) -> bool {
645 self.pos >= self.chars.len()
646 }
647
648 fn byte_index(&self, char_pos: usize) -> usize {
649 self.chars.iter().take(char_pos).map(|c| c.len_utf8()).sum()
650 }
651}