1use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32
33use crate::parser_warn as warn;
34use packageurl::PackageUrl;
35use serde_json::Value as JsonValue;
36
37use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
38use crate::parsers::pep508::{Pep508Requirement, parse_pep508_requirement};
39use crate::parsers::utils::{
40 CappedIterExt, MAX_ITERATION_COUNT, MAX_RECURSION_DEPTH, RecursionGuard,
41 capped_iteration_limit, read_file_to_string, truncate_field,
42};
43
44use super::PackageParser;
45
46pub struct RequirementsTxtParser;
51
52impl PackageParser for RequirementsTxtParser {
53 const PACKAGE_TYPE: PackageType = PackageType::Pypi;
54
55 fn extract_packages(path: &Path) -> Vec<PackageData> {
56 vec![extract_from_requirements_txt(path)]
57 }
58
59 fn is_match(path: &Path) -> bool {
60 let filename = path.file_name().and_then(|name| name.to_str());
61 let Some(name) = filename else {
62 return false;
63 };
64
65 is_requirements_txt_filename(name)
66 || (is_requirements_like_extension(name) && has_requirements_like_ancestor(path))
67 }
68
69 fn metadata() -> Vec<super::metadata::ParserMetadata> {
70 vec![super::metadata::ParserMetadata {
71 description: "pip requirements file",
72 file_patterns: &[
73 "**/requirements*.txt",
74 "**/*requirements.txt",
75 "**/reqs.txt",
76 "**/minreqs.txt",
77 "**/*-reqs.txt",
78 "**/*_reqs.txt",
79 "**/*.reqs.txt",
80 "**/*-minreqs.txt",
81 "**/*_minreqs.txt",
82 "**/*.minreqs.txt",
83 "**/requirements*.in",
84 "**/*requirements.in",
85 "**/requires.txt",
86 "**/requirements/*.txt",
87 "**/requirements/*.in",
88 "**/requirements/**/*.txt",
89 "**/requirements/**/*.in",
90 "**/requirements*/*.txt",
91 "**/requirements*/*.in",
92 "**/requirements*/**/*.txt",
93 "**/requirements*/**/*.in",
94 ],
95 package_type: "pypi",
96 primary_language: "Python",
97 documentation_url: Some(
98 "https://pip.pypa.io/en/latest/reference/requirements-file-format/",
99 ),
100 }]
101 }
102}
103
104fn is_requirements_txt_filename(name: &str) -> bool {
105 if name == "requirements.txt" || name == "requires.txt" {
106 return true;
107 }
108
109 let (stem, extension) = if let Some(stem) = name.strip_suffix(".txt") {
110 (stem, "txt")
111 } else if let Some(stem) = name.strip_suffix(".in") {
112 (stem, "in")
113 } else {
114 return false;
115 };
116
117 stem == "requirements"
121 || stem.starts_with("requirements")
122 || stem.ends_with("requirements")
123 || (extension == "txt" && is_reqs_alias_stem(stem))
124}
125
126fn is_reqs_alias_stem(stem: &str) -> bool {
127 matches_requirement_alias_stem(stem, "reqs") || matches_requirement_alias_stem(stem, "minreqs")
128}
129
130fn matches_requirement_alias_stem(stem: &str, alias: &str) -> bool {
131 stem == alias
132 || stem
133 .strip_suffix(alias)
134 .is_some_and(|prefix| matches!(prefix.chars().last(), Some('-' | '_' | '.')))
135}
136
137fn is_requirements_like_extension(name: &str) -> bool {
138 name.ends_with(".txt") || name.ends_with(".in")
139}
140
141fn has_requirements_like_ancestor(path: &Path) -> bool {
142 path.parent()
143 .into_iter()
144 .flat_map(Path::ancestors)
145 .filter_map(|ancestor| ancestor.file_name())
146 .filter_map(|name| name.to_str())
147 .any(is_requirements_like_dir_name)
148}
149
150fn is_requirements_like_dir_name(name: &str) -> bool {
151 name == "requirements" || name.starts_with("requirements") || name.ends_with("requirements")
152}
153
154struct ParseState {
155 dependencies: Vec<Dependency>,
156 extra_index_urls: Vec<String>,
157 index_url: Option<String>,
158 includes: Vec<String>,
159 constraints: Vec<String>,
160 guard: RecursionGuard<PathBuf>,
161}
162
163fn extract_from_requirements_txt(path: &Path) -> PackageData {
164 let mut state = ParseState {
165 dependencies: Vec::new(),
166 extra_index_urls: Vec::new(),
167 index_url: None,
168 includes: Vec::new(),
169 constraints: Vec::new(),
170 guard: RecursionGuard::new(),
171 };
172
173 let (scope, is_runtime) = scope_from_filename(path);
174
175 parse_requirements_with_includes(path, &mut state, &scope, is_runtime);
176
177 let mut extra_data = HashMap::new();
178 if let Some(url) = state.index_url {
179 extra_data.insert(
180 "index_url".to_string(),
181 JsonValue::String(truncate_field(url)),
182 );
183 }
184 if !state.extra_index_urls.is_empty() {
185 extra_data.insert(
186 "extra_index_urls".to_string(),
187 JsonValue::Array(
188 state
189 .extra_index_urls
190 .into_iter()
191 .map(|u| JsonValue::String(truncate_field(u)))
192 .collect(),
193 ),
194 );
195 }
196 if !state.includes.is_empty() {
197 extra_data.insert(
198 "requirements_includes".to_string(),
199 JsonValue::Array(
200 state
201 .includes
202 .into_iter()
203 .map(|i| JsonValue::String(truncate_field(i)))
204 .collect(),
205 ),
206 );
207 }
208 if !state.constraints.is_empty() {
209 extra_data.insert(
210 "constraints".to_string(),
211 JsonValue::Array(
212 state
213 .constraints
214 .into_iter()
215 .map(|c| JsonValue::String(truncate_field(c)))
216 .collect(),
217 ),
218 );
219 }
220
221 let extra_data = if extra_data.is_empty() {
222 None
223 } else {
224 Some(extra_data)
225 };
226
227 default_package_data(state.dependencies, extra_data)
228}
229
230fn parse_requirements_with_includes(
231 path: &Path,
232 state: &mut ParseState,
233 scope: &str,
234 is_runtime: bool,
235) {
236 if state.guard.exceeded() {
237 warn!(
238 "Maximum recursion depth ({}) exceeded for include: {:?}",
239 MAX_RECURSION_DEPTH, path
240 );
241 return;
242 }
243
244 let abs_path = match path.canonicalize() {
245 Ok(p) => p,
246 Err(_) => {
247 warn!("Cannot resolve path: {:?}", path);
248 return;
249 }
250 };
251
252 if state.guard.enter(abs_path.clone()) {
253 warn!("Circular include detected: {:?}", path);
254 return;
255 }
256
257 let content = match read_file_to_string(&abs_path, None) {
258 Ok(c) => c,
259 Err(e) => {
260 warn!("Cannot read file {:?}: {}", abs_path, e);
261 return;
262 }
263 };
264
265 let logical_lines = collect_logical_lines(&content);
266 let limit = capped_iteration_limit(logical_lines.len(), "requirements.txt logical lines");
267 for line in logical_lines.into_iter().take(limit) {
268 let cleaned = strip_inline_comment(&line);
269 let trimmed = cleaned.trim();
270 if trimmed.is_empty() || trimmed.starts_with('#') {
271 continue;
272 }
273
274 if let Some(url) = parse_option_value(trimmed, "--extra-index-url") {
275 state.extra_index_urls.push(truncate_field(url));
276 continue;
277 }
278
279 if let Some(url) = parse_option_value(trimmed, "--index-url") {
280 state.index_url = Some(truncate_field(url));
281 continue;
282 }
283
284 if let Some(path_value) = parse_option_value(trimmed, "-r")
285 .or_else(|| parse_option_value(trimmed, "--requirement"))
286 {
287 state.includes.push(truncate_field(path_value.clone()));
288 let included_path = abs_path
289 .parent()
290 .unwrap_or_else(|| Path::new("."))
291 .join(&path_value);
292
293 if included_path.exists() {
294 parse_requirements_with_includes(&included_path, state, scope, is_runtime);
295 } else {
296 warn!("Included file not found: {:?}", included_path);
297 }
298 continue;
299 }
300
301 if let Some(path_value) = parse_option_value(trimmed, "-c")
302 .or_else(|| parse_option_value(trimmed, "--constraint"))
303 {
304 state.constraints.push(truncate_field(path_value.clone()));
305 let constraint_path = abs_path
306 .parent()
307 .unwrap_or_else(|| Path::new("."))
308 .join(&path_value);
309
310 if constraint_path.exists() {
311 parse_requirements_with_includes(&constraint_path, state, scope, is_runtime);
312 } else {
313 warn!("Constraint file not found: {:?}", constraint_path);
314 }
315 continue;
316 }
317
318 if trimmed.starts_with('-')
319 && !trimmed.starts_with("-e")
320 && !trimmed.starts_with("--editable")
321 {
322 continue;
323 }
324
325 if let Some(dependency) = build_dependency(trimmed, scope, is_runtime) {
326 if state.dependencies.len() >= MAX_ITERATION_COUNT {
327 warn!(
328 "Reached maximum dependency count ({}) in {:?}",
329 MAX_ITERATION_COUNT, abs_path
330 );
331 break;
332 }
333 state.dependencies.push(dependency);
334 }
335 }
336
337 state.guard.leave(abs_path);
338}
339
340fn default_package_data(
341 dependencies: Vec<Dependency>,
342 extra_data: Option<HashMap<String, JsonValue>>,
343) -> PackageData {
344 PackageData {
345 package_type: Some(RequirementsTxtParser::PACKAGE_TYPE),
346 primary_language: Some("Python".to_string()),
347 extra_data,
348 dependencies,
349 datasource_id: Some(DatasourceId::PipRequirements),
350 ..Default::default()
351 }
352}
353
354fn collect_logical_lines(content: &str) -> Vec<String> {
355 let mut lines = Vec::new();
356 let mut current = String::new();
357
358 for raw_line in content.lines().capped("requirements.txt raw lines") {
359 let line = raw_line.trim_end_matches('\r');
360 let trimmed = line.trim_end();
361 let is_continuation = trimmed.ends_with('\\');
362 let line_without = if is_continuation {
363 trimmed.trim_end_matches('\\')
364 } else {
365 line
366 };
367
368 if !line_without.trim().is_empty() {
369 if !current.is_empty() {
370 current.push(' ');
371 }
372 current.push_str(line_without.trim());
373 }
374
375 if !is_continuation && !current.is_empty() {
376 lines.push(current.trim().to_string());
377 current.clear();
378 }
379 }
380
381 if !current.is_empty() {
382 lines.push(current.trim().to_string());
383 }
384
385 lines
386}
387
388fn strip_inline_comment(line: &str) -> String {
389 let mut in_single = false;
390 let mut in_double = false;
391 for (idx, ch) in line.char_indices() {
392 match ch {
393 '\'' if !in_double => in_single = !in_single,
394 '"' if !in_single => in_double = !in_double,
395 '#' if !in_single && !in_double => {
396 let prefix = &line[..idx];
397 if prefix.trim_end().is_empty() || prefix.ends_with(char::is_whitespace) {
398 return prefix.trim_end().to_string();
399 }
400 }
401 _ => {}
402 }
403 }
404 line.to_string()
405}
406
407fn parse_option_value(line: &str, option: &str) -> Option<String> {
408 let stripped = line.strip_prefix(option)?;
409 let mut rest = stripped.trim();
410 if let Some(rest_stripped) = rest.strip_prefix('=') {
411 rest = rest_stripped.trim();
412 }
413 if rest.is_empty() {
414 None
415 } else {
416 Some(rest.to_string())
417 }
418}
419
420fn scope_from_filename(path: &Path) -> (String, bool) {
421 let filename = path
422 .file_name()
423 .and_then(|name| name.to_str())
424 .unwrap_or_default()
425 .to_ascii_lowercase();
426
427 if filename.contains("dev") {
428 return ("develop".to_string(), false);
429 }
430 if filename.contains("test") {
431 return ("test".to_string(), false);
432 }
433 if filename.contains("doc") {
434 return ("docs".to_string(), false);
435 }
436
437 ("install".to_string(), true)
438}
439
440fn build_dependency(line: &str, scope: &str, is_runtime: bool) -> Option<Dependency> {
441 let trimmed = line.trim();
442 if trimmed.is_empty() {
443 return None;
444 }
445
446 let mut is_editable = false;
447 let mut requirement = truncate_field(trimmed.to_string());
448 let mut extracted_requirement = truncate_field(trimmed.to_string());
449
450 if let Some(rest) = trimmed.strip_prefix("-e") {
451 is_editable = true;
452 requirement = truncate_field(rest.trim().to_string());
453 extracted_requirement = truncate_field(format!("--editable {}", requirement));
454 } else if let Some(rest) = trimmed.strip_prefix("--editable") {
455 is_editable = true;
456 requirement = truncate_field(rest.trim().to_string());
457 extracted_requirement = truncate_field(format!("--editable {}", requirement));
458 }
459
460 let (requirement, hash_options) = split_hash_options(&requirement);
461 let requirement = requirement.trim();
462 if requirement.is_empty() {
463 return None;
464 }
465
466 if looks_like_hash_only_requirement(requirement) {
467 return None;
468 }
469
470 let parsed = parse_requirement(requirement);
471
472 let pinned_version = parsed
473 .specifiers
474 .as_deref()
475 .and_then(extract_pinned_version);
476 let is_pinned = pinned_version.is_some();
477
478 let purl = parsed
479 .name
480 .as_ref()
481 .and_then(|name| create_pypi_purl(name, pinned_version.as_deref()));
482
483 let mut extra_data = HashMap::new();
484 extra_data.insert("is_editable".to_string(), JsonValue::Bool(is_editable));
485 extra_data.insert(
486 "link".to_string(),
487 parsed
488 .link
489 .clone()
490 .map(|l| JsonValue::String(truncate_field(l)))
491 .unwrap_or(JsonValue::Null),
492 );
493 extra_data.insert(
494 "hash_options".to_string(),
495 JsonValue::Array(
496 hash_options
497 .into_iter()
498 .map(|h| JsonValue::String(truncate_field(h)))
499 .collect(),
500 ),
501 );
502 extra_data.insert("is_constraint".to_string(), JsonValue::Bool(false));
503 extra_data.insert(
504 "is_archive".to_string(),
505 parsed
506 .is_archive
507 .map(JsonValue::Bool)
508 .unwrap_or(JsonValue::Null),
509 );
510 extra_data.insert("is_wheel".to_string(), JsonValue::Bool(parsed.is_wheel));
511 extra_data.insert(
512 "is_url".to_string(),
513 parsed
514 .is_url
515 .map(JsonValue::Bool)
516 .unwrap_or(JsonValue::Null),
517 );
518 extra_data.insert(
519 "is_vcs_url".to_string(),
520 parsed
521 .is_vcs_url
522 .map(JsonValue::Bool)
523 .unwrap_or(JsonValue::Null),
524 );
525 extra_data.insert(
526 "is_name_at_url".to_string(),
527 JsonValue::Bool(parsed.is_name_at_url),
528 );
529 extra_data.insert(
530 "is_local_path".to_string(),
531 parsed
532 .is_local_path
533 .map(|value| value || is_editable)
534 .map(JsonValue::Bool)
535 .unwrap_or(JsonValue::Null),
536 );
537
538 if let Some(marker) = parsed.marker {
539 extra_data.insert(
540 "markers".to_string(),
541 JsonValue::String(truncate_field(marker)),
542 );
543 }
544
545 Some(Dependency {
546 purl,
547 extracted_requirement: Some(truncate_field(extracted_requirement)),
548 scope: Some(scope.to_string()),
549 is_runtime: Some(is_runtime),
550 is_optional: Some(false),
551 is_pinned: Some(is_pinned),
552 is_direct: Some(true),
553 resolved_package: None,
554 extra_data: Some(extra_data),
555 })
556}
557
558fn looks_like_hash_only_requirement(requirement: &str) -> bool {
559 let trimmed = requirement.trim();
560 if !matches!(trimmed.len(), 32 | 40 | 64 | 96 | 128) {
561 return false;
562 }
563
564 if trimmed.contains(char::is_whitespace)
565 || trimmed.contains(['[', ']', '@', ';', '/', '\\'])
566 || trimmed.contains("==")
567 || trimmed.contains("://")
568 || trimmed.contains("git+")
569 {
570 return false;
571 }
572
573 trimmed.chars().all(|ch| ch.is_ascii_hexdigit())
574}
575
576fn split_hash_options(input: &str) -> (String, Vec<String>) {
577 let mut filtered = Vec::new();
578 let mut hashes = Vec::new();
579
580 for token in input.split_whitespace() {
581 if let Some(value) = token.strip_prefix("--hash=") {
582 if !value.is_empty() {
583 hashes.push(value.to_string());
584 }
585 } else {
586 filtered.push(token);
587 }
588 }
589
590 (filtered.join(" "), hashes)
591}
592
593struct ParsedRequirement {
594 name: Option<String>,
595 specifiers: Option<String>,
596 marker: Option<String>,
597 link: Option<String>,
598 is_url: Option<bool>,
599 is_vcs_url: Option<bool>,
600 is_local_path: Option<bool>,
601 is_name_at_url: bool,
602 is_archive: Option<bool>,
603 is_wheel: bool,
604}
605
606fn parse_requirement(input: &str) -> ParsedRequirement {
607 if let Some(parsed) = parse_pep508_requirement(input) {
608 if let Some(url) = parsed.url.clone() {
609 return parsed_with_link(parsed, &url);
610 }
611
612 if !is_link_like(input) {
613 let name = Some(normalize_pypi_name(&parsed.name));
614 return ParsedRequirement {
615 name,
616 specifiers: parsed.specifiers.map(truncate_field),
617 marker: parsed.marker.map(truncate_field),
618 link: None,
619 is_url: None,
620 is_vcs_url: None,
621 is_local_path: None,
622 is_name_at_url: false,
623 is_archive: None,
624 is_wheel: false,
625 };
626 }
627 }
628
629 if let Some((name, link)) = parse_link_with_name(input) {
630 let normalized_name = normalize_pypi_name(&name);
631 let link_info = parse_link_flags(&link);
632 return ParsedRequirement {
633 name: Some(normalized_name),
634 specifiers: None,
635 marker: None,
636 link: Some(truncate_field(link)),
637 is_url: Some(link_info.is_url),
638 is_vcs_url: Some(link_info.is_vcs_url),
639 is_local_path: Some(link_info.is_local_path),
640 is_name_at_url: link_info.is_name_at_url,
641 is_archive: link_info.is_archive,
642 is_wheel: link_info.is_wheel,
643 };
644 }
645
646 let link_info = parse_link_flags(input);
647 ParsedRequirement {
648 name: None,
649 specifiers: None,
650 marker: None,
651 link: Some(truncate_field(input.to_string())),
652 is_url: Some(link_info.is_url),
653 is_vcs_url: Some(link_info.is_vcs_url),
654 is_local_path: Some(link_info.is_local_path),
655 is_name_at_url: link_info.is_name_at_url,
656 is_archive: link_info.is_archive,
657 is_wheel: link_info.is_wheel,
658 }
659}
660
661fn parsed_with_link(parsed: Pep508Requirement, link: &str) -> ParsedRequirement {
662 let name = normalize_pypi_name(&parsed.name);
663 let link_info = parse_link_flags(link);
664 ParsedRequirement {
665 name: Some(name),
666 specifiers: parsed.specifiers.map(truncate_field),
667 marker: parsed.marker.map(truncate_field),
668 link: Some(truncate_field(link.to_string())),
669 is_url: Some(link_info.is_url),
670 is_vcs_url: Some(link_info.is_vcs_url),
671 is_local_path: Some(link_info.is_local_path),
672 is_name_at_url: parsed.is_name_at_url,
673 is_archive: link_info.is_archive,
674 is_wheel: link_info.is_wheel,
675 }
676}
677
678fn parse_link_with_name(input: &str) -> Option<(String, String)> {
679 if let Some(egg) = extract_egg_name(input) {
680 return Some((egg, input.to_string()));
681 }
682 None
683}
684
685fn extract_egg_name(input: &str) -> Option<String> {
686 let fragment = input.split('#').nth(1)?;
687 let egg_part = fragment.strip_prefix("egg=")?;
688 let name_part = egg_part.split('&').next()?.trim();
689 if name_part.is_empty() {
690 return None;
691 }
692 let (name, _extras, _) = parse_pep508_requirement(name_part)
693 .map(|parsed| (parsed.name, parsed.extras, parsed.specifiers))
694 .unwrap_or_else(|| (name_part.to_string(), Vec::new(), None));
695 Some(name)
696}
697
698struct LinkFlags {
699 is_url: bool,
700 is_vcs_url: bool,
701 is_local_path: bool,
702 is_name_at_url: bool,
703 is_archive: Option<bool>,
704 is_wheel: bool,
705}
706
707fn parse_link_flags(link: &str) -> LinkFlags {
708 let trimmed = link.trim();
709 let is_vcs_url = trimmed.starts_with("git+")
710 || trimmed.starts_with("hg+")
711 || trimmed.starts_with("svn+")
712 || trimmed.starts_with("bzr+");
713 let has_scheme = trimmed.contains("://") || trimmed.starts_with("file:");
714 let is_local_path = trimmed.starts_with("./")
715 || trimmed.starts_with("../")
716 || trimmed.starts_with('/')
717 || trimmed.starts_with('~')
718 || trimmed.starts_with("file:");
719
720 let is_wheel = trimmed.ends_with(".whl");
721 let is_archive = if is_wheel
722 || trimmed.ends_with(".zip")
723 || trimmed.ends_with(".tar.gz")
724 || trimmed.ends_with(".tgz")
725 || trimmed.ends_with(".tar.bz2")
726 || trimmed.ends_with(".tar")
727 {
728 Some(true)
729 } else if has_scheme || is_local_path {
730 Some(false)
731 } else {
732 None
733 };
734
735 LinkFlags {
736 is_url: has_scheme || is_vcs_url,
737 is_vcs_url,
738 is_local_path,
739 is_name_at_url: false,
740 is_archive,
741 is_wheel,
742 }
743}
744
745fn is_link_like(input: &str) -> bool {
746 let trimmed = input.trim();
747 trimmed.starts_with("git+")
748 || trimmed.starts_with("hg+")
749 || trimmed.starts_with("svn+")
750 || trimmed.starts_with("bzr+")
751 || trimmed.starts_with("file:")
752 || trimmed.contains("://")
753 || trimmed.starts_with("./")
754 || trimmed.starts_with("../")
755 || trimmed.starts_with('/')
756 || trimmed.starts_with('~')
757}
758
759fn extract_pinned_version(specifiers: &str) -> Option<String> {
760 let trimmed = specifiers.trim();
761 if trimmed.contains(',') {
762 return None;
763 }
764
765 let stripped = if let Some(version) = trimmed.strip_prefix("===") {
766 version
767 } else {
768 trimmed.strip_prefix("==")?
769 };
770
771 let version = stripped.trim();
772 if version.is_empty() {
773 None
774 } else {
775 Some(version.to_string())
776 }
777}
778
779fn create_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
780 PackageUrl::new(RequirementsTxtParser::PACKAGE_TYPE.as_str(), name)
781 .ok()
782 .map(|_| match version {
783 Some(version) => format!("pkg:pypi/{name}@{}", encode_pypi_purl_version(version)),
784 None => format!("pkg:pypi/{name}"),
785 })
786}
787
788fn encode_pypi_purl_version(version: &str) -> String {
789 version.replace('*', "%2A")
790}
791
792fn normalize_pypi_name(name: &str) -> String {
793 let lower = name.trim().to_ascii_lowercase();
794 let mut normalized = String::new();
795 let mut last_was_sep = false;
796 for ch in lower.chars() {
797 let is_sep = matches!(ch, '-' | '_' | '.');
798 if is_sep {
799 if !last_was_sep {
800 normalized.push('-');
801 last_was_sep = true;
802 }
803 } else {
804 normalized.push(ch);
805 last_was_sep = false;
806 }
807 }
808 normalized
809}