summaryrefslogtreecommitdiffstats
path: root/src/protocol.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
committersalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
commit058432130628d00764663ecf819a9adc6793768d (patch)
treed3f49f111fbfaa48647cca2600adcf9017af2d30 /src/protocol.rs
parentweb: serve latest.php through PHP and refuse stray PHP files (diff)
downloadzagan-bet-058432130628d00764663ecf819a9adc6793768d.tar.gz
zagan-bet-058432130628d00764663ecf819a9adc6793768d.zip
v5.0.0: protocol v5 and the browser frontend
Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point. Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials. Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph. The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO.
Diffstat (limited to 'src/protocol.rs')
-rw-r--r--src/protocol.rs226
1 files changed, 198 insertions, 28 deletions
diff --git a/src/protocol.rs b/src/protocol.rs
index 11f54a1..e838b08 100644
--- a/src/protocol.rs
+++ b/src/protocol.rs
@@ -1,16 +1,60 @@
1//! Newline-delimited protocol helpers shared by the client and server. 1//! Protocol v5: bounded frames and the request/reply/event envelope.
2//! 2//!
3//! TLS reads can return more than one line at a time. Keeping the leftover 3//! Every packet is `[u32 length][UTF-8 payload]`. The length is big-endian and
4//! bytes is essential: dropping them silently loses the next message. 4//! capped, so a peer cannot make the reader allocate without bound. Inside a
5//! frame the payload is text:
6//!
7//! ```text
8//! REQ <id> [idem=<token>] <command>
9//! RES <id> <body>
10//! EVT <seq> <body>
11//! ```
12//!
13//! The command and body grammar is the same text protocol the games already
14//! use (`HELLO`, `PLAY_START`, `GAME_RESULT`, ...). The envelope only adds
15//! correlation, event numbering and a hard size limit.
16
17use std::fmt;
18use std::io::{self, Read, Write};
19
20/// Largest payload a peer may send in one frame. A game message is a few
21/// hundred bytes; 64 KiB is generous and still bounds the reader.
22pub const MAX_FRAME_LEN: usize = 64 * 1024;
23
24/// How many request ids a connection remembers for idempotent retries.
25pub const REPLAY_CACHE_LEN: usize = 256;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum FrameError {
29 /// The declared payload length exceeds [`MAX_FRAME_LEN`].
30 TooLarge(usize),
31 /// The payload was not valid UTF-8.
32 InvalidUtf8,
33}
34
35impl fmt::Display for FrameError {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 match self {
38 FrameError::TooLarge(length) => {
39 write!(
40 f,
41 "frame of {length} bytes exceeds the {MAX_FRAME_LEN}-byte limit"
42 )
43 }
44 FrameError::InvalidUtf8 => write!(f, "frame payload is not valid UTF-8"),
45 }
46 }
47}
5 48
6use std::io::{self, Read}; 49impl std::error::Error for FrameError {}
7 50
51/// A bounded frame decoder. Feed it bytes from a socket, take whole frames.
8#[derive(Debug, Default)] 52#[derive(Debug, Default)]
9pub struct LineBuffer { 53pub struct FrameBuffer {
10 buffer: Vec<u8>, 54 buffer: Vec<u8>,
11} 55}
12 56
13impl LineBuffer { 57impl FrameBuffer {
14 pub fn new() -> Self { 58 pub fn new() -> Self {
15 Self::default() 59 Self::default()
16 } 60 }
@@ -19,46 +63,172 @@ impl LineBuffer {
19 self.buffer.extend_from_slice(bytes); 63 self.buffer.extend_from_slice(bytes);
20 } 64 }
21 65
22 /// Return the next complete line, including a line that ended at EOF. 66 /// Return the next complete frame, if the buffer holds one.
23 pub fn next_line(&mut self) -> Option<String> { 67 ///
24 let end = self.buffer.iter().position(|byte| *byte == b'\n')?; 68 /// A declared length above the limit is reported immediately instead of
25 let mut line: Vec<u8> = self.buffer.drain(..=end).collect(); 69 /// waiting for bytes that will never be accepted; the caller should close
26 line.pop(); // '\n' 70 /// the connection.
27 if line.last() == Some(&b'\r') { 71 pub fn next_frame(&mut self) -> Option<Result<String, FrameError>> {
28 line.pop(); 72 if self.buffer.len() < 4 {
73 return None;
29 } 74 }
30 Some(String::from_utf8_lossy(&line).into_owned())
31 }
32 75
33 /// Return the unterminated remainder at EOF, if any. 76 let length = u32::from_be_bytes([
34 pub fn take_rest(&mut self) -> Option<String> { 77 self.buffer[0],
35 if self.buffer.is_empty() { 78 self.buffer[1],
79 self.buffer[2],
80 self.buffer[3],
81 ]) as usize;
82
83 if length > MAX_FRAME_LEN {
84 self.buffer.clear();
85 return Some(Err(FrameError::TooLarge(length)));
86 }
87 if self.buffer.len() < 4 + length {
36 return None; 88 return None;
37 } 89 }
38 let mut rest = std::mem::take(&mut self.buffer); 90
39 if rest.last() == Some(&b'\r') { 91 let payload: Vec<u8> = self.buffer[4..4 + length].to_vec();
40 rest.pop(); 92 self.buffer.drain(..4 + length);
93 match String::from_utf8(payload) {
94 Ok(text) => Some(Ok(text)),
95 Err(_) => Some(Err(FrameError::InvalidUtf8)),
41 } 96 }
42 Some(String::from_utf8_lossy(&rest).into_owned())
43 } 97 }
44 98
45 /// Read one line from a blocking reader, preserving any extra lines for 99 /// Read one frame from a blocking reader, preserving leftover bytes.
46 /// the next call. Returns `None` at EOF with nothing buffered. 100 /// `None` means EOF with no complete frame buffered.
47 pub fn read_line<R: Read>(&mut self, reader: &mut R) -> io::Result<Option<String>> { 101 pub fn read_frame<R: Read>(&mut self, reader: &mut R) -> io::Result<Option<String>> {
48 loop { 102 loop {
49 if let Some(line) = self.next_line() { 103 match self.next_frame() {
50 return Ok(Some(line)); 104 Some(Ok(frame)) => return Ok(Some(frame)),
105 Some(Err(error)) => return Err(io::Error::new(io::ErrorKind::InvalidData, error)),
106 None => {}
51 } 107 }
52 108
53 let mut chunk = [0u8; 4096]; 109 let mut chunk = [0u8; 4096];
54 match reader.read(&mut chunk) { 110 match reader.read(&mut chunk) {
55 Ok(0) => return Ok(self.take_rest()), 111 Ok(0) => return Ok(None),
56 Ok(n) => self.push(&chunk[..n]), 112 Ok(n) => self.push(&chunk[..n]),
57 Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, 113 Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
58 Err(error) => return Err(error), 114 Err(error) => return Err(error),
59 } 115 }
60 } 116 }
61 } 117 }
118
119 /// True when a partial frame is buffered (used by tests and diagnostics).
120 pub fn is_empty(&self) -> bool {
121 self.buffer.is_empty()
122 }
123}
124
125/// Encode one payload as a length-prefixed frame.
126pub fn encode_frame(payload: &str) -> Vec<u8> {
127 let length = payload.len() as u32;
128 let mut bytes = Vec::with_capacity(4 + payload.len());
129 bytes.extend_from_slice(&length.to_be_bytes());
130 bytes.extend_from_slice(payload.as_bytes());
131 bytes
132}
133
134/// Write one payload as a length-prefixed frame.
135pub fn write_frame<W: Write>(writer: &mut W, payload: &str) -> io::Result<()> {
136 writer.write_all(&encode_frame(payload))?;
137 writer.flush()
138}
139
140/// One client request. `id` is the correlation key; `idem` is optional
141/// metadata for logs and a future cross-connection cache.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct Request {
144 pub id: u64,
145 pub idem: Option<String>,
146 pub payload: String,
147}
148
149/// A frame received by a client.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum Packet {
152 Reply { id: u64, body: String },
153 Event { seq: u64, body: String },
154}
155
156pub fn request_line(id: u64, idem: Option<&str>, payload: &str) -> String {
157 match idem {
158 Some(idem) if !idem.is_empty() => format!("REQ {id} idem={idem} {payload}"),
159 _ => format!("REQ {id} {payload}"),
160 }
161}
162
163pub fn reply_line(id: u64, body: &str) -> String {
164 format!("RES {id} {body}")
165}
166
167pub fn event_line(seq: u64, body: &str) -> String {
168 format!("EVT {seq} {body}")
169}
170
171/// Parse a `REQ` payload. Errors are safe to return to the peer.
172pub fn parse_request(frame: &str) -> Result<Request, String> {
173 let frame = frame.trim_end();
174 let rest = frame
175 .strip_prefix("REQ ")
176 .ok_or_else(|| "Malformed request: expected REQ".to_string())?;
177
178 let (id_token, mut rest) = split_token(rest);
179 let id: u64 = id_token
180 .parse()
181 .map_err(|_| "Malformed request id".to_string())?;
182
183 let mut idem = None;
184 let (next, after) = split_token(rest);
185 if let Some(value) = next.strip_prefix("idem=") {
186 if value.is_empty() {
187 return Err("Malformed idempotency key".to_string());
188 }
189 idem = Some(value.to_string());
190 rest = after;
191 }
192
193 Ok(Request {
194 id,
195 idem,
196 payload: rest.to_string(),
197 })
198}
199
200/// Parse a `RES` or `EVT` payload on the client side.
201pub fn parse_packet(frame: &str) -> Result<Packet, String> {
202 let frame = frame.trim_end();
203 if let Some(rest) = frame.strip_prefix("RES ") {
204 let (id_token, body) = split_token(rest);
205 let id: u64 = id_token
206 .parse()
207 .map_err(|_| "Malformed reply id".to_string())?;
208 return Ok(Packet::Reply {
209 id,
210 body: body.to_string(),
211 });
212 }
213 if let Some(rest) = frame.strip_prefix("EVT ") {
214 let (seq_token, body) = split_token(rest);
215 let seq: u64 = seq_token
216 .parse()
217 .map_err(|_| "Malformed event sequence".to_string())?;
218 return Ok(Packet::Event {
219 seq,
220 body: body.to_string(),
221 });
222 }
223 Err(format!("Unexpected packet: {frame}"))
224}
225
226fn split_token(rest: &str) -> (&str, &str) {
227 let rest = rest.trim_start();
228 match rest.find(' ') {
229 Some(index) => (&rest[..index], rest[index + 1..].trim_start()),
230 None => (rest, ""),
231 }
62} 232}
63 233
64/// A stable, user-facing message for a failed request. 234/// A stable, user-facing message for a failed request.