Skip to main content

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}