Skip to main content

gwk_kernel/wire/
hello.rs

1//! The handshake: the first frame, on a deadline, and what it settles.
2//!
3//! Three things are negotiated and one is not. The MAJOR version must match
4//! exactly — an unknown major is a refusal, never a best-effort session, because
5//! two peers that disagree about the frame grammar cannot discover it by trying.
6//! The MINOR negotiates DOWNWARD to `min(client, server)`, so a newer client
7//! degrades rather than failing. Capabilities INTERSECT: the client asks, the
8//! kernel answers with what it also has, and a client must treat anything
9//! absent as unavailable.
10//!
11//! What is not negotiated is authority. A capability is a feature flag, not a
12//! grant — ADR 0002 makes the UDS peer credential the only identity here — and
13//! `client` is a free-form label for logs. Nothing in a hello can widen what the
14//! connection may do, which is why the handshake can be this permissive about
15//! what it accepts and this strict about what it promises.
16
17use gwk_domain::ids::Seq;
18use gwk_domain::protocol::{
19    CapabilityName, ClientControl, FrameKind, HELLO_DEADLINE_SECS, HELLO_MAX_BYTES,
20    KernelErrorCode, MAX_CAPABILITIES, PROTOCOL_MINOR, ProtocolVersion, ServerControl,
21};
22use tokio::io::{AsyncRead, AsyncWrite};
23
24use super::frame::{Budget, Incoming, read_frame, write_frame};
25use super::{WireError, strict};
26
27/// What this kernel offers to negotiate.
28///
29/// Empty, and that is the honest answer for v1: every request in the contract
30/// is served by every v1 daemon, so there is no optional surface to advertise.
31/// A name here would be something a client branches on, and once branched on it
32/// is owed forever — including by the terminal-engine daemon that would have to
33/// keep meaning the same thing by it.
34pub const OFFERED_CAPABILITIES: &[&str] = &[];
35
36/// What a completed handshake settled.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Session {
39    pub minor: u32,
40    pub capabilities: Vec<CapabilityName>,
41    pub client: Option<String>,
42}
43
44/// The kernel state a `HelloAck` reports.
45#[derive(Debug, Clone, Copy)]
46pub struct Readiness {
47    pub sealed: bool,
48    pub watermark: Option<Seq>,
49}
50
51/// Read the first frame and settle the session, or refuse and say why.
52///
53/// On refusal the client is sent a `HelloRefusal` before the connection closes:
54/// a peer that guessed a major version wrong has to be able to tell that from a
55/// socket that was never there. The refusal is still returned to the caller, so
56/// the connection is closed by the one place that owns closing it.
57pub async fn negotiate<R, W>(
58    reader: &mut R,
59    writer: &mut W,
60    budget: &mut Budget,
61    readiness: Readiness,
62) -> Result<Session, WireError>
63where
64    R: AsyncRead + Unpin,
65    W: AsyncWrite + Unpin,
66{
67    let outcome = read_hello(reader, budget).await;
68    match outcome {
69        Ok(session) => {
70            let ack = ServerControl::HelloAck {
71                protocol_major: ProtocolVersion::V1,
72                protocol_minor: session.minor,
73                capabilities: session.capabilities.clone(),
74                sealed: readiness.sealed,
75                watermark: readiness.watermark,
76            };
77            send(writer, budget, &ack).await?;
78            Ok(session)
79        }
80        Err(refusal) => {
81            // Best effort: the budget may be exhausted or the socket already
82            // gone, and the refusal the caller gets must be the original one
83            // rather than whatever failed while reporting it.
84            let told = ServerControl::HelloRefusal {
85                code: refusal.code,
86                message: refusal.message.clone(),
87            };
88            let _ = send(writer, budget, &told).await;
89            Err(refusal)
90        }
91    }
92}
93
94async fn read_hello<R>(reader: &mut R, budget: &mut Budget) -> Result<Session, WireError>
95where
96    R: AsyncRead + Unpin,
97{
98    // The deadline covers reading the frame, not parsing it: a peer that opens
99    // a connection and says nothing is the case this exists for, and it costs a
100    // file descriptor for as long as it is allowed to.
101    let deadline = std::time::Duration::from_secs(HELLO_DEADLINE_SECS);
102    let incoming = tokio::time::timeout(deadline, read_frame(reader, HELLO_MAX_BYTES, budget))
103        .await
104        .map_err(|_| {
105            WireError::new(
106                KernelErrorCode::Handshake,
107                format!("no hello within {HELLO_DEADLINE_SECS}s"),
108            )
109        })??;
110
111    let frame = match incoming {
112        Incoming::Frame(frame) => frame,
113        Incoming::Closed => {
114            return Err(WireError::new(
115                KernelErrorCode::Handshake,
116                "the connection closed before its hello",
117            ));
118        }
119    };
120    if frame.kind != FrameKind::Json {
121        return Err(WireError::new(
122            KernelErrorCode::Handshake,
123            format!("the first frame must be JSON, not kind {:?}", frame.kind),
124        ));
125    }
126
127    let control: ClientControl = strict::decode(&frame.body)?;
128    let (protocol_major, protocol_minor, capabilities, client) = match control {
129        ClientControl::Hello {
130            protocol_major,
131            protocol_minor,
132            capabilities,
133            client,
134        } => (protocol_major, protocol_minor, capabilities, client),
135        // A request before a hello is refused rather than served: the session
136        // has not agreed on a grammar yet, so serving it would mean guessing
137        // which one it was written against.
138        ClientControl::Request { .. } => {
139            return Err(WireError::new(
140                KernelErrorCode::Handshake,
141                "the first frame must be a hello, not a request",
142            ));
143        }
144    };
145
146    // Redundant today — `ProtocolVersion` refuses an unknown major at
147    // `Deserialize` time, so an unsupported one never reaches here as a value.
148    // Kept because the day a second major exists this becomes the only check
149    // that says "known, but not this connection's", and the `Version` code is
150    // what a client distinguishes that by.
151    if protocol_major != ProtocolVersion::V1 {
152        return Err(WireError::new(
153            KernelErrorCode::UnsupportedVersion,
154            format!(
155                "protocol major {protocol_major} is not {}",
156                ProtocolVersion::V1
157            ),
158        ));
159    }
160
161    if capabilities.len() > MAX_CAPABILITIES {
162        return Err(WireError::new(
163            KernelErrorCode::Capability,
164            format!(
165                "{} capabilities exceeds the {MAX_CAPABILITIES} maximum",
166                capabilities.len()
167            ),
168        ));
169    }
170
171    Ok(Session {
172        // Downward, so a client speaking a minor this kernel has not heard of
173        // gets a session at the minor they share instead of a refusal.
174        //
175        // Clippy is right that this is a no-op while `PROTOCOL_MINOR` is 0, and
176        // wrong that it should therefore be written as the constant: the min IS
177        // the negotiation rule, and the version of this line that says
178        // `PROTOCOL_MINOR` would keep compiling — and start lying — on the day
179        // the minor first moves.
180        #[allow(clippy::unnecessary_min_or_max)]
181        minor: protocol_minor.min(PROTOCOL_MINOR),
182        capabilities: intersect(&capabilities),
183        client,
184    })
185}
186
187/// What both sides have. Order follows the CLIENT's list so the answer reads as
188/// a reply to what was asked, and duplicates in the request collapse.
189fn intersect(asked: &[CapabilityName]) -> Vec<CapabilityName> {
190    let mut out: Vec<CapabilityName> = Vec::new();
191    for name in asked {
192        if OFFERED_CAPABILITIES.contains(&name.as_str()) && !out.contains(name) {
193            out.push(name.clone());
194        }
195    }
196    out
197}
198
199async fn send<W>(
200    writer: &mut W,
201    budget: &mut Budget,
202    control: &ServerControl,
203) -> Result<(), WireError>
204where
205    W: AsyncWrite + Unpin,
206{
207    let body = serde_json::to_vec(control).map_err(|e| {
208        WireError::new(
209            KernelErrorCode::Storage,
210            format!("serialize server control: {e}"),
211        )
212    })?;
213    write_frame(writer, FrameKind::Json, &body, budget).await
214}
215
216#[cfg(test)]
217mod tests {
218    use gwk_domain::protocol::{
219        CONNECTION_EGRESS_BYTES_PER_WINDOW, CONNECTION_INGRESS_BYTES_PER_WINDOW,
220        FRAME_BODY_MAX_BYTES,
221    };
222
223    use super::*;
224
225    fn budget() -> Budget {
226        Budget::new(
227            CONNECTION_INGRESS_BYTES_PER_WINDOW,
228            CONNECTION_EGRESS_BYTES_PER_WINDOW,
229        )
230    }
231
232    fn ready() -> Readiness {
233        Readiness {
234            sealed: true,
235            watermark: Some(Seq::new(1)),
236        }
237    }
238
239    /// Frame `raw` as a client would, run the handshake, and hand back both the
240    /// outcome and whatever the kernel said on the wire.
241    async fn handshake(raw: &str) -> (Result<Session, WireError>, ServerControl) {
242        let mut client_bytes = Vec::new();
243        write_frame(
244            &mut client_bytes,
245            FrameKind::Json,
246            raw.as_bytes(),
247            &mut budget(),
248        )
249        .await
250        .expect("frame the hello");
251
252        let mut reader = std::io::Cursor::new(client_bytes);
253        let mut written = Vec::new();
254        let mut b = budget();
255        let outcome = negotiate(&mut reader, &mut written, &mut b, ready()).await;
256
257        let mut back = std::io::Cursor::new(written);
258        let answer = match read_frame(&mut back, FRAME_BODY_MAX_BYTES, &mut budget())
259            .await
260            .expect("the kernel answered")
261        {
262            Incoming::Frame(frame) => {
263                strict::decode::<ServerControl>(&frame.body).expect("decode the answer")
264            }
265            Incoming::Closed => panic!("the kernel said nothing"),
266        };
267        (outcome, answer)
268    }
269
270    #[tokio::test]
271    async fn a_matching_hello_is_acked_with_the_kernels_state() {
272        let (session, answer) = handshake(
273            r#"{"type":"hello","protocol_major":1,"protocol_minor":0,"capabilities":[],"client":"gw/0.0.1"}"#,
274        )
275        .await;
276        let session = session.expect("negotiate");
277        assert_eq!(session.minor, 0);
278        assert_eq!(session.client.as_deref(), Some("gw/0.0.1"));
279        match answer {
280            ServerControl::HelloAck {
281                protocol_major,
282                protocol_minor,
283                capabilities,
284                sealed,
285                watermark,
286            } => {
287                assert_eq!(protocol_major, ProtocolVersion::V1);
288                assert_eq!(protocol_minor, 0);
289                assert!(capabilities.is_empty());
290                // A sealed kernel says so in the ack, so a client knows before
291                // its first request that only the epoch commands are admitted.
292                assert!(sealed);
293                assert_eq!(watermark, Some(Seq::new(1)));
294            }
295            other => panic!("{other:?}"),
296        }
297    }
298
299    #[tokio::test]
300    async fn an_unknown_major_is_refused_in_words_the_client_can_read() {
301        // Not a dropped socket: a peer that guessed the major wrong must be
302        // able to tell that from a daemon that was never listening.
303        let (outcome, answer) = handshake(
304            r#"{"type":"hello","protocol_major":2,"protocol_minor":0,"capabilities":[]}"#,
305        )
306        .await;
307        let error = outcome.expect_err("major 2 accepted");
308        assert!(matches!(
309            error.code,
310            KernelErrorCode::UnsupportedVersion | KernelErrorCode::Validation
311        ));
312        match answer {
313            ServerControl::HelloRefusal { message, .. } => {
314                assert!(message.contains('2'), "{message}");
315            }
316            other => panic!("{other:?}"),
317        }
318    }
319
320    #[tokio::test]
321    async fn a_newer_minor_negotiates_down_instead_of_failing() {
322        let (session, answer) = handshake(
323            r#"{"type":"hello","protocol_major":1,"protocol_minor":99,"capabilities":[]}"#,
324        )
325        .await;
326        assert_eq!(session.expect("negotiate").minor, PROTOCOL_MINOR);
327        assert!(matches!(
328            answer,
329            ServerControl::HelloAck { protocol_minor, .. } if protocol_minor == PROTOCOL_MINOR
330        ));
331    }
332
333    #[tokio::test]
334    async fn capabilities_intersect_and_this_kernel_offers_none() {
335        // The machinery is live — a client may ask for anything and gets back
336        // what both sides have, which in v1 is nothing. Answering with what was
337        // asked would be a promise no code keeps.
338        let (session, _) = handshake(
339            r#"{"type":"hello","protocol_major":1,"protocol_minor":0,"capabilities":["event_subscribe","blob_transfer"]}"#,
340        )
341        .await;
342        assert!(session.expect("negotiate").capabilities.is_empty());
343    }
344
345    #[tokio::test]
346    async fn too_many_capabilities_is_a_typed_refusal() {
347        let names: Vec<String> = (0..=MAX_CAPABILITIES)
348            .map(|i| format!("\"cap_{i}\""))
349            .collect();
350        let (outcome, _) = handshake(&format!(
351            r#"{{"type":"hello","protocol_major":1,"protocol_minor":0,"capabilities":[{}]}}"#,
352            names.join(",")
353        ))
354        .await;
355        let error = outcome.expect_err("65 capabilities accepted");
356        assert_eq!(error.code, KernelErrorCode::Capability);
357    }
358
359    #[tokio::test]
360    async fn a_request_before_a_hello_is_refused() {
361        let (outcome, _) =
362            handshake(r#"{"type":"request","request_id":"r-1","request":{"type":"health"}}"#).await;
363        let error = outcome.expect_err("a request served before the handshake");
364        assert_eq!(error.code, KernelErrorCode::Handshake);
365    }
366
367    #[tokio::test]
368    async fn silence_is_closed_on_the_deadline() {
369        // A peer that connects and says nothing holds a descriptor. `tokio`'s
370        // test clock makes the five seconds pass without spending them.
371        tokio::time::pause();
372        let (client, mut server) = tokio::io::duplex(64);
373        let mut b = budget();
374        let mut written = Vec::new();
375        let handshaking = tokio::spawn(async move {
376            negotiate(&mut server, &mut written, &mut b, ready())
377                .await
378                .map(|_| ())
379        });
380        tokio::time::advance(std::time::Duration::from_secs(HELLO_DEADLINE_SECS + 1)).await;
381        let error = handshaking
382            .await
383            .expect("join")
384            .expect_err("silence accepted");
385        assert_eq!(error.code, KernelErrorCode::Handshake);
386        assert!(error.message.contains("hello"), "{error}");
387        drop(client);
388    }
389
390    #[tokio::test]
391    async fn an_oversized_hello_is_refused_at_its_own_lower_cap() {
392        // 64 KiB, not the 4 MiB every later frame gets: the handshake is the
393        // one frame an unauthenticated peer can send, so it is bounded hardest.
394        let mut raw = (HELLO_MAX_BYTES + 1).to_be_bytes().to_vec();
395        raw.push(FrameKind::Json.as_u8());
396        let mut reader = std::io::Cursor::new(raw);
397        let mut written = Vec::new();
398        let error = negotiate(&mut reader, &mut written, &mut budget(), ready())
399            .await
400            .expect_err("oversized hello accepted");
401        assert_eq!(error.code, KernelErrorCode::FrameSize);
402    }
403}