use std::fmt::Write;
use crate::error::{ArchToolkitError, Result};
use crate::types::index::{MirrorDiscoveryLimits, MirrorInfo};
pub const ARCH_MIRROR_STATUS_URL: &str = "https://archlinux.org/mirrors/status/json/";
pub const MAX_MIRRORLIST_BYTES: usize = 512 * 1024;
const MAX_MIRROR_URL_BYTES: usize = 4 * 1024;
pub async fn fetch_arch_mirrors(
client: &reqwest::Client,
limits: MirrorDiscoveryLimits,
) -> Result<Vec<MirrorInfo>> {
fetch_mirrors_from(client, ARCH_MIRROR_STATUS_URL, limits).await
}
pub async fn fetch_mirrors_from(
client: &reqwest::Client,
status_url: &str,
limits: MirrorDiscoveryLimits,
) -> Result<Vec<MirrorInfo>> {
validate_discovery_limits(limits)?;
let body = fetch_bounded_mirror_status(client, status_url, limits.max_response_bytes).await?;
parse_mirrors_from_json(&body, limits.max_mirrors)
}
pub fn generate_mirrorlist(mirrors: &[MirrorInfo], maximum_mirrors: usize) -> Result<String> {
if maximum_mirrors == 0 {
return Err(ArchToolkitError::InvalidInput(
"maximum mirrorlist entries must be greater than zero".to_string(),
));
}
let mut urls = collect_active_https_urls(mirrors);
urls.truncate(maximum_mirrors);
let mut output = String::from(
"# Generated from caller-selected mirror status data.\n# Only active HTTPS mirrors are listed.\n",
);
for url in urls {
append_mirror_server_line(&mut output, &url)?;
}
Ok(output)
}
fn validate_discovery_limits(limits: MirrorDiscoveryLimits) -> Result<()> {
if limits.max_response_bytes == 0 || limits.max_mirrors == 0 {
return Err(ArchToolkitError::InvalidInput(
"mirror discovery response and row limits must be greater than zero".to_string(),
));
}
Ok(())
}
async fn fetch_bounded_mirror_status(
client: &reqwest::Client,
status_url: &str,
maximum_bytes: usize,
) -> Result<String> {
let parsed_url = parse_http_url(status_url)?;
let mut response = client.get(parsed_url).send().await.map_err(|error| {
ArchToolkitError::Parse(format!("mirror status request failed: {error}"))
})?;
let status = response.status();
if !status.is_success() {
return Err(ArchToolkitError::Parse(format!(
"mirror status returned status {status}"
)));
}
let maximum_length = u64::try_from(maximum_bytes).map_err(|_| {
ArchToolkitError::InvalidInput("mirror response bound is too large".to_string())
})?;
if response
.content_length()
.is_some_and(|length| length > maximum_length)
{
return Err(mirror_response_too_large(maximum_bytes));
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|error| {
ArchToolkitError::Parse(format!("mirror status response read failed: {error}"))
})? {
if chunk.len() > maximum_bytes.saturating_sub(bytes.len()) {
return Err(mirror_response_too_large(maximum_bytes));
}
bytes.extend_from_slice(&chunk);
}
String::from_utf8(bytes).map_err(|error| {
ArchToolkitError::Parse(format!(
"mirror status response was not valid UTF-8: {error}"
))
})
}
fn parse_http_url(url: &str) -> Result<reqwest::Url> {
let parsed = reqwest::Url::parse(url).map_err(|error| {
ArchToolkitError::InvalidInput(format!("invalid mirror status URL: {error}"))
})?;
if matches!(parsed.scheme(), "http" | "https") {
return Ok(parsed);
}
Err(ArchToolkitError::InvalidInput(
"mirror status URL must use http or https".to_string(),
))
}
fn mirror_response_too_large(maximum_bytes: usize) -> ArchToolkitError {
ArchToolkitError::InputTooLong {
field: "mirror status response".to_string(),
max_length: maximum_bytes,
actual_length: maximum_bytes.saturating_add(1),
}
}
fn parse_mirrors_from_json(body: &str, maximum_mirrors: usize) -> Result<Vec<MirrorInfo>> {
let document: serde_json::Value = serde_json::from_str(body)?;
let rows = document
.get("urls")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
ArchToolkitError::Parse("mirror status response is missing a 'urls' array".to_string())
})?;
let mut mirrors = rows
.iter()
.filter_map(parse_mirror_row)
.collect::<Vec<MirrorInfo>>();
mirrors.sort_by(|left, right| left.url.cmp(&right.url));
mirrors.dedup_by(|left, right| left.url == right.url);
mirrors.truncate(maximum_mirrors);
Ok(mirrors)
}
fn parse_mirror_row(row: &serde_json::Value) -> Option<MirrorInfo> {
let raw_url = row.get("url")?.as_str()?.trim();
if raw_url.is_empty() || raw_url.len() > MAX_MIRROR_URL_BYTES {
return None;
}
let parsed_url = reqwest::Url::parse(raw_url).ok()?;
if !matches!(parsed_url.scheme(), "http" | "https") {
return None;
}
let protocols = row
.get("protocols")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|protocol| !protocol.is_empty() && protocol.len() <= 32)
.take(16)
.map(ToString::to_string)
.collect();
Some(MirrorInfo {
url: raw_url.trim_end_matches('/').to_string(),
active: row
.get("active")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
protocols,
})
}
fn collect_active_https_urls(mirrors: &[MirrorInfo]) -> Vec<String> {
let mut urls = mirrors
.iter()
.filter(|mirror| mirror.active && supports_https(mirror))
.filter_map(valid_mirror_base_url)
.collect::<Vec<String>>();
urls.sort();
urls.dedup();
urls
}
fn supports_https(mirror: &MirrorInfo) -> bool {
mirror
.protocols
.iter()
.any(|protocol| protocol.eq_ignore_ascii_case("https"))
}
fn valid_mirror_base_url(mirror: &MirrorInfo) -> Option<String> {
let url = mirror.url.trim_end_matches('/');
if url.is_empty() || url.len() > MAX_MIRROR_URL_BYTES {
return None;
}
let parsed = reqwest::Url::parse(url).ok()?;
if parsed.scheme() != "https" {
return None;
}
Some(url.to_string())
}
fn append_mirror_server_line(output: &mut String, base_url: &str) -> Result<()> {
let line = format!("Server = {base_url}/$repo/os/$arch\n");
if line.len() > MAX_MIRRORLIST_BYTES.saturating_sub(output.len()) {
return Err(ArchToolkitError::InputTooLong {
field: "generated mirrorlist".to_string(),
max_length: MAX_MIRRORLIST_BYTES,
actual_length: MAX_MIRRORLIST_BYTES.saturating_add(1),
});
}
output
.write_str(&line)
.map_err(|_| ArchToolkitError::Parse("failed to build mirrorlist text".to_string()))
}
#[cfg(test)]
mod tests {
use super::{MirrorDiscoveryLimits, MirrorInfo, generate_mirrorlist, parse_mirrors_from_json};
#[test]
fn parses_bounded_mirror_fixture() {
let body = r#"{"urls":[
{"url":"https://z.example/","active":true,"protocols":["https"]},
{"url":"javascript:bad","active":true,"protocols":["https"]},
{"url":"https://a.example/","active":false,"protocols":["https","rsync"]},
{"url":"https://z.example/","active":true,"protocols":["https"]}
]}"#;
let mirrors = parse_mirrors_from_json(body, 10).expect("parse fixture");
assert_eq!(mirrors.len(), 2);
assert_eq!(mirrors[0].url, "https://a.example");
assert_eq!(mirrors[1].url, "https://z.example");
}
#[test]
fn generates_deterministic_https_mirrorlist() {
let mirrors = vec![
MirrorInfo {
url: "https://fast.example/".to_string(),
active: true,
protocols: vec!["https".to_string()],
},
MirrorInfo {
url: "http://insecure.example/".to_string(),
active: true,
protocols: vec!["http".to_string()],
},
MirrorInfo {
url: "https://inactive.example/".to_string(),
active: false,
protocols: vec!["https".to_string()],
},
];
let mirrorlist = generate_mirrorlist(&mirrors, 4).expect("generate mirrorlist");
assert!(mirrorlist.contains("Server = https://fast.example/$repo/os/$arch"));
assert!(!mirrorlist.contains("insecure.example"));
assert!(!mirrorlist.contains("inactive.example"));
}
#[test]
fn default_limits_are_bounded() {
let limits = MirrorDiscoveryLimits::default();
assert!(limits.max_response_bytes > 0);
assert!(limits.max_mirrors > 0);
}
}