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. No URL parser: only
156/// the authority component is asked for, and `about:blank` or a `file:`
157/// path names no host.
158///
159/// # Errors
160/// Refuses an authority this build cannot read, which would leave an
161/// egress unjudged.
162pub fn host_of(url: &str) -> Result<Option<String>, AxError> {
163    let Some((_scheme, rest)) = url.split_once("://") else {
164        return Ok(None);
165    };
166    let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
167    let after_user = authority
168        .rsplit_once('@')
169        .map_or(authority, |(_userinfo, host)| host);
170    if after_user.is_empty() {
171        return Ok(None);
172    }
173    if let Some(bracketed) = after_user.strip_prefix('[') {
174        let (inside, _tail) = bracketed.split_once(']').ok_or_else(malformed_host)?;
175        if inside.is_empty() {
176            return Err(malformed_host());
177        }
178        return Ok(Some(inside.to_ascii_lowercase()));
179    }
180    let host = if let Some((candidate, port)) = after_user.rsplit_once(':') {
181        if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) {
182            candidate
183        } else {
184            after_user
185        }
186    } else {
187        after_user
188    };
189    if host.is_empty() || host.contains(':') {
190        return Err(malformed_host());
191    }
192    Ok(Some(host.to_ascii_lowercase()))
193}
194
195fn malformed_host() -> AxError {
196    AxError::failure(
197        AxCode::InvalidArgs,
198        "read a url",
199        "the url's host is not a host",
200    )
201    .with_recovery("pass an absolute url, for example `https://example.com/page`")
202}
203
204/// Classifies a host name or address for the egress doors.
205///
206/// The one authority for what this city counts as loopback and as the
207/// private network around it: the target kind decides whether the first
208/// public egress notice is due and whether an attachment to a person's
209/// browser is on this machine at all. The host arrives already resolved
210/// to a name or an address; this function never resolves anything, and
211/// a host it cannot read as an address is public, which is the side
212/// that keeps the notice rather than the side that drops it.
213///
214/// Brackets around an IPv6 literal are stripped, so `[::1]` and `::1`
215/// are one address rather than two rules.
216#[must_use]
217pub fn target_of(host: &str) -> EgressTarget {
218    let bare = host
219        .trim()
220        .trim_end_matches('.')
221        .trim_start_matches('[')
222        .trim_end_matches(']')
223        .to_ascii_lowercase();
224    if bare == "localhost" || bare == "::1" {
225        return EgressTarget::Loopback;
226    }
227    let octets = ipv4_octets(&bare);
228    if let Some([first, second, ..]) = octets {
229        if first == 127 {
230            return EgressTarget::Loopback;
231        }
232        let private = first == 10
233            || (first == 192 && second == 168)
234            || (first == 172 && (16..=31).contains(&second))
235            || (first == 169 && second == 254);
236        if private {
237            return EgressTarget::Private;
238        }
239    }
240    if let Some(leading) = ipv6_leading(&bare) {
241        // fc00::/7 unique-local and fe80::/10 link-local: the mask keeps
242        // the prefix bits and compares the group that carries them.
243        if (leading & 0xfe00) == 0xfc00 || (leading & 0xffc0) == 0xfe80 {
244            return EgressTarget::Private;
245        }
246    }
247    EgressTarget::Public {
248        host: bare.to_owned(),
249    }
250}
251
252/// Four octets when the string is a dotted IPv4 address, `None`
253/// otherwise. Anything dotted but out of range is a name, which lands
254/// on the public side.
255fn ipv4_octets(host: &str) -> Option<[u8; 4]> {
256    let mut octets = [0u8; 4];
257    let mut at = 0usize;
258    for part in host.split('.') {
259        let octet: u8 = part.parse().ok()?;
260        let slot = octets.get_mut(at)?;
261        *slot = octet;
262        at = at.saturating_add(1);
263    }
264    if at == 4 { Some(octets) } else { None }
265}
266
267/// The first sixteen-bit group of an IPv6 literal, when this is one.
268fn ipv6_leading(host: &str) -> Option<u16> {
269    if !host.contains(':') {
270        return None;
271    }
272    let first = host.split(':').next()?;
273    u16::from_str_radix(first, 16).ok()
274}
275
276/// The destination half of the egress question: may this run reach this
277/// host at all? The payload half stays with [`egress`] — "may these bytes
278/// leave" and "may this host be reached" are two questions, and a caller
279/// that asks only one of them should not look as if it asked both.
280///
281/// Loopback and private targets are not on the list: a local model or a
282/// machine on the same network is not egress, and putting them behind a
283/// domain list would mean a confidential building could not reach its own
284/// inference server.
285#[must_use]
286pub fn egress_target(list: &EgressAllowlist, target: &EgressTarget) -> EgressOutcome {
287    // A connector is admitted by the building's own server table, frozen
288    // at run start; this list answers a different question, about hosts,
289    // and a connector label is not a host.
290    if let EgressTarget::Connector { .. } = target {
291        return EgressOutcome::Allow {
292            first_public_egress: true,
293        };
294    }
295    let EgressTarget::Public { host } = target else {
296        return EgressOutcome::Allow {
297            first_public_egress: false,
298        };
299    };
300    if list.admits(host) {
301        return EgressOutcome::Allow {
302            first_public_egress: true,
303        };
304    }
305    let known: Vec<&str> = list.entries().collect();
306    let alternative = if known.is_empty() {
307        "this building reaches nothing public; add a domain to its egress list, \
308         or do the work in a building that has one"
309            .to_owned()
310    } else {
311        format!("reachable domains here: {}", known.join(", "))
312    };
313    EgressOutcome::Deny {
314        refusal: Box::new(
315            AxError::refusal(
316                AxCode::GateDenied,
317                "reach a host outside this building's egress list",
318                host.clone(),
319                GateRefusal::new(
320                    "a building reaches only the domains it names",
321                    format!("{host} is not one of them"),
322                    alternative,
323                ),
324            )
325            .with_recovery(format!(
326                "the egress list lives in the building's `.sprawling/RULES.toml`, \
327                 which the User edits; ask the User to add {host} to it"
328            )),
329        ),
330    }
331}
332
333#[cfg(test)]
334#[allow(
335    clippy::unwrap_used,
336    clippy::expect_used,
337    clippy::panic,
338    clippy::indexing_slicing,
339    reason = "test code"
340)]
341mod tests;