Skip to main content

volto/tunnel/
status.rs

1//! Refusing a request, and the RFC 9209 vocabulary a refusal is written in.
2//!
3//! Everything about *answering* a request this server will not tunnel: the
4//! `Proxy-Status` error types, the two failures that end in one of them (the
5//! failure a connect attempt carries out of its address walk, and the failure a
6//! name resolution carries out of its budget), and the handful of helpers that
7//! put a status on the wire under the bound
8//! [`h3api::Stream::respond_within`] sets.
9//!
10//! The choice of which answer to send is not here. It is made where the fact
11//! is known -- in [`super::admit_target`], in each tunnel's connect step, in
12//! [`crate::conn`] -- and this module is what those choices are said in.
13
14use std::time::Duration;
15
16use tracing::debug;
17
18use crate::h3api::{self, FieldValue, Fields, RespondError, Status, Stream};
19
20/// An RFC 9209 proxy error type, for the `Proxy-Status` field of a refusal.
21///
22/// Only registered types (RFC 9209 §2.3.2) appear here. There is no registered
23/// type for "that port is closed by policy", so a denied port is reported as
24/// `http_request_denied` — the registry's general "denied per policy" type —
25/// rather than stretching `destination_ip_prohibited` to cover something that is
26/// not about the address at all.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ProxyError {
29    /// The target name could not be resolved.
30    DnsError,
31    /// The resolver did not answer inside the `[limits] connect_timeout` budget.
32    DnsTimeout,
33    /// Every address the target resolved to is prohibited by policy.
34    DestinationIpProhibited,
35    /// The target is a legal destination but could not be reached.
36    DestinationUnavailable,
37    /// The target actively refused the connection.
38    ConnectionRefused,
39    /// The connection attempt timed out.
40    ConnectionTimeout,
41    /// This host could not spare the resources to reach the target at all.
42    ///
43    /// RFC 9209 §2.3.30 describes the type as "the intermediary encountered an
44    /// internal error unrelated to the origin", which is exactly the case: the
45    /// descriptor, the buffer or the source port ran out here, and nothing at
46    /// all was learned about the destination. See `is_local_exhaustion`.
47    ProxyInternalError,
48    /// This connection already holds as many tunnels as it may.
49    ConnectionLimitReached,
50    /// The request is refused by policy.
51    HttpRequestDenied,
52}
53
54/// A target that could not be reached, and the address the attempt failed on.
55///
56/// The address is what RFC 9209 §2.1.2 calls the `next-hop`: "the intermediary or
57/// origin server selected (and used, if contacted) to obtain this response".
58/// Carried out of the connect helpers rather than recomputed, because with
59/// several resolved addresses only they know which one was tried last.
60pub(crate) struct Unreachable {
61    /// The last address attempted, or `None` if there was nothing to attempt.
62    pub(crate) next_hop: Option<std::net::SocketAddr>,
63    /// Why that attempt failed.
64    pub(crate) error: std::io::Error,
65}
66
67impl Unreachable {
68    /// The failure of a target that offered nothing to attempt.
69    ///
70    /// Both tunnel types walk a list of addresses and keep the last failure, so
71    /// both need an answer for a list that was empty. Neither can actually reach
72    /// it — [`super::admit_target`] never hands back an empty list — so this is the
73    /// empty-list arm written out rather than asserted, and there is no hop to
74    /// name in it.
75    pub(crate) fn no_addresses() -> Self {
76        Self {
77            next_hop: None,
78            error: std::io::Error::new(std::io::ErrorKind::InvalidInput, "no addresses to try"),
79        }
80    }
81}
82
83/// A name that could not be turned into addresses, and why not.
84///
85/// The two cases are reported differently, so the callers have to be able to
86/// tell them apart: a resolver that answered "no" is a 502, while one that did
87/// not answer at all inside the `[limits] connect_timeout` budget is a 504.
88#[derive(Debug)]
89pub(crate) enum ResolveFailure {
90    /// The resolver answered, unsuccessfully.
91    Failed(std::io::Error),
92    /// The budget expired before the resolver answered.
93    TimedOut(Duration),
94}
95
96impl ResolveFailure {
97    /// The RFC 9209 type this failure is reported as.
98    pub(crate) fn proxy_error(&self) -> ProxyError {
99        match self {
100            Self::Failed(_) => ProxyError::DnsError,
101            Self::TimedOut(_) => ProxyError::DnsTimeout,
102        }
103    }
104}
105
106impl std::fmt::Display for ResolveFailure {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        match self {
109            Self::Failed(error) => write!(f, "{error}"),
110            Self::TimedOut(budget) => write!(f, "no answer within {budget:?}"),
111        }
112    }
113}
114
115impl ProxyError {
116    /// The complete `Proxy-Status` field value.
117    ///
118    /// RFC 9209 §2 shape: an identifier for this proxy, then parameters. Kept as
119    /// static strings so building a refusal cannot itself fail.
120    fn field_value(self) -> &'static str {
121        match self {
122            Self::DnsError => "volto; error=dns_error",
123            Self::DnsTimeout => "volto; error=dns_timeout",
124            Self::DestinationIpProhibited => "volto; error=destination_ip_prohibited",
125            Self::DestinationUnavailable => "volto; error=destination_unavailable",
126            Self::ConnectionRefused => "volto; error=connection_refused",
127            Self::ConnectionTimeout => "volto; error=connection_timeout",
128            Self::ProxyInternalError => "volto; error=proxy_internal_error",
129            Self::ConnectionLimitReached => "volto; error=connection_limit_reached",
130            Self::HttpRequestDenied => "volto; error=http_request_denied",
131        }
132    }
133
134    /// This error as the field lines of a response.
135    pub fn fields(self) -> Fields {
136        let mut fields = Fields::new();
137        fields.append(PROXY_STATUS, FieldValue::from_static(self.field_value()));
138        fields
139    }
140
141    /// Whether this error may name the address it happened on.
142    ///
143    /// Only the three failures that are *about reaching a specific hop* qualify.
144    /// RFC 9209 §2.1.2 allows the parameter anywhere, so this list is a privacy
145    /// judgement rather than a syntactic one, and every exclusion is deliberate:
146    ///
147    /// * `dns_error` and `dns_timeout` — there is no hop to name. The lookup is
148    ///   what failed, so any address in the response would be invented.
149    /// * `destination_ip_prohibited` and `http_request_denied` — the request was
150    ///   refused *by this proxy*, and echoing the resolved address would turn
151    ///   every refusal into a lookup oracle: a client that cannot reach an
152    ///   internal resolver could read this server's view of a name straight out
153    ///   of the refusal. The policy exists to keep exactly that reachable-only-
154    ///   from-here information in, so the refusal must not carry it out.
155    /// * `connection_limit_reached`, and the 407 path, say nothing about a
156    ///   target: they are verdicts on the client.
157    /// * `proxy_internal_error` — nothing was contacted. The allocation this
158    ///   host could not make failed before the first packet, so naming the
159    ///   address would be reporting a hop that was never tried, and would hand
160    ///   a client this server's resolution of a name it never reached (D89).
161    fn discloses_next_hop(self) -> bool {
162        matches!(
163            self,
164            Self::ConnectionRefused | Self::ConnectionTimeout | Self::DestinationUnavailable
165        )
166    }
167
168    /// This error as the field lines of a response, naming the hop it happened on.
169    ///
170    /// The address is dropped unless `Self::discloses_next_hop` allows it, so a
171    /// caller cannot leak one by passing it to the wrong error type.
172    pub fn fields_with_next_hop(self, next_hop: Option<std::net::SocketAddr>) -> Fields {
173        let Some(address) = next_hop.filter(|_| self.discloses_next_hop()) else {
174            return self.fields();
175        };
176
177        // `<identifier>; error=<type>; next-hop="<address>"`. RFC 9209 §2.1.2
178        // accepts a String or a Token; an IPv6 address needs its brackets, which
179        // no Token may contain, so the String form is used for both families.
180        let value = format!(
181            "{}; next-hop={}",
182            self.field_value(),
183            sf_string(&address.to_string())
184        );
185
186        let mut fields = Fields::new();
187        fields.append(
188            PROXY_STATUS,
189            // A rendered socket address is printable ASCII, so this cannot fail;
190            // falling back to the parameterless value keeps that from being a
191            // panic if it ever somehow did, which is the property the whole
192            // refusal path is built on: refusing must never itself fail.
193            FieldValue::parse(value.as_bytes())
194                .unwrap_or_else(|| FieldValue::from_static(self.field_value())),
195        );
196        fields
197    }
198
199    /// The error type that best describes a failure to reach a target.
200    ///
201    /// The local failures are separated out first, because everything below
202    /// them is a statement *about the target* and they are not one: see
203    /// `is_local_exhaustion`.
204    pub fn from_connect_error(error: &std::io::Error) -> Self {
205        if is_local_exhaustion(error) {
206            return Self::ProxyInternalError;
207        }
208
209        match error.kind() {
210            std::io::ErrorKind::ConnectionRefused => Self::ConnectionRefused,
211            std::io::ErrorKind::TimedOut => Self::ConnectionTimeout,
212            _ => Self::DestinationUnavailable,
213        }
214    }
215
216    /// The HTTP status code RFC 9209 §2.3.2 recommends for this error type.
217    ///
218    /// The registry pairs each type with a status, and following it costs
219    /// nothing while telling an operator reading a log which failure it was: a
220    /// 504 is a target that never answered, a 503 is one that could not be
221    /// reached at all, and a 502 is one that actively refused.
222    ///
223    /// One deliberate departure: `destination_ip_prohibited` is recommended as
224    /// 502, and this server answers 403. Decision D11 made both policy refusals
225    /// — denied port and denied address — a 403, because they are refusals by
226    /// this proxy rather than reports about an upstream hop, and a client that
227    /// sees 502 would reasonably retry.
228    ///
229    /// One address refusal never arrives here at all (decision D49, a carve-out
230    /// from D11 rather than a revision of it): a target whose every resolved
231    /// address is the unspecified one is a name the upstream resolver filtered,
232    /// so it is answered with a 200 that closes on the spot — see
233    /// `accept_then_close` — and carries no `Proxy-Status` field, because
234    /// nothing about it is this proxy's verdict. Every refusal that is actually
235    /// sent still follows the table below.
236    pub fn recommended_status(self) -> Status {
237        match self {
238            Self::DnsError | Self::ConnectionRefused => Status::BAD_GATEWAY,
239            Self::DnsTimeout | Self::ConnectionTimeout => Status::GATEWAY_TIMEOUT,
240            Self::DestinationUnavailable | Self::ConnectionLimitReached => {
241                Status::SERVICE_UNAVAILABLE
242            }
243            Self::DestinationIpProhibited | Self::HttpRequestDenied => Status::FORBIDDEN,
244            Self::ProxyInternalError => Status::INTERNAL_SERVER_ERROR,
245        }
246    }
247}
248
249/// Whether a socket could not be opened because *this host* had nothing left.
250///
251/// The distinction the RFC 9209 registry draws, and the one D89 is about.
252/// `destination_unavailable` is defined as "the intermediary considers the next
253/// hop to be unavailable; e.g., recent attempts to communicate with it may have
254/// failed, or a health check may indicate that it is down" (§2.3.4) — a
255/// statement about the target. None of the errors below is one. They are raised
256/// by the allocation itself, before a single packet is addressed anywhere, so
257/// what they report is that this process ran out of something:
258///
259/// * `EMFILE` / `ENFILE` — no descriptor, per process or system-wide. This is
260///   the one an operator meets: `max_connections` x `max_targets_per_conn`
261///   sockets is what `crate::quic`'s startup check sizes `RLIMIT_NOFILE`
262///   against, and a host that is over it fails every `socket()` at once.
263/// * `ENOBUFS` / `ENOMEM` — no kernel buffer or memory for another socket.
264/// * `EADDRNOTAVAIL` — no source address left to bind, which on a connect to a
265///   remote address is the ephemeral port range exhausted. A target address
266///   that is itself unassignable cannot reach here: the unspecified address is
267///   answered by decision D49 before any dial, and every other refusal by the
268///   destination policy.
269///
270/// Reported as `proxy_internal_error` — "the intermediary encountered an
271/// internal error unrelated to the origin" (§2.3.30) — which is the registry's
272/// own name for this, rather than as a healthy destination being down.
273///
274/// Deliberately *not* here: `EACCES` and `EPERM`. A local firewall refusing the
275/// route to one target is a fact about reaching that target, so it keeps
276/// `destination_unavailable`; running out of descriptors is not.
277///
278/// A plain function over the OS error number, for the reason
279/// `udp::is_per_packet_error` gives for the same shape: `std` maps none of
280/// these onto an `ErrorKind` that is stable to match on, and both hosts this
281/// server builds for define every constant.
282fn is_local_exhaustion(error: &std::io::Error) -> bool {
283    matches!(
284        error.raw_os_error(),
285        Some(libc::EMFILE | libc::ENFILE | libc::ENOBUFS | libc::ENOMEM | libc::EADDRNOTAVAIL)
286    )
287}
288
289/// The field an RFC 9209 refusal explains itself in.
290const PROXY_STATUS: &str = "proxy-status";
291
292/// Renders `value` as a structured field String (RFC 8941 §3.3.3).
293///
294/// A String is DQUOTE-delimited, and inside it only DQUOTE and backslash are
295/// escaped. A rendered socket address contains neither, so this never actually
296/// escapes anything today — it exists so that the one field value this server
297/// builds at runtime is correct by construction rather than by argument about its
298/// inputs.
299fn sf_string(value: &str) -> String {
300    let mut quoted = String::with_capacity(value.len() + 2);
301    quoted.push('"');
302    for character in value.chars() {
303        if character == '"' || character == '\\' {
304            quoted.push('\\');
305        }
306        quoted.push(character);
307    }
308    quoted.push('"');
309    quoted
310}
311
312/// What became of a response written under [`h3api::Stream::respond_within`]'s
313/// bound.
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub(crate) enum Responded {
316    /// The response is on the stream.
317    Sent,
318    /// The write failed outright, and [`respond`] has already reported it.
319    Failed,
320    /// The write did not complete within its bound, and the stream has already
321    /// been reset with H3_REQUEST_CANCELLED.
322    ///
323    /// [`Responded::landed`] is the follow-up all four callers share: it writes
324    /// the line and answers that the write did not land. What a caller has left
325    /// to clean up stays with the caller; `tcp::run` aborts the target
326    /// connection RFC 9114 section 4.4 asks it to, for a lapse and for an
327    /// outright failure alike.
328    Expired,
329}
330
331impl Responded {
332    /// Whether the response reached the stream, reporting a lapse as `gave_up`.
333    ///
334    /// The follow-up is the same at every call site: a sent response is what
335    /// the caller asked for, a failed one has already been reported by
336    /// [`respond`], and a lapsed one owes one line. Written here so a fifth
337    /// caller cannot answer it a fifth way.
338    ///
339    /// What a lapse leaves behind is still the caller's business. `false` says
340    /// to stop; anything past that stays where the thing to undo is.
341    pub(crate) fn landed(self, stream_id: u64, status: Status, gave_up: &'static str) -> bool {
342        match self {
343            Self::Sent => true,
344            Self::Failed => false,
345            Self::Expired => {
346                debug!(stream_id, status = status.as_str(), "{gave_up}");
347                false
348            }
349        }
350    }
351}
352
353/// Sends `status` with `fields` under the bound
354/// [`h3api::Stream::respond_within`] carries, reporting a write that fails
355/// outright.
356///
357/// Every response this server sends with no tunnel behind it is written this
358/// way, and for the reason the bound exists: a peer that grants no flow-control
359/// credit never takes even the few bytes of a status line, and nothing else
360/// would ever end the wait — whatever the caller does next does not exist until
361/// this write returns. It is also why the count of authentication failures that
362/// is meant to cost a guesser a handshake is recorded by the caller before this
363/// call rather than after it (review H1/H2).
364///
365/// `failed` is the caller's own wording for a write that failed, kept a literal
366/// at the call site so what this server can say still reads out of `src/`.
367pub(crate) async fn respond(
368    stream: &mut Stream,
369    status: Status,
370    fields: Fields,
371    failed: &'static str,
372) -> Responded {
373    let stream_id = stream.id();
374
375    match stream.respond_within(status, fields).await {
376        Ok(()) => Responded::Sent,
377        Err(RespondError::Failed(error)) => {
378            debug!(stream_id, %error, "{failed}");
379            Responded::Failed
380        }
381        Err(RespondError::Expired) => Responded::Expired,
382    }
383}
384
385/// Answers a request we will not serve, then closes the stream tidily.
386///
387/// Any request body is unwanted, so the client is told to stop sending before
388/// the status goes out.
389pub(crate) async fn refuse(stream: &mut Stream, status: Status) {
390    refuse_with(stream, status, Fields::new()).await;
391}
392
393/// Refuses a request, explaining why in an RFC 9209 `Proxy-Status` field.
394///
395/// The status is the error's own (`recommended_status`), so the table that
396/// argues for each pairing — including D11's departure from the registry — is
397/// the only thing that decides one.
398pub(crate) async fn refuse_because(stream: &mut Stream, error: ProxyError) {
399    refuse_with(stream, error.recommended_status(), error.fields()).await;
400}
401
402/// Refuses a request whose target could not be reached, naming the failed hop.
403///
404/// Both tunnel types end up here from their connect step, so the mapping from an
405/// `io::Error` to a status and an RFC 9209 field is written once: the type
406/// decides the status (`recommended_status`) and whether the address may be
407/// disclosed (`discloses_next_hop`).
408pub(crate) async fn refuse_unreachable(stream: &mut Stream, failure: &Unreachable) {
409    let error = ProxyError::from_connect_error(&failure.error);
410    refuse_with(
411        stream,
412        error.recommended_status(),
413        error.fields_with_next_hop(failure.next_hop),
414    )
415    .await;
416}
417
418/// Refuses a request with an explicit set of response fields.
419///
420/// The write is bounded by one QUIC idle timeout and a lapsed one is abandoned
421/// with a reset; [`respond`] carries the reasoning.
422pub(crate) async fn refuse_with(stream: &mut Stream, status: Status, fields: Fields) {
423    let stream_id = stream.id();
424    stream.stop_receiving(h3api::NO_ERROR);
425
426    if !respond(stream, status, fields, "failed to send error response")
427        .await
428        .landed(
429            stream_id,
430            status,
431            "gave up on an error response the peer would not take",
432        )
433    {
434        return;
435    }
436
437    if let Err(error) = stream.finish() {
438        debug!(stream_id, %error, "failed to finish error response");
439    }
440}
441
442/// Accepts a request with a 200 and closes the tunnel again immediately.
443///
444/// **Not a refusal, and deliberately not named like one.** The response carries
445/// no `Proxy-Status` field and says nothing about a failure; `fields` is
446/// whatever an accepted response of that tunnel type has to carry — nothing for
447/// a TCP tunnel, the RFC 9297 `Capsule-Protocol` field for CONNECT-UDP.
448///
449/// Exactly one case uses it (decision D49): a target whose every resolved
450/// address is the unspecified one, which is how a filtering resolver upstream
451/// says "this name is blocked". That block is not this proxy's verdict, and an
452/// error status makes the client attribute it here; a tunnel that opens and
453/// closes at once is instead what every transport without an in-band refusal
454/// channel shows for such a name, and what a target that accepts a connection
455/// and hangs up immediately looks like on the wire.
456///
457/// Mechanically the close is the tidy one, and it is the same one an
458/// established session ends with: STOP_SENDING with H3_NO_ERROR for anything
459/// the client is still sending, then a FIN on the response stream. **Never a
460/// reset** — RFC 9114 §4.4 reserves an abruptly terminated stream for a failure
461/// of the target connection (H3_CONNECT_ERROR), which this is not, and a reset
462/// would also be the one signal a client is entitled to read as "the proxy
463/// broke".
464///
465/// The FIN that follows is this server's own choice rather than a rule read off
466/// a spec. RFC 9297 §3.3 mentions a cleanly terminated capsule stream only to
467/// rule on what a truncated last capsule means then — "If the receive side of a
468/// stream carrying Capsules is terminated cleanly (for example, in HTTP/3 this
469/// is defined as receiving a QUIC STREAM frame with the FIN bit set) and the
470/// last Capsule on the stream was truncated, this MUST be treated as if it were
471/// a malformed or incomplete message" — and says nothing about ending one on
472/// purpose. RFC 9298 §3.1 ties the request stream to a socket that exists: a
473/// UDP proxy "MUST keep the socket open while the request stream is open", and
474/// when it closes a socket it "MUST close the request stream". No socket is
475/// ever opened here, so neither sentence reaches this case. So this is this
476/// server's own, and the FIN is what it picks: the ending an established
477/// session gets, and the only one that says nothing went wrong.
478///
479/// The STOP_SENDING up front is the shape to keep. RFC 9114 §4.1 leaves the
480/// choice open — a server may abort reading the request, or leave the client to
481/// finish and close it — and the other shape was tried once (D59): 200 and FIN
482/// only, then read and discard until the client closed, on the theory that a
483/// stop landing on a client still writing its first bytes was what made one
484/// client stack answer with a transport-level PROTOCOL_VIOLATION. A frame-level
485/// A/B against that very stack showed the two shapes are indistinguishable to it
486/// (it resets within a round trip either way) and production kept failing under
487/// the drain, so the simpler shape came back. That fault is on the client's
488/// side and does not depend on what this close sends.
489pub(crate) async fn accept_then_close(stream: &mut Stream, fields: Fields) {
490    let stream_id = stream.id();
491    stream.stop_receiving(h3api::NO_ERROR);
492
493    let sent = respond(
494        stream,
495        Status::OK,
496        fields,
497        "failed to send 200 for a tunnel closed on the spot",
498    )
499    .await;
500
501    if !sent.landed(
502        stream_id,
503        Status::OK,
504        "gave up on a 200 the peer would not take for a tunnel closed on the spot",
505    ) {
506        return;
507    }
508
509    if let Err(error) = stream.finish() {
510        debug!(stream_id, %error, "failed to close a tunnel after its 200");
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn proxy_status_values_are_rfc_9209_shaped() {
520        for (error, expected) in [
521            (ProxyError::DnsError, "dns_error"),
522            (ProxyError::DnsTimeout, "dns_timeout"),
523            (
524                ProxyError::DestinationIpProhibited,
525                "destination_ip_prohibited",
526            ),
527            (
528                ProxyError::ConnectionLimitReached,
529                "connection_limit_reached",
530            ),
531            (ProxyError::HttpRequestDenied, "http_request_denied"),
532            (ProxyError::ProxyInternalError, "proxy_internal_error"),
533        ] {
534            let value = error.field_value();
535            // `<identifier>; error=<type>`: the identifier names this proxy, the
536            // parameter names the failure.
537            assert_eq!(value, format!("volto; error={expected}"));
538
539            let fields = error.fields();
540            assert_eq!(
541                fields.get(PROXY_STATUS).and_then(FieldValue::to_str),
542                Some(value)
543            );
544        }
545    }
546
547    /// RFC 9209 §2.3.2 pairs each registered type with a recommended status.
548    /// Every pairing here is the registry's, except the documented D11 choice of
549    /// 403 for a destination the policy refuses.
550    #[test]
551    fn every_error_type_carries_its_recommended_status() {
552        for (error, expected) in [
553            (ProxyError::DnsError, Status::BAD_GATEWAY),
554            (ProxyError::DnsTimeout, Status::GATEWAY_TIMEOUT),
555            (ProxyError::ConnectionRefused, Status::BAD_GATEWAY),
556            (ProxyError::ConnectionTimeout, Status::GATEWAY_TIMEOUT),
557            (
558                ProxyError::DestinationUnavailable,
559                Status::SERVICE_UNAVAILABLE,
560            ),
561            (
562                ProxyError::ConnectionLimitReached,
563                Status::SERVICE_UNAVAILABLE,
564            ),
565            (ProxyError::HttpRequestDenied, Status::FORBIDDEN),
566            (ProxyError::DestinationIpProhibited, Status::FORBIDDEN),
567            (
568                ProxyError::ProxyInternalError,
569                Status::INTERNAL_SERVER_ERROR,
570            ),
571        ] {
572            assert_eq!(
573                error.recommended_status(),
574                expected,
575                "{error:?} must answer {expected}"
576            );
577        }
578    }
579
580    /// A failure of *this host* is not a report about the target (D89).
581    ///
582    /// Every errno here is raised by the allocation itself, before anything is
583    /// addressed anywhere, so none of them can be evidence that "the next hop
584    /// [is] unavailable" — RFC 9209 §2.3.4's definition of the type they used
585    /// to be reported as. `proxy_internal_error` is §2.3.30's "internal error
586    /// unrelated to the origin", which is what happened.
587    #[test]
588    fn a_local_resource_failure_does_not_blame_the_target() {
589        for code in [
590            libc::EMFILE,
591            libc::ENFILE,
592            libc::ENOBUFS,
593            libc::ENOMEM,
594            libc::EADDRNOTAVAIL,
595        ] {
596            let error = std::io::Error::from_raw_os_error(code);
597            assert_eq!(
598                ProxyError::from_connect_error(&error),
599                ProxyError::ProxyInternalError,
600                "errno {code} ({error}) is this host's failure, not the target's"
601            );
602            assert_eq!(
603                ProxyError::from_connect_error(&error).recommended_status(),
604                Status::INTERNAL_SERVER_ERROR
605            );
606        }
607    }
608
609    /// The other side of that line: a failure that really is about reaching
610    /// this target keeps saying so.
611    ///
612    /// `EACCES` and `EPERM` are the pair worth naming — a local firewall
613    /// refusing the route to one destination is a fact about that destination,
614    /// however local the component enforcing it is.
615    #[test]
616    fn a_failure_to_reach_the_target_still_blames_the_target() {
617        for code in [
618            libc::ECONNREFUSED,
619            libc::ETIMEDOUT,
620            libc::EHOSTUNREACH,
621            libc::ENETUNREACH,
622            libc::EACCES,
623            libc::EPERM,
624        ] {
625            let error = std::io::Error::from_raw_os_error(code);
626            assert_ne!(
627                ProxyError::from_connect_error(&error),
628                ProxyError::ProxyInternalError,
629                "errno {code} ({error}) is about the target"
630            );
631        }
632    }
633
634    /// The three failures `from_connect_error` distinguishes must stay
635    /// distinguishable in the response, which is the point of computing them.
636    #[test]
637    fn connect_failures_do_not_collapse_onto_one_status() {
638        use std::io::{Error, ErrorKind};
639
640        let statuses: Vec<Status> = [
641            ErrorKind::ConnectionRefused,
642            ErrorKind::TimedOut,
643            ErrorKind::PermissionDenied,
644        ]
645        .into_iter()
646        .map(|kind| ProxyError::from_connect_error(&Error::from(kind)).recommended_status())
647        .collect();
648
649        assert_eq!(
650            statuses,
651            vec![
652                Status::BAD_GATEWAY,
653                Status::GATEWAY_TIMEOUT,
654                Status::SERVICE_UNAVAILABLE
655            ]
656        );
657    }
658
659    #[test]
660    fn connect_errors_map_onto_registered_types() {
661        use std::io::{Error, ErrorKind};
662
663        assert_eq!(
664            ProxyError::from_connect_error(&Error::from(ErrorKind::ConnectionRefused)),
665            ProxyError::ConnectionRefused
666        );
667        assert_eq!(
668            ProxyError::from_connect_error(&Error::from(ErrorKind::TimedOut)),
669            ProxyError::ConnectionTimeout
670        );
671        assert_eq!(
672            ProxyError::from_connect_error(&Error::from(ErrorKind::PermissionDenied)),
673            ProxyError::DestinationUnavailable
674        );
675    }
676
677    /// Reads the `Proxy-Status` field out of a field list.
678    fn proxy_status(fields: &Fields) -> String {
679        fields
680            .get(PROXY_STATUS)
681            .and_then(FieldValue::to_str)
682            .expect("every refusal carries a Proxy-Status field")
683            .to_owned()
684    }
685
686    /// RFC 9209 §2.1.2's `next-hop` "identifies the intermediary or origin server
687    /// selected (and used, if contacted) to obtain this response" — so it belongs
688    /// on the failures that are about reaching one, and nowhere else.
689    #[test]
690    fn only_failures_to_reach_a_hop_name_it() {
691        let hop: std::net::SocketAddr = "192.0.2.7:443".parse().expect("address");
692
693        for error in [
694            ProxyError::ConnectionRefused,
695            ProxyError::ConnectionTimeout,
696            ProxyError::DestinationUnavailable,
697        ] {
698            assert_eq!(
699                proxy_status(&error.fields_with_next_hop(Some(hop))),
700                format!("{}; next-hop=\"192.0.2.7:443\"", error.field_value())
701            );
702        }
703    }
704
705    /// The exclusions, and the reason for the middle two: a policy refusal that
706    /// echoed the resolved address would hand the client this server's view of a
707    /// name it is not allowed to reach — an internal DNS mapping, read straight
708    /// out of the refusal. `dns_error` has no hop to name at all.
709    #[test]
710    fn refusals_that_are_not_about_a_hop_never_echo_an_address() {
711        let hop: std::net::SocketAddr = "10.1.2.3:53".parse().expect("address");
712
713        for error in [
714            ProxyError::DnsError,
715            ProxyError::DnsTimeout,
716            ProxyError::DestinationIpProhibited,
717            ProxyError::HttpRequestDenied,
718            ProxyError::ConnectionLimitReached,
719            // Nothing was contacted, so there is no hop this one could name
720            // even though it is produced by the connect step (D89).
721            ProxyError::ProxyInternalError,
722        ] {
723            let value = proxy_status(&error.fields_with_next_hop(Some(hop)));
724            assert_eq!(
725                value,
726                error.field_value(),
727                "{error:?} must stay exactly as it is without a next-hop"
728            );
729            assert!(
730                !value.contains("10.1.2.3") && !value.contains("next-hop"),
731                "{error:?} leaked an address: {value}"
732            );
733        }
734    }
735
736    /// No address to report — an empty candidate list — leaves the field as the
737    /// plain one, byte for byte.
738    #[test]
739    fn a_missing_address_leaves_the_field_untouched() {
740        for error in [
741            ProxyError::ConnectionRefused,
742            ProxyError::ConnectionTimeout,
743            ProxyError::DestinationUnavailable,
744        ] {
745            assert_eq!(
746                error.fields_with_next_hop(None).get(PROXY_STATUS),
747                error.fields().get(PROXY_STATUS)
748            );
749        }
750    }
751
752    /// RFC 9209 §2.1.2 accepts a String or a Token, and RFC 8941 §3.3.3 defines
753    /// the String: DQUOTE-delimited, with DQUOTE and backslash escaped. An IPv6
754    /// hop is the case that decides it — its brackets are not Token characters.
755    #[test]
756    fn a_next_hop_is_a_quoted_structured_field_string() {
757        let ipv6: std::net::SocketAddr = "[2001:db8::1]:53".parse().expect("address");
758        assert_eq!(
759            proxy_status(&ProxyError::ConnectionRefused.fields_with_next_hop(Some(ipv6))),
760            "volto; error=connection_refused; next-hop=\"[2001:db8::1]:53\""
761        );
762
763        // The escaping rule itself, on inputs a socket address cannot produce but
764        // the encoder must still handle.
765        assert_eq!(sf_string("192.0.2.7:443"), "\"192.0.2.7:443\"");
766        assert_eq!(sf_string("[2001:db8::1]:53"), "\"[2001:db8::1]:53\"");
767        assert_eq!(sf_string(r#"a"b"#), r#""a\"b""#);
768        assert_eq!(sf_string(r"a\b"), r#""a\\b""#);
769        assert_eq!(sf_string(""), "\"\"");
770    }
771
772    /// The mapping the two resolution failures are told apart by.
773    #[test]
774    fn a_resolver_timeout_is_a_different_type_from_a_resolver_failure() {
775        use std::io::{Error, ErrorKind};
776
777        let failed = ResolveFailure::Failed(Error::from(ErrorKind::NotFound));
778        assert_eq!(failed.proxy_error(), ProxyError::DnsError);
779        assert_eq!(
780            failed.proxy_error().recommended_status(),
781            Status::BAD_GATEWAY
782        );
783
784        let timed_out = ResolveFailure::TimedOut(Duration::from_secs(10));
785        assert_eq!(timed_out.proxy_error(), ProxyError::DnsTimeout);
786        assert_eq!(
787            timed_out.proxy_error().recommended_status(),
788            Status::GATEWAY_TIMEOUT
789        );
790    }
791}