Skip to main content

kernel/gate/
egress.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//! The egress doors: whether bytes may leave and whether a host may be
7//! reached.
8//! The part `crates/kernel/spec/Gate.lean` specifies this module with the
9//! rest of `kernel::gate`.
10
11use crate::error::{AxCode, AxError, GateRefusal};
12use crate::secret::SecretSpan;
13
14/// address resolution; kernel never resolves names.
15///
16/// Closed: the kinds of destination a city can distinguish still grow,
17/// and every module that decides about a destination has to be shown
18/// the new kind by the compiler on the day it is added.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum EgressTarget {
21    Loopback,
22    Private,
23    Public {
24        host: String,
25    },
26    /// An external server this building configured. The city names the
27    /// connector rather than a host because a connector's own
28    /// destination is not visible from here: claiming a host would be
29    /// inventing one, and claiming loopback would understate where the
30    /// bytes can end up.
31    Connector {
32        label: crate::tool::ServerLabel,
33    },
34}
35
36/// Deliberately exhaustive.
37#[derive(Debug)]
38pub enum EgressOutcome {
39    Allow {
40        /// True exactly once per run: the pipeline hangs the net notice
41        /// on this flag (4.3). Loopback and private targets never set it.
42        first_public_egress: bool,
43    },
44    Deny {
45        refusal: Box<AxError>,
46    },
47}
48
49/// The Egress door: secret shapes never leave (C13), and the first
50/// public egress of a run is named. The subject counts spans and offsets
51/// — the matched bytes never appear (the error message is itself an
52/// egress surface).
53pub fn egress(
54    spans: &[SecretSpan],
55    target: &EgressTarget,
56    prior_public_egress: bool,
57) -> EgressOutcome {
58    if !spans.is_empty() {
59        let offsets: Vec<String> = spans
60            .iter()
61            .map(|s| format!("{}+{}", s.start, s.len))
62            .collect();
63        return EgressOutcome::Deny {
64            refusal: Box::new(
65                AxError::refusal(
66                    AxCode::SecretEgress,
67                    "send bytes out",
68                    format!(
69                        "{} secret-shaped span(s) at {}",
70                        spans.len(),
71                        offsets.join(", ")
72                    ),
73                    GateRefusal::new(
74                        "credentials leave only as secret: references (C13)",
75                        format!("the payload carries {} secret-shaped span(s)", spans.len()),
76                        "replace each span with its secret:<realm>/<name> reference; \
77                         if a real credential already left, rotation is the only remedy",
78                    ),
79                )
80                .with_recovery(
81                    "cut the bytes at the offsets above out of the payload and send the \
82                     secret:<realm>/<name> reference in their place; `sprawling status --secrets` \
83                     lists the references this machine holds",
84                ),
85            ),
86        };
87    }
88    // A connector counts as leaving, for the same reason a public host
89    // does: the run has reached a service outside itself, and the one
90    // notice a run gets about that must not depend on whether the city
91    // could name the far end.
92    let leaves_the_machine = matches!(
93        target,
94        EgressTarget::Public { .. } | EgressTarget::Connector { .. }
95    );
96    let first_public_egress = leaves_the_machine && !prior_public_egress;
97    EgressOutcome::Allow {
98        first_public_egress,
99    }
100}
101
102/// Which hosts a run may reach. A suffix list, because a building names
103/// domains rather than addresses, and an empty list is the honest way to
104/// say "nothing public" — a confidential building holds exactly this.
105#[derive(Debug, Clone, Default, PartialEq, Eq)]
106pub struct EgressAllowlist {
107    suffixes: Vec<String>,
108}
109
110impl EgressAllowlist {
111    /// Builds the list. Entries are lowercased and stripped of a leading
112    /// dot, so `.example.com` and `example.com` are the same rule rather
113    /// than two rules with different behaviour.
114    #[must_use]
115    pub fn new(entries: Vec<String>) -> EgressAllowlist {
116        let mut suffixes: Vec<String> = entries
117            .into_iter()
118            .map(|entry| entry.trim().trim_start_matches('.').to_ascii_lowercase())
119            .filter(|entry| !entry.is_empty())
120            .collect();
121        suffixes.sort();
122        suffixes.dedup();
123        EgressAllowlist { suffixes }
124    }
125
126    #[must_use]
127    pub fn is_empty(&self) -> bool {
128        self.suffixes.is_empty()
129    }
130
131    pub fn entries(&self) -> impl Iterator<Item = &str> {
132        self.suffixes.iter().map(String::as_str)
133    }
134
135    /// True when `host` is the domain itself or one of its subdomains.
136    /// Matching is on label boundaries: `notexample.com` does not match
137    /// `example.com`, which a plain suffix test would let through.
138    #[must_use]
139    pub fn admits(&self, host: &str) -> bool {
140        let host = host.trim_end_matches('.').to_ascii_lowercase();
141        self.suffixes.iter().any(|allowed| {
142            host == *allowed
143                || host
144                    .strip_suffix(allowed.as_str())
145                    .is_some_and(|head| head.ends_with('.'))
146        })
147    }
148}
149
150/// The host a URL names, when it names one.
151///
152/// One authority for the whole workspace: the browser tool reading a page
153/// address and a building reading the address a person declared for
154/// their browser are the same question, and two parsers would answer it
155/// differently the first time either was corrected. The URL is read by
156/// the WHATWG URL Standard, the parser browsers follow, so the host the
157/// egress door judges is the host a browser connects to (D59): a
158/// backslash ends the authority of a special scheme, the host is
159/// percent-decoded and lowercased, and a number spells an IPv4 address.
160/// A URL with no authority - `about:blank`, a `file:` path, text with no
161/// scheme - names no host.
162///
163/// # Errors
164/// Refuses a URL this build cannot read, which would leave an egress
165/// unjudged.
166pub fn host_of(url: &str) -> Result<Option<String>, AxError> {
167    let parsed = match url::Url::parse(url.trim()) {
168        Ok(parsed) => parsed,
169        Err(url::ParseError::RelativeUrlWithoutBase) => return Ok(None),
170        Err(_) => return Err(malformed_host()),
171    };
172    Ok(parsed
173        .host_str()
174        .filter(|host| !host.is_empty())
175        .map(|host| {
176            host.trim_start_matches('[')
177                .trim_end_matches(']')
178                .to_ascii_lowercase()
179        }))
180}
181
182fn malformed_host() -> AxError {
183    AxError::failure(
184        AxCode::InvalidArgs,
185        "read a url",
186        "the url's host is not a host",
187    )
188    .with_recovery("pass an absolute url, for example `https://example.com/page`")
189}
190
191/// Classifies a host name or address for the egress doors.
192///
193/// The one authority for what this city counts as loopback and as the
194/// private network around it: the target kind decides whether the first
195/// public egress notice is due and whether an attachment to a person's
196/// browser is on this machine at all. The host arrives already resolved
197/// to a name or an address; this function never resolves anything, and
198/// a host it cannot read as an address is public, which is the side
199/// that keeps the notice rather than the side that drops it.
200///
201/// Brackets around an IPv6 literal are stripped, so `[::1]` and `::1`
202/// are one address rather than two rules.
203#[must_use]
204pub fn target_of(host: &str) -> EgressTarget {
205    let bare = host
206        .trim()
207        .trim_end_matches('.')
208        .trim_start_matches('[')
209        .trim_end_matches(']')
210        .to_ascii_lowercase();
211    if bare == "localhost" || bare == "::1" {
212        return EgressTarget::Loopback;
213    }
214    let octets = ipv4_octets(&bare);
215    if let Some([first, second, ..]) = octets {
216        if first == 127 {
217            return EgressTarget::Loopback;
218        }
219        let private = first == 10
220            || (first == 192 && second == 168)
221            || (first == 172 && (16..=31).contains(&second))
222            || (first == 169 && second == 254);
223        if private {
224            return EgressTarget::Private;
225        }
226    }
227    if let Some(leading) = ipv6_leading(&bare) {
228        // fc00::/7 unique-local and fe80::/10 link-local: the mask keeps
229        // the prefix bits and compares the group that carries them.
230        if (leading & 0xfe00) == 0xfc00 || (leading & 0xffc0) == 0xfe80 {
231            return EgressTarget::Private;
232        }
233    }
234    EgressTarget::Public {
235        host: bare.to_owned(),
236    }
237}
238
239/// Four octets when the string is a dotted IPv4 address, `None`
240/// otherwise. Anything dotted but out of range is a name, which lands
241/// on the public side.
242fn ipv4_octets(host: &str) -> Option<[u8; 4]> {
243    let mut octets = [0u8; 4];
244    let mut at = 0usize;
245    for part in host.split('.') {
246        let octet: u8 = part.parse().ok()?;
247        let slot = octets.get_mut(at)?;
248        *slot = octet;
249        at = at.saturating_add(1);
250    }
251    if at == 4 { Some(octets) } else { None }
252}
253
254/// The first sixteen-bit group of an IPv6 literal, when this is one.
255fn ipv6_leading(host: &str) -> Option<u16> {
256    if !host.contains(':') {
257        return None;
258    }
259    let first = host.split(':').next()?;
260    u16::from_str_radix(first, 16).ok()
261}
262
263/// The destination half of the egress question: may this run reach this
264/// host at all? The payload half stays with [`egress`] — "may these bytes
265/// leave" and "may this host be reached" are two questions, and a caller
266/// that asks only one of them should not look as if it asked both.
267///
268/// Loopback and private targets are not on the list: a local model or a
269/// machine on the same network is not egress, and putting them behind a
270/// domain list would mean a confidential building could not reach its own
271/// inference server.
272#[must_use]
273pub fn egress_target(list: &EgressAllowlist, target: &EgressTarget) -> EgressOutcome {
274    // A connector is admitted by the building's own server table, frozen
275    // at run start; this list answers a different question, about hosts,
276    // and a connector label is not a host.
277    if let EgressTarget::Connector { .. } = target {
278        return EgressOutcome::Allow {
279            first_public_egress: true,
280        };
281    }
282    let EgressTarget::Public { host } = target else {
283        return EgressOutcome::Allow {
284            first_public_egress: false,
285        };
286    };
287    if list.admits(host) {
288        return EgressOutcome::Allow {
289            first_public_egress: true,
290        };
291    }
292    let known: Vec<&str> = list.entries().collect();
293    let alternative = if known.is_empty() {
294        "this building reaches nothing public; add a domain to its egress list, \
295         or do the work in a building that has one"
296            .to_owned()
297    } else {
298        format!("reachable domains here: {}", known.join(", "))
299    };
300    EgressOutcome::Deny {
301        refusal: Box::new(
302            AxError::refusal(
303                AxCode::GateDenied,
304                "reach a host outside this building's egress list",
305                host.clone(),
306                GateRefusal::new(
307                    "a building reaches only the domains it names",
308                    format!("{host} is not one of them"),
309                    alternative,
310                ),
311            )
312            .with_recovery(format!(
313                "the egress list lives in the building's `.sprawling/RULES.toml`, \
314                 which the User edits; ask the User to add {host} to it"
315            )),
316        ),
317    }
318}
319
320#[cfg(test)]
321#[allow(
322    clippy::unwrap_used,
323    clippy::expect_used,
324    clippy::panic,
325    clippy::indexing_slicing,
326    reason = "test code"
327)]
328mod tests;