1use std::collections::HashSet;
7
8use crate::deps::parse::parse_dep_spec;
9#[cfg(feature = "aur")]
10use crate::error::Result;
11use crate::types::dependency::SrcinfoData;
12
13#[cfg(feature = "aur")]
14use crate::aur::utils::percent_encode;
15
16#[cfg(feature = "aur")]
18const MAX_AUR_SRCINFO_RESPONSE_BYTES: usize = 10 * 1024 * 1024;
19
20#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub(super) struct SrcinfoPackage {
33 pub(super) name: String,
35 pub(super) depends: Vec<String>,
37 pub(super) makedepends: Vec<String>,
39 pub(super) checkdepends: Vec<String>,
41 pub(super) optdepends: Vec<String>,
43 pub(super) conflicts: Vec<String>,
45 pub(super) provides: Vec<String>,
47 pub(super) replaces: Vec<String>,
49}
50
51#[derive(Clone, Debug, Default)]
63pub(super) struct GraphSrcinfoData {
64 pub(super) pkgbase: String,
66 pub(super) epoch: String,
68 pub(super) pkgver: String,
70 pub(super) pkgrel: String,
72 pub(super) packages: Vec<SrcinfoPackage>,
74}
75
76#[allow(clippy::case_sensitive_file_extension_comparisons)]
91#[must_use]
92pub fn parse_srcinfo_deps(srcinfo: &str) -> (Vec<String>, Vec<String>, Vec<String>, Vec<String>) {
93 let mut depends = Vec::new();
94 let mut makedepends = Vec::new();
95 let mut checkdepends = Vec::new();
96 let mut optdepends = Vec::new();
97
98 let mut seen_depends = HashSet::new();
100 let mut seen_makedepends = HashSet::new();
101 let mut seen_checkdepends = HashSet::new();
102 let mut seen_optdepends = HashSet::new();
103
104 for line in srcinfo.lines() {
105 let line = line.trim();
106 if line.is_empty() || line.starts_with('#') {
107 continue;
108 }
109
110 if let Some((key, value)) = line.split_once('=') {
112 let key = key.trim();
113 let value = value.trim();
114
115 let value_lower = value.to_lowercase();
117 if value_lower.ends_with(".so")
118 || value_lower.contains(".so.")
119 || value_lower.contains(".so=")
120 {
121 continue;
122 }
123
124 let base_key = key
126 .find('_')
127 .map_or(key, |underscore_pos| &key[..underscore_pos]);
128
129 match base_key {
130 "depends" if seen_depends.insert(value.to_string()) => {
131 depends.push(value.to_string());
132 }
133 "makedepends" if seen_makedepends.insert(value.to_string()) => {
134 makedepends.push(value.to_string());
135 }
136 "checkdepends" if seen_checkdepends.insert(value.to_string()) => {
137 checkdepends.push(value.to_string());
138 }
139 "optdepends" if seen_optdepends.insert(value.to_string()) => {
140 optdepends.push(value.to_string());
141 }
142 _ => {}
143 }
144 }
145 }
146
147 (depends, makedepends, checkdepends, optdepends)
148}
149
150#[allow(clippy::case_sensitive_file_extension_comparisons)]
164#[must_use]
165pub fn parse_srcinfo_conflicts(srcinfo: &str) -> Vec<String> {
166 let mut conflicts = Vec::new();
167 let mut seen = HashSet::new();
168
169 for line in srcinfo.lines() {
170 let line = line.trim();
171 if line.is_empty() || line.starts_with('#') {
172 continue;
173 }
174
175 if let Some((key, value)) = line.split_once('=') {
177 let key = key.trim();
178 let value = value.trim();
179
180 let base_key = key
182 .find('_')
183 .map_or(key, |underscore_pos| &key[..underscore_pos]);
184
185 if base_key == "conflicts" {
186 let value_lower = value.to_lowercase();
188 if value_lower.ends_with(".so")
189 || value_lower.contains(".so.")
190 || value_lower.contains(".so=")
191 {
192 continue;
193 }
194 let spec = parse_dep_spec(value);
196 if !spec.name.is_empty() && seen.insert(spec.name.clone()) {
197 conflicts.push(spec.name);
198 }
199 }
200 }
201 }
202
203 conflicts
204}
205
206fn srcinfo_base_key(key: &str) -> &str {
217 key.find('_').map_or(key, |position| &key[..position])
218}
219
220fn push_srcinfo_value(values: &mut Vec<String>, value: &str) {
232 if !values.iter().any(|existing| existing == value) {
233 values.push(value.to_string());
234 }
235}
236
237fn apply_package_field(package: &mut SrcinfoPackage, key: &str, value: &str) {
251 match key {
252 "depends" => push_srcinfo_value(&mut package.depends, value),
253 "makedepends" => push_srcinfo_value(&mut package.makedepends, value),
254 "checkdepends" => push_srcinfo_value(&mut package.checkdepends, value),
255 "optdepends" => push_srcinfo_value(&mut package.optdepends, value),
256 "conflicts" => push_srcinfo_value(&mut package.conflicts, value),
257 "provides" => push_srcinfo_value(&mut package.provides, value),
258 "replaces" => push_srcinfo_value(&mut package.replaces, value),
259 _ => {}
260 }
261}
262
263fn merge_package_base(package: &mut SrcinfoPackage, base: &SrcinfoPackage) {
275 for (target, shared) in [
276 (&mut package.depends, &base.depends),
277 (&mut package.makedepends, &base.makedepends),
278 (&mut package.checkdepends, &base.checkdepends),
279 (&mut package.optdepends, &base.optdepends),
280 (&mut package.conflicts, &base.conflicts),
281 (&mut package.provides, &base.provides),
282 (&mut package.replaces, &base.replaces),
283 ] {
284 for value in shared {
285 push_srcinfo_value(target, value);
286 }
287 }
288}
289
290fn parse_srcinfo_packages(content: &str) -> Vec<SrcinfoPackage> {
302 let mut base = SrcinfoPackage::default();
303 let mut packages = Vec::new();
304 let mut current_package = None;
305
306 for raw_line in content.lines() {
307 let line = raw_line.trim();
308 if line.is_empty() || line.starts_with('#') {
309 continue;
310 }
311 let Some((raw_key, raw_value)) = line.split_once('=') else {
312 continue;
313 };
314 let key = srcinfo_base_key(raw_key.trim());
315 let value = raw_value.trim();
316 if key == "pkgname" {
317 packages.push(SrcinfoPackage {
318 name: value.to_string(),
319 ..SrcinfoPackage::default()
320 });
321 current_package = Some(packages.len() - 1);
322 continue;
323 }
324 if let Some(index) = current_package {
325 apply_package_field(&mut packages[index], key, value);
326 } else {
327 apply_package_field(&mut base, key, value);
328 }
329 }
330
331 for package in &mut packages {
332 merge_package_base(package, &base);
333 }
334 packages
335}
336
337pub(super) fn parse_srcinfo_graph(content: &str) -> GraphSrcinfoData {
349 let mut data = GraphSrcinfoData {
350 packages: parse_srcinfo_packages(content),
351 ..GraphSrcinfoData::default()
352 };
353 for raw_line in content.lines() {
354 let line = raw_line.trim();
355 if line.is_empty() || line.starts_with('#') {
356 continue;
357 }
358 let Some((raw_key, raw_value)) = line.split_once('=') else {
359 continue;
360 };
361 let key = srcinfo_base_key(raw_key.trim());
362 let value = raw_value.trim();
363 match key {
364 "pkgbase" if data.pkgbase.is_empty() => data.pkgbase = value.to_string(),
365 "epoch" if data.epoch.is_empty() => data.epoch = value.to_string(),
366 "pkgver" if data.pkgver.is_empty() => data.pkgver = value.to_string(),
367 "pkgrel" if data.pkgrel.is_empty() => data.pkgrel = value.to_string(),
368 _ => {}
369 }
370 }
371 data
372}
373
374#[must_use]
388pub fn parse_srcinfo(content: &str) -> SrcinfoData {
389 let mut data = SrcinfoData::default();
390 let mut pkgname_found = false;
391
392 let (depends, makedepends, checkdepends, optdepends) = parse_srcinfo_deps(content);
394 data.depends = depends;
395 data.makedepends = makedepends;
396 data.checkdepends = checkdepends;
397 data.optdepends = optdepends;
398 data.conflicts = parse_srcinfo_conflicts(content);
399
400 let mut seen_provides = HashSet::new();
402 let mut seen_replaces = HashSet::new();
403
404 for line in content.lines() {
405 let line = line.trim();
406 if line.is_empty() || line.starts_with('#') {
407 continue;
408 }
409
410 if let Some((key, value)) = line.split_once('=') {
411 let key = key.trim();
412 let value = value.trim();
413
414 let base_key = key
416 .find('_')
417 .map_or(key, |underscore_pos| &key[..underscore_pos]);
418
419 match base_key {
420 "pkgbase"
421 if data.pkgbase.is_empty() => {
422 data.pkgbase = value.to_string();
423 }
424 "pkgname"
425 if !pkgname_found => {
427 data.pkgname = value.to_string();
428 pkgname_found = true;
429 }
430 "pkgver"
431 if data.pkgver.is_empty() => {
432 data.pkgver = value.to_string();
433 }
434 "pkgrel"
435 if data.pkgrel.is_empty() => {
436 data.pkgrel = value.to_string();
437 }
438 "provides"
439 if seen_provides.insert(value.to_string()) => {
440 data.provides.push(value.to_string());
441 }
442 "replaces"
443 if seen_replaces.insert(value.to_string()) => {
444 data.replaces.push(value.to_string());
445 }
446 _ => {}
447 }
448 }
449 }
450
451 data
452}
453
454#[cfg(feature = "aur")]
475pub async fn fetch_srcinfo(client: &reqwest::Client, name: &str) -> Result<String> {
476 let url = format!(
477 "https://aur.archlinux.org/cgit/aur.git/plain/.SRCINFO?h={}",
478 percent_encode(name)
479 );
480 fetch_srcinfo_from_url(client, name, &url).await
481}
482
483#[cfg(feature = "aur")]
497async fn fetch_srcinfo_from_url(client: &reqwest::Client, name: &str, url: &str) -> Result<String> {
498 use crate::error::ArchToolkitError;
499
500 tracing::debug!(package = %name, "fetching AUR .SRCINFO");
501 let response = client
502 .get(url)
503 .send()
504 .await
505 .map_err(ArchToolkitError::Network)?;
506 let status = response.status();
507 if !status.is_success() {
508 return Err(ArchToolkitError::InvalidInput(format!(
509 "AUR .SRCINFO fetch failed for package '{name}' with status {status}"
510 )));
511 }
512
513 let resource_label = format!("AUR .SRCINFO for package '{name}'");
514 let text = crate::http::read_bounded_response_text(
515 response,
516 MAX_AUR_SRCINFO_RESPONSE_BYTES,
517 &resource_label,
518 |error| {
519 ArchToolkitError::Parse(format!(
520 "{resource_label} response body read failed: {error}"
521 ))
522 },
523 )
524 .await?;
525
526 if text.trim().is_empty() {
527 return Err(ArchToolkitError::EmptyInput {
528 field: format!("AUR .SRCINFO response for package '{name}'"),
529 message: "response body was empty".to_string(),
530 });
531 }
532 if text.trim_start().starts_with("<html") || text.trim_start().starts_with("<!DOCTYPE") {
533 return Err(ArchToolkitError::Parse(format!(
534 "AUR .SRCINFO fetch for package '{name}' received an HTML error page"
535 )));
536 }
537 if !text.contains("pkgbase =") && !text.contains("pkgname =") {
538 return Err(ArchToolkitError::Parse(format!(
539 "AUR .SRCINFO response for package '{name}' is not valid .SRCINFO format"
540 )));
541 }
542
543 Ok(text)
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 #[cfg(feature = "aur")]
550 use crate::error::ArchToolkitError;
551 #[cfg(feature = "aur")]
552 use wiremock::matchers::{method, path};
553 #[cfg(feature = "aur")]
554 use wiremock::{Mock, MockServer, ResponseTemplate};
555
556 #[test]
557 fn test_parse_srcinfo_deps() {
558 let srcinfo = r"
559pkgbase = test-package
560pkgname = test-package
561pkgver = 1.0.0
562pkgrel = 1
563depends = foo
564depends = bar>=1.2.3
565makedepends = make
566makedepends = gcc
567checkdepends = check
568optdepends = optional: optional-package
569depends = libfoo.so=1-64
570";
571
572 let (depends, makedepends, checkdepends, optdepends) = parse_srcinfo_deps(srcinfo);
573
574 assert_eq!(depends.len(), 2);
576 assert!(depends.contains(&"foo".to_string()));
577 assert!(depends.contains(&"bar>=1.2.3".to_string()));
578
579 assert_eq!(makedepends.len(), 2);
581 assert!(makedepends.contains(&"make".to_string()));
582 assert!(makedepends.contains(&"gcc".to_string()));
583
584 assert_eq!(checkdepends.len(), 1);
586 assert!(checkdepends.contains(&"check".to_string()));
587
588 assert_eq!(optdepends.len(), 1);
590 assert!(optdepends.contains(&"optional: optional-package".to_string()));
591 }
592
593 #[test]
594 fn test_parse_srcinfo_deps_deduplicates() {
595 let srcinfo = r"
596depends = glibc
597depends = gtk3
598depends = glibc
599depends = nss
600";
601
602 let (depends, _, _, _) = parse_srcinfo_deps(srcinfo);
603 assert_eq!(depends.len(), 3, "Should deduplicate dependencies");
604 assert!(depends.contains(&"glibc".to_string()));
605 assert!(depends.contains(&"gtk3".to_string()));
606 assert!(depends.contains(&"nss".to_string()));
607 }
608
609 #[test]
610 fn test_parse_srcinfo_deps_arch_specific() {
611 let srcinfo = r"
612depends = common-dep
613depends_x86_64 = arch-specific-dep
614depends_aarch64 = arm-dep
615";
616
617 let (depends, _, _, _) = parse_srcinfo_deps(srcinfo);
618 assert!(depends.contains(&"common-dep".to_string()));
620 assert!(depends.contains(&"arch-specific-dep".to_string()));
621 assert!(depends.contains(&"arm-dep".to_string()));
622 }
623
624 #[test]
625 fn test_parse_srcinfo_conflicts() {
626 let srcinfo = r"
627pkgbase = test-package
628pkgname = test-package
629pkgver = 1.0.0
630pkgrel = 1
631conflicts = conflicting-pkg1
632conflicts = conflicting-pkg2>=2.0
633conflicts = libfoo.so=1-64
634";
635
636 let conflicts = parse_srcinfo_conflicts(srcinfo);
637
638 assert_eq!(conflicts.len(), 2);
640 assert!(conflicts.contains(&"conflicting-pkg1".to_string()));
641 assert!(conflicts.contains(&"conflicting-pkg2".to_string()));
642 }
643
644 #[test]
645 fn test_parse_srcinfo_conflicts_empty() {
646 let srcinfo = r"
647pkgbase = test-package
648pkgname = test-package
649pkgver = 1.0.0
650";
651
652 let conflicts = parse_srcinfo_conflicts(srcinfo);
653 assert!(conflicts.is_empty());
654 }
655
656 #[test]
657 fn test_parse_srcinfo_conflicts_deduplicates() {
658 let srcinfo = r"
659conflicts = pkg1
660conflicts = pkg2
661conflicts = pkg1
662conflicts = pkg3
663";
664
665 let conflicts = parse_srcinfo_conflicts(srcinfo);
666 assert_eq!(conflicts.len(), 3, "Should deduplicate conflicts");
667 assert!(conflicts.contains(&"pkg1".to_string()));
668 assert!(conflicts.contains(&"pkg2".to_string()));
669 assert!(conflicts.contains(&"pkg3".to_string()));
670 }
671
672 #[test]
673 fn test_parse_srcinfo_full() {
674 let srcinfo = r"
675pkgbase = test-package
676pkgname = test-package
677pkgver = 1.0.0
678pkgrel = 1
679depends = glibc
680depends = python>=3.12
681makedepends = make
682checkdepends = check
683optdepends = optional: optional-package
684conflicts = conflicting-pkg
685provides = provided-pkg
686replaces = replaced-pkg
687";
688
689 let data = parse_srcinfo(srcinfo);
690
691 assert_eq!(data.pkgbase, "test-package");
692 assert_eq!(data.pkgname, "test-package");
693 assert_eq!(data.pkgver, "1.0.0");
694 assert_eq!(data.pkgrel, "1");
695 assert_eq!(data.depends.len(), 2);
696 assert!(data.depends.contains(&"glibc".to_string()));
697 assert!(data.depends.contains(&"python>=3.12".to_string()));
698 assert_eq!(data.makedepends.len(), 1);
699 assert!(data.makedepends.contains(&"make".to_string()));
700 assert_eq!(data.checkdepends.len(), 1);
701 assert!(data.checkdepends.contains(&"check".to_string()));
702 assert_eq!(data.optdepends.len(), 1);
703 assert!(
704 data.optdepends
705 .contains(&"optional: optional-package".to_string())
706 );
707 assert_eq!(data.conflicts.len(), 1);
708 assert!(data.conflicts.contains(&"conflicting-pkg".to_string()));
709 assert_eq!(data.provides.len(), 1);
710 assert!(data.provides.contains(&"provided-pkg".to_string()));
711 assert_eq!(data.replaces.len(), 1);
712 assert!(data.replaces.contains(&"replaced-pkg".to_string()));
713 }
714
715 #[test]
727 fn test_parse_srcinfo_split_packages() {
728 let srcinfo = r"
729pkgbase = split-package
730depends = shared-base
731pkgname = split-package-base
732depends = base-only
733pkgname = split-package-gui
734depends = gui-only
735provides = virtual-gui=1
736pkgver = 1.0.0
737pkgrel = 1
738";
739
740 let data = parse_srcinfo(srcinfo);
741 assert_eq!(data.pkgname, "split-package-base");
742 assert_eq!(data.pkgbase, "split-package");
743 let graph_data = parse_srcinfo_graph(srcinfo);
744 assert_eq!(graph_data.packages.len(), 2);
745 let base = graph_data
746 .packages
747 .iter()
748 .find(|package| package.name == "split-package-base");
749 let gui = graph_data
750 .packages
751 .iter()
752 .find(|package| package.name == "split-package-gui");
753 assert!(base.is_some_and(|package| {
754 package.depends == vec!["base-only".to_string(), "shared-base".to_string()]
755 }));
756 assert!(gui.is_some_and(|package| {
757 package.depends == vec!["gui-only".to_string(), "shared-base".to_string()]
758 && package.provides == vec!["virtual-gui=1".to_string()]
759 }));
760 }
761
762 #[test]
763 fn test_parse_srcinfo_comments_and_blank_lines() {
764 let srcinfo = r"
765# This is a comment
766pkgbase = test-package
767
768pkgname = test-package
769# Another comment
770pkgver = 1.0.0
771";
772
773 let data = parse_srcinfo(srcinfo);
774 assert_eq!(data.pkgbase, "test-package");
775 assert_eq!(data.pkgname, "test-package");
776 assert_eq!(data.pkgver, "1.0.0");
777 }
778
779 #[test]
780 fn test_parse_srcinfo_empty() {
781 let data = parse_srcinfo("");
782 assert_eq!(data.pkgbase, "");
783 assert_eq!(data.pkgname, "");
784 assert_eq!(data.pkgver, "");
785 assert_eq!(data.pkgrel, "");
786 assert!(data.depends.is_empty());
787 assert!(data.makedepends.is_empty());
788 assert!(data.checkdepends.is_empty());
789 assert!(data.optdepends.is_empty());
790 assert!(data.conflicts.is_empty());
791 assert!(data.provides.is_empty());
792 assert!(data.replaces.is_empty());
793 }
794
795 #[test]
796 fn test_parse_srcinfo_malformed() {
797 let srcinfo = r"
799pkgbase test-package
800invalid line
801";
802
803 let data = parse_srcinfo(srcinfo);
804 assert_eq!(data.pkgbase, "");
806 }
807
808 #[cfg(feature = "aur")]
809 #[tokio::test]
810 async fn oversized_aur_srcinfo_response_is_rejected() {
821 let server = MockServer::start().await;
822 Mock::given(method("GET"))
823 .and(path("/.SRCINFO"))
824 .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![
825 b'x';
826 MAX_AUR_SRCINFO_RESPONSE_BYTES
827 + 1
828 ]))
829 .mount(&server)
830 .await;
831
832 let error = fetch_srcinfo_from_url(
833 &reqwest::Client::new(),
834 "yay",
835 &format!("{}/.SRCINFO", server.uri()),
836 )
837 .await
838 .expect_err("oversized .SRCINFO response must fail");
839 let message = error.to_string();
840
841 assert!(matches!(
842 error,
843 ArchToolkitError::InputTooLong {
844 max_length: MAX_AUR_SRCINFO_RESPONSE_BYTES,
845 ..
846 }
847 ));
848 assert!(message.contains(".SRCINFO"));
849 assert!(message.contains("yay"));
850 }
851
852 #[cfg(feature = "aur")]
853 #[tokio::test]
854 async fn aur_srcinfo_status_empty_and_malformed_errors_are_contextual() {
865 for (path_value, template) in [
866 ("/status", ResponseTemplate::new(404)),
867 ("/empty", ResponseTemplate::new(200)),
868 (
869 "/malformed",
870 ResponseTemplate::new(200).set_body_string("not metadata"),
871 ),
872 (
873 "/html",
874 ResponseTemplate::new(200).set_body_string("<!DOCTYPE html>error"),
875 ),
876 ] {
877 let server = MockServer::start().await;
878 Mock::given(method("GET"))
879 .and(path(path_value))
880 .respond_with(template)
881 .mount(&server)
882 .await;
883
884 let error = fetch_srcinfo_from_url(
885 &reqwest::Client::new(),
886 "yay",
887 &format!("{}{path_value}", server.uri()),
888 )
889 .await
890 .expect_err("invalid .SRCINFO response must fail");
891 let message = error.to_string();
892
893 assert!(message.contains(".SRCINFO"));
894 assert!(message.contains("yay"));
895 }
896 }
897
898 #[cfg(feature = "aur")]
899 #[tokio::test]
900 async fn normal_aur_srcinfo_fixture_is_read() {
911 let server = MockServer::start().await;
912 let srcinfo = "pkgbase = yay\npkgname = yay\npkgver = 1\n";
913 Mock::given(method("GET"))
914 .and(path("/.SRCINFO"))
915 .respond_with(ResponseTemplate::new(200).set_body_string(srcinfo))
916 .mount(&server)
917 .await;
918
919 let body = fetch_srcinfo_from_url(
920 &reqwest::Client::new(),
921 "yay",
922 &format!("{}/.SRCINFO", server.uri()),
923 )
924 .await
925 .expect("normal .SRCINFO fixture");
926
927 assert_eq!(body, srcinfo);
928 }
929}