Skip to main content

ferroday_cage/provision/
fetch.rs

1//! The transport seam: how bytes are fetched from the archive.
2
3use std::fmt;
4use std::io::{self, Write};
5
6/// The inputs to one fetch.
7///
8/// [`Fetch::fetch`] takes its inputs as this value rather than as loose
9/// parameters, so that per-request context an implementation comes to need — a
10/// byte range, an expected digest, a conditional-request stamp, extra headers —
11/// can be added as accessors without changing the trait method's signature.
12///
13/// A `Fetch` implementation reads what it understands and ignores the rest.
14#[derive(Debug, Clone, Copy)]
15pub struct FetchRequest<'a> {
16    url: &'a str,
17    size: Option<u64>,
18}
19
20impl<'a> FetchRequest<'a> {
21    /// Returns a request for `url`.
22    pub fn new(url: &'a str) -> FetchRequest<'a> {
23        FetchRequest { url, size: None }
24    }
25
26    /// Records the size the body is expected to be, in bytes.
27    ///
28    /// The provisioner states it wherever a verified source declares it: the
29    /// signed release records the length of every index alongside its digest,
30    /// and the index records the length of every package. Both are known before
31    /// the request is made and are as trustworthy as the signature they hang
32    /// from.
33    pub fn sized(mut self, bytes: u64) -> FetchRequest<'a> {
34        self.size = Some(bytes);
35        self
36    }
37
38    /// The URL to fetch.
39    ///
40    /// The scheme is case-insensitive (RFC 3986 §3.1); the rest of the URL is
41    /// not. An implementation that routes on the scheme — handing `https://`
42    /// URLs to a TLS transport and delegating the rest, say — reaches it
43    /// through [`scheme`](Self::scheme) rather than by splitting this string,
44    /// so that the two layers cannot disagree about which URLs name TLS.
45    pub fn url(&self) -> &'a str {
46        self.url
47    }
48
49    /// The scheme the URL names, or `None` for a reference that names none.
50    ///
51    /// This is the accessor a transport routes on. The scheme is the text
52    /// before `://`, spelled `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )` by
53    /// RFC 3986 §3.1, so a path reference that happens to contain `://` does
54    /// not read as one. `None` is a real input rather than a broken one: a
55    /// caller may name a mirror by a bare path, and a transport that serves
56    /// only absolute URLs answers it with [`FetchError::url`].
57    ///
58    /// It is returned exactly as written. Schemes are case-insensitive, so a
59    /// comparison against it must be too — this crate's own client matches
60    /// `HTTP://` and `http://` alike, and a transport that matched literally
61    /// would refuse URLs the layer above it had already accepted.
62    ///
63    /// # Example
64    ///
65    /// Routing by scheme, compared the way the rule requires:
66    ///
67    /// ```
68    /// use ferroday_cage::provision::FetchRequest;
69    ///
70    /// let request = FetchRequest::new("HTTPS://deb.debian.org/debian/Release");
71    /// let scheme = request.scheme().unwrap_or_default();
72    ///
73    /// // `eq_ignore_ascii_case`, not `==`: the archive may be configured in
74    /// // any case, and the two spellings name one transport.
75    /// assert!(scheme.eq_ignore_ascii_case("https"));
76    /// assert_eq!(scheme, "HTTPS", "the scheme is returned as written");
77    /// ```
78    pub fn scheme(&self) -> Option<&'a str> {
79        scheme_of(self.url)
80    }
81
82    /// The size the body is expected to be, or `None` where nothing declares
83    /// one — a release file, which is the trust root and so has no signed
84    /// length of its own.
85    ///
86    /// An implementation that honours it refuses a body larger than the stated
87    /// size, and may refuse a declared framing larger than it before reading
88    /// anything: a mirror that answers a request for a few megabytes with
89    /// gigabytes is not serving the resource that was asked for, and the digest
90    /// check that would catch the substitution runs only after the bytes have
91    /// been spent. It is a ceiling, not a promise — an implementation is free
92    /// to ignore it, and the provisioner bounds its own sink regardless.
93    pub fn size(&self) -> Option<u64> {
94        self.size
95    }
96}
97
98/// The scheme `url` names, or `None` for a reference that names none.
99///
100/// The scheme is the text before `://`, and RFC 3986 §3.1 spells it
101/// `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )` — so a path reference that
102/// happens to contain `://` does not read as one. It is returned as written;
103/// schemes are case-insensitive, so a comparison against it must be too.
104///
105/// This is the crate's one answer to the question, reached from inside by the
106/// client's own routing and from outside through [`FetchRequest::scheme`].
107pub(super) fn scheme_of(url: &str) -> Option<&str> {
108    let (scheme, _) = url.split_once("://")?;
109    let mut bytes = scheme.bytes();
110    let first = bytes.next()?;
111    (first.is_ascii_alphabetic()
112        && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')))
113    .then_some(scheme)
114}
115
116/// Fetches the contents of a URL.
117///
118/// A userland provisioner fetches every index and package through a `Fetch`
119/// implementation, so a consumer can substitute a transport the built-in
120/// client does not provide — HTTPS, a proxy, an on-disk cache, a private
121/// mirror protocol. The default is [`HttpFetch`](super::HttpFetch), which
122/// speaks plain HTTP and `file://`. A substitute that routes by scheme takes
123/// it from [`FetchRequest::scheme`] and compares it case-insensitively.
124///
125/// A fetch writes the resource to `sink` as it arrives. A missing resource
126/// must be reported as [`FetchError::not_found`] rather than a generic error,
127/// so the provisioner can fall back — trying a different index compression or
128/// a non-by-hash path — instead of failing.
129///
130/// # Threads
131///
132/// A transport is `Send` because the provisioner owns it: a provisioner that
133/// could not cross a thread boundary would strand every value built from it on
134/// the thread that built it. `Sync` is deliberately not required — every
135/// operation on a provisioner takes `&mut self`, so a shared reference to one
136/// buys nothing, and demanding `Sync` would rule out a transport that keeps a
137/// cache or a connection count behind a [`Cell`].
138///
139/// Fetches always run on the thread that called into the provisioner; the
140/// requirement is on moving the transport, not on using it concurrently. A
141/// transport wrapping a genuinely thread-bound handle keeps that handle in a
142/// [`thread_local!`] or behind a channel to the thread that owns it.
143///
144/// [`Cell`]: std::cell::Cell
145///
146/// # Stability
147///
148/// Every method added to this trait in a later release will carry a default
149/// body, so an existing implementation keeps compiling. New per-request inputs
150/// arrive as accessors on [`FetchRequest`] rather than as parameters, for the
151/// same reason.
152///
153/// # Example
154///
155/// ```
156/// use std::io::Write;
157///
158/// use ferroday_cage::provision::{Fetch, FetchError, FetchRequest};
159///
160/// /// A transport that serves one canned response and nothing else.
161/// struct Canned(Vec<u8>);
162///
163/// impl Fetch for Canned {
164///     fn fetch(
165///         &mut self,
166///         request: &FetchRequest<'_>,
167///         sink: &mut dyn Write,
168///     ) -> Result<(), FetchError> {
169///         if request.url().ends_with("/Release") {
170///             sink.write_all(&self.0)
171///                 .map_err(|err| FetchError::io("writing the body", request.url(), err))
172///         } else {
173///             Err(FetchError::not_found(request.url()))
174///         }
175///     }
176/// }
177/// ```
178pub trait Fetch: Send {
179    /// Fetches the resource `request` names, writing its body to `sink`.
180    fn fetch(&mut self, request: &FetchRequest<'_>, sink: &mut dyn Write)
181    -> Result<(), FetchError>;
182
183    /// Fetches every job in `jobs`, answering one outcome per job, in order.
184    ///
185    /// This is where a transport says it can do more than one at a time. The
186    /// default body is [`fetch`](Self::fetch) per job in order, so an
187    /// implementation that overrides nothing behaves exactly as it did before
188    /// this method existed; [`HttpFetch`](super::HttpFetch) overrides it and
189    /// runs several at once.
190    ///
191    /// The jobs are independent. One that fails does not stop the others, and
192    /// the answer at index `i` is the outcome of the job at index `i` -- which
193    /// is what lets a caller retry only what did not arrive.
194    ///
195    /// A caller batches what it can afford to have in flight together, since
196    /// each job holds an open sink for as long as the batch runs. Nothing here
197    /// bounds that: how many is a question about the caller's file descriptors
198    /// and memory, not about the transport.
199    ///
200    /// # Overriding it
201    ///
202    /// An override answers every job in the slice; Rust offers no way to call
203    /// this default body from an implementation that replaces it. A transport
204    /// that speaks for only some of the jobs therefore delivers the rest
205    /// itself, which is what [`FetchJob::parts`] is for.
206    ///
207    /// The rest need not go one at a time. [`FetchRequest`] is [`Copy`] and
208    /// [`FetchJob::new`] is public, so the halves `parts` hands back rebuild a
209    /// job at a shorter borrow: a subset of the slice becomes a batch of its
210    /// own, and the transport behind this one still overlaps it. Keep each
211    /// job's index while doing so, since the answers are read by position.
212    ///
213    /// # Example
214    ///
215    /// A transport that serves one scheme itself and passes the rest to
216    /// [`HttpFetch`](super::HttpFetch) as a batch:
217    ///
218    /// ```
219    /// use std::io::Write;
220    ///
221    /// use ferroday_cage::provision::{Fetch, FetchError, FetchJob, FetchRequest, HttpFetch};
222    ///
223    /// /// Serves `canned://` itself; every other scheme is the bundled client's.
224    /// struct Routed(HttpFetch);
225    ///
226    /// fn is_canned(request: &FetchRequest<'_>) -> bool {
227    ///     // `eq_ignore_ascii_case`, because a scheme is case-insensitive.
228    ///     request
229    ///         .scheme()
230    ///         .is_some_and(|scheme| scheme.eq_ignore_ascii_case("canned"))
231    /// }
232    ///
233    /// impl Fetch for Routed {
234    ///     fn fetch(
235    ///         &mut self,
236    ///         request: &FetchRequest<'_>,
237    ///         sink: &mut dyn Write,
238    ///     ) -> Result<(), FetchError> {
239    ///         if is_canned(request) {
240    ///             sink.write_all(b"canned")
241    ///                 .map_err(|err| FetchError::io("writing the body", request.url(), err))
242    ///         } else {
243    ///             self.0.fetch(request, sink)
244    ///         }
245    ///     }
246    ///
247    ///     fn fetch_all(&mut self, jobs: &mut [FetchJob<'_>]) -> Vec<Result<(), FetchError>> {
248    ///         let mut outcomes: Vec<Option<Result<(), FetchError>>> =
249    ///             jobs.iter().map(|_| None).collect();
250    ///         let mut passed_on: Vec<(usize, FetchJob<'_>)> = Vec::new();
251    ///
252    ///         for (index, job) in jobs.iter_mut().enumerate() {
253    ///             let (request, sink) = job.parts();
254    ///             if is_canned(request) {
255    ///                 let written = sink.write_all(b"canned").map_err(|err| {
256    ///                     FetchError::io("writing the body", request.url(), err)
257    ///                 });
258    ///                 outcomes[index] = Some(written);
259    ///             } else {
260    ///                 // Rebuilt, so what is passed on is one batch rather than
261    ///                 // one request at a time.
262    ///                 passed_on.push((index, FetchJob::new(*request, sink)));
263    ///             }
264    ///         }
265    ///
266    ///         let (indices, mut batch): (Vec<usize>, Vec<FetchJob<'_>>) =
267    ///             passed_on.into_iter().unzip();
268    ///         for (index, outcome) in indices.into_iter().zip(self.0.fetch_all(&mut batch)) {
269    ///             outcomes[index] = Some(outcome);
270    ///         }
271    ///
272    ///         outcomes
273    ///             .into_iter()
274    ///             .map(|outcome| outcome.expect("every job was served or passed on"))
275    ///             .collect()
276    ///     }
277    /// }
278    ///
279    /// let (mut served, mut absent) = (Vec::new(), Vec::new());
280    /// let mut jobs = vec![
281    ///     FetchJob::new(FetchRequest::new("canned://body"), &mut served),
282    ///     FetchJob::new(FetchRequest::new("file:///nonexistent/body"), &mut absent),
283    /// ];
284    /// let outcomes = Routed(HttpFetch::new()).fetch_all(&mut jobs);
285    /// drop(jobs);
286    ///
287    /// assert!(outcomes[0].is_ok(), "the scheme this transport speaks");
288    /// assert!(outcomes[1].is_err(), "passed on, and answered at its own index");
289    /// assert_eq!(served, b"canned".as_slice());
290    /// ```
291    fn fetch_all(&mut self, jobs: &mut [FetchJob<'_>]) -> Vec<Result<(), FetchError>> {
292        jobs.iter_mut()
293            .map(|job| {
294                let (request, sink) = job.parts();
295                self.fetch(request, sink)
296            })
297            .collect()
298    }
299}
300
301/// One entry of a batch: what to fetch, and where its body goes.
302///
303/// Handed to [`Fetch::fetch_all`]. The sink is `Send` because a transport that
304/// fetches several at once writes each body from a thread of its own; it is
305/// borrowed rather than owned so that a caller keeps whatever it needs to
306/// finish the job -- a digest to check, a staging file to rename -- and gets it
307/// back when the batch ends.
308pub struct FetchJob<'a> {
309    request: FetchRequest<'a>,
310    sink: &'a mut (dyn Write + Send),
311}
312
313impl<'a> FetchJob<'a> {
314    /// A job fetching `request` into `sink`.
315    pub fn new(request: FetchRequest<'a>, sink: &'a mut (dyn Write + Send)) -> FetchJob<'a> {
316        FetchJob { request, sink }
317    }
318
319    /// What this job fetches.
320    pub fn request(&self) -> &FetchRequest<'a> {
321        &self.request
322    }
323
324    /// What to fetch and where it goes, borrowed apart.
325    ///
326    /// This is how a transport delivers a job it took from
327    /// [`Fetch::fetch_all`]: [`request`](Self::request) alone says what was
328    /// asked for, and this says where the body goes. One method rather than
329    /// two, because a transport needs both at once and the request is behind
330    /// the same borrow as the sink.
331    ///
332    /// It hands back the two halves that share one borrow, not an exhaustive
333    /// view of the job. Anything a job comes to carry later arrives as an
334    /// accessor of its own, or as one on [`FetchRequest`], rather than as a
335    /// third element here.
336    ///
337    /// The sink is reborrowed from `self`, so it cannot outlive the job it came
338    /// from, and the job cannot be handed to a second transport while it is
339    /// out.
340    pub fn parts(&mut self) -> (&FetchRequest<'a>, &mut (dyn Write + Send)) {
341        (&self.request, self.sink)
342    }
343}
344
345impl fmt::Debug for FetchJob<'_> {
346    /// Renders the request. The sink is the caller's writer, so it renders as
347    /// its presence rather than its contents.
348    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
349        f.debug_struct("FetchJob")
350            .field("request", &self.request)
351            .finish_non_exhaustive()
352    }
353}
354
355/// A failure fetching a URL.
356#[derive(Debug)]
357#[non_exhaustive]
358pub enum FetchError {
359    /// The URL could not be parsed, could not be formed from what was meant to
360    /// name it, or uses an unsupported scheme.
361    #[non_exhaustive]
362    Url {
363        /// The offending URL, or the locator that could not be made into one.
364        url: String,
365        /// What was wrong with it.
366        reason: String,
367    },
368    /// The resource does not exist (HTTP 404, or a missing `file://` path).
369    #[non_exhaustive]
370    NotFound {
371        /// The URL that was not found.
372        url: String,
373    },
374    /// The server returned an unsuccessful status other than 404.
375    #[non_exhaustive]
376    Status {
377        /// The URL fetched.
378        url: String,
379        /// The HTTP status code.
380        code: u16,
381    },
382    /// The transport failed: connection, timeout, a read/write error, or an
383    /// answer it refused to read — one over the size the resource may weigh,
384    /// or a redirect chain that never arrives at a body.
385    #[non_exhaustive]
386    Io {
387        /// What the transport was doing.
388        op: &'static str,
389        /// The URL fetched.
390        url: String,
391        /// The underlying error.
392        source: io::Error,
393    },
394}
395
396impl FetchError {
397    /// A URL that could not be parsed or formed, or whose scheme is
398    /// unsupported.
399    pub fn url(url: impl Into<String>, reason: impl Into<String>) -> FetchError {
400        FetchError::Url {
401            url: url.into(),
402            reason: reason.into(),
403        }
404    }
405
406    /// A resource the transport could not find.
407    ///
408    /// The provisioner treats this as a recoverable absence and falls back —
409    /// to a different index compression, or a non-by-hash path — so a
410    /// [`Fetch`] implementation must report a missing resource with this
411    /// rather than with [`status`](Self::status) or [`io`](Self::io).
412    pub fn not_found(url: impl Into<String>) -> FetchError {
413        FetchError::NotFound { url: url.into() }
414    }
415
416    /// An unsuccessful transport status other than "not found".
417    pub fn status(url: impl Into<String>, code: u16) -> FetchError {
418        FetchError::Status {
419            url: url.into(),
420            code,
421        }
422    }
423
424    /// A transport failure: connection, timeout, or a read/write error.
425    ///
426    /// `op` names what the transport was doing, for the message. Arguments run
427    /// operation, then locator, then cause, which is the order every
428    /// constructor in the crate takes them in.
429    pub fn io(op: &'static str, url: impl Into<String>, source: io::Error) -> FetchError {
430        FetchError::Io {
431            op,
432            url: url.into(),
433            source,
434        }
435    }
436
437    /// The same failure as a [`map_err`](Result::map_err) argument: names the
438    /// operation and the URL now, and takes the cause when it arrives.
439    ///
440    /// ```
441    /// use std::io::Write;
442    ///
443    /// use ferroday_cage::provision::FetchError;
444    ///
445    /// # fn send(url: &str, sink: &mut dyn Write, body: &[u8]) -> Result<(), FetchError> {
446    /// sink.write_all(body)
447    ///     .map_err(FetchError::at("writing the body", url))
448    /// # }
449    /// ```
450    pub fn at(op: &'static str, url: impl Into<String>) -> impl FnOnce(io::Error) -> FetchError {
451        let url = url.into();
452        move |source| FetchError::Io { op, url, source }
453    }
454
455    /// Whether this failure says that *this* source could not serve the
456    /// resource, so a caller walking a list of interchangeable mirrors advances
457    /// to the next one.
458    ///
459    /// A missing resource, a transport failure, and an unsuccessful status all
460    /// qualify. The status case is the one worth naming: a 503 during an outage
461    /// or a CDN's 403 for an absent object is exactly what a second mirror, or a
462    /// snapshot backstop, exists to answer, and a walk that stopped there would
463    /// fail without trying either. A walk keeps the last such failure and
464    /// reports it when no mirror answers, so the diagnostic survives.
465    ///
466    /// [`Url`](Self::Url) does not qualify. A malformed or unsupported URL is
467    /// the caller's configuration rather than the mirror's state, and a walk
468    /// that advanced past it would report the failure against the last mirror in
469    /// the list rather than the one that was spelled wrong. That is all it
470    /// names: an answer over the size the resource may weigh, or a redirect
471    /// chain that never arrives at one, is what a mirror did rather than how the
472    /// caller spelled it, and each is carried as a transport failure so that a
473    /// walk treats it as one — as is a redirect refused for leaving the host,
474    /// since where a mirror sends a request is that mirror's arrangement.
475    ///
476    /// This asks only about the transport. A digest or signature failure is not
477    /// a `FetchError` at all — it is the layer's own, raised over bytes that did
478    /// arrive — and the answer to one is never to ask somewhere else.
479    pub(crate) fn is_failover(&self) -> bool {
480        matches!(
481            self,
482            FetchError::NotFound { .. } | FetchError::Io { .. } | FetchError::Status { .. }
483        )
484    }
485}
486
487impl fmt::Display for FetchError {
488    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
489        match self {
490            FetchError::Url { url, reason } => write!(f, "cannot fetch {url}: {reason}"),
491            FetchError::NotFound { url } => write!(f, "{url} was not found"),
492            FetchError::Status { url, code } => {
493                write!(f, "fetching {url} failed with HTTP status {code}")
494            }
495            FetchError::Io { url, op, source } => {
496                write!(f, "fetching {url} failed while {op}: {source}")
497            }
498        }
499    }
500}
501
502impl std::error::Error for FetchError {
503    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
504        match self {
505            FetchError::Io { source, .. } => Some(source),
506            _ => None,
507        }
508    }
509}
510
511/// A failure a mirror walk may advance past.
512///
513/// Every archive layer walks a list of mirrors the same way: try one, and on a
514/// failure that says "ask somewhere else" try the next, keeping the last such
515/// failure to report when none is left. What differs between the layers is the
516/// error type the walk carries, and this is the one question
517/// [`walk_mirrors`] has to ask of it.
518///
519/// The predicate itself is [`FetchError::is_failover`] in every implementation.
520/// A layer that wraps a transport failure in its own error reaches through the
521/// wrapper; a failure that is not a transport one at all -- a digest or
522/// signature mismatch over bytes that did arrive -- never advances a walk,
523/// because the answer to one is never to ask somewhere else.
524pub(crate) trait Failover {
525    /// Whether this failure is one to try the next mirror for.
526    fn is_failover(&self) -> bool;
527}
528
529impl Failover for FetchError {
530    fn is_failover(&self) -> bool {
531        FetchError::is_failover(self)
532    }
533}
534
535/// Tries `attempt` against each entry of `over` in turn, taking the first that
536/// answers.
537///
538/// A failure [`Failover`] recognizes advances to the next entry and is kept;
539/// any other failure ends the walk where it happened. When the list runs out,
540/// the last kept failure is what is reported, so the diagnostic describes a
541/// mirror rather than the walk -- and `exhausted` supplies an error only for
542/// the case where nothing was tried at all, which an empty list is.
543///
544/// Generic over the entry rather than fixed to a mirror URL, because two of the
545/// walks here are over something else: the Debian index walk tries several
546/// index encodings within each mirror, which is the same rule one level down
547/// and nests as one call inside another. What the entry means is the caller's;
548/// what this owns is the rule for advancing past a failure and for which
549/// failure is reported at the end.
550pub(crate) fn walk_mirrors<Entry, T, E: Failover>(
551    over: &[Entry],
552    mut attempt: impl FnMut(&Entry) -> Result<T, E>,
553    exhausted: impl FnOnce() -> E,
554) -> Result<T, E> {
555    let mut unserved = None;
556    for entry in over {
557        match attempt(entry) {
558            Ok(answered) => return Ok(answered),
559            Err(err) if err.is_failover() => unserved = Some(err),
560            Err(err) => return Err(err),
561        }
562    }
563    Err(unserved.unwrap_or_else(exhausted))
564}
565
566/// Joins a mirror-relative path onto a mirror's URL.
567///
568/// A configured mirror may or may not end in a slash and a composed suffix may
569/// or may not begin with one, so the join owns both: `http://mirror/debian/`
570/// and `http://mirror/debian` address the same archive, and a doubled or
571/// missing separator addresses neither.
572pub(crate) fn mirror_url(mirror: &str, suffix: &str) -> String {
573    format!(
574        "{}/{}",
575        mirror.trim_end_matches('/'),
576        suffix.trim_start_matches('/'),
577    )
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    /// The accessor hands back the scheme as the URL spells it, rather than a
585    /// case-folded copy.
586    ///
587    /// Folding here would look helpful and cost the caller the ability to
588    /// report what was configured, and it would answer a question the caller
589    /// has to ask anyway: a router comparing case-insensitively is correct
590    /// whether or not the accessor folds, and one comparing literally is wrong
591    /// either way.
592    #[test]
593    fn the_scheme_is_returned_as_written() {
594        for (url, expected) in [
595            ("http://deb.debian.org/debian/x", "http"),
596            ("HTTPS://deb.debian.org/debian/x", "HTTPS"),
597            ("Http://deb.debian.org/debian/x", "Http"),
598            ("file:///var/cache/x", "file"),
599        ] {
600            let request = FetchRequest::new(url);
601            assert_eq!(request.scheme(), Some(expected), "{url}");
602            assert!(
603                request
604                    .scheme()
605                    .is_some_and(|scheme| scheme.eq_ignore_ascii_case(expected)),
606                "{url}: the documented comparison holds",
607            );
608        }
609    }
610
611    /// A reference naming no scheme is a real input, so it answers `None`
612    /// rather than inventing one. A path that merely contains `://` is such a
613    /// reference: the text before it is not a scheme.
614    #[test]
615    fn a_reference_naming_no_scheme_answers_none() {
616        for url in ["/var/cache/archives/x.deb", "deb.debian.org/debian/x", ""] {
617            assert_eq!(FetchRequest::new(url).scheme(), None, "{url}");
618        }
619    }
620
621    #[test]
622    fn a_mirror_walk_advances_past_what_this_mirror_could_not_serve() {
623        // The question every layer's mirror walk asks, pinned here rather than
624        // only through each walk that asks it: three failures say "ask
625        // somewhere else" and one says "you asked wrongly".
626        for failure in [
627            FetchError::not_found("http://mirror.invalid/x"),
628            FetchError::status("http://mirror.invalid/x", 503),
629            FetchError::status("http://mirror.invalid/x", 403),
630            FetchError::io(
631                "connecting",
632                "http://mirror.invalid/x",
633                io::Error::from(io::ErrorKind::ConnectionRefused),
634            ),
635        ] {
636            assert!(failure.is_failover(), "{failure}");
637        }
638
639        // A URL the transport will not accept is the caller's configuration
640        // rather than the mirror's state. A walk that advanced past it would
641        // report the failure against whichever mirror came last instead of
642        // against the one that was spelled wrong.
643        let malformed = FetchError::url("gopher://mirror.invalid/x", "unsupported scheme");
644        assert!(!malformed.is_failover(), "{malformed}");
645    }
646
647    #[test]
648    fn only_an_os_failure_carries_a_source() {
649        let source = FetchError::io(
650            "connecting",
651            "http://mirror.invalid/x",
652            io::Error::from(io::ErrorKind::ConnectionRefused),
653        );
654        assert!(std::error::Error::source(&source).is_some());
655        for bare in [
656            FetchError::not_found("http://mirror.invalid/x"),
657            FetchError::status("http://mirror.invalid/x", 503),
658            FetchError::url("gopher://mirror.invalid/x", "unsupported scheme"),
659        ] {
660            assert!(std::error::Error::source(&bare).is_none(), "{bare}");
661        }
662    }
663}