1use std::collections::HashMap;
5use std::path::Path;
6
7use packageurl::PackageUrl;
8use serde_json::Value as JsonValue;
9
10use crate::models::{
11 DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage, Sha256Digest,
12};
13use crate::parser_warn as warn;
14use crate::parsers::utils::{
15 MAX_ITERATION_COUNT, MAX_RECURSION_DEPTH, capped_iteration_limit, read_file_to_string,
16 truncate_field,
17};
18
19use super::PackageParser;
20use super::metadata::ParserMetadata;
21
22pub struct ErlangAppSrcParser;
25pub struct RebarConfigParser;
26pub struct RebarLockParser;
27
28#[derive(Clone, Debug)]
31enum ErlTerm {
32 Atom(String),
33 String(String),
34 Binary(String),
35 Integer(i64),
36 Float(f64),
37 Tuple(Vec<ErlTerm>),
38 List(Vec<ErlTerm>),
39 Map(Vec<(ErlTerm, ErlTerm)>),
40}
41
42struct ErlParser {
45 chars: Vec<char>,
46 pos: usize,
47 depth: usize,
48}
49
50impl ErlParser {
51 fn new(source: &str) -> Self {
52 Self {
53 chars: source.chars().collect(),
54 pos: 0,
55 depth: 0,
56 }
57 }
58
59 fn parse_term(&mut self) -> Result<ErlTerm, String> {
60 if self.depth >= MAX_RECURSION_DEPTH {
61 return Err("recursion depth exceeded".to_string());
62 }
63 self.depth += 1;
64 let result = self.parse_term_inner();
65 self.depth -= 1;
66 result
67 }
68
69 fn parse_term_inner(&mut self) -> Result<ErlTerm, String> {
70 self.skip_whitespace_and_comments();
71 match self.peek() {
72 Some('{') => self.parse_tuple(),
73 Some('[') => self.parse_list(),
74 Some('#') if self.peek_n(1) == Some('{') => self.parse_map(),
75 Some('"') => self.parse_string().map(ErlTerm::String),
76 Some('<') if self.peek_n(1) == Some('<') => self.parse_binary().map(ErlTerm::Binary),
77 Some('\'') => self.parse_quoted_atom().map(ErlTerm::Atom),
78 Some(c) if c.is_ascii_digit() || c == '-' => self.parse_number(),
79 Some(c) if c.is_ascii_lowercase() || c == '_' => self.parse_atom_or_bool(),
80 Some(c) => Err(format!(
81 "Unexpected character '{}' at position {}",
82 c, self.pos
83 )),
84 None => Err("Unexpected end of input".to_string()),
85 }
86 }
87
88 fn parse_tuple(&mut self) -> Result<ErlTerm, String> {
89 self.expect('{')?;
90 let items = self.parse_comma_separated('}')?;
91 Ok(ErlTerm::Tuple(items))
92 }
93
94 fn parse_list(&mut self) -> Result<ErlTerm, String> {
95 self.expect('[')?;
96 let items = self.parse_comma_separated(']')?;
97 Ok(ErlTerm::List(items))
98 }
99
100 fn parse_map(&mut self) -> Result<ErlTerm, String> {
101 self.expect('#')?;
102 self.expect('{')?;
103
104 let mut entries = Vec::new();
105 let mut count = 0usize;
106
107 loop {
108 self.skip_whitespace_and_comments();
109 if self.peek() == Some('}') {
110 self.pos += 1;
111 break;
112 }
113
114 if count >= MAX_ITERATION_COUNT {
115 return Err("too many map entries".to_string());
116 }
117
118 let key = self.parse_term()?;
119 self.skip_whitespace_and_comments();
120
121 match (self.peek(), self.peek_n(1)) {
122 (Some('='), Some('>')) | (Some(':'), Some('=')) => {
123 self.pos += 2;
124 }
125 _ => {
126 return Err(format!(
127 "Expected map association operator at position {}",
128 self.pos
129 ));
130 }
131 }
132
133 let value = self.parse_term()?;
134 entries.push((key, value));
135 count += 1;
136
137 self.skip_whitespace_and_comments();
138 match self.peek() {
139 Some(',') => {
140 self.pos += 1;
141 }
142 Some('}') => {
143 self.pos += 1;
144 break;
145 }
146 Some(c) => {
147 return Err(format!(
148 "Expected ',' or '}}' in map but found '{}' at position {}",
149 c, self.pos
150 ));
151 }
152 None => return Err("Unterminated map literal".to_string()),
153 }
154 }
155
156 Ok(ErlTerm::Map(entries))
157 }
158
159 fn parse_comma_separated(&mut self, closing: char) -> Result<Vec<ErlTerm>, String> {
160 let mut items = Vec::new();
161 let mut count = 0usize;
162 loop {
163 self.skip_whitespace_and_comments();
164 if self.peek() == Some(closing) {
165 self.pos += 1;
166 break;
167 }
168 if count >= MAX_ITERATION_COUNT {
169 return Err("too many items".to_string());
170 }
171 items.push(self.parse_term()?);
172 count += 1;
173 self.skip_whitespace_and_comments();
174 if self.peek() == Some(',') {
175 self.pos += 1;
176 } else if self.peek() == Some('|') {
177 self.pos += 1;
179 self.parse_term()?;
180 self.skip_whitespace_and_comments();
181 if self.peek() == Some(closing) {
182 self.pos += 1;
183 }
184 break;
185 }
186 }
187 Ok(items)
188 }
189
190 fn parse_string(&mut self) -> Result<String, String> {
191 self.expect('"')?;
192 let mut out = String::new();
193 while let Some(c) = self.peek() {
194 self.pos += 1;
195 match c {
196 '"' => return Ok(out),
197 '\\' => {
198 let escaped = self
199 .peek()
200 .ok_or_else(|| "Unterminated string escape".to_string())?;
201 self.pos += 1;
202 out.push(match escaped {
203 'n' => '\n',
204 'r' => '\r',
205 't' => '\t',
206 '"' => '"',
207 '\\' => '\\',
208 other => other,
209 });
210 }
211 other => out.push(other),
212 }
213 }
214 Err("Unterminated string literal".to_string())
215 }
216
217 fn parse_binary(&mut self) -> Result<String, String> {
218 self.expect('<')?;
219 self.expect('<')?;
220 self.skip_whitespace_and_comments();
221 let value = if self.peek() == Some('"') {
222 self.parse_string()?
223 } else {
224 String::new()
225 };
226 self.skip_whitespace_and_comments();
227 self.expect('>')?;
228 self.expect('>')?;
229 Ok(value)
230 }
231
232 fn parse_quoted_atom(&mut self) -> Result<String, String> {
233 self.expect('\'')?;
234 let mut out = String::new();
235 while let Some(c) = self.peek() {
236 self.pos += 1;
237 match c {
238 '\'' => return Ok(out),
239 '\\' => {
240 if let Some(escaped) = self.peek() {
241 self.pos += 1;
242 out.push(escaped);
243 }
244 }
245 other => out.push(other),
246 }
247 }
248 Err("Unterminated quoted atom".to_string())
249 }
250
251 fn parse_atom_or_bool(&mut self) -> Result<ErlTerm, String> {
252 let atom = self.parse_bare_atom()?;
253 match atom.as_str() {
254 "true" => Ok(ErlTerm::Atom("true".to_string())),
255 "false" => Ok(ErlTerm::Atom("false".to_string())),
256 _ => Ok(ErlTerm::Atom(atom)),
257 }
258 }
259
260 fn parse_bare_atom(&mut self) -> Result<String, String> {
261 let start = self.pos;
262 while let Some(c) = self.peek() {
263 if c.is_ascii_alphanumeric() || c == '_' || c == '@' {
264 self.pos += 1;
265 } else {
266 break;
267 }
268 }
269 if self.pos == start {
270 return Err("Expected atom".to_string());
271 }
272 Ok(self.chars[start..self.pos].iter().collect())
273 }
274
275 fn parse_number(&mut self) -> Result<ErlTerm, String> {
276 let start = self.pos;
277 if self.peek() == Some('-') {
278 self.pos += 1;
279 }
280 while let Some(c) = self.peek() {
281 if c.is_ascii_digit() {
282 self.pos += 1;
283 } else {
284 break;
285 }
286 }
287 if self.peek() == Some('.') && self.peek_n(1).is_some_and(|c| c.is_ascii_digit()) {
288 self.pos += 1;
289 while let Some(c) = self.peek() {
290 if c.is_ascii_digit() {
291 self.pos += 1;
292 } else {
293 break;
294 }
295 }
296 let s: String = self.chars[start..self.pos].iter().collect();
297 return s
298 .parse::<f64>()
299 .map(ErlTerm::Float)
300 .map_err(|e| format!("Invalid float: {}", e));
301 }
302 let s: String = self.chars[start..self.pos].iter().collect();
303 s.parse::<i64>()
304 .map(ErlTerm::Integer)
305 .map_err(|e| format!("Invalid integer: {}", e))
306 }
307
308 fn skip_whitespace_and_comments(&mut self) {
309 loop {
310 match self.peek() {
311 Some(c) if c.is_whitespace() => {
312 self.pos += 1;
313 }
314 Some('%') => {
315 while let Some(c) = self.peek() {
316 self.pos += 1;
317 if c == '\n' {
318 break;
319 }
320 }
321 }
322 _ => break,
323 }
324 }
325 }
326
327 fn expect(&mut self, expected: char) -> Result<(), String> {
328 self.skip_whitespace_and_comments();
329 match self.peek() {
330 Some(c) if c == expected => {
331 self.pos += 1;
332 Ok(())
333 }
334 Some(c) => Err(format!(
335 "Expected '{}' but found '{}' at position {}",
336 expected, c, self.pos
337 )),
338 None => Err(format!("Expected '{}' but reached end of input", expected)),
339 }
340 }
341
342 fn peek(&self) -> Option<char> {
343 self.chars.get(self.pos).copied()
344 }
345
346 fn peek_n(&self, n: usize) -> Option<char> {
347 self.chars.get(self.pos + n).copied()
348 }
349
350 fn is_eof(&self) -> bool {
351 self.pos >= self.chars.len()
352 }
353}
354
355fn parse_dotted_terms(content: &str) -> Result<Vec<ErlTerm>, String> {
356 let normalized = strip_template_placeholders(content);
357 let mut parser = ErlParser::new(&normalized);
358 let mut terms = Vec::new();
359 let mut count = 0usize;
360 loop {
361 parser.skip_whitespace_and_comments();
362 if parser.is_eof() {
363 break;
364 }
365 if parser.peek() == Some('.') {
366 parser.pos += 1;
367 continue;
368 }
369 if count >= MAX_ITERATION_COUNT {
370 warn!(
371 "Truncated Erlang term parsing at {} top-level terms (MAX_ITERATION_COUNT); remaining terms dropped",
372 MAX_ITERATION_COUNT
373 );
374 break;
375 }
376 let term = parser.parse_term()?;
377 terms.push(term);
378 count += 1;
379 parser.skip_whitespace_and_comments();
380 if parser.peek() == Some('.') {
381 parser.pos += 1;
382 }
383 }
384 Ok(terms)
385}
386
387fn strip_template_placeholders(source: &str) -> String {
388 let chars: Vec<char> = source.chars().collect();
389 let mut result = String::with_capacity(source.len());
390 let mut i = 0usize;
391 let mut in_string = false;
392 let mut in_quoted_atom = false;
393
394 while i < chars.len() {
395 let c = chars[i];
396
397 if in_string {
398 result.push(c);
399 i += 1;
400 if c == '\\' && i < chars.len() {
401 result.push(chars[i]);
402 i += 1;
403 continue;
404 }
405 if c == '"' {
406 in_string = false;
407 }
408 continue;
409 }
410
411 if in_quoted_atom {
412 result.push(c);
413 i += 1;
414 if c == '\\' && i < chars.len() {
415 result.push(chars[i]);
416 i += 1;
417 continue;
418 }
419 if c == '\'' {
420 in_quoted_atom = false;
421 }
422 continue;
423 }
424
425 match c {
426 '"' => {
427 in_string = true;
428 result.push(c);
429 i += 1;
430 }
431 '\'' => {
432 in_quoted_atom = true;
433 result.push(c);
434 i += 1;
435 }
436 '%' if chars.get(i + 1) != Some(&'%') => {
437 let line_end = chars[i..]
438 .iter()
439 .position(|&ch| ch == '\n')
440 .map(|offset| i + offset)
441 .unwrap_or(chars.len());
442
443 let last_percent = chars[i + 1..line_end]
444 .iter()
445 .rposition(|&ch| ch == '%')
446 .map(|offset| i + 1 + offset);
447
448 if let Some(last_percent) = last_percent {
449 let placeholder_body: String = chars[i + 1..last_percent].iter().collect();
450 let trailing: String = chars[last_percent + 1..line_end].iter().collect();
451 let looks_like_placeholder = !placeholder_body.is_empty()
452 && placeholder_body.chars().all(|ch| {
453 ch.is_ascii_uppercase()
454 || ch.is_ascii_digit()
455 || matches!(ch, '_' | ',' | '%')
456 })
457 && trailing
458 .chars()
459 .all(|ch| ch.is_whitespace() || matches!(ch, ',' | ']' | '}' | ')'));
460
461 if looks_like_placeholder {
462 i = last_percent + 1;
463 continue;
464 }
465 }
466
467 result.push(c);
468 i += 1;
469 }
470 _ => {
471 result.push(c);
472 i += 1;
473 }
474 }
475 }
476
477 result
478}
479
480fn term_to_str(term: &ErlTerm) -> Option<String> {
483 match term {
484 ErlTerm::String(s) | ErlTerm::Binary(s) | ErlTerm::Atom(s) => Some(s.clone()),
485 ErlTerm::Integer(n) => Some(n.to_string()),
486 ErlTerm::Float(f) => Some(f.to_string()),
487 _ => None,
488 }
489}
490
491fn term_to_proplist(term: &ErlTerm) -> Option<Vec<(String, ErlTerm)>> {
492 let items = match term {
493 ErlTerm::List(items) => items,
494 _ => return None,
495 };
496 let mut result = Vec::new();
497 for item in items {
498 if let ErlTerm::Tuple(fields) = item
499 && fields.len() == 2
500 && let Some(key) = term_to_str(&fields[0])
501 {
502 result.push((key, fields[1].clone()));
503 }
504 }
505 Some(result)
506}
507
508fn term_to_key_value_pairs(term: &ErlTerm) -> Option<Vec<(String, ErlTerm)>> {
509 match term {
510 ErlTerm::Map(entries) => Some(
511 entries
512 .iter()
513 .filter_map(|(key, value)| term_to_str(key).map(|key| (key, value.clone())))
514 .collect(),
515 ),
516 _ => term_to_proplist(term),
517 }
518}
519
520fn term_to_atom_list(term: &ErlTerm) -> Vec<String> {
521 match term {
522 ErlTerm::List(items) => items.iter().filter_map(term_to_str).collect(),
523 _ => Vec::new(),
524 }
525}
526
527fn build_hex_purl(name: &str, version: Option<&str>) -> Option<String> {
528 let mut purl = PackageUrl::new("hex", name).ok()?;
529 if let Some(version) = version {
530 purl.with_version(version).ok()?;
531 }
532 Some(purl.to_string())
533}
534
535impl PackageParser for ErlangAppSrcParser {
538 const PACKAGE_TYPE: PackageType = PackageType::Hex;
539
540 fn metadata() -> Vec<ParserMetadata> {
541 vec![ParserMetadata {
542 description: "Erlang OTP application resource file",
543 file_patterns: &["**/*.app.src"],
544 package_type: "hex",
545 primary_language: "Erlang",
546 documentation_url: Some("https://www.erlang.org/doc/apps/kernel/application"),
547 }]
548 }
549
550 fn is_match(path: &Path) -> bool {
551 path.extension()
552 .and_then(|e| e.to_str())
553 .is_some_and(|ext| ext == "src")
554 && path
555 .file_stem()
556 .and_then(|s| s.to_str())
557 .is_some_and(|stem| stem.ends_with(".app"))
558 }
559
560 fn extract_packages(path: &Path) -> Vec<PackageData> {
561 let content = match read_file_to_string(path, None) {
562 Ok(c) => c,
563 Err(e) => {
564 warn!("Failed to read {:?}: {}", path, e);
565 return vec![default_app_src_package()];
566 }
567 };
568
569 match parse_app_src(&content) {
570 Ok(pkg) => vec![pkg],
571 Err(e) => {
572 warn!("Failed to parse {:?}: {}", path, e);
573 vec![default_app_src_package()]
574 }
575 }
576 }
577}
578
579fn default_app_src_package() -> PackageData {
580 PackageData {
581 package_type: Some(PackageType::Hex),
582 primary_language: Some("Erlang".to_string()),
583 datasource_id: Some(DatasourceId::ErlangOtpAppSrc),
584 ..Default::default()
585 }
586}
587
588fn parse_app_src(content: &str) -> Result<PackageData, String> {
589 let terms = parse_dotted_terms(content)?;
590
591 let app_tuple = terms
592 .into_iter()
593 .find_map(|term| {
594 if let ErlTerm::Tuple(fields) = &term
595 && fields.len() == 3
596 && term_to_str(&fields[0]).as_deref() == Some("application")
597 {
598 Some(term)
599 } else {
600 None
601 }
602 })
603 .ok_or_else(|| "No {application, _, _} tuple found".to_string())?;
604
605 let fields = match app_tuple {
606 ErlTerm::Tuple(fields) => fields,
607 _ => unreachable!(),
608 };
609
610 let app_name = term_to_str(&fields[1]);
611 let props = term_to_proplist(&fields[2]).unwrap_or_default();
612
613 let mut package = default_app_src_package();
614 package.name = app_name.map(truncate_field);
615
616 let mut extra_data = HashMap::new();
617
618 for (key, value) in &props {
619 match key.as_str() {
620 "vsn" => {
621 if let Some(v) = term_to_str(value)
622 && !v.contains('%')
623 {
624 package.version = Some(truncate_field(v));
625 }
626 }
627 "description" => {
628 package.description = term_to_str(value).map(truncate_field);
629 }
630 "licenses" => {
631 let licenses = term_to_atom_list(value);
632 if !licenses.is_empty() {
633 package.extracted_license_statement = Some(truncate_field(licenses.join(", ")));
634 }
635 }
636 "links" => {
637 if let Some(link_props) = term_to_key_value_pairs(value) {
638 for (link_name, link_val) in &link_props {
639 if let Some(url) = term_to_str(link_val) {
640 let lower = link_name.to_lowercase();
641 if lower.contains("github")
642 || lower.contains("source")
643 || lower.contains("repo")
644 {
645 package.vcs_url = Some(truncate_field(url.clone()));
646 }
647 if package.homepage_url.is_none() {
648 package.homepage_url = Some(truncate_field(url));
649 }
650 }
651 }
652 }
653 }
654 "applications" => {
655 let apps = term_to_atom_list(value);
656 for app in apps {
657 if is_otp_stdlib(&app) {
658 continue;
659 }
660 package.dependencies.push(Dependency {
661 purl: build_hex_purl(&app, None).map(truncate_field),
662 extracted_requirement: None,
663 scope: Some("dependencies".to_string()),
664 is_runtime: Some(true),
665 is_optional: None,
666 is_pinned: None,
667 is_direct: None,
668 resolved_package: None,
669 extra_data: None,
670 });
671 }
672 }
673 "runtime_dependencies" => {
674 let deps = term_to_atom_list(value);
675 for dep_str in deps {
676 if let Some((name, version)) = dep_str.split_once('-') {
677 if is_otp_stdlib(name) {
678 continue;
679 }
680 let version_str = if version.starts_with('@') {
681 None
682 } else {
683 Some(version)
684 };
685 package.dependencies.push(Dependency {
686 purl: build_hex_purl(name, version_str).map(truncate_field),
687 extracted_requirement: version_str
688 .map(|v| truncate_field(v.to_string())),
689 scope: Some("dependencies".to_string()),
690 is_runtime: Some(true),
691 is_optional: None,
692 is_pinned: None,
693 is_direct: None,
694 resolved_package: None,
695 extra_data: None,
696 });
697 }
698 }
699 }
700 "maintainers" => {
701 let maintainers = term_to_atom_list(value);
702 if !maintainers.is_empty() {
703 extra_data.insert(
704 "maintainers".to_string(),
705 JsonValue::Array(
706 maintainers
707 .into_iter()
708 .map(|m| JsonValue::String(truncate_field(m)))
709 .collect(),
710 ),
711 );
712 }
713 }
714 "keywords" => {
715 let keywords = term_to_atom_list(value);
716 if !keywords.is_empty() {
717 package.keywords = keywords.into_iter().map(truncate_field).collect();
718 }
719 }
720 _ => {}
721 }
722 }
723
724 if let Some(ref name) = package.name {
725 package.purl = build_hex_purl(name, package.version.as_deref()).map(truncate_field);
726 package.repository_homepage_url =
727 Some(truncate_field(format!("https://hex.pm/packages/{}", name)));
728 package.api_data_url = Some(truncate_field(format!(
729 "https://hex.pm/api/packages/{}",
730 name
731 )));
732 }
733
734 if !extra_data.is_empty() {
735 package.extra_data = Some(extra_data);
736 }
737
738 Ok(package)
739}
740
741fn is_otp_stdlib(name: &str) -> bool {
742 matches!(
743 name,
744 "kernel"
745 | "stdlib"
746 | "sasl"
747 | "erts"
748 | "compiler"
749 | "crypto"
750 | "inets"
751 | "ssl"
752 | "public_key"
753 | "asn1"
754 | "syntax_tools"
755 | "tools"
756 | "os_mon"
757 | "runtime_tools"
758 | "mnesia"
759 | "observer"
760 | "wx"
761 | "debugger"
762 | "reltool"
763 | "xmerl"
764 | "edoc"
765 | "eunit"
766 | "common_test"
767 | "dialyzer"
768 | "et"
769 | "megaco"
770 | "parsetools"
771 | "snmp"
772 | "ssh"
773 | "tftp"
774 | "ftp"
775 | "erl_interface"
776 | "jinterface"
777 | "odbc"
778 | "eldap"
779 | "diameter"
780 )
781}
782
783impl PackageParser for RebarConfigParser {
786 const PACKAGE_TYPE: PackageType = PackageType::Hex;
787
788 fn metadata() -> Vec<ParserMetadata> {
789 vec![ParserMetadata {
790 description: "Rebar3 configuration",
791 file_patterns: &["**/rebar.config"],
792 package_type: "hex",
793 primary_language: "Erlang",
794 documentation_url: Some("https://rebar3.org/docs/configuration/configuration/"),
795 }]
796 }
797
798 fn is_match(path: &Path) -> bool {
799 path.file_name().and_then(|n| n.to_str()) == Some("rebar.config")
800 }
801
802 fn extract_packages(path: &Path) -> Vec<PackageData> {
803 let content = match read_file_to_string(path, None) {
804 Ok(c) => c,
805 Err(e) => {
806 warn!("Failed to read {:?}: {}", path, e);
807 return vec![default_rebar_config_package()];
808 }
809 };
810
811 match parse_rebar_config(&content) {
812 Ok(pkg) => vec![pkg],
813 Err(e) => {
814 warn!("Failed to parse {:?}: {}", path, e);
815 vec![default_rebar_config_package()]
816 }
817 }
818 }
819}
820
821fn default_rebar_config_package() -> PackageData {
822 PackageData {
823 package_type: Some(PackageType::Hex),
824 primary_language: Some("Erlang".to_string()),
825 datasource_id: Some(DatasourceId::RebarConfig),
826 ..Default::default()
827 }
828}
829
830fn parse_rebar_config(content: &str) -> Result<PackageData, String> {
831 let terms = parse_dotted_terms(content)?;
832
833 let mut package = default_rebar_config_package();
834
835 for term in &terms {
836 if let ErlTerm::Tuple(fields) = term
837 && fields.len() == 2
838 {
839 let key = term_to_str(&fields[0]);
840 match key.as_deref() {
841 Some("deps") => {
842 if let ErlTerm::List(deps) = &fields[1] {
843 let deps_limit = capped_iteration_limit(deps.len(), "rebar.config deps");
844 for dep in deps.iter().take(deps_limit) {
845 if let Some(d) = parse_rebar_dep(dep) {
846 package.dependencies.push(d);
847 }
848 }
849 }
850 }
851 Some("profiles") => {
852 parse_profile_deps(&fields[1], &mut package.dependencies);
853 }
854 _ => {}
855 }
856 }
857 }
858
859 Ok(package)
860}
861
862fn parse_rebar_dep(term: &ErlTerm) -> Option<Dependency> {
863 let fields = match term {
864 ErlTerm::Tuple(fields) => fields,
865 _ => return None,
866 };
867
868 if fields.is_empty() {
869 return None;
870 }
871
872 if let Some(key) = term_to_str(&fields[0])
873 && key.starts_with("if_")
874 {
875 return None;
876 }
877
878 let app_name = term_to_str(&fields[0])?;
879
880 match fields.len() {
881 2 => {
883 if let Some(version) = term_to_str(&fields[1]) {
884 Some(Dependency {
886 purl: build_hex_purl(&app_name, Some(&version)).map(truncate_field),
887 extracted_requirement: Some(truncate_field(version)),
888 scope: Some("dependencies".to_string()),
889 is_runtime: None,
890 is_optional: None,
891 is_pinned: None,
892 is_direct: None,
893 resolved_package: None,
894 extra_data: None,
895 })
896 } else {
897 let package_name = extract_rebar_package_name(&fields[1], &app_name);
898 let vcs_url = extract_git_url(&fields[1]);
899 let version = extract_git_version(&fields[1]);
900 Some(Dependency {
901 purl: build_hex_purl(&package_name, version.as_deref()).map(truncate_field),
902 extracted_requirement: version.map(truncate_field),
903 scope: Some("dependencies".to_string()),
904 is_runtime: None,
905 is_optional: None,
906 is_pinned: None,
907 is_direct: None,
908 resolved_package: None,
909 extra_data: build_rebar_dependency_extra_data(
910 vcs_url,
911 app_name.as_str(),
912 package_name.as_str(),
913 ),
914 })
915 }
916 }
917 3 => {
919 if let Some(version) = term_to_str(&fields[1]) {
920 let package_name = extract_rebar_package_name(&fields[2], &app_name);
921 let vcs_url = extract_git_url(&fields[2]);
922 Some(Dependency {
923 purl: build_hex_purl(&package_name, Some(&version)).map(truncate_field),
924 extracted_requirement: Some(truncate_field(version)),
925 scope: Some("dependencies".to_string()),
926 is_runtime: None,
927 is_optional: None,
928 is_pinned: None,
929 is_direct: None,
930 resolved_package: None,
931 extra_data: build_rebar_dependency_extra_data(
932 vcs_url,
933 app_name.as_str(),
934 package_name.as_str(),
935 ),
936 })
937 } else {
938 let package_name = extract_rebar_package_name(&fields[1], &app_name);
939 let vcs_url = extract_git_url(&fields[1]);
940 let version = extract_git_version(&fields[1]);
941 Some(Dependency {
942 purl: build_hex_purl(&package_name, version.as_deref()).map(truncate_field),
943 extracted_requirement: version.map(truncate_field),
944 scope: Some("dependencies".to_string()),
945 is_runtime: None,
946 is_optional: None,
947 is_pinned: None,
948 is_direct: None,
949 resolved_package: None,
950 extra_data: build_rebar_dependency_extra_data(
951 vcs_url,
952 app_name.as_str(),
953 package_name.as_str(),
954 ),
955 })
956 }
957 }
958 _ => None,
959 }
960}
961
962fn extract_rebar_package_name(term: &ErlTerm, fallback_name: &str) -> String {
963 if let ErlTerm::Tuple(fields) = term
964 && fields.len() >= 2
965 && term_to_str(&fields[0]).as_deref() == Some("pkg")
966 && let Some(package_name) = term_to_str(&fields[1])
967 {
968 package_name
969 } else {
970 fallback_name.to_string()
971 }
972}
973
974fn build_rebar_dependency_extra_data(
975 vcs_url: Option<String>,
976 app_name: &str,
977 package_name: &str,
978) -> Option<HashMap<String, JsonValue>> {
979 let mut extra_data = HashMap::new();
980
981 if let Some(url) = vcs_url {
982 extra_data.insert(
983 "vcs_url".to_string(),
984 JsonValue::String(truncate_field(url)),
985 );
986 }
987
988 if app_name != package_name {
989 extra_data.insert(
990 "app_name".to_string(),
991 JsonValue::String(truncate_field(app_name.to_string())),
992 );
993 }
994
995 if extra_data.is_empty() {
996 None
997 } else {
998 Some(extra_data)
999 }
1000}
1001
1002fn extract_git_url(term: &ErlTerm) -> Option<String> {
1003 if let ErlTerm::Tuple(fields) = term
1004 && fields.len() >= 2
1005 && matches!(
1006 term_to_str(&fields[0]).as_deref(),
1007 Some("git") | Some("git_subdir")
1008 )
1009 {
1010 term_to_str(&fields[1])
1011 } else {
1012 None
1013 }
1014}
1015
1016fn extract_git_version(term: &ErlTerm) -> Option<String> {
1017 if let ErlTerm::Tuple(fields) = term
1018 && fields.len() >= 3
1019 && matches!(
1020 term_to_str(&fields[0]).as_deref(),
1021 Some("git") | Some("git_subdir")
1022 )
1023 {
1024 if let ErlTerm::Tuple(ref_fields) = &fields[2]
1025 && ref_fields.len() == 2
1026 {
1027 let ref_type = term_to_str(&ref_fields[0])?;
1028 let ref_val = term_to_str(&ref_fields[1])?;
1029 match ref_type.as_str() {
1030 "tag" => Some(ref_val),
1031 _ => None,
1032 }
1033 } else {
1034 None
1035 }
1036 } else {
1037 None
1038 }
1039}
1040
1041fn parse_profile_deps(term: &ErlTerm, dependencies: &mut Vec<Dependency>) {
1042 let profiles = match term {
1043 ErlTerm::List(items) => items,
1044 _ => return,
1045 };
1046
1047 let profiles_limit = capped_iteration_limit(profiles.len(), "rebar.config profiles");
1048 for profile in profiles.iter().take(profiles_limit) {
1049 if let ErlTerm::Tuple(fields) = profile
1050 && fields.len() == 2
1051 {
1052 let profile_name = term_to_str(&fields[0]).unwrap_or_default();
1053 if let ErlTerm::List(profile_opts) = &fields[1] {
1054 for opt in profile_opts {
1055 if let ErlTerm::Tuple(opt_fields) = opt
1056 && opt_fields.len() == 2
1057 && term_to_str(&opt_fields[0]).as_deref() == Some("deps")
1058 && let ErlTerm::List(deps) = &opt_fields[1]
1059 {
1060 let profile_deps_limit =
1061 capped_iteration_limit(deps.len(), "rebar.config profile deps");
1062 for dep in deps.iter().take(profile_deps_limit) {
1063 if let Some(mut d) = parse_rebar_dep(dep) {
1064 d.scope = Some(truncate_field(profile_name.clone()));
1065 dependencies.push(d);
1066 }
1067 }
1068 }
1069 }
1070 }
1071 }
1072 }
1073}
1074
1075impl PackageParser for RebarLockParser {
1078 const PACKAGE_TYPE: PackageType = PackageType::Hex;
1079
1080 fn metadata() -> Vec<ParserMetadata> {
1081 vec![ParserMetadata {
1082 description: "Rebar3 lockfile",
1083 file_patterns: &["**/rebar.lock"],
1084 package_type: "hex",
1085 primary_language: "Erlang",
1086 documentation_url: Some("https://rebar3.org/docs/configuration/configuration/"),
1087 }]
1088 }
1089
1090 fn is_match(path: &Path) -> bool {
1091 path.file_name().and_then(|n| n.to_str()) == Some("rebar.lock")
1092 }
1093
1094 fn extract_packages(path: &Path) -> Vec<PackageData> {
1095 let content = match read_file_to_string(path, None) {
1096 Ok(c) => c,
1097 Err(e) => {
1098 warn!("Failed to read {:?}: {}", path, e);
1099 return vec![default_rebar_lock_package()];
1100 }
1101 };
1102
1103 match parse_rebar_lock(&content) {
1104 Ok(pkg) => vec![pkg],
1105 Err(e) => {
1106 warn!("Failed to parse {:?}: {}", path, e);
1107 vec![default_rebar_lock_package()]
1108 }
1109 }
1110 }
1111}
1112
1113fn default_rebar_lock_package() -> PackageData {
1114 PackageData {
1115 package_type: Some(PackageType::Hex),
1116 primary_language: Some("Erlang".to_string()),
1117 datasource_id: Some(DatasourceId::RebarLock),
1118 ..Default::default()
1119 }
1120}
1121
1122fn parse_rebar_lock(content: &str) -> Result<PackageData, String> {
1123 let terms = parse_dotted_terms(content)?;
1124
1125 let (dep_list, hash_map) = match terms.as_slice() {
1131 [ErlTerm::Tuple(fields), rest @ ..] if fields.len() == 2 => {
1133 let deps = match &fields[1] {
1134 ErlTerm::List(items) => items.clone(),
1135 _ => return Err("Expected dependency list in lock tuple".to_string()),
1136 };
1137 let hashes = rest.first().map(extract_pkg_hashes).unwrap_or_default();
1138 (deps, hashes)
1139 }
1140 [ErlTerm::List(items), rest @ ..] => {
1142 let hashes = rest.first().map(extract_pkg_hashes).unwrap_or_default();
1143 (items.clone(), hashes)
1144 }
1145 _ => return Err("Unrecognized rebar.lock format".to_string()),
1146 };
1147
1148 let mut package = default_rebar_lock_package();
1149
1150 let dep_list_limit = capped_iteration_limit(dep_list.len(), "rebar.lock deps");
1151 for dep_term in dep_list.iter().take(dep_list_limit) {
1152 if let Some(dep) = parse_lock_dep(dep_term, &hash_map) {
1153 package.dependencies.push(dep);
1154 }
1155 }
1156
1157 Ok(package)
1158}
1159
1160fn parse_lock_dep(term: &ErlTerm, hashes: &HashMap<String, String>) -> Option<Dependency> {
1161 let fields = match term {
1162 ErlTerm::Tuple(fields) if fields.len() >= 3 => fields,
1163 _ => return None,
1164 };
1165
1166 let app_name = term_to_str(&fields[0])?;
1167 let (package_name, version, vcs_url) = match &fields[1] {
1170 ErlTerm::Tuple(pkg_fields)
1172 if pkg_fields.len() >= 3 && term_to_str(&pkg_fields[0]).as_deref() == Some("pkg") =>
1173 {
1174 let package_name = term_to_str(&pkg_fields[1]).unwrap_or_else(|| app_name.clone());
1175 let ver = term_to_str(&pkg_fields[2]);
1176 (package_name, ver, None)
1177 }
1178 ErlTerm::Tuple(git_fields)
1180 if git_fields.len() >= 2
1181 && matches!(
1182 term_to_str(&git_fields[0]).as_deref(),
1183 Some("git") | Some("git_subdir")
1184 ) =>
1185 {
1186 let url = term_to_str(&git_fields[1]);
1187 let ver = if git_fields.len() >= 3 {
1188 extract_git_version_from_lock_ref(&git_fields[2])
1189 } else {
1190 None
1191 };
1192 (app_name.clone(), ver, url)
1193 }
1194 _ => (app_name.clone(), None, None),
1195 };
1196
1197 let sha256 = hashes
1198 .get(&app_name)
1199 .or_else(|| hashes.get(&package_name))
1200 .and_then(|h| Sha256Digest::from_hex(h).ok());
1201
1202 let resolved_package = ResolvedPackage {
1203 primary_language: Some("Erlang".to_string()),
1204 sha256,
1205 is_virtual: true,
1206 datasource_id: Some(DatasourceId::RebarLock),
1207 purl: build_hex_purl(&package_name, version.as_deref()).map(truncate_field),
1208 repository_homepage_url: Some(truncate_field(format!(
1209 "https://hex.pm/packages/{}",
1210 package_name
1211 ))),
1212 api_data_url: Some(truncate_field(format!(
1213 "https://hex.pm/api/packages/{}",
1214 package_name
1215 ))),
1216 ..ResolvedPackage::new(
1217 PackageType::Hex,
1218 String::new(),
1219 package_name.clone(),
1220 version.clone().unwrap_or_default(),
1221 )
1222 };
1223
1224 Some(Dependency {
1225 purl: build_hex_purl(&package_name, version.as_deref()).map(truncate_field),
1226 extracted_requirement: version.map(truncate_field),
1227 scope: Some("dependencies".to_string()),
1228 is_runtime: None,
1229 is_optional: None,
1230 is_pinned: Some(true),
1231 is_direct: None,
1232 resolved_package: Some(Box::new(resolved_package)),
1233 extra_data: build_rebar_dependency_extra_data(
1234 vcs_url,
1235 app_name.as_str(),
1236 package_name.as_str(),
1237 ),
1238 })
1239}
1240
1241fn extract_git_version_from_lock_ref(term: &ErlTerm) -> Option<String> {
1242 if let ErlTerm::Tuple(fields) = term
1243 && fields.len() == 2
1244 && term_to_str(&fields[0]).as_deref() == Some("ref")
1245 {
1246 term_to_str(&fields[1])
1247 } else {
1248 None
1249 }
1250}
1251
1252fn extract_pkg_hashes(term: &ErlTerm) -> HashMap<String, String> {
1253 let items = match term {
1254 ErlTerm::List(items) => items,
1255 _ => return HashMap::new(),
1256 };
1257
1258 let mut hashes = HashMap::new();
1259 for item in items {
1260 if let ErlTerm::Tuple(fields) = item
1261 && fields.len() == 2
1262 && term_to_str(&fields[0]).as_deref() == Some("pkg_hash")
1263 && let ErlTerm::List(hash_list) = &fields[1]
1264 {
1265 let hash_list_limit =
1266 capped_iteration_limit(hash_list.len(), "rebar.lock pkg_hash entries");
1267 for entry in hash_list.iter().take(hash_list_limit) {
1268 if let ErlTerm::Tuple(pair) = entry
1269 && pair.len() == 2
1270 && let (Some(name), Some(hash)) = (term_to_str(&pair[0]), term_to_str(&pair[1]))
1271 {
1272 hashes.insert(name, hash);
1273 }
1274 }
1275 }
1276 }
1277 hashes
1278}