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;