use crate::error::{ArchToolkitError, Result};
use crate::types::index::{MirrorInfo, OfficialPackage};
use crate::types::package::{
MetadataFetchLimits, MirrorHealth, MirrorHealthLimits, MirrorHealthStatus,
};
pub const ARCH_PACKAGE_SEARCH_URL: &str = "https://archlinux.org/packages/search/json/";
const MAX_MIRROR_URL_BYTES: usize = 4 * 1024;
const MAX_PROBE_DETAIL_BYTES: usize = 240;
pub async fn fetch_arch_package_detail(
client: &reqwest::Client,
package: &OfficialPackage,
limits: MetadataFetchLimits,
) -> Result<Option<OfficialPackage>> {
fetch_official_package_detail_from(client, ARCH_PACKAGE_SEARCH_URL, package, limits).await
}
pub async fn fetch_official_package_detail_from(
client: &reqwest::Client,
endpoint: &str,
package: &OfficialPackage,
limits: MetadataFetchLimits,
) -> Result<Option<OfficialPackage>> {
validate_metadata_request(package, limits)?;
let request_url = detail_request_url(endpoint, package)?;
let body = fetch_bounded_json(
client,
request_url,
limits.max_response_bytes,
"package detail",
)
.await?;
parse_official_package_detail(&body, package, limits.max_candidates)
}
pub async fn check_mirror_health(
client: &reqwest::Client,
mirrors: &[MirrorInfo],
probe_path: &str,
limits: MirrorHealthLimits,
) -> Result<Vec<MirrorHealth>> {
validate_probe_request(probe_path, limits)?;
let mut health = Vec::with_capacity(mirrors.len().min(limits.max_mirrors));
for mirror in mirrors.iter().take(limits.max_mirrors) {
health.push(probe_one_mirror(client, mirror, probe_path).await);
}
Ok(health)
}
fn validate_metadata_request(package: &OfficialPackage, limits: MetadataFetchLimits) -> Result<()> {
if package.name.trim().is_empty() {
return Err(ArchToolkitError::EmptyInput {
field: "official package name".to_string(),
message: "an official package detail request needs a package name".to_string(),
});
}
if limits.max_response_bytes == 0 || limits.max_candidates == 0 {
return Err(ArchToolkitError::InvalidInput(
"official package response and candidate limits must be greater than zero".to_string(),
));
}
Ok(())
}
fn detail_request_url(endpoint: &str, package: &OfficialPackage) -> Result<reqwest::Url> {
let mut url = parse_http_url(endpoint, "official package endpoint")?;
{
let mut query = url.query_pairs_mut();
query.append_pair("name", &package.name);
if !package.repo.is_empty() {
query.append_pair("repo", &package.repo);
}
if !package.arch.is_empty() {
query.append_pair("arch", &package.arch);
}
}
Ok(url)
}
async fn fetch_bounded_json(
client: &reqwest::Client,
url: reqwest::Url,
maximum_bytes: usize,
resource_name: &str,
) -> Result<String> {
let mut response = client.get(url).send().await.map_err(|error| {
ArchToolkitError::Parse(format!("{resource_name} request failed: {error}"))
})?;
let status = response.status();
if !status.is_success() {
return Err(ArchToolkitError::Parse(format!(
"{resource_name} returned status {status}"
)));
}
let maximum_length = u64::try_from(maximum_bytes).map_err(|_| {
ArchToolkitError::InvalidInput(format!("{resource_name} response bound is too large"))
})?;
if response
.content_length()
.is_some_and(|length| length > maximum_length)
{
return Err(response_too_large(resource_name, maximum_bytes));
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|error| {
ArchToolkitError::Parse(format!("{resource_name} response read failed: {error}"))
})? {
if chunk.len() > maximum_bytes.saturating_sub(bytes.len()) {
return Err(response_too_large(resource_name, maximum_bytes));
}
bytes.extend_from_slice(&chunk);
}
String::from_utf8(bytes).map_err(|error| {
ArchToolkitError::Parse(format!(
"{resource_name} response was not valid UTF-8: {error}"
))
})
}
fn parse_official_package_detail(
body: &str,
selector: &OfficialPackage,
maximum_candidates: usize,
) -> Result<Option<OfficialPackage>> {
let document: serde_json::Value = serde_json::from_str(body)?;
let results = document
.get("results")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
ArchToolkitError::Parse(
"official package detail response is missing a 'results' array".to_string(),
)
})?;
Ok(results
.iter()
.take(maximum_candidates)
.filter_map(parse_official_package)
.find(|candidate| exact_official_match(candidate, selector)))
}
fn parse_official_package(row: &serde_json::Value) -> Option<OfficialPackage> {
let name = row.get("pkgname")?.as_str()?.to_string();
let version = row
.get("pkgver")
.and_then(serde_json::Value::as_str)
.map_or_else(String::new, |pkgver| {
row.get("pkgrel")
.and_then(serde_json::Value::as_str)
.filter(|pkgrel| !pkgrel.is_empty())
.map_or_else(|| pkgver.to_string(), |pkgrel| format!("{pkgver}-{pkgrel}"))
});
Some(OfficialPackage {
name,
repo: string_field(row, "repo"),
arch: string_field(row, "arch"),
version,
description: string_field(row, "pkgdesc"),
})
}
fn string_field(row: &serde_json::Value, field: &str) -> String {
row.get(field)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string()
}
fn exact_official_match(candidate: &OfficialPackage, selector: &OfficialPackage) -> bool {
candidate.name == selector.name
&& (selector.repo.is_empty() || candidate.repo == selector.repo)
&& (selector.arch.is_empty() || candidate.arch == selector.arch)
}
fn validate_probe_request(probe_path: &str, limits: MirrorHealthLimits) -> Result<()> {
if limits.max_mirrors == 0 {
return Err(ArchToolkitError::InvalidInput(
"mirror health maximum probes must be greater than zero".to_string(),
));
}
let has_parent = probe_path.split('/').any(|segment| segment == "..");
if !probe_path.starts_with('/')
|| probe_path.contains('?')
|| probe_path.contains('#')
|| has_parent
{
return Err(ArchToolkitError::InvalidInput(
"mirror health probe path must be an absolute path without query, fragment, or '..'"
.to_string(),
));
}
Ok(())
}
async fn probe_one_mirror(
client: &reqwest::Client,
mirror: &MirrorInfo,
probe_path: &str,
) -> MirrorHealth {
let mirror_url = bounded_detail(&mirror.url, MAX_MIRROR_URL_BYTES);
let probe_url = match mirror_probe_url(&mirror.url, probe_path) {
Ok(url) => url,
Err(error) => return invalid_mirror_health(mirror_url, &error),
};
match client.get(probe_url).send().await {
Ok(response) if response.status().is_success() => MirrorHealth {
mirror_url,
status: MirrorHealthStatus::Reachable,
status_code: Some(response.status().as_u16()),
detail: None,
},
Ok(response) => MirrorHealth {
mirror_url,
status: MirrorHealthStatus::Unreachable,
status_code: Some(response.status().as_u16()),
detail: Some(format!("probe returned HTTP {}", response.status())),
},
Err(error) => MirrorHealth {
mirror_url,
status: MirrorHealthStatus::Unreachable,
status_code: None,
detail: Some(bounded_detail(&error.to_string(), MAX_PROBE_DETAIL_BYTES)),
},
}
}
fn mirror_probe_url(mirror_url: &str, probe_path: &str) -> Result<reqwest::Url> {
if mirror_url.len() > MAX_MIRROR_URL_BYTES {
return Err(ArchToolkitError::InputTooLong {
field: "mirror URL".to_string(),
max_length: MAX_MIRROR_URL_BYTES,
actual_length: mirror_url.len(),
});
}
let mut base = parse_http_url(mirror_url, "mirror URL")?;
base.set_query(None);
base.set_fragment(None);
if !base.path().ends_with('/') {
let directory_path = format!("{}/", base.path());
base.set_path(&directory_path);
}
base.join(probe_path.trim_start_matches('/'))
.map_err(|error| {
ArchToolkitError::InvalidInput(format!("invalid mirror health probe URL: {error}"))
})
}
fn parse_http_url(input: &str, field: &str) -> Result<reqwest::Url> {
let parsed = reqwest::Url::parse(input)
.map_err(|error| ArchToolkitError::InvalidInput(format!("invalid {field}: {error}")))?;
if matches!(parsed.scheme(), "http" | "https") {
return Ok(parsed);
}
Err(ArchToolkitError::InvalidInput(format!(
"{field} must use http or https"
)))
}
fn response_too_large(resource_name: &str, maximum_bytes: usize) -> ArchToolkitError {
ArchToolkitError::InputTooLong {
field: format!("{resource_name} response"),
max_length: maximum_bytes,
actual_length: maximum_bytes.saturating_add(1),
}
}
fn invalid_mirror_health(mirror_url: String, error: &ArchToolkitError) -> MirrorHealth {
MirrorHealth {
mirror_url,
status: MirrorHealthStatus::Invalid,
status_code: None,
detail: Some(bounded_detail(&error.to_string(), MAX_PROBE_DETAIL_BYTES)),
}
}
fn bounded_detail(value: &str, maximum_chars: usize) -> String {
let mut characters = value.chars();
let detail: String = characters.by_ref().take(maximum_chars).collect();
if characters.next().is_some() {
return format!("{detail}…");
}
detail
}
#[cfg(test)]
mod tests {
use super::{
MetadataFetchLimits, MirrorHealthLimits, detail_request_url, validate_probe_request,
};
use crate::types::index::OfficialPackage;
#[test]
fn detail_url_encodes_selector_values() {
let package = OfficialPackage {
name: "pkg+name".to_string(),
repo: "extra&bad".to_string(),
arch: "x86_64".to_string(),
version: String::new(),
description: String::new(),
};
let url =
detail_request_url("https://example.invalid/search", &package).expect("valid test URL");
assert_eq!(url.host_str(), Some("example.invalid"));
assert!(
url.query()
.is_some_and(|query| query.contains("pkg%2Bname"))
);
assert_eq!(MetadataFetchLimits::default().max_candidates, 16);
}
#[test]
fn rejects_invalid_probe_requests() {
assert!(
validate_probe_request("/../etc/passwd", MirrorHealthLimits { max_mirrors: 1 })
.is_err()
);
assert!(validate_probe_request("/core.db", MirrorHealthLimits { max_mirrors: 0 }).is_err());
}
}