pinch_points/transport/wire.rs
1//! Names and lines of chat, as they travel.
2//!
3//! Fixed-width and NUL-padded, because [`NetMsg`] is `Copy` (the host
4//! relays what it is told by copying it) and because a length prefix is
5//! one more thing a stranger on the LAN could lie about. Everything read
6//! back off the wire goes through here, so a name is tidied in exactly one
7//! place no matter which message carried it.
8
9/// A player name on the wire: UTF-8, NUL-padded, truncated to fit at a
10/// character boundary. 24 bytes carries the full 12-char name cap for
11/// every Latin-alphabet name; scripts wider than two bytes a character
12/// lose tail characters, not validity. Fixed-size so [`NetMsg`] stays
13/// `Copy`: the host relays messages by copying them.
14pub const WIRE_NAME: usize = 24;
15pub type WireName = [u8; WIRE_NAME];
16
17/// A name into its wire form.
18pub fn wire_name(name: &str) -> WireName {
19 let mut out = [0u8; WIRE_NAME];
20 let mut len = 0;
21 for ch in name.chars() {
22 let next = len + ch.len_utf8();
23 if next > WIRE_NAME {
24 break;
25 }
26 ch.encode_utf8(&mut out[len..next]);
27 len = next;
28 }
29 out
30}
31
32/// A line of lobby chat on the wire, in the same fixed-size, NUL-padded
33/// form as a name and for the same reason: [`NetMsg`] stays `Copy`, which
34/// is how the host relays one by copying it. 96 bytes carries the
35/// full [`CHAT_CHARS`] cap for Latin text; wider scripts lose tail
36/// characters, not validity.
37pub const WIRE_CHAT: usize = 96;
38pub type WireChat = [u8; WIRE_CHAT];
39
40/// The most characters a line of chat keeps. Short on purpose: it is a
41/// lobby, not a messaging app, and a line that fits the row is a line
42/// everyone can read at a glance.
43pub const CHAT_CHARS: usize = 48;
44
45/// A line of chat into its wire form, truncated at a character boundary.
46pub fn wire_chat(line: &str) -> WireChat {
47 let mut out = [0u8; WIRE_CHAT];
48 let mut len = 0;
49 for ch in line.chars().take(CHAT_CHARS) {
50 let next = len + ch.len_utf8();
51 if next > WIRE_CHAT {
52 break;
53 }
54 ch.encode_utf8(&mut out[len..next]);
55 len = next;
56 }
57 out
58}
59
60/// A line of chat back into text, distrusting the sender: invalid UTF-8 is
61/// dropped rather than replaced, and control characters are stripped,
62/// since they are not text a child typed and a newline or an escape in a
63/// UI label is nobody's idea of a good time.
64///
65/// Unlike a name this keeps `|` and `:`, which no save file will ever see
66/// and which people actually type.
67pub fn chat_from_wire(bytes: &WireChat) -> String {
68 let end = bytes.iter().position(|&b| b == 0).unwrap_or(WIRE_CHAT);
69 let Ok(text) = std::str::from_utf8(&bytes[..end]) else {
70 return String::new();
71 };
72 text.chars()
73 .filter(|ch| !ch.is_control())
74 .take(CHAT_CHARS)
75 .collect::<String>()
76 .trim()
77 .to_string()
78}
79
80/// The most characters a name keeps coming off the wire: the same cap the
81/// settings screen puts on typed names (`settings::NAME_MAX`), stated here
82/// rather than imported because this layer knows nothing about menus.
83const NAME_CHARS: usize = 12;
84
85/// A wire name back into text, distrusting the sender: invalid UTF-8 is
86/// dropped rather than replaced, control characters and the save-file
87/// separators are stripped, and the length cap is the same one the
88/// settings screen enforces on typed names.
89pub fn name_from_wire(bytes: &WireName) -> String {
90 let end = bytes.iter().position(|&b| b == 0).unwrap_or(WIRE_NAME);
91 let Ok(text) = std::str::from_utf8(&bytes[..end]) else {
92 return String::new();
93 };
94 text.chars()
95 .filter(|&c| !c.is_control() && c != '|' && c != ':')
96 .take(NAME_CHARS)
97 .collect::<String>()
98 .trim()
99 .to_string()
100}
101
102#[cfg(test)]
103mod chat_tests {
104 use super::*;
105
106 /// A line of chat comes off the wire from a child on the next machine,
107 /// or, put plainly, from a stranger: it is capped, trimmed, and stripped
108 /// of anything that is not text a person typed.
109 #[test]
110 fn a_line_of_chat_survives_the_trip_and_is_tidied_on_the_way() {
111 for line in ["ready?", "wait for me!", "Anna: 3 > 2 | ok", "héllo"] {
112 assert_eq!(chat_from_wire(&wire_chat(line)), line, "{line}");
113 }
114 // Newlines and escapes are not chat; they are ways to make a mess
115 // of a UI label.
116 assert_eq!(chat_from_wire(&wire_chat("one\ntwo\u{1b}[0m")), "onetwo[0m");
117 // Trimmed at both ends, and empty is empty.
118 assert_eq!(chat_from_wire(&wire_chat(" ")), "");
119 assert_eq!(chat_from_wire(&wire_chat(" hi ")), "hi");
120 // Capped, at a character boundary, with no panic on the way.
121 let long = "é".repeat(400);
122 let short = chat_from_wire(&wire_chat(&long));
123 assert!(short.chars().count() <= CHAT_CHARS, "{}", short.len());
124 // And nonsense bytes are declined rather than replaced.
125 let mut junk = [0xFFu8; WIRE_CHAT];
126 junk[WIRE_CHAT - 1] = 0;
127 assert_eq!(chat_from_wire(&junk), "");
128 }
129}