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}