Skip to main content

kernel/
reach.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Where a call to a provider stops, stage by stage.
7//! The part `crates/kernel/spec/Reach.lean` specifies this module.
8//!
9//! **A refusal reading `error sending request for url (...): operation
10//! timed out` tells a person nothing they can act on.** Four things go
11//! wrong before a provider ever sees a request, and each has a different
12//! next step: the name does not resolve (check the spelling, or the
13//! proxy), the connection is refused or never answers (a firewall, a
14//! wrong port, a proxy that is not running), the handshake fails (a
15//! hostname that is not a legal DNS name, a certificate this machine
16//! does not trust), or the provider answers with a status (401 is a key,
17//! 404 is a path). This is the vocabulary of that reading; `gateway`
18//! takes it, because the reading needs a socket and this crate has none.
19
20use serde::{Deserialize, Serialize};
21
22/// How the name resolved.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case", tag = "state", content = "detail")]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26pub enum Named {
27    /// How many addresses came back. A name that resolves to nothing is
28    /// `NotFound`, never `Resolved(0)`.
29    Resolved(u32),
30    /// The resolver knows the name does not exist.
31    NotFound,
32    /// The resolver itself failed, and said this.
33    Refused(String),
34    /// A proxy resolves the name, so this machine did not.
35    ProxiedAway,
36}
37
38/// Whether a socket opened to the first address the name gave.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case", tag = "state", content = "detail")]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42pub enum Connected {
43    Open,
44    /// Something answered and said no: a closed port, or a firewall
45    /// that refuses rather than drops.
46    Refused,
47    /// Nothing answered inside the deadline. The most common shape of a
48    /// blocked network, and the one that looks like a slow provider.
49    Silent,
50    Failed(String),
51    /// Not attempted: the name did not resolve, or a proxy connects.
52    Skipped,
53}
54
55/// What the request itself did, once the transport was somebody else's
56/// problem. Carries the handshake, because a TLS failure surfaces as a
57/// request that never got a status.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case", tag = "state", content = "detail")]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61pub enum Answered {
62    /// The provider answered. The status is the whole finding: 200 is
63    /// reachable, 401 is a key, 404 is a base URL with a path on it.
64    Status(u16),
65    /// The name is not one TLS can put in a handshake — an underscore,
66    /// a trailing dot, a label that is not ASCII.
67    NameNotUsable(String),
68    /// The handshake failed for any other reason, certificates
69    /// included.
70    HandshakeFailed(String),
71    /// The request never reached a handshake.
72    Unreachable(String),
73}
74
75/// What the city believes it will send this request through.
76///
77/// Four of the five readings mean "no proxy", and they are kept apart
78/// because the next step differs for each: nothing names one, the
79/// person's own `NO_PROXY` excludes this host, the city's rule takes an
80/// address on this machine off the proxy, or the person settled
81/// [`Proxying::Never`] for this endpoint. A person whose call fails can
82/// act on the reason and cannot act on "direct".
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case", tag = "state", content = "detail")]
85#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
86pub enum Through {
87    /// Straight out, as far as this machine's environment says.
88    Direct,
89    /// An environment variable named it, and this is the variable.
90    Environment(String),
91    /// This host is on `NO_PROXY`, so the variables do not apply to it.
92    Excluded,
93    /// The address is on the machine the city runs on, and
94    /// [`Proxying::ExceptLocal`] keeps such a call off the proxy.
95    LocalAddress,
96    /// The person settled [`Proxying::Never`] for this endpoint, so no
97    /// proxy applies whatever this machine is configured with.
98    Disabled,
99}
100
101/// Which of this endpoint's calls go through the machine's proxy.
102///
103/// A proxy that intercepts loopback answers 502 for a local inference
104/// server, so the city takes an address on its own machine off the
105/// proxy by default — and that default is a rule about the common
106/// machine, not a fact about every machine. Two other settings are real
107/// needs rather than symmetry: a person whose provider sits behind a
108/// relay on loopback that their organisation requires them to audit
109/// needs [`Always`](Proxying::Always), and a person on a virtual network
110/// adapter, whose routes already carry every packet, needs
111/// [`Never`](Proxying::Never) so a stale proxy variable cannot break a
112/// call the machine would otherwise make.
113#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
116pub enum Proxying {
117    /// The machine's proxy applies, except to an address on this
118    /// machine. What a person who has not thought about proxies wants.
119    #[default]
120    ExceptLocal,
121    /// The machine's proxy applies to every call, loopback included.
122    Always,
123    /// No proxy applies, whatever this machine is configured with.
124    Never,
125}
126
127/// One staged reading of one host.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
130pub struct Reach {
131    pub host: String,
132    pub named: Named,
133    pub connected: Connected,
134    pub answered: Answered,
135    pub through: Through,
136    /// Stamped by the caller, which is the one place a clock is read.
137    pub elapsed_ms: u64,
138}
139
140impl Reach {
141    /// Whether the provider answered at all, whatever it answered.
142    pub fn answered_at_all(&self) -> bool {
143        matches!(self.answered, Answered::Status(_))
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    /// Any status is an answer, a refusing one included; every other
152    /// last stage is a provider that did not answer at all.
153    #[test]
154    fn any_status_is_an_answer_and_nothing_else_is() {
155        let reading = |answered: Answered| Reach {
156            host: "api.example.test".to_owned(),
157            named: Named::Resolved(1),
158            connected: Connected::Open,
159            answered,
160            through: Through::Direct,
161            elapsed_ms: 40,
162        };
163        let verdicts: Vec<bool> = [
164            Answered::Status(200),
165            Answered::Status(401),
166            Answered::NameNotUsable("a_b".to_owned()),
167            Answered::HandshakeFailed("certificate".to_owned()),
168            Answered::Unreachable("timed out".to_owned()),
169        ]
170        .into_iter()
171        .map(|answered| reading(answered).answered_at_all())
172        .collect();
173        assert_eq!(verdicts, [true, true, false, false, false]);
174    }
175}