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