wire/reception.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//! Every judgement the process boundary makes, as pure functions with
7//! exhaustive verdicts.
8//!
9//! Five questions, asked in five different places and answered here:
10//! may this address be bound at all and which face does it then present,
11//! may this peer put a credential in the vault, may this peer's opening
12//! frame be accepted, what does a frame mean in the state the session is
13//! in, and which records must a session tell the peer it lost. None of
14//! them touches a socket, a clock or a file, so all five are tested by
15//! calling them.
16//!
17//! **This is the thick half of the Humble Object** the listener is
18//! built as (ARCHITECTURE section 9). `wire::server` is declared an
19//! adapter — "thin, no policy" — and policy is what these functions
20//! are; a shell that also holds the rules it applies has no way to show
21//! that it applies all of them. Every branch left in the shell is a
22//! send, a receive, or the end of a session.
23//!
24//! Binding is loopback by default, and the face that comes back from
25//! [`decide_bind`] carries the credential it demands: a listener
26//! reachable beyond this machine refuses to start without one, and no
27//! shell can be built with an exposed face that demands nothing.
28
29mod admission;
30pub(crate) mod inbound;
31
32pub use admission::{Admission, Door, decide_admission, offered_pairing};
33
34use std::net::SocketAddr;
35
36use kernel::{Address, AxCode, AxError, B3Hash, Seq};
37
38use crate::auth;
39use crate::command::WireCommand;
40use crate::frames::{
41 Ask, ClientFrame, Hello, Lagged, Monitoring, WIRE_V, Watched, Welcome, schema_hash,
42};
43
44/// Which face the listener presents, and the credential it demands.
45///
46/// **The digest travels inside the exposed face rather than beside it.**
47/// A listener reachable beyond this machine with no credential is not a
48/// state this type can hold, and [`decide_bind`] is the only producer of
49/// one: it refuses that configuration before the socket exists. A
50/// `token_configured: bool` argument said the same thing and left every
51/// shell free to disagree with the verdict it was handed.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum BindFace {
54 /// Reachable from this machine only. A token may still be configured - a
55 /// person set one on a city they serve to nobody - and the doors then
56 /// demand it exactly as the exposed face does.
57 Loopback { token: Option<B3Hash> },
58 /// Reachable from elsewhere, and never served without a credential.
59 Exposed { token: B3Hash },
60}
61
62impl BindFace {
63 /// The digest this face demands of every caller. `None` only on the
64 /// loopback face of a city nobody configured a token for.
65 #[must_use]
66 pub fn token_digest(&self) -> Option<&B3Hash> {
67 match self {
68 BindFace::Loopback { token } => token.as_ref(),
69 BindFace::Exposed { token } => Some(token),
70 }
71 }
72}
73
74/// The whole of the binding policy.
75#[derive(Debug)]
76pub enum BindVerdict {
77 Serve(BindFace),
78 Refuse(AxError),
79}
80
81/// Decides whether the listener may bind `addr`, and which face it then
82/// presents.
83///
84/// Pure. Four cells, one of which refuses: an address reachable from outside
85/// this machine with no pairing token configured. The refusal happens before
86/// the socket exists, so there is no window in which the port is open and
87/// unauthenticated - and the credential the exposed face demands comes back
88/// inside the verdict, so there is no second way to learn it.
89#[must_use]
90pub fn decide_bind(addr: &SocketAddr, token: Option<B3Hash>) -> BindVerdict {
91 if addr.ip().is_loopback() {
92 return BindVerdict::Serve(BindFace::Loopback { token });
93 }
94 match token {
95 Some(token) => BindVerdict::Serve(BindFace::Exposed { token }),
96 None => BindVerdict::Refuse(
97 AxError::failure(
98 AxCode::ConfigInvalid,
99 "bind the control surface",
100 format!(
101 "{addr} is reachable beyond this machine and no pairing token is configured"
102 ),
103 )
104 .with_recovery(
105 "configure a pairing token before exposing the port, or bind a loopback address",
106 ),
107 ),
108 }
109}
110/// Whether one peer may enrol a credential.
111#[derive(Debug)]
112pub enum EnrollVerdict {
113 Accept,
114 Refuse(AxError),
115}
116
117/// Decides whether `peer` may put plaintext into this machine's vault.
118///
119/// Pure, and the whole of the policy: only a caller on this machine may.
120/// A pairing token is not enough, because a token authenticates a person
121/// and this rule is about where the bytes travel. The design's guarantee
122/// is that a credential cannot be enrolled remotely at all - the socket
123/// half is type-level (`PutSecret` has no wire form), and this is the
124/// HTTP half, which needs a runtime check because bytes can always be
125/// posted at a route.
126#[must_use]
127pub fn decide_enroll(peer: &SocketAddr) -> EnrollVerdict {
128 if peer.ip().is_loopback() {
129 return EnrollVerdict::Accept;
130 }
131 EnrollVerdict::Refuse(
132 AxError::failure(
133 AxCode::GateDenied,
134 "enrol a credential",
135 format!("{peer} is not on this machine"),
136 )
137 .with_recovery(
138 "enrol the credential from the machine running sprawling; a tunnelled session can \
139 use it afterwards but cannot deliver it",
140 ),
141 )
142}
143
144/// The verdict on one peer's opening frame.
145#[derive(Debug)]
146pub enum HandshakeVerdict {
147 Accept,
148 Reject(AxError),
149}
150
151/// Decides whether to accept a peer.
152///
153/// Order is deliberate: protocol agreement is settled before credentials.
154/// A browser holding a cached older client is the common case and deserves
155/// "refresh", not "wrong password". Pure - `expected` and `face` are
156/// parameters, never read from ambient state.
157///
158/// The credential a caller must present is the face's, which means an
159/// exposed face cannot be served without one: [`BindFace::Exposed`] holds
160/// the digest, and there is no other way to reach this function.
161///
162/// The digest is a digest, not the token. This crate never holds the
163/// plaintext of a pairing token: the side that owns the token digests it
164/// once, and the boundary compares digests. That keeps credential exposure
165/// at the redemption points where it is audited, and it costs nothing here
166/// because the comparison hashes both sides anyway.
167#[must_use]
168pub fn decide_handshake(hello: &Hello, expected: &Welcome, face: &BindFace) -> HandshakeVerdict {
169 if hello.wire_v != expected.wire_v || hello.schema != expected.schema {
170 return HandshakeVerdict::Reject(
171 AxError::failure(
172 AxCode::WireMismatch,
173 "accept a client connection",
174 format!(
175 "client speaks wire v{} and this server speaks v{}",
176 hello.wire_v, expected.wire_v
177 ),
178 )
179 .with_recovery("reload the page to fetch the client this server was built with"),
180 );
181 }
182 let Some(expected_digest) = face.token_digest() else {
183 return HandshakeVerdict::Accept;
184 };
185 if auth::verify(hello.token.as_deref(), expected_digest) {
186 return HandshakeVerdict::Accept;
187 }
188 HandshakeVerdict::Reject(
189 AxError::failure(
190 AxCode::ConfigInvalid,
191 "accept a client connection",
192 "the pairing token does not match",
193 )
194 .with_recovery("re-enter the pairing code shown on the host machine"),
195 )
196}
197/// How far a socket session has got. Two states, because there are two:
198/// a peer that has not identified itself, and one that has.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum SessionState {
201 AwaitingHello,
202 Live,
203}
204
205/// What the shell does with one client frame. Exhaustive: a new frame
206/// kind has to be answered here rather than falling through to silence.
207#[derive(Debug)]
208pub enum SessionStep {
209 /// Answer with this welcome and move to [`SessionState::Live`].
210 Welcome(Box<Welcome>),
211 /// Hand this command to the sink.
212 Deliver(Box<WireCommand>),
213 /// Evaluate this question and answer it under its own number.
214 Answer(Box<Ask>),
215 /// Count this session as watching the monitor and send it readings.
216 Watch(Watched),
217 /// Stop counting this session and stop sending it readings.
218 Release,
219 /// Send this refusal; `close` ends the session afterwards.
220 Refuse { error: Box<AxError>, close: bool },
221}
222
223/// What a welcome tells a peer about the city it reached: which city it
224/// is, the seq of the last record the city has broadcast, and which
225/// ledger it is. One value
226/// because the two travel together into every welcome.
227#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
228pub struct WelcomeFacts<'a> {
229 pub city: Option<&'a Address>,
230 pub head: Option<Seq>,
231 pub epoch: Option<B3Hash>,
232}
233
234/// The whole session policy, as a pure function: which frames are legal
235/// when, and what a mismatch does. Tested without a socket, for the same
236/// reason [`decide_bind`] is.
237///
238/// A command that arrives before the hello is refused rather than queued:
239/// the peer has not yet shown that it speaks this wire, and running work
240/// for it would be trusting a stranger's first sentence.
241#[must_use]
242pub fn decide_frame(
243 state: SessionState,
244 frame: ClientFrame,
245 face: &BindFace,
246 standing: WelcomeFacts<'_>,
247) -> SessionStep {
248 let expected = Welcome {
249 wire_v: WIRE_V,
250 schema: schema_hash(),
251 resume_from: standing.head,
252 city: standing.city.cloned(),
253 epoch: standing.epoch,
254 };
255 match (state, frame) {
256 (SessionState::AwaitingHello, ClientFrame::Hello(hello)) => {
257 match decide_handshake(&hello, &expected, face) {
258 HandshakeVerdict::Accept => SessionStep::Welcome(Box::new(expected)),
259 HandshakeVerdict::Reject(error) => SessionStep::Refuse {
260 error: Box::new(error),
261 close: true,
262 },
263 }
264 }
265 (SessionState::AwaitingHello, _) => SessionStep::Refuse {
266 error: Box::new(
267 AxError::failure(
268 AxCode::WireMismatch,
269 "accept a client frame",
270 "the session has not been opened with a hello",
271 )
272 .with_recovery("send hello first; reload the page if the client did not"),
273 ),
274 close: true,
275 },
276 (SessionState::Live, ClientFrame::Command(command)) => SessionStep::Deliver(command),
277 (SessionState::Live, ClientFrame::Ask(ask)) => SessionStep::Answer(Box::new(ask)),
278 (SessionState::Live, ClientFrame::Monitor(Monitoring::Watch)) => {
279 SessionStep::Watch(Watched::Everything)
280 }
281 (SessionState::Live, ClientFrame::Monitor(Monitoring::WatchSummary)) => {
282 SessionStep::Watch(Watched::Summary)
283 }
284 (SessionState::Live, ClientFrame::Monitor(Monitoring::Release)) => SessionStep::Release,
285 (SessionState::Live, ClientFrame::Hello(_)) => SessionStep::Refuse {
286 error: Box::new(
287 AxError::failure(
288 AxCode::WireMismatch,
289 "accept a client frame",
290 "this session is already open",
291 )
292 .with_recovery("open a second connection instead of re-greeting on this one"),
293 ),
294 close: false,
295 },
296 }
297}
298
299/// The range of ledger records a session must name when its event stream
300/// skipped some.
301///
302/// `delivered` is the last record this session sent the peer, or `None`
303/// when it has sent none; `next` is the first record to arrive after the
304/// gap. Pure, and the whole of the rule. The count a lagged subscription
305/// reports says how many messages were skipped and neither endpoint, which
306/// is why both ends are derived from records this session can name - and
307/// why the far end is only knowable here, at the record that ends the gap.
308///
309/// `None` when there is no gap to state: nothing was skipped before
310/// `next`, which happens when `next` is the record `delivered` already
311/// named or when it is the first record there is. A sequence number that
312/// cannot be stepped returns `None` for the same reason and is a case a
313/// ledger which produced `next` cannot reach.
314#[must_use]
315pub(crate) fn decide_lag(delivered: Option<Seq>, next: Seq) -> Option<Lagged> {
316 let from = match delivered {
317 Some(last) => last.value().checked_add(1).map(Seq::new),
318 // A session that has delivered nothing has shown the peer no
319 // stream, so the gap relative to it starts at the first record
320 // the city ever wrote.
321 None => Some(Seq::FIRST),
322 };
323 let to = next.value().checked_sub(1).map(Seq::new);
324 match (from, to) {
325 (Some(from), Some(to)) if to >= from => Some(Lagged { from, to }),
326 _ => None,
327 }
328}
329
330/// What a session owes the peer about the event stream.
331///
332/// Two facts that belong together: the last record this session delivered,
333/// and whether the stream skipped past it since. They travel as one value
334/// because every other combination is meaningless - a session that owes a
335/// range has a delivery to name the near end of it, and one that owes none
336/// has said everything it holds. The rule lives here rather than in the
337/// shell for the reason every other judgement does: it is testable without
338/// a socket.
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub(crate) enum Stream {
341 /// The peer holds every record this session sent it.
342 Even(Option<Seq>),
343 /// The stream skipped records, and the next one to arrive names the far
344 /// end of what was skipped. The payload is the last delivery.
345 Owed(Option<Seq>),
346}
347
348impl Stream {
349 /// A session that has delivered nothing and owes nothing, which is how
350 /// every session starts.
351 pub(crate) const fn opening() -> Stream {
352 Stream::Even(None)
353 }
354
355 /// The record this session last delivered, from either state.
356 #[must_use]
357 pub(crate) fn delivered(self) -> Option<Seq> {
358 match self {
359 Stream::Even(delivered) | Stream::Owed(delivered) => delivered,
360 }
361 }
362
363 /// The state a skip leaves behind. A session that has not been
364 /// welcomed has shown the peer no stream to measure a gap against - its
365 /// view begins at the welcome, and what a page needs of what came
366 /// before is a question rather than a frame - so it owes nothing.
367 #[must_use]
368 pub(crate) fn skipped(self, live: bool) -> Stream {
369 if live {
370 Stream::Owed(self.delivered())
371 } else {
372 self
373 }
374 }
375
376 /// What to say before sending `next`, and the state that sending it
377 /// leaves behind. The far end of a skipped range is only knowable at
378 /// the record that ends it.
379 #[must_use]
380 pub(crate) fn before(self, next: Seq) -> (Option<Lagged>, Stream) {
381 let lag = match self {
382 // A record that never reached the broadcast leaves only a gap
383 // in `seq`; before anything was delivered there is no gap.
384 Stream::Even(None) => None,
385 Stream::Even(delivered @ Some(_)) | Stream::Owed(delivered) => {
386 decide_lag(delivered, next)
387 }
388 };
389 (lag, Stream::Even(Some(next)))
390 }
391}
392#[cfg(test)]
393#[allow(
394 clippy::unwrap_used,
395 clippy::expect_used,
396 clippy::panic,
397 clippy::indexing_slicing,
398 reason = "test code"
399)]
400mod tests;