arch_toolkit/index/mirrors.rs
1//! Bounded caller-client mirror discovery and deterministic mirrorlist generation.
2
3use std::fmt::Write;
4
5use crate::error::{ArchToolkitError, Result};
6use crate::types::index::{MirrorDiscoveryLimits, MirrorInfo};
7
8/// Official Arch mirror-status endpoint used only by the opt-in convenience API.
9pub const ARCH_MIRROR_STATUS_URL: &str = "https://archlinux.org/mirrors/status/json/";
10/// Maximum bytes emitted by one generated mirrorlist.
11pub const MAX_MIRRORLIST_BYTES: usize = 512 * 1024;
12/// Maximum accepted mirror base URL length before storing or generating it.
13const MAX_MIRROR_URL_BYTES: usize = 4 * 1024;
14
15/// What: Fetch Arch's standard mirror-status endpoint with caller-owned transport policy.
16///
17/// Inputs:
18/// - `client`: Caller-provided reqwest client controlling timeout, proxy, TLS,
19/// redirect, and user-agent policy.
20/// - `limits`: Explicit response and result bounds for this request.
21///
22/// Output:
23/// - Valid mirror rows accepted from the standard Arch mirror-status schema.
24///
25/// Details:
26/// - Delegates to [`fetch_mirrors_from`] and does not create its own client or
27/// execute any system command.
28/// - Callers needing another source can supply it directly with
29/// [`fetch_mirrors_from`].
30///
31/// # Errors
32///
33/// Returns an error for invalid limits, failed requests, non-success statuses,
34/// oversized or invalid JSON bodies, and malformed root schema.
35pub async fn fetch_arch_mirrors(
36 client: &reqwest::Client,
37 limits: MirrorDiscoveryLimits,
38) -> Result<Vec<MirrorInfo>> {
39 fetch_mirrors_from(client, ARCH_MIRROR_STATUS_URL, limits).await
40}
41
42/// What: Discover portable mirror metadata from a caller-selected JSON endpoint.
43///
44/// Inputs:
45/// - `client`: Caller-provided reqwest client controlling transport policy.
46/// - `status_url`: Absolute HTTP(S) endpoint returning an Arch-compatible
47/// `{ "urls": [...] }` mirror-status document.
48/// - `limits`: Explicit response and accepted-row bounds for this request.
49///
50/// Output:
51/// - Deterministically URL-sorted valid [`MirrorInfo`] rows, capped at
52/// `limits.max_mirrors`.
53///
54/// Details:
55/// - The parser accepts only active/inactive boolean metadata, a base HTTP(S)
56/// URL, and a bounded string protocol list. It does not rank mirrors or make
57/// any claim about current latency/health.
58/// - The endpoint is caller-selected, enabling derivative distributions, test
59/// servers, and trusted application mirrors without shelling out to `curl`.
60///
61/// # Errors
62///
63/// Returns an error for invalid URLs/limits, request or response failures,
64/// oversized bodies, invalid UTF-8/JSON, or a missing `urls` array.
65pub async fn fetch_mirrors_from(
66 client: &reqwest::Client,
67 status_url: &str,
68 limits: MirrorDiscoveryLimits,
69) -> Result<Vec<MirrorInfo>> {
70 validate_discovery_limits(limits)?;
71 let body = fetch_bounded_mirror_status(client, status_url, limits.max_response_bytes).await?;
72 parse_mirrors_from_json(&body, limits.max_mirrors)
73}
74
75/// What: Generate deterministic pacman mirrorlist text from discovered metadata.
76///
77/// Inputs:
78/// - `mirrors`: Mirror rows from discovery or trusted caller-owned metadata.
79/// - `maximum_mirrors`: Explicit cap on generated server lines.
80///
81/// Output:
82/// - Pacman-compatible `Server = .../$repo/os/$arch` lines for active HTTPS
83/// mirrors, sorted and deduplicated.
84///
85/// Details:
86/// - Invalid, inactive, non-HTTPS, oversized, and duplicate base URLs are
87/// excluded. No file is written and no command is executed.
88/// - The generated output is independently bounded by
89/// [`MAX_MIRRORLIST_BYTES`].
90///
91/// # Errors
92///
93/// Returns `InvalidInput` for a zero line limit or `InputTooLong` when valid
94/// input would exceed the generated-output bound.
95pub fn generate_mirrorlist(mirrors: &[MirrorInfo], maximum_mirrors: usize) -> Result<String> {
96 if maximum_mirrors == 0 {
97 return Err(ArchToolkitError::InvalidInput(
98 "maximum mirrorlist entries must be greater than zero".to_string(),
99 ));
100 }
101 let mut urls = collect_active_https_urls(mirrors);
102 urls.truncate(maximum_mirrors);
103
104 let mut output = String::from(
105 "# Generated from caller-selected mirror status data.\n# Only active HTTPS mirrors are listed.\n",
106 );
107 for url in urls {
108 append_mirror_server_line(&mut output, &url)?;
109 }
110 Ok(output)
111}
112
113/// What: Validate explicit mirror discovery bounds before making a request.
114///
115/// Inputs:
116/// - `limits`: Candidate response and result limits.
117///
118/// Output:
119/// - `Ok(())` when both limits are non-zero.
120///
121/// Details:
122/// - Rejecting zero avoids silently treating a request as a successful empty
123/// response or generating an accidental unbounded default.
124fn validate_discovery_limits(limits: MirrorDiscoveryLimits) -> Result<()> {
125 if limits.max_response_bytes == 0 || limits.max_mirrors == 0 {
126 return Err(ArchToolkitError::InvalidInput(
127 "mirror discovery response and row limits must be greater than zero".to_string(),
128 ));
129 }
130 Ok(())
131}
132
133/// What: Fetch a successful mirror-status body without exceeding its byte bound.
134///
135/// Inputs:
136/// - `client`: Caller-provided reqwest client.
137/// - `status_url`: Absolute HTTP(S) mirror-status endpoint.
138/// - `maximum_bytes`: Inclusive response-size bound.
139///
140/// Output:
141/// - UTF-8 JSON text no larger than `maximum_bytes`.
142///
143/// Details:
144/// - Checks Content-Length and streamed chunks so absent or misleading headers
145/// cannot bypass the resource bound.
146///
147/// # Errors
148///
149/// Returns errors for invalid URLs, request/status/read failures, oversized
150/// bodies, or invalid UTF-8.
151async fn fetch_bounded_mirror_status(
152 client: &reqwest::Client,
153 status_url: &str,
154 maximum_bytes: usize,
155) -> Result<String> {
156 let parsed_url = parse_http_url(status_url)?;
157 let mut response = client.get(parsed_url).send().await.map_err(|error| {
158 ArchToolkitError::Parse(format!("mirror status request failed: {error}"))
159 })?;
160 let status = response.status();
161 if !status.is_success() {
162 return Err(ArchToolkitError::Parse(format!(
163 "mirror status returned status {status}"
164 )));
165 }
166
167 let maximum_length = u64::try_from(maximum_bytes).map_err(|_| {
168 ArchToolkitError::InvalidInput("mirror response bound is too large".to_string())
169 })?;
170 if response
171 .content_length()
172 .is_some_and(|length| length > maximum_length)
173 {
174 return Err(mirror_response_too_large(maximum_bytes));
175 }
176
177 let mut bytes = Vec::new();
178 while let Some(chunk) = response.chunk().await.map_err(|error| {
179 ArchToolkitError::Parse(format!("mirror status response read failed: {error}"))
180 })? {
181 if chunk.len() > maximum_bytes.saturating_sub(bytes.len()) {
182 return Err(mirror_response_too_large(maximum_bytes));
183 }
184 bytes.extend_from_slice(&chunk);
185 }
186 String::from_utf8(bytes).map_err(|error| {
187 ArchToolkitError::Parse(format!(
188 "mirror status response was not valid UTF-8: {error}"
189 ))
190 })
191}
192
193/// What: Validate a caller-selected mirror status URL.
194///
195/// Inputs:
196/// - `url`: Candidate mirror-status endpoint.
197///
198/// Output:
199/// - Parsed HTTP(S) URL ready for a caller-client request.
200///
201/// Details:
202/// - Rejects other schemes without making a network request.
203fn parse_http_url(url: &str) -> Result<reqwest::Url> {
204 let parsed = reqwest::Url::parse(url).map_err(|error| {
205 ArchToolkitError::InvalidInput(format!("invalid mirror status URL: {error}"))
206 })?;
207 if matches!(parsed.scheme(), "http" | "https") {
208 return Ok(parsed);
209 }
210 Err(ArchToolkitError::InvalidInput(
211 "mirror status URL must use http or https".to_string(),
212 ))
213}
214
215/// What: Build a consistent response-bound error for mirror discovery.
216///
217/// Inputs:
218/// - `maximum_bytes`: Configured maximum body size.
219///
220/// Output:
221/// - `InputTooLong` with the known mirror response bound.
222///
223/// Details:
224/// - The exact received size can be incomplete when a streamed body is rejected.
225fn mirror_response_too_large(maximum_bytes: usize) -> ArchToolkitError {
226 ArchToolkitError::InputTooLong {
227 field: "mirror status response".to_string(),
228 max_length: maximum_bytes,
229 actual_length: maximum_bytes.saturating_add(1),
230 }
231}
232
233/// What: Parse an Arch-compatible mirror-status JSON document into bounded rows.
234///
235/// Inputs:
236/// - `body`: Successful bounded UTF-8 JSON response body.
237/// - `maximum_mirrors`: Maximum valid rows to return.
238///
239/// Output:
240/// - URL-sorted valid mirror rows capped at `maximum_mirrors`.
241///
242/// Details:
243/// - Rows lacking a valid base HTTP(S) URL are skipped rather than causing one
244/// remote record to make all discovery unusable.
245fn parse_mirrors_from_json(body: &str, maximum_mirrors: usize) -> Result<Vec<MirrorInfo>> {
246 let document: serde_json::Value = serde_json::from_str(body)?;
247 let rows = document
248 .get("urls")
249 .and_then(serde_json::Value::as_array)
250 .ok_or_else(|| {
251 ArchToolkitError::Parse("mirror status response is missing a 'urls' array".to_string())
252 })?;
253
254 let mut mirrors = rows
255 .iter()
256 .filter_map(parse_mirror_row)
257 .collect::<Vec<MirrorInfo>>();
258 mirrors.sort_by(|left, right| left.url.cmp(&right.url));
259 mirrors.dedup_by(|left, right| left.url == right.url);
260 mirrors.truncate(maximum_mirrors);
261 Ok(mirrors)
262}
263
264/// What: Parse one candidate JSON row into safe bounded mirror metadata.
265///
266/// Inputs:
267/// - `row`: One value from the mirror-status `urls` array.
268///
269/// Output:
270/// - Valid normalized mirror metadata, or `None` for an unusable row.
271///
272/// Details:
273/// - The mirror URL must be absolute HTTP(S), base-URL length is bounded, and
274/// protocol names are bounded before copying into public output.
275fn parse_mirror_row(row: &serde_json::Value) -> Option<MirrorInfo> {
276 let raw_url = row.get("url")?.as_str()?.trim();
277 if raw_url.is_empty() || raw_url.len() > MAX_MIRROR_URL_BYTES {
278 return None;
279 }
280 let parsed_url = reqwest::Url::parse(raw_url).ok()?;
281 if !matches!(parsed_url.scheme(), "http" | "https") {
282 return None;
283 }
284 let protocols = row
285 .get("protocols")
286 .and_then(serde_json::Value::as_array)
287 .into_iter()
288 .flatten()
289 .filter_map(serde_json::Value::as_str)
290 .map(str::trim)
291 .filter(|protocol| !protocol.is_empty() && protocol.len() <= 32)
292 .take(16)
293 .map(ToString::to_string)
294 .collect();
295 Some(MirrorInfo {
296 url: raw_url.trim_end_matches('/').to_string(),
297 active: row
298 .get("active")
299 .and_then(serde_json::Value::as_bool)
300 .unwrap_or(false),
301 protocols,
302 })
303}
304
305/// What: Collect active HTTPS mirror base URLs in deterministic lexical order.
306///
307/// Inputs:
308/// - `mirrors`: Candidate mirror metadata.
309///
310/// Output:
311/// - Sorted, deduplicated, validated base URLs suitable for server-line output.
312///
313/// Details:
314/// - The protocol list must include HTTPS even when the URL itself is HTTPS, so
315/// callers retain the mirror-status source's advertised transport contract.
316fn collect_active_https_urls(mirrors: &[MirrorInfo]) -> Vec<String> {
317 let mut urls = mirrors
318 .iter()
319 .filter(|mirror| mirror.active && supports_https(mirror))
320 .filter_map(valid_mirror_base_url)
321 .collect::<Vec<String>>();
322 urls.sort();
323 urls.dedup();
324 urls
325}
326
327/// What: Confirm a mirror-status row advertises HTTPS support.
328///
329/// Inputs:
330/// - `mirror`: Candidate mirror metadata.
331///
332/// Output:
333/// - `true` when one advertised protocol equals `https` case-insensitively.
334///
335/// Details:
336/// - Avoids emitting HTTP-only mirrors even if a malformed row contains an
337/// HTTPS-looking URL string.
338fn supports_https(mirror: &MirrorInfo) -> bool {
339 mirror
340 .protocols
341 .iter()
342 .any(|protocol| protocol.eq_ignore_ascii_case("https"))
343}
344
345/// What: Validate a mirror base URL before generating a pacman server line.
346///
347/// Inputs:
348/// - `mirror`: Candidate active HTTPS mirror metadata.
349///
350/// Output:
351/// - Normalized base URL, or `None` when invalid/oversized/non-HTTPS.
352///
353/// Details:
354/// - Revalidating caller-built `MirrorInfo` makes generation safe independently
355/// of whether values came from this module's discovery parser.
356fn valid_mirror_base_url(mirror: &MirrorInfo) -> Option<String> {
357 let url = mirror.url.trim_end_matches('/');
358 if url.is_empty() || url.len() > MAX_MIRROR_URL_BYTES {
359 return None;
360 }
361 let parsed = reqwest::Url::parse(url).ok()?;
362 if parsed.scheme() != "https" {
363 return None;
364 }
365 Some(url.to_string())
366}
367
368/// What: Append one bounded pacman `Server` line to a generated mirrorlist.
369///
370/// Inputs:
371/// - `output`: Mirrorlist buffer to extend.
372/// - `base_url`: Prevalidated HTTPS mirror base URL.
373///
374/// Output:
375/// - `Ok(())` after a server line is appended, or a size error.
376///
377/// Details:
378/// - Uses pacman's standard variable placeholders without expanding them or
379/// writing to any system mirrorlist file.
380fn append_mirror_server_line(output: &mut String, base_url: &str) -> Result<()> {
381 let line = format!("Server = {base_url}/$repo/os/$arch\n");
382 if line.len() > MAX_MIRRORLIST_BYTES.saturating_sub(output.len()) {
383 return Err(ArchToolkitError::InputTooLong {
384 field: "generated mirrorlist".to_string(),
385 max_length: MAX_MIRRORLIST_BYTES,
386 actual_length: MAX_MIRRORLIST_BYTES.saturating_add(1),
387 });
388 }
389 output
390 .write_str(&line)
391 .map_err(|_| ArchToolkitError::Parse("failed to build mirrorlist text".to_string()))
392}
393
394#[cfg(test)]
395mod tests {
396 use super::{MirrorDiscoveryLimits, MirrorInfo, generate_mirrorlist, parse_mirrors_from_json};
397
398 #[test]
399 /// What: Verify mirror JSON parsing filters invalid rows and sorts valid URLs.
400 ///
401 /// Inputs:
402 /// - A fixture-shaped response with valid, invalid, and duplicate rows.
403 ///
404 /// Output:
405 /// - Bounded unique valid mirror metadata in lexical URL order.
406 ///
407 /// Details:
408 /// - Proves parser behavior without a live Arch endpoint.
409 fn parses_bounded_mirror_fixture() {
410 let body = r#"{"urls":[
411 {"url":"https://z.example/","active":true,"protocols":["https"]},
412 {"url":"javascript:bad","active":true,"protocols":["https"]},
413 {"url":"https://a.example/","active":false,"protocols":["https","rsync"]},
414 {"url":"https://z.example/","active":true,"protocols":["https"]}
415 ]}"#;
416 let mirrors = parse_mirrors_from_json(body, 10).expect("parse fixture");
417
418 assert_eq!(mirrors.len(), 2);
419 assert_eq!(mirrors[0].url, "https://a.example");
420 assert_eq!(mirrors[1].url, "https://z.example");
421 }
422
423 #[test]
424 /// What: Verify mirrorlist generation emits only bounded active HTTPS rows.
425 ///
426 /// Inputs:
427 /// - Active/inactive HTTP/HTTPS fixture metadata with a duplicate URL.
428 ///
429 /// Output:
430 /// - One deterministic pacman server line for the active HTTPS mirror.
431 ///
432 /// Details:
433 /// - No file write occurs; callers own applying the generated text.
434 fn generates_deterministic_https_mirrorlist() {
435 let mirrors = vec![
436 MirrorInfo {
437 url: "https://fast.example/".to_string(),
438 active: true,
439 protocols: vec!["https".to_string()],
440 },
441 MirrorInfo {
442 url: "http://insecure.example/".to_string(),
443 active: true,
444 protocols: vec!["http".to_string()],
445 },
446 MirrorInfo {
447 url: "https://inactive.example/".to_string(),
448 active: false,
449 protocols: vec!["https".to_string()],
450 },
451 ];
452 let mirrorlist = generate_mirrorlist(&mirrors, 4).expect("generate mirrorlist");
453
454 assert!(mirrorlist.contains("Server = https://fast.example/$repo/os/$arch"));
455 assert!(!mirrorlist.contains("insecure.example"));
456 assert!(!mirrorlist.contains("inactive.example"));
457 }
458
459 #[test]
460 /// What: Verify default discovery limits remain explicitly non-zero.
461 ///
462 /// Inputs:
463 /// - Default [`MirrorDiscoveryLimits`].
464 ///
465 /// Output:
466 /// - Positive response and row limits.
467 ///
468 /// Details:
469 /// - Guards the resource-bound invariant for convenience callers.
470 fn default_limits_are_bounded() {
471 let limits = MirrorDiscoveryLimits::default();
472 assert!(limits.max_response_bytes > 0);
473 assert!(limits.max_mirrors > 0);
474 }
475}