Skip to main content

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}