use std::collections::HashSet;
use crate::deps::parse::parse_dep_spec;
#[cfg(feature = "aur")]
use crate::error::Result;
use crate::types::dependency::SrcinfoData;
#[cfg(feature = "aur")]
use crate::aur::utils::percent_encode;
#[cfg(feature = "aur")]
const MAX_AUR_SRCINFO_RESPONSE_BYTES: usize = 10 * 1024 * 1024;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(super) struct SrcinfoPackage {
pub(super) name: String,
pub(super) depends: Vec<String>,
pub(super) makedepends: Vec<String>,
pub(super) checkdepends: Vec<String>,
pub(super) optdepends: Vec<String>,
pub(super) conflicts: Vec<String>,
pub(super) provides: Vec<String>,
pub(super) replaces: Vec<String>,
}
#[derive(Clone, Debug, Default)]
pub(super) struct GraphSrcinfoData {
pub(super) pkgbase: String,
pub(super) epoch: String,
pub(super) pkgver: String,
pub(super) pkgrel: String,
pub(super) packages: Vec<SrcinfoPackage>,
}
#[allow(clippy::case_sensitive_file_extension_comparisons)]
#[must_use]
pub fn parse_srcinfo_deps(srcinfo: &str) -> (Vec<String>, Vec<String>, Vec<String>, Vec<String>) {
let mut depends = Vec::new();
let mut makedepends = Vec::new();
let mut checkdepends = Vec::new();
let mut optdepends = Vec::new();
let mut seen_depends = HashSet::new();
let mut seen_makedepends = HashSet::new();
let mut seen_checkdepends = HashSet::new();
let mut seen_optdepends = HashSet::new();
for line in srcinfo.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim();
let value_lower = value.to_lowercase();
if value_lower.ends_with(".so")
|| value_lower.contains(".so.")
|| value_lower.contains(".so=")
{
continue;
}
let base_key = key
.find('_')
.map_or(key, |underscore_pos| &key[..underscore_pos]);
match base_key {
"depends" if seen_depends.insert(value.to_string()) => {
depends.push(value.to_string());
}
"makedepends" if seen_makedepends.insert(value.to_string()) => {
makedepends.push(value.to_string());
}
"checkdepends" if seen_checkdepends.insert(value.to_string()) => {
checkdepends.push(value.to_string());
}
"optdepends" if seen_optdepends.insert(value.to_string()) => {
optdepends.push(value.to_string());
}
_ => {}
}
}
}
(depends, makedepends, checkdepends, optdepends)
}
#[allow(clippy::case_sensitive_file_extension_comparisons)]
#[must_use]
pub fn parse_srcinfo_conflicts(srcinfo: &str) -> Vec<String> {
let mut conflicts = Vec::new();
let mut seen = HashSet::new();
for line in srcinfo.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim();
let base_key = key
.find('_')
.map_or(key, |underscore_pos| &key[..underscore_pos]);
if base_key == "conflicts" {
let value_lower = value.to_lowercase();
if value_lower.ends_with(".so")
|| value_lower.contains(".so.")
|| value_lower.contains(".so=")
{
continue;
}
let spec = parse_dep_spec(value);
if !spec.name.is_empty() && seen.insert(spec.name.clone()) {
conflicts.push(spec.name);
}
}
}
}
conflicts
}
fn srcinfo_base_key(key: &str) -> &str {
key.find('_').map_or(key, |position| &key[..position])
}
fn push_srcinfo_value(values: &mut Vec<String>, value: &str) {
if !values.iter().any(|existing| existing == value) {
values.push(value.to_string());
}
}
fn apply_package_field(package: &mut SrcinfoPackage, key: &str, value: &str) {
match key {
"depends" => push_srcinfo_value(&mut package.depends, value),
"makedepends" => push_srcinfo_value(&mut package.makedepends, value),
"checkdepends" => push_srcinfo_value(&mut package.checkdepends, value),
"optdepends" => push_srcinfo_value(&mut package.optdepends, value),
"conflicts" => push_srcinfo_value(&mut package.conflicts, value),
"provides" => push_srcinfo_value(&mut package.provides, value),
"replaces" => push_srcinfo_value(&mut package.replaces, value),
_ => {}
}
}
fn merge_package_base(package: &mut SrcinfoPackage, base: &SrcinfoPackage) {
for (target, shared) in [
(&mut package.depends, &base.depends),
(&mut package.makedepends, &base.makedepends),
(&mut package.checkdepends, &base.checkdepends),
(&mut package.optdepends, &base.optdepends),
(&mut package.conflicts, &base.conflicts),
(&mut package.provides, &base.provides),
(&mut package.replaces, &base.replaces),
] {
for value in shared {
push_srcinfo_value(target, value);
}
}
}
fn parse_srcinfo_packages(content: &str) -> Vec<SrcinfoPackage> {
let mut base = SrcinfoPackage::default();
let mut packages = Vec::new();
let mut current_package = None;
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((raw_key, raw_value)) = line.split_once('=') else {
continue;
};
let key = srcinfo_base_key(raw_key.trim());
let value = raw_value.trim();
if key == "pkgname" {
packages.push(SrcinfoPackage {
name: value.to_string(),
..SrcinfoPackage::default()
});
current_package = Some(packages.len() - 1);
continue;
}
if let Some(index) = current_package {
apply_package_field(&mut packages[index], key, value);
} else {
apply_package_field(&mut base, key, value);
}
}
for package in &mut packages {
merge_package_base(package, &base);
}
packages
}
pub(super) fn parse_srcinfo_graph(content: &str) -> GraphSrcinfoData {
let mut data = GraphSrcinfoData {
packages: parse_srcinfo_packages(content),
..GraphSrcinfoData::default()
};
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((raw_key, raw_value)) = line.split_once('=') else {
continue;
};
let key = srcinfo_base_key(raw_key.trim());
let value = raw_value.trim();
match key {
"pkgbase" if data.pkgbase.is_empty() => data.pkgbase = value.to_string(),
"epoch" if data.epoch.is_empty() => data.epoch = value.to_string(),
"pkgver" if data.pkgver.is_empty() => data.pkgver = value.to_string(),
"pkgrel" if data.pkgrel.is_empty() => data.pkgrel = value.to_string(),
_ => {}
}
}
data
}
#[must_use]
pub fn parse_srcinfo(content: &str) -> SrcinfoData {
let mut data = SrcinfoData::default();
let mut pkgname_found = false;
let (depends, makedepends, checkdepends, optdepends) = parse_srcinfo_deps(content);
data.depends = depends;
data.makedepends = makedepends;
data.checkdepends = checkdepends;
data.optdepends = optdepends;
data.conflicts = parse_srcinfo_conflicts(content);
let mut seen_provides = HashSet::new();
let mut seen_replaces = HashSet::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim();
let base_key = key
.find('_')
.map_or(key, |underscore_pos| &key[..underscore_pos]);
match base_key {
"pkgbase"
if data.pkgbase.is_empty() => {
data.pkgbase = value.to_string();
}
"pkgname"
if !pkgname_found => {
data.pkgname = value.to_string();
pkgname_found = true;
}
"pkgver"
if data.pkgver.is_empty() => {
data.pkgver = value.to_string();
}
"pkgrel"
if data.pkgrel.is_empty() => {
data.pkgrel = value.to_string();
}
"provides"
if seen_provides.insert(value.to_string()) => {
data.provides.push(value.to_string());
}
"replaces"
if seen_replaces.insert(value.to_string()) => {
data.replaces.push(value.to_string());
}
_ => {}
}
}
}
data
}
#[cfg(feature = "aur")]
pub async fn fetch_srcinfo(client: &reqwest::Client, name: &str) -> Result<String> {
let url = format!(
"https://aur.archlinux.org/cgit/aur.git/plain/.SRCINFO?h={}",
percent_encode(name)
);
fetch_srcinfo_from_url(client, name, &url).await
}
#[cfg(feature = "aur")]
async fn fetch_srcinfo_from_url(client: &reqwest::Client, name: &str, url: &str) -> Result<String> {
use crate::error::ArchToolkitError;
tracing::debug!(package = %name, "fetching AUR .SRCINFO");
let response = client
.get(url)
.send()
.await
.map_err(ArchToolkitError::Network)?;
let status = response.status();
if !status.is_success() {
return Err(ArchToolkitError::InvalidInput(format!(
"AUR .SRCINFO fetch failed for package '{name}' with status {status}"
)));
}
let resource_label = format!("AUR .SRCINFO for package '{name}'");
let text = crate::http::read_bounded_response_text(
response,
MAX_AUR_SRCINFO_RESPONSE_BYTES,
&resource_label,
|error| {
ArchToolkitError::Parse(format!(
"{resource_label} response body read failed: {error}"
))
},
)
.await?;
if text.trim().is_empty() {
return Err(ArchToolkitError::EmptyInput {
field: format!("AUR .SRCINFO response for package '{name}'"),
message: "response body was empty".to_string(),
});
}
if text.trim_start().starts_with("<html") || text.trim_start().starts_with("<!DOCTYPE") {
return Err(ArchToolkitError::Parse(format!(
"AUR .SRCINFO fetch for package '{name}' received an HTML error page"
)));
}
if !text.contains("pkgbase =") && !text.contains("pkgname =") {
return Err(ArchToolkitError::Parse(format!(
"AUR .SRCINFO response for package '{name}' is not valid .SRCINFO format"
)));
}
Ok(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "aur")]
use crate::error::ArchToolkitError;
#[cfg(feature = "aur")]
use wiremock::matchers::{method, path};
#[cfg(feature = "aur")]
use wiremock::{Mock, MockServer, ResponseTemplate};
#[test]
fn test_parse_srcinfo_deps() {
let srcinfo = r"
pkgbase = test-package
pkgname = test-package
pkgver = 1.0.0
pkgrel = 1
depends = foo
depends = bar>=1.2.3
makedepends = make
makedepends = gcc
checkdepends = check
optdepends = optional: optional-package
depends = libfoo.so=1-64
";
let (depends, makedepends, checkdepends, optdepends) = parse_srcinfo_deps(srcinfo);
assert_eq!(depends.len(), 2);
assert!(depends.contains(&"foo".to_string()));
assert!(depends.contains(&"bar>=1.2.3".to_string()));
assert_eq!(makedepends.len(), 2);
assert!(makedepends.contains(&"make".to_string()));
assert!(makedepends.contains(&"gcc".to_string()));
assert_eq!(checkdepends.len(), 1);
assert!(checkdepends.contains(&"check".to_string()));
assert_eq!(optdepends.len(), 1);
assert!(optdepends.contains(&"optional: optional-package".to_string()));
}
#[test]
fn test_parse_srcinfo_deps_deduplicates() {
let srcinfo = r"
depends = glibc
depends = gtk3
depends = glibc
depends = nss
";
let (depends, _, _, _) = parse_srcinfo_deps(srcinfo);
assert_eq!(depends.len(), 3, "Should deduplicate dependencies");
assert!(depends.contains(&"glibc".to_string()));
assert!(depends.contains(&"gtk3".to_string()));
assert!(depends.contains(&"nss".to_string()));
}
#[test]
fn test_parse_srcinfo_deps_arch_specific() {
let srcinfo = r"
depends = common-dep
depends_x86_64 = arch-specific-dep
depends_aarch64 = arm-dep
";
let (depends, _, _, _) = parse_srcinfo_deps(srcinfo);
assert!(depends.contains(&"common-dep".to_string()));
assert!(depends.contains(&"arch-specific-dep".to_string()));
assert!(depends.contains(&"arm-dep".to_string()));
}
#[test]
fn test_parse_srcinfo_conflicts() {
let srcinfo = r"
pkgbase = test-package
pkgname = test-package
pkgver = 1.0.0
pkgrel = 1
conflicts = conflicting-pkg1
conflicts = conflicting-pkg2>=2.0
conflicts = libfoo.so=1-64
";
let conflicts = parse_srcinfo_conflicts(srcinfo);
assert_eq!(conflicts.len(), 2);
assert!(conflicts.contains(&"conflicting-pkg1".to_string()));
assert!(conflicts.contains(&"conflicting-pkg2".to_string()));
}
#[test]
fn test_parse_srcinfo_conflicts_empty() {
let srcinfo = r"
pkgbase = test-package
pkgname = test-package
pkgver = 1.0.0
";
let conflicts = parse_srcinfo_conflicts(srcinfo);
assert!(conflicts.is_empty());
}
#[test]
fn test_parse_srcinfo_conflicts_deduplicates() {
let srcinfo = r"
conflicts = pkg1
conflicts = pkg2
conflicts = pkg1
conflicts = pkg3
";
let conflicts = parse_srcinfo_conflicts(srcinfo);
assert_eq!(conflicts.len(), 3, "Should deduplicate conflicts");
assert!(conflicts.contains(&"pkg1".to_string()));
assert!(conflicts.contains(&"pkg2".to_string()));
assert!(conflicts.contains(&"pkg3".to_string()));
}
#[test]
fn test_parse_srcinfo_full() {
let srcinfo = r"
pkgbase = test-package
pkgname = test-package
pkgver = 1.0.0
pkgrel = 1
depends = glibc
depends = python>=3.12
makedepends = make
checkdepends = check
optdepends = optional: optional-package
conflicts = conflicting-pkg
provides = provided-pkg
replaces = replaced-pkg
";
let data = parse_srcinfo(srcinfo);
assert_eq!(data.pkgbase, "test-package");
assert_eq!(data.pkgname, "test-package");
assert_eq!(data.pkgver, "1.0.0");
assert_eq!(data.pkgrel, "1");
assert_eq!(data.depends.len(), 2);
assert!(data.depends.contains(&"glibc".to_string()));
assert!(data.depends.contains(&"python>=3.12".to_string()));
assert_eq!(data.makedepends.len(), 1);
assert!(data.makedepends.contains(&"make".to_string()));
assert_eq!(data.checkdepends.len(), 1);
assert!(data.checkdepends.contains(&"check".to_string()));
assert_eq!(data.optdepends.len(), 1);
assert!(
data.optdepends
.contains(&"optional: optional-package".to_string())
);
assert_eq!(data.conflicts.len(), 1);
assert!(data.conflicts.contains(&"conflicting-pkg".to_string()));
assert_eq!(data.provides.len(), 1);
assert!(data.provides.contains(&"provided-pkg".to_string()));
assert_eq!(data.replaces.len(), 1);
assert!(data.replaces.contains(&"replaced-pkg".to_string()));
}
#[test]
fn test_parse_srcinfo_split_packages() {
let srcinfo = r"
pkgbase = split-package
depends = shared-base
pkgname = split-package-base
depends = base-only
pkgname = split-package-gui
depends = gui-only
provides = virtual-gui=1
pkgver = 1.0.0
pkgrel = 1
";
let data = parse_srcinfo(srcinfo);
assert_eq!(data.pkgname, "split-package-base");
assert_eq!(data.pkgbase, "split-package");
let graph_data = parse_srcinfo_graph(srcinfo);
assert_eq!(graph_data.packages.len(), 2);
let base = graph_data
.packages
.iter()
.find(|package| package.name == "split-package-base");
let gui = graph_data
.packages
.iter()
.find(|package| package.name == "split-package-gui");
assert!(base.is_some_and(|package| {
package.depends == vec!["base-only".to_string(), "shared-base".to_string()]
}));
assert!(gui.is_some_and(|package| {
package.depends == vec!["gui-only".to_string(), "shared-base".to_string()]
&& package.provides == vec!["virtual-gui=1".to_string()]
}));
}
#[test]
fn test_parse_srcinfo_comments_and_blank_lines() {
let srcinfo = r"
# This is a comment
pkgbase = test-package
pkgname = test-package
# Another comment
pkgver = 1.0.0
";
let data = parse_srcinfo(srcinfo);
assert_eq!(data.pkgbase, "test-package");
assert_eq!(data.pkgname, "test-package");
assert_eq!(data.pkgver, "1.0.0");
}
#[test]
fn test_parse_srcinfo_empty() {
let data = parse_srcinfo("");
assert_eq!(data.pkgbase, "");
assert_eq!(data.pkgname, "");
assert_eq!(data.pkgver, "");
assert_eq!(data.pkgrel, "");
assert!(data.depends.is_empty());
assert!(data.makedepends.is_empty());
assert!(data.checkdepends.is_empty());
assert!(data.optdepends.is_empty());
assert!(data.conflicts.is_empty());
assert!(data.provides.is_empty());
assert!(data.replaces.is_empty());
}
#[test]
fn test_parse_srcinfo_malformed() {
let srcinfo = r"
pkgbase test-package
invalid line
";
let data = parse_srcinfo(srcinfo);
assert_eq!(data.pkgbase, "");
}
#[cfg(feature = "aur")]
#[tokio::test]
async fn oversized_aur_srcinfo_response_is_rejected() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.SRCINFO"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(vec![
b'x';
MAX_AUR_SRCINFO_RESPONSE_BYTES
+ 1
]))
.mount(&server)
.await;
let error = fetch_srcinfo_from_url(
&reqwest::Client::new(),
"yay",
&format!("{}/.SRCINFO", server.uri()),
)
.await
.expect_err("oversized .SRCINFO response must fail");
let message = error.to_string();
assert!(matches!(
error,
ArchToolkitError::InputTooLong {
max_length: MAX_AUR_SRCINFO_RESPONSE_BYTES,
..
}
));
assert!(message.contains(".SRCINFO"));
assert!(message.contains("yay"));
}
#[cfg(feature = "aur")]
#[tokio::test]
async fn aur_srcinfo_status_empty_and_malformed_errors_are_contextual() {
for (path_value, template) in [
("/status", ResponseTemplate::new(404)),
("/empty", ResponseTemplate::new(200)),
(
"/malformed",
ResponseTemplate::new(200).set_body_string("not metadata"),
),
(
"/html",
ResponseTemplate::new(200).set_body_string("<!DOCTYPE html>error"),
),
] {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(path_value))
.respond_with(template)
.mount(&server)
.await;
let error = fetch_srcinfo_from_url(
&reqwest::Client::new(),
"yay",
&format!("{}{path_value}", server.uri()),
)
.await
.expect_err("invalid .SRCINFO response must fail");
let message = error.to_string();
assert!(message.contains(".SRCINFO"));
assert!(message.contains("yay"));
}
}
#[cfg(feature = "aur")]
#[tokio::test]
async fn normal_aur_srcinfo_fixture_is_read() {
let server = MockServer::start().await;
let srcinfo = "pkgbase = yay\npkgname = yay\npkgver = 1\n";
Mock::given(method("GET"))
.and(path("/.SRCINFO"))
.respond_with(ResponseTemplate::new(200).set_body_string(srcinfo))
.mount(&server)
.await;
let body = fetch_srcinfo_from_url(
&reqwest::Client::new(),
"yay",
&format!("{}/.SRCINFO", server.uri()),
)
.await
.expect("normal .SRCINFO fixture");
assert_eq!(body, srcinfo);
}
}