diff options
Diffstat (limited to '')
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | src/bin/client/connection.rs | 8 | ||||
| -rw-r--r-- | src/bin/client/repl.rs | 5 | ||||
| -rw-r--r-- | src/bin/client/tls.rs | 36 | ||||
| -rw-r--r-- | src/bin/server/tls.rs | 26 | ||||
| -rw-r--r-- | src/lib.rs | 1 | ||||
| -rw-r--r-- | src/protocol.rs | 70 | ||||
| -rw-r--r-- | tests/protocol.rs | 78 |
8 files changed, 203 insertions, 23 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "0.2.5" | 3 | version = "0.2.6" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | 5 | ||
| 6 | [dependencies] | 6 | [dependencies] |
diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs index 93a7829..49695dd 100644 --- a/src/bin/client/connection.rs +++ b/src/bin/client/connection.rs | |||
| @@ -66,6 +66,7 @@ pub fn connect( | |||
| 66 | } | 66 | } |
| 67 | 67 | ||
| 68 | let mut tls = tls; | 68 | let mut tls = tls; |
| 69 | tls.set_read_timeout(Some(Duration::from_secs(10)))?; | ||
| 69 | 70 | ||
| 70 | let start = Instant::now(); | 71 | let start = Instant::now(); |
| 71 | tls.write_line("PING")?; | 72 | tls.write_line("PING")?; |
| @@ -124,6 +125,13 @@ impl TlsStream { | |||
| 124 | self.inner.write_line(msg) | 125 | self.inner.write_line(msg) |
| 125 | } | 126 | } |
| 126 | 127 | ||
| 128 | pub fn set_read_timeout( | ||
| 129 | &self, | ||
| 130 | duration: Option<std::time::Duration>, | ||
| 131 | ) -> std::io::Result<()> { | ||
| 132 | self.inner.set_read_timeout(duration) | ||
| 133 | } | ||
| 134 | |||
| 127 | pub fn try_clone(&self) -> std::io::Result<TlsStream> { | 135 | pub fn try_clone(&self) -> std::io::Result<TlsStream> { |
| 128 | Ok(TlsStream { | 136 | Ok(TlsStream { |
| 129 | inner: self.inner.try_clone()?, | 137 | inner: self.inner.try_clone()?, |
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index 76b149b..033fb1a 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs | |||
| @@ -18,6 +18,7 @@ use zagan_bet::client::database; | |||
| 18 | use zagan_bet::client::ui::{ | 18 | use zagan_bet::client::ui::{ |
| 19 | display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up, | 19 | display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up, |
| 20 | }; | 20 | }; |
| 21 | use zagan_bet::protocol::io_failure_message; | ||
| 21 | 22 | ||
| 22 | use crate::connection::TlsStream; | 23 | use crate::connection::TlsStream; |
| 23 | 24 | ||
| @@ -299,9 +300,9 @@ impl ClientState { | |||
| 299 | self.stream = Some(stream); | 300 | self.stream = Some(stream); |
| 300 | response | 301 | response |
| 301 | } | 302 | } |
| 302 | Err(_) => { | 303 | Err(error) => { |
| 303 | self.stream = None; | 304 | self.stream = None; |
| 304 | "ERROR Connection lost".to_string() | 305 | io_failure_message(&error).to_string() |
| 305 | } | 306 | } |
| 306 | } | 307 | } |
| 307 | } | 308 | } |
diff --git a/src/bin/client/tls.rs b/src/bin/client/tls.rs index ca99838..b118f63 100644 --- a/src/bin/client/tls.rs +++ b/src/bin/client/tls.rs | |||
| @@ -19,6 +19,7 @@ use std::sync::Arc; | |||
| 19 | use parking_lot::Mutex; | 19 | use parking_lot::Mutex; |
| 20 | use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; | 20 | use rustls::client::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; |
| 21 | use rustls::DigitallySignedStruct; | 21 | use rustls::DigitallySignedStruct; |
| 22 | use zagan_bet::protocol::LineBuffer; | ||
| 22 | use zagan_bet::tofu::{decide, spki_fingerprint, TrustDecision}; | 23 | use zagan_bet::tofu::{decide, spki_fingerprint, TrustDecision}; |
| 23 | 24 | ||
| 24 | pub struct TlsConnector { | 25 | pub struct TlsConnector { |
| @@ -59,7 +60,11 @@ impl TlsConnector { | |||
| 59 | conn.complete_io(&mut sock)?; | 60 | conn.complete_io(&mut sock)?; |
| 60 | 61 | ||
| 61 | Ok(TlsStream { | 62 | Ok(TlsStream { |
| 62 | session: Arc::new(Mutex::new(Session { sock, tls: conn })), | 63 | session: Arc::new(Mutex::new(Session { |
| 64 | sock, | ||
| 65 | tls: conn, | ||
| 66 | lines: LineBuffer::new(), | ||
| 67 | })), | ||
| 63 | }) | 68 | }) |
| 64 | } | 69 | } |
| 65 | 70 | ||
| @@ -144,6 +149,7 @@ impl ServerCertVerifier for TofuVerifier { | |||
| 144 | struct Session { | 149 | struct Session { |
| 145 | sock: TcpStream, | 150 | sock: TcpStream, |
| 146 | tls: rustls::ClientConnection, | 151 | tls: rustls::ClientConnection, |
| 152 | lines: LineBuffer, | ||
| 147 | } | 153 | } |
| 148 | 154 | ||
| 149 | impl Session { | 155 | impl Session { |
| @@ -159,17 +165,25 @@ pub struct TlsStream { | |||
| 159 | 165 | ||
| 160 | impl TlsStream { | 166 | impl TlsStream { |
| 161 | pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> { | 167 | pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> { |
| 162 | let mut byte_buf = [0u8; 1024]; | ||
| 163 | let mut s = self.session.lock(); | 168 | let mut s = self.session.lock(); |
| 164 | loop { | 169 | loop { |
| 170 | if let Some(line) = s.lines.next_line() { | ||
| 171 | buf.push_str(&line); | ||
| 172 | buf.push('\n'); | ||
| 173 | return Ok(line.len() + 1); | ||
| 174 | } | ||
| 175 | |||
| 176 | let mut byte_buf = [0u8; 4096]; | ||
| 165 | match s.tls.reader().read(&mut byte_buf) { | 177 | match s.tls.reader().read(&mut byte_buf) { |
| 166 | Ok(0) => return Ok(0), | 178 | Ok(0) => { |
| 167 | Ok(n) => { | 179 | if let Some(rest) = s.lines.take_rest() { |
| 168 | buf.push_str(std::str::from_utf8(&byte_buf[..n]).unwrap_or("")); | 180 | let read = rest.len(); |
| 169 | if buf.contains('\n') { | 181 | buf.push_str(&rest); |
| 170 | return Ok(n); | 182 | return Ok(read); |
| 171 | } | 183 | } |
| 184 | return Ok(0); | ||
| 172 | } | 185 | } |
| 186 | Ok(n) => s.lines.push(&byte_buf[..n]), | ||
| 173 | Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, | 187 | Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => s.drive()?, |
| 174 | Err(e) => return Err(e), | 188 | Err(e) => return Err(e), |
| 175 | } | 189 | } |
| @@ -183,6 +197,14 @@ impl TlsStream { | |||
| 183 | s.drive() | 197 | s.drive() |
| 184 | } | 198 | } |
| 185 | 199 | ||
| 200 | /// Apply a read timeout to the underlying TCP socket. | ||
| 201 | pub fn set_read_timeout( | ||
| 202 | &self, | ||
| 203 | duration: Option<std::time::Duration>, | ||
| 204 | ) -> std::io::Result<()> { | ||
| 205 | self.session.lock().sock.set_read_timeout(duration) | ||
| 206 | } | ||
| 207 | |||
| 186 | /// Clones share the same underlying TLS session. | 208 | /// Clones share the same underlying TLS session. |
| 187 | pub fn try_clone(&self) -> std::io::Result<TlsStream> { | 209 | pub fn try_clone(&self) -> std::io::Result<TlsStream> { |
| 188 | Ok(self.clone()) | 210 | Ok(self.clone()) |
diff --git a/src/bin/server/tls.rs b/src/bin/server/tls.rs index acf3315..62a25db 100644 --- a/src/bin/server/tls.rs +++ b/src/bin/server/tls.rs | |||
| @@ -1,6 +1,8 @@ | |||
| 1 | use std::io::{Read, Write}; | 1 | use std::io::{Read, Write}; |
| 2 | use std::net::TcpStream; | 2 | use std::net::TcpStream; |
| 3 | 3 | ||
| 4 | use zagan_bet::protocol::LineBuffer; | ||
| 5 | |||
| 4 | #[derive(Clone)] | 6 | #[derive(Clone)] |
| 5 | pub struct TlsServer { | 7 | pub struct TlsServer { |
| 6 | acceptor: native_tls::TlsAcceptor, | 8 | acceptor: native_tls::TlsAcceptor, |
| @@ -42,27 +44,25 @@ impl TlsServer { | |||
| 42 | 44 | ||
| 43 | pub struct TlsStream { | 45 | pub struct TlsStream { |
| 44 | stream: native_tls::TlsStream<TcpStream>, | 46 | stream: native_tls::TlsStream<TcpStream>, |
| 47 | lines: LineBuffer, | ||
| 45 | } | 48 | } |
| 46 | 49 | ||
| 47 | impl TlsStream { | 50 | impl TlsStream { |
| 48 | pub fn new(stream: native_tls::TlsStream<TcpStream>) -> Self { | 51 | pub fn new(stream: native_tls::TlsStream<TcpStream>) -> Self { |
| 49 | Self { stream } | 52 | Self { |
| 53 | stream, | ||
| 54 | lines: LineBuffer::new(), | ||
| 55 | } | ||
| 50 | } | 56 | } |
| 51 | 57 | ||
| 52 | pub fn read_line(&mut self, s: &mut String) -> std::io::Result<usize> { | 58 | pub fn read_line(&mut self, s: &mut String) -> std::io::Result<usize> { |
| 53 | let mut buf = [0u8; 1024]; | 59 | match self.lines.read_line(&mut self.stream)? { |
| 54 | loop { | 60 | Some(line) => { |
| 55 | match self.stream.read(&mut buf) { | 61 | s.push_str(&line); |
| 56 | Ok(0) => return Ok(0), | 62 | s.push('\n'); |
| 57 | Ok(n) => { | 63 | Ok(line.len() + 1) |
| 58 | let chunk = std::str::from_utf8(&buf[..n]).unwrap_or(""); | ||
| 59 | s.push_str(chunk); | ||
| 60 | if s.contains('\n') { | ||
| 61 | return Ok(n); | ||
| 62 | } | ||
| 63 | } | ||
| 64 | Err(e) => return Err(e), | ||
| 65 | } | 64 | } |
| 65 | None => Ok(0), | ||
| 66 | } | 66 | } |
| 67 | } | 67 | } |
| 68 | 68 | ||
| @@ -6,5 +6,6 @@ | |||
| 6 | //! `tests/` without a network or a terminal. | 6 | //! `tests/` without a network or a terminal. |
| 7 | 7 | ||
| 8 | pub mod client; | 8 | pub mod client; |
| 9 | pub mod protocol; | ||
| 9 | pub mod server; | 10 | pub mod server; |
| 10 | pub mod tofu; | 11 | pub mod tofu; |
diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..11f54a1 --- /dev/null +++ b/src/protocol.rs | |||
| @@ -0,0 +1,70 @@ | |||
| 1 | //! Newline-delimited protocol helpers shared by the client and server. | ||
| 2 | //! | ||
| 3 | //! TLS reads can return more than one line at a time. Keeping the leftover | ||
| 4 | //! bytes is essential: dropping them silently loses the next message. | ||
| 5 | |||
| 6 | use std::io::{self, Read}; | ||
| 7 | |||
| 8 | #[derive(Debug, Default)] | ||
| 9 | pub struct LineBuffer { | ||
| 10 | buffer: Vec<u8>, | ||
| 11 | } | ||
| 12 | |||
| 13 | impl LineBuffer { | ||
| 14 | pub fn new() -> Self { | ||
| 15 | Self::default() | ||
| 16 | } | ||
| 17 | |||
| 18 | pub fn push(&mut self, bytes: &[u8]) { | ||
| 19 | self.buffer.extend_from_slice(bytes); | ||
| 20 | } | ||
| 21 | |||
| 22 | /// Return the next complete line, including a line that ended at EOF. | ||
| 23 | pub fn next_line(&mut self) -> Option<String> { | ||
| 24 | let end = self.buffer.iter().position(|byte| *byte == b'\n')?; | ||
| 25 | let mut line: Vec<u8> = self.buffer.drain(..=end).collect(); | ||
| 26 | line.pop(); // '\n' | ||
| 27 | if line.last() == Some(&b'\r') { | ||
| 28 | line.pop(); | ||
| 29 | } | ||
| 30 | Some(String::from_utf8_lossy(&line).into_owned()) | ||
| 31 | } | ||
| 32 | |||
| 33 | /// Return the unterminated remainder at EOF, if any. | ||
| 34 | pub fn take_rest(&mut self) -> Option<String> { | ||
| 35 | if self.buffer.is_empty() { | ||
| 36 | return None; | ||
| 37 | } | ||
| 38 | let mut rest = std::mem::take(&mut self.buffer); | ||
| 39 | if rest.last() == Some(&b'\r') { | ||
| 40 | rest.pop(); | ||
| 41 | } | ||
| 42 | Some(String::from_utf8_lossy(&rest).into_owned()) | ||
| 43 | } | ||
| 44 | |||
| 45 | /// Read one line from a blocking reader, preserving any extra lines for | ||
| 46 | /// the next call. Returns `None` at EOF with nothing buffered. | ||
| 47 | pub fn read_line<R: Read>(&mut self, reader: &mut R) -> io::Result<Option<String>> { | ||
| 48 | loop { | ||
| 49 | if let Some(line) = self.next_line() { | ||
| 50 | return Ok(Some(line)); | ||
| 51 | } | ||
| 52 | |||
| 53 | let mut chunk = [0u8; 4096]; | ||
| 54 | match reader.read(&mut chunk) { | ||
| 55 | Ok(0) => return Ok(self.take_rest()), | ||
| 56 | Ok(n) => self.push(&chunk[..n]), | ||
| 57 | Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, | ||
| 58 | Err(error) => return Err(error), | ||
| 59 | } | ||
| 60 | } | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | /// A stable, user-facing message for a failed request. | ||
| 65 | pub fn io_failure_message(error: &io::Error) -> &'static str { | ||
| 66 | match error.kind() { | ||
| 67 | io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock => "ERROR Request timed out", | ||
| 68 | _ => "ERROR Connection lost", | ||
| 69 | } | ||
| 70 | } | ||
diff --git a/tests/protocol.rs b/tests/protocol.rs new file mode 100644 index 0000000..2962574 --- /dev/null +++ b/tests/protocol.rs | |||
| @@ -0,0 +1,78 @@ | |||
| 1 | use std::io::{Cursor, Error, ErrorKind}; | ||
| 2 | |||
| 3 | use zagan_bet::protocol::{io_failure_message, LineBuffer}; | ||
| 4 | |||
| 5 | #[test] | ||
| 6 | fn keeps_data_after_the_first_newline() { | ||
| 7 | let mut buffer = LineBuffer::new(); | ||
| 8 | buffer.push(b"PONG\nGAME_RESULT session 60 10 990 OSS two\n"); | ||
| 9 | |||
| 10 | assert_eq!(buffer.next_line().as_deref(), Some("PONG")); | ||
| 11 | assert_eq!( | ||
| 12 | buffer.next_line().as_deref(), | ||
| 13 | Some("GAME_RESULT session 60 10 990 OSS two") | ||
| 14 | ); | ||
| 15 | assert_eq!(buffer.next_line(), None); | ||
| 16 | } | ||
| 17 | |||
| 18 | #[test] | ||
| 19 | fn handles_crlf_and_empty_lines() { | ||
| 20 | let mut buffer = LineBuffer::new(); | ||
| 21 | buffer.push(b"PONG\r\n\r\n"); | ||
| 22 | |||
| 23 | assert_eq!(buffer.next_line().as_deref(), Some("PONG")); | ||
| 24 | assert_eq!(buffer.next_line().as_deref(), Some("")); | ||
| 25 | assert_eq!(buffer.next_line(), None); | ||
| 26 | } | ||
| 27 | |||
| 28 | #[test] | ||
| 29 | fn waits_for_a_split_line() { | ||
| 30 | let mut buffer = LineBuffer::new(); | ||
| 31 | buffer.push(b"PO"); | ||
| 32 | assert_eq!(buffer.next_line(), None); | ||
| 33 | |||
| 34 | buffer.push(b"NG\n"); | ||
| 35 | assert_eq!(buffer.next_line().as_deref(), Some("PONG")); | ||
| 36 | } | ||
| 37 | |||
| 38 | #[test] | ||
| 39 | fn reads_multiple_lines_from_a_reader() { | ||
| 40 | let mut buffer = LineBuffer::new(); | ||
| 41 | let mut reader = Cursor::new(b"one\ntwo\n".to_vec()); | ||
| 42 | |||
| 43 | assert_eq!(buffer.read_line(&mut reader).unwrap().as_deref(), Some("one")); | ||
| 44 | assert_eq!(buffer.read_line(&mut reader).unwrap().as_deref(), Some("two")); | ||
| 45 | assert_eq!(buffer.read_line(&mut reader).unwrap(), None); | ||
| 46 | } | ||
| 47 | |||
| 48 | #[test] | ||
| 49 | fn returns_a_partial_line_at_eof() { | ||
| 50 | let mut buffer = LineBuffer::new(); | ||
| 51 | let mut reader = Cursor::new(b"partial".to_vec()); | ||
| 52 | |||
| 53 | assert_eq!( | ||
| 54 | buffer.read_line(&mut reader).unwrap().as_deref(), | ||
| 55 | Some("partial") | ||
| 56 | ); | ||
| 57 | assert_eq!(buffer.read_line(&mut reader).unwrap(), None); | ||
| 58 | } | ||
| 59 | |||
| 60 | #[test] | ||
| 61 | fn timeout_errors_get_a_timeout_message() { | ||
| 62 | assert_eq!( | ||
| 63 | io_failure_message(&Error::from(ErrorKind::TimedOut)), | ||
| 64 | "ERROR Request timed out" | ||
| 65 | ); | ||
| 66 | assert_eq!( | ||
| 67 | io_failure_message(&Error::from(ErrorKind::WouldBlock)), | ||
| 68 | "ERROR Request timed out" | ||
| 69 | ); | ||
| 70 | assert_eq!( | ||
| 71 | io_failure_message(&Error::from(ErrorKind::UnexpectedEof)), | ||
| 72 | "ERROR Connection lost" | ||
| 73 | ); | ||
| 74 | assert_eq!( | ||
| 75 | io_failure_message(&Error::from(ErrorKind::ConnectionReset)), | ||
| 76 | "ERROR Connection lost" | ||
| 77 | ); | ||
| 78 | } | ||
