Skip to main content

arch_toolkit/aur/
official.rs

1//! Bounded caller-client official package metadata and mirror-health requests.
2//!
3//! The official package detail helper reuses the established Arch Packages JSON
4//! search endpoint and the public index package selector model. Mirror health
5//! checks accept existing `MirrorInfo` rows plus a caller-selected relative probe
6//! path. Neither helper creates a reqwest client, changes configuration, invokes
7//! a shell command, ranks mirrors, or applies a mirrorlist.
8
9use crate::error::{ArchToolkitError, Result};
10use crate::types::index::{MirrorInfo, OfficialPackage};
11use crate::types::package::{
12    MetadataFetchLimits, MirrorHealth, MirrorHealthLimits, MirrorHealthStatus,
13};
14
15/// Established Arch Packages JSON search endpoint used by the opt-in convenience helper.
16pub const ARCH_PACKAGE_SEARCH_URL: &str = "https://archlinux.org/packages/search/json/";
17/// Maximum source URL bytes retained in one mirror-health result.
18const MAX_MIRROR_URL_BYTES: usize = 4 * 1024;
19/// Maximum bytes retained in one mirror probe failure detail.
20const MAX_PROBE_DETAIL_BYTES: usize = 240;
21
22/// What: Fetch one official Arch package detail with caller-owned transport policy.
23///
24/// Inputs:
25/// - `client`: Caller-provided reqwest client controlling timeout, proxy, TLS,
26///   redirect, and user-agent behavior.
27/// - `package`: Existing public index model selecting a package name and,
28///   optionally, repository and architecture.
29/// - `limits`: Explicit response and candidate bounds for this request.
30///
31/// Output:
32/// - An enriched `OfficialPackage` when the endpoint has an exact matching row,
33///   or `None` when no exact package row is present.
34///
35/// Details:
36/// - Uses [`ARCH_PACKAGE_SEARCH_URL`], which is already the repository's
37///   official-index fallback endpoint. For deterministic tests, derivative
38///   distributions, or another trusted endpoint, use
39///   [`fetch_official_package_detail_from`].
40/// - It performs one bounded HTTP request and never invokes `pacman` or a shell.
41///
42/// # Errors
43///
44/// Returns an error for invalid selector/limits, transport/status/read failures,
45/// oversized responses, or malformed JSON response roots.
46pub async fn fetch_arch_package_detail(
47    client: &reqwest::Client,
48    package: &OfficialPackage,
49    limits: MetadataFetchLimits,
50) -> Result<Option<OfficialPackage>> {
51    fetch_official_package_detail_from(client, ARCH_PACKAGE_SEARCH_URL, package, limits).await
52}
53
54/// What: Fetch one official package detail from a caller-selected JSON endpoint.
55///
56/// Inputs:
57/// - `client`: Caller-provided reqwest client controlling all transport policy.
58/// - `endpoint`: Absolute HTTP(S) Arch Packages-compatible search endpoint.
59/// - `package`: Existing public index model selecting package name/repo/arch.
60/// - `limits`: Explicit response and candidate bounds for this request.
61///
62/// Output:
63/// - An exact `OfficialPackage` match enriched from one bounded response, or
64///   `None` when no matching row exists.
65///
66/// Details:
67/// - Adds `name`, and non-empty `repo` / `arch`, query parameters to the
68///   caller-selected endpoint.
69/// - Response parsing is restricted to the existing `OfficialPackage` fields;
70///   it does not introduce a parallel official-package model or pagination API.
71/// - The supplied client makes this fixture-friendly and keeps retry/timeouts
72///   under caller ownership.
73///
74/// # Errors
75///
76/// Returns an error for invalid input, endpoint, response, or JSON root.
77pub async fn fetch_official_package_detail_from(
78    client: &reqwest::Client,
79    endpoint: &str,
80    package: &OfficialPackage,
81    limits: MetadataFetchLimits,
82) -> Result<Option<OfficialPackage>> {
83    validate_metadata_request(package, limits)?;
84    let request_url = detail_request_url(endpoint, package)?;
85    let body = fetch_bounded_json(
86        client,
87        request_url,
88        limits.max_response_bytes,
89        "package detail",
90    )
91    .await?;
92    parse_official_package_detail(&body, package, limits.max_candidates)
93}
94
95/// What: Probe existing public mirror rows with a caller-selected relative path.
96///
97/// Inputs:
98/// - `client`: Caller-provided reqwest client controlling all transport policy.
99/// - `mirrors`: Existing public index mirror metadata in caller-selected order.
100/// - `probe_path`: Absolute-path component appended to each mirror base URL.
101/// - `limits`: Explicit maximum number of sequential probes.
102///
103/// Output:
104/// - One ordered `MirrorHealth` record per selected input row, with response
105///   status evidence or a bounded validation/transport detail.
106///
107/// Details:
108/// - Only the first `limits.max_mirrors` rows are checked, sequentially.
109/// - A 2xx final HTTP status is `Reachable`; all other statuses and transport
110///   errors are `Unreachable`; malformed source URLs are `Invalid`.
111/// - No fixed health endpoint is assumed: callers supply a safe relative probe
112///   path suitable for their repositories and mirrors.
113///
114/// # Errors
115///
116/// Returns an error only for an invalid global probe bound or probe path. Per
117/// mirror failures are returned as structured health evidence.
118pub async fn check_mirror_health(
119    client: &reqwest::Client,
120    mirrors: &[MirrorInfo],
121    probe_path: &str,
122    limits: MirrorHealthLimits,
123) -> Result<Vec<MirrorHealth>> {
124    validate_probe_request(probe_path, limits)?;
125    let mut health = Vec::with_capacity(mirrors.len().min(limits.max_mirrors));
126    for mirror in mirrors.iter().take(limits.max_mirrors) {
127        health.push(probe_one_mirror(client, mirror, probe_path).await);
128    }
129    Ok(health)
130}
131
132/// What: Validate a bounded official package-detail request before I/O.
133///
134/// Inputs:
135/// - `package`: Existing index package selector.
136/// - `limits`: Candidate response and parse bounds.
137///
138/// Output:
139/// - `Ok(())` when package name and all bounds are usable.
140///
141/// Details:
142/// - Rejecting zero bounds avoids silently converting a request into a success
143///   with no parsed data.
144fn validate_metadata_request(package: &OfficialPackage, limits: MetadataFetchLimits) -> Result<()> {
145    if package.name.trim().is_empty() {
146        return Err(ArchToolkitError::EmptyInput {
147            field: "official package name".to_string(),
148            message: "an official package detail request needs a package name".to_string(),
149        });
150    }
151    if limits.max_response_bytes == 0 || limits.max_candidates == 0 {
152        return Err(ArchToolkitError::InvalidInput(
153            "official package response and candidate limits must be greater than zero".to_string(),
154        ));
155    }
156    Ok(())
157}
158
159/// What: Build a validated official package-detail request URL.
160///
161/// Inputs:
162/// - `endpoint`: Caller-selected absolute HTTP(S) search endpoint.
163/// - `package`: Existing public selector model.
164///
165/// Output:
166/// - URL with encoded name/repository/architecture query parameters.
167///
168/// Details:
169/// - Query construction uses `reqwest::Url` rather than string concatenation so
170///   package selector values cannot change the endpoint path or authority.
171fn detail_request_url(endpoint: &str, package: &OfficialPackage) -> Result<reqwest::Url> {
172    let mut url = parse_http_url(endpoint, "official package endpoint")?;
173    {
174        let mut query = url.query_pairs_mut();
175        query.append_pair("name", &package.name);
176        if !package.repo.is_empty() {
177            query.append_pair("repo", &package.repo);
178        }
179        if !package.arch.is_empty() {
180            query.append_pair("arch", &package.arch);
181        }
182    }
183    Ok(url)
184}
185
186/// What: Fetch a successful JSON body without exceeding an explicit byte bound.
187///
188/// Inputs:
189/// - `client`: Caller-provided reqwest client.
190/// - `url`: Prevalidated HTTP(S) endpoint.
191/// - `maximum_bytes`: Inclusive response-size bound.
192/// - `resource_name`: Error-context label for the requested resource.
193///
194/// Output:
195/// - UTF-8 response text no larger than `maximum_bytes`.
196///
197/// Details:
198/// - Checks both `Content-Length` and streamed chunks, so absent or misleading
199///   response headers cannot bypass the resource bound.
200async fn fetch_bounded_json(
201    client: &reqwest::Client,
202    url: reqwest::Url,
203    maximum_bytes: usize,
204    resource_name: &str,
205) -> Result<String> {
206    let mut response = client.get(url).send().await.map_err(|error| {
207        ArchToolkitError::Parse(format!("{resource_name} request failed: {error}"))
208    })?;
209    let status = response.status();
210    if !status.is_success() {
211        return Err(ArchToolkitError::Parse(format!(
212            "{resource_name} returned status {status}"
213        )));
214    }
215
216    let maximum_length = u64::try_from(maximum_bytes).map_err(|_| {
217        ArchToolkitError::InvalidInput(format!("{resource_name} response bound is too large"))
218    })?;
219    if response
220        .content_length()
221        .is_some_and(|length| length > maximum_length)
222    {
223        return Err(response_too_large(resource_name, maximum_bytes));
224    }
225
226    let mut bytes = Vec::new();
227    while let Some(chunk) = response.chunk().await.map_err(|error| {
228        ArchToolkitError::Parse(format!("{resource_name} response read failed: {error}"))
229    })? {
230        if chunk.len() > maximum_bytes.saturating_sub(bytes.len()) {
231            return Err(response_too_large(resource_name, maximum_bytes));
232        }
233        bytes.extend_from_slice(&chunk);
234    }
235    String::from_utf8(bytes).map_err(|error| {
236        ArchToolkitError::Parse(format!(
237            "{resource_name} response was not valid UTF-8: {error}"
238        ))
239    })
240}
241
242/// What: Parse a bounded official package search response into an exact selector match.
243///
244/// Inputs:
245/// - `body`: Bounded UTF-8 JSON response text.
246/// - `selector`: Existing index model that identifies the desired package.
247/// - `maximum_candidates`: Maximum rows considered for an exact match.
248///
249/// Output:
250/// - Enriched matching `OfficialPackage`, or `None` when no exact row appears.
251///
252/// Details:
253/// - A malformed non-matching row is skipped. Parsing does not create a new
254///   official data model or infer metadata from a partial name match.
255fn parse_official_package_detail(
256    body: &str,
257    selector: &OfficialPackage,
258    maximum_candidates: usize,
259) -> Result<Option<OfficialPackage>> {
260    let document: serde_json::Value = serde_json::from_str(body)?;
261    let results = document
262        .get("results")
263        .and_then(serde_json::Value::as_array)
264        .ok_or_else(|| {
265            ArchToolkitError::Parse(
266                "official package detail response is missing a 'results' array".to_string(),
267            )
268        })?;
269
270    Ok(results
271        .iter()
272        .take(maximum_candidates)
273        .filter_map(parse_official_package)
274        .find(|candidate| exact_official_match(candidate, selector)))
275}
276
277/// What: Convert one API JSON row into the existing official package model.
278///
279/// Inputs:
280/// - `row`: Candidate JSON object from a bounded response.
281///
282/// Output:
283/// - Populated `OfficialPackage`, or `None` if its package name is unavailable.
284///
285/// Details:
286/// - Missing non-name fields become empty strings, matching existing index
287///   model conventions and allowing callers to distinguish absent enrichment.
288fn parse_official_package(row: &serde_json::Value) -> Option<OfficialPackage> {
289    let name = row.get("pkgname")?.as_str()?.to_string();
290    let version = row
291        .get("pkgver")
292        .and_then(serde_json::Value::as_str)
293        .map_or_else(String::new, |pkgver| {
294            row.get("pkgrel")
295                .and_then(serde_json::Value::as_str)
296                .filter(|pkgrel| !pkgrel.is_empty())
297                .map_or_else(|| pkgver.to_string(), |pkgrel| format!("{pkgver}-{pkgrel}"))
298        });
299    Some(OfficialPackage {
300        name,
301        repo: string_field(row, "repo"),
302        arch: string_field(row, "arch"),
303        version,
304        description: string_field(row, "pkgdesc"),
305    })
306}
307
308/// What: Read an optional JSON string field as an owned string.
309///
310/// Inputs:
311/// - `row`: Candidate JSON object.
312/// - `field`: JSON key to retrieve.
313///
314/// Output:
315/// - Field value or an empty string when absent/non-string.
316///
317/// Details:
318/// - Matches the existing `OfficialPackage` convention for optional metadata.
319fn string_field(row: &serde_json::Value, field: &str) -> String {
320    row.get(field)
321        .and_then(serde_json::Value::as_str)
322        .unwrap_or_default()
323        .to_string()
324}
325
326/// What: Verify an API package row exactly matches the caller's index selector.
327///
328/// Inputs:
329/// - `candidate`: Parsed API row.
330/// - `selector`: Caller-selected name and optional repo/architecture filters.
331///
332/// Output:
333/// - `true` only for an exact name and every non-empty selector field.
334///
335/// Details:
336/// - Empty repo or architecture selector fields intentionally mean "do not
337///   constrain this field", which supports enrichment of pacman `-Sl` rows.
338fn exact_official_match(candidate: &OfficialPackage, selector: &OfficialPackage) -> bool {
339    candidate.name == selector.name
340        && (selector.repo.is_empty() || candidate.repo == selector.repo)
341        && (selector.arch.is_empty() || candidate.arch == selector.arch)
342}
343
344/// What: Validate global mirror probe bounds and relative path semantics.
345///
346/// Inputs:
347/// - `probe_path`: Relative-to-mirror absolute path component.
348/// - `limits`: Maximum selected mirrors.
349///
350/// Output:
351/// - `Ok(())` when the request cannot change mirror authorities or traverse up.
352///
353/// Details:
354/// - Requiring a leading slash and rejecting `..`, query, and fragment syntax
355///   keeps caller-selected probes scoped beneath each source mirror base URL.
356fn validate_probe_request(probe_path: &str, limits: MirrorHealthLimits) -> Result<()> {
357    if limits.max_mirrors == 0 {
358        return Err(ArchToolkitError::InvalidInput(
359            "mirror health maximum probes must be greater than zero".to_string(),
360        ));
361    }
362    let has_parent = probe_path.split('/').any(|segment| segment == "..");
363    if !probe_path.starts_with('/')
364        || probe_path.contains('?')
365        || probe_path.contains('#')
366        || has_parent
367    {
368        return Err(ArchToolkitError::InvalidInput(
369            "mirror health probe path must be an absolute path without query, fragment, or '..'"
370                .to_string(),
371        ));
372    }
373    Ok(())
374}
375
376/// What: Probe one source mirror and return structured per-mirror evidence.
377///
378/// Inputs:
379/// - `client`: Caller-provided reqwest client.
380/// - `mirror`: Existing public mirror model to probe.
381/// - `probe_path`: Already-validated relative path to append.
382///
383/// Output:
384/// - A reachable, unreachable, or invalid `MirrorHealth` record.
385///
386/// Details:
387/// - The response body is not read, so the check is bounded by one request and
388///   the caller's client timeout; no configuration is written or command run.
389async fn probe_one_mirror(
390    client: &reqwest::Client,
391    mirror: &MirrorInfo,
392    probe_path: &str,
393) -> MirrorHealth {
394    let mirror_url = bounded_detail(&mirror.url, MAX_MIRROR_URL_BYTES);
395    let probe_url = match mirror_probe_url(&mirror.url, probe_path) {
396        Ok(url) => url,
397        Err(error) => return invalid_mirror_health(mirror_url, &error),
398    };
399
400    match client.get(probe_url).send().await {
401        Ok(response) if response.status().is_success() => MirrorHealth {
402            mirror_url,
403            status: MirrorHealthStatus::Reachable,
404            status_code: Some(response.status().as_u16()),
405            detail: None,
406        },
407        Ok(response) => MirrorHealth {
408            mirror_url,
409            status: MirrorHealthStatus::Unreachable,
410            status_code: Some(response.status().as_u16()),
411            detail: Some(format!("probe returned HTTP {}", response.status())),
412        },
413        Err(error) => MirrorHealth {
414            mirror_url,
415            status: MirrorHealthStatus::Unreachable,
416            status_code: None,
417            detail: Some(bounded_detail(&error.to_string(), MAX_PROBE_DETAIL_BYTES)),
418        },
419    }
420}
421
422/// What: Build a safe probe URL under one existing mirror base URL.
423///
424/// Inputs:
425/// - `mirror_url`: Source mirror base URL.
426/// - `probe_path`: Validated absolute path component to append.
427///
428/// Output:
429/// - HTTP(S) probe URL scoped under the mirror base path.
430///
431/// Details:
432/// - Clears source query/fragment metadata and ensures the base is directory
433///   shaped before URL joining, so caller probe paths cannot replace authority.
434fn mirror_probe_url(mirror_url: &str, probe_path: &str) -> Result<reqwest::Url> {
435    if mirror_url.len() > MAX_MIRROR_URL_BYTES {
436        return Err(ArchToolkitError::InputTooLong {
437            field: "mirror URL".to_string(),
438            max_length: MAX_MIRROR_URL_BYTES,
439            actual_length: mirror_url.len(),
440        });
441    }
442    let mut base = parse_http_url(mirror_url, "mirror URL")?;
443    base.set_query(None);
444    base.set_fragment(None);
445    if !base.path().ends_with('/') {
446        let directory_path = format!("{}/", base.path());
447        base.set_path(&directory_path);
448    }
449    base.join(probe_path.trim_start_matches('/'))
450        .map_err(|error| {
451            ArchToolkitError::InvalidInput(format!("invalid mirror health probe URL: {error}"))
452        })
453}
454
455/// What: Validate an absolute HTTP(S) URL used by a caller-client helper.
456///
457/// Inputs:
458/// - `input`: Candidate URL string.
459/// - `field`: Human-readable input label for error context.
460///
461/// Output:
462/// - Parsed HTTP(S) URL ready for a reqwest request.
463///
464/// Details:
465/// - Other schemes are rejected before any request is made.
466fn parse_http_url(input: &str, field: &str) -> Result<reqwest::Url> {
467    let parsed = reqwest::Url::parse(input)
468        .map_err(|error| ArchToolkitError::InvalidInput(format!("invalid {field}: {error}")))?;
469    if matches!(parsed.scheme(), "http" | "https") {
470        return Ok(parsed);
471    }
472    Err(ArchToolkitError::InvalidInput(format!(
473        "{field} must use http or https"
474    )))
475}
476
477/// What: Build a consistent oversized-response error.
478///
479/// Inputs:
480/// - `resource_name`: Error-context label.
481/// - `maximum_bytes`: Configured response size bound.
482///
483/// Output:
484/// - `InputTooLong` with a saturating actual-size sentinel.
485///
486/// Details:
487/// - Streamed responses can be rejected before their total size is known.
488fn response_too_large(resource_name: &str, maximum_bytes: usize) -> ArchToolkitError {
489    ArchToolkitError::InputTooLong {
490        field: format!("{resource_name} response"),
491        max_length: maximum_bytes,
492        actual_length: maximum_bytes.saturating_add(1),
493    }
494}
495
496/// What: Produce a structured invalid-mirror record from a validation error.
497///
498/// Inputs:
499/// - `mirror_url`: Bounded source URL retained as evidence.
500/// - `error`: Validation failure from URL construction.
501///
502/// Output:
503/// - `MirrorHealthStatus::Invalid` with no HTTP status code.
504///
505/// Details:
506/// - Per-mirror invalid data does not abort checks for the remaining bounded
507///   input rows.
508fn invalid_mirror_health(mirror_url: String, error: &ArchToolkitError) -> MirrorHealth {
509    MirrorHealth {
510        mirror_url,
511        status: MirrorHealthStatus::Invalid,
512        status_code: None,
513        detail: Some(bounded_detail(&error.to_string(), MAX_PROBE_DETAIL_BYTES)),
514    }
515}
516
517/// What: Limit a detail string to a fixed byte-compatible character count.
518///
519/// Inputs:
520/// - `value`: Arbitrary source URL or error detail.
521/// - `maximum_chars`: Maximum Unicode scalar values retained.
522///
523/// Output:
524/// - Original value when within the bound, otherwise a truncated value with an ellipsis.
525///
526/// Details:
527/// - This avoids preserving an unbounded remote error or source string in a
528///   structured health record.
529fn bounded_detail(value: &str, maximum_chars: usize) -> String {
530    let mut characters = value.chars();
531    let detail: String = characters.by_ref().take(maximum_chars).collect();
532    if characters.next().is_some() {
533        return format!("{detail}…");
534    }
535    detail
536}
537
538#[cfg(test)]
539mod tests {
540    use super::{
541        MetadataFetchLimits, MirrorHealthLimits, detail_request_url, validate_probe_request,
542    };
543    use crate::types::index::OfficialPackage;
544
545    #[test]
546    /// What: Encode official selector values in the caller endpoint query.
547    ///
548    /// Inputs:
549    /// - A selector with spaces and query-significant characters.
550    ///
551    /// Output:
552    /// - A URL retaining the endpoint authority and encoded query values.
553    ///
554    /// Details:
555    /// - Guards against string-concatenation endpoint injection.
556    fn detail_url_encodes_selector_values() {
557        let package = OfficialPackage {
558            name: "pkg+name".to_string(),
559            repo: "extra&bad".to_string(),
560            arch: "x86_64".to_string(),
561            version: String::new(),
562            description: String::new(),
563        };
564        let url =
565            detail_request_url("https://example.invalid/search", &package).expect("valid test URL");
566        assert_eq!(url.host_str(), Some("example.invalid"));
567        assert!(
568            url.query()
569                .is_some_and(|query| query.contains("pkg%2Bname"))
570        );
571        assert_eq!(MetadataFetchLimits::default().max_candidates, 16);
572    }
573
574    #[test]
575    /// What: Reject an authority-changing or unbounded mirror probe request.
576    ///
577    /// Inputs:
578    /// - A parent traversal path and zero mirror bound.
579    ///
580    /// Output:
581    /// - Both requests return validation errors before I/O.
582    ///
583    /// Details:
584    /// - Protects the bounded caller-selected relative probe contract.
585    fn rejects_invalid_probe_requests() {
586        assert!(
587            validate_probe_request("/../etc/passwd", MirrorHealthLimits { max_mirrors: 1 })
588                .is_err()
589        );
590        assert!(validate_probe_request("/core.db", MirrorHealthLimits { max_mirrors: 0 }).is_err());
591    }
592}