Skip to main content

pinch_points/
share.rs

1//! Share codes: a beach, a level, or a whole round as one line of text.
2//!
3//! A code is `PP<kind><body>`, where the body is the payload compressed
4//! ([`crate::lzw`]) and written in an alphabet chosen to survive being read
5//! aloud, retyped, and passed through a chat window that likes to capitalise
6//! things. The last character is a checksum, so a code with a typo in it is
7//! refused rather than half-loaded.
8//!
9//! The alphabet is Crockford's base32: ten digits and twenty-two letters,
10//! with `I`, `L`, `O` and `U` left out: the first three because they are
11//! the ones people confuse with `1` and `0`, and the last because leaving
12//! it out means no code ever spells anything unfortunate. Decoding maps the
13//! confusable characters back, so a code someone typed as `IL0` still reads.
14
15use crate::lzw;
16
17const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
18
19/// What a code carries. One letter each, in the code's third character, so a
20/// code says what it is before anything tries to load it. Pasting a level
21/// where a round was wanted should say so, not fail obscurely.
22#[derive(Clone, Copy, PartialEq, Eq, Debug)]
23pub enum Kind {
24    /// A beach mid-play: the board exactly as it stands, and the table
25    /// it is being played at. Pasting one puts you where its sender was.
26    Beach,
27    /// A level, as the level format's own text.
28    Level,
29    /// A recorded round, as the replay format's own text.
30    Round,
31}
32
33impl Kind {
34    fn letter(self) -> char {
35        match self {
36            Kind::Beach => 'B',
37            Kind::Level => 'L',
38            Kind::Round => 'R',
39        }
40    }
41
42    fn from_letter(letter: char) -> Option<Kind> {
43        match letter {
44            'B' => Some(Kind::Beach),
45            'L' => Some(Kind::Level),
46            'R' => Some(Kind::Round),
47            _ => None,
48        }
49    }
50}
51
52/// Group size for readability. A code arrives as one run of characters and
53/// is shown in fives, which is how people read a licence key.
54const GROUP: usize = 5;
55
56/// Write a payload as a share code, hyphenated into readable groups.
57pub fn encode(kind: Kind, payload: &[u8]) -> String {
58    let packed = lzw::compress(payload, 8);
59    let mut body = base32_encode(&packed);
60    body.push(ALPHABET[usize::from(checksum(&body))] as char);
61    let mut out = format!("PP{}", kind.letter());
62    for (i, ch) in body.chars().enumerate() {
63        if i > 0 && i.is_multiple_of(GROUP) {
64            out.push('-');
65        }
66        out.push(ch);
67    }
68    out
69}
70
71/// Read a share code back, or `None` if it is not one: wrong prefix, a
72/// character that is not in the alphabet, or a checksum that does not match
73/// what was typed.
74///
75/// Deliberately forgiving about everything that does not change the
76/// meaning (case, spaces, hyphens, line breaks, and the four letters the
77/// alphabet leaves out) because a code is something a person retypes from
78/// another screen, or pastes off a clipboard that carries whatever the
79/// textarea or the mail client wrapped it in.
80pub fn decode(code: &str) -> Option<(Kind, Vec<u8>)> {
81    let mut chars = code
82        .chars()
83        .filter(|ch| !matches!(ch, '-' | ' ' | '\t' | '\r' | '\n'));
84    let (p, q) = (chars.next()?, chars.next()?);
85    if !p.eq_ignore_ascii_case(&'P') || !q.eq_ignore_ascii_case(&'P') {
86        return None;
87    }
88    let kind = Kind::from_letter(chars.next()?.to_ascii_uppercase())?;
89    let body: String = chars.map(tidy).collect();
90    let (digits, check) = body.split_at_checked(body.len().checked_sub(1)?)?;
91    if check.chars().next()? != ALPHABET[usize::from(checksum(digits))] as char {
92        return None;
93    }
94    let packed = base32_decode(digits)?;
95    let payload = lzw::decompress(&packed, 8)?;
96    Some((kind, payload))
97}
98
99/// The characters people substitute, mapped back to what they meant.
100fn tidy(ch: char) -> char {
101    match ch.to_ascii_uppercase() {
102        'I' | 'L' => '1',
103        'O' => '0',
104        'U' => 'V',
105        other => other,
106    }
107}
108
109/// A checksum over the body. Not a hash: it is here to catch a mistyped
110/// character, a swapped pair, or a dropped group, and one character of code
111/// is the right price for that.
112///
113/// Two details carry the guarantee that *every* single wrong character is
114/// caught. The sum is over each character's **alphabet index**, not its byte:
115/// the byte values are not contiguous, and `'0'` and `'P'` happen to sit 32
116/// apart, so a swap between them would vanish under the modulus. And the
117/// weights are **odd**, which makes them invertible modulo 32, so one wrong
118/// character always moves the sum, where an even weight can be cancelled
119/// by a difference that shares its factors of two.
120///
121/// The cost of that choice, and it is the right way round: adjacent
122/// transpositions move the sum by twice the difference of the two indices,
123/// so the one pair this misses is two characters exactly 16 apart in the
124/// alphabet. Both guarantees at once are beyond a single base-32 check
125/// character by a weighted sum: full substitution cover needs odd weights,
126/// and the difference of two odd weights is always even. Mistyping one
127/// character is the common error; transposing a pair that happens to be 16
128/// apart is not.
129fn checksum(body: &str) -> u8 {
130    let mut sum = 0u32;
131    for (i, ch) in body.chars().enumerate() {
132        let value = index_of(ch).map_or(0, u32::from);
133        let weight = 2 * (i as u32 % 16) + 1;
134        sum = sum.wrapping_add(value.wrapping_mul(weight));
135    }
136    (sum % 32) as u8
137}
138
139/// Where `ch` sits in the alphabet, if it is in it at all.
140fn index_of(ch: char) -> Option<u8> {
141    // Compared as a byte only once it is one: `as u8` truncates, and a
142    // character past ASCII whose low byte happened to spell a digit was
143    // being read as that digit.
144    let byte = u8::try_from(ch).ok()?;
145    ALPHABET
146        .iter()
147        .position(|&a| a == byte)
148        .map(|index| index as u8)
149}
150
151fn base32_encode(bytes: &[u8]) -> String {
152    let mut out = String::new();
153    let (mut acc, mut bits) = (0u32, 0u8);
154    for &byte in bytes {
155        acc = (acc << 8) | u32::from(byte);
156        bits += 8;
157        while bits >= 5 {
158            bits -= 5;
159            out.push(ALPHABET[((acc >> bits) & 0x1F) as usize] as char);
160        }
161    }
162    if bits > 0 {
163        out.push(ALPHABET[((acc << (5 - bits)) & 0x1F) as usize] as char);
164    }
165    out
166}
167
168fn base32_decode(text: &str) -> Option<Vec<u8>> {
169    let mut out = Vec::new();
170    let (mut acc, mut bits) = (0u32, 0u8);
171    for ch in text.chars() {
172        acc = (acc << 5) | u32::from(index_of(ch)?);
173        bits += 5;
174        if bits >= 8 {
175            bits -= 8;
176            out.push(((acc >> bits) & 0xFF) as u8);
177        }
178    }
179    // The tail bits are the encoder's padding, not data.
180    Some(out)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn codes_round_trip_at_every_size() {
189        for payload in [
190            vec![],
191            vec![0u8],
192            b"wrap: on\n".to_vec(),
193            (0..500u32).map(|i| (i % 251) as u8).collect(),
194        ] {
195            for kind in [Kind::Beach, Kind::Level, Kind::Round] {
196                let code = encode(kind, &payload);
197                assert_eq!(decode(&code), Some((kind, payload.clone())), "{code}");
198            }
199        }
200    }
201
202    /// A code is something a person reads off one screen and types into
203    /// another, so everything that survives that has to survive this.
204    #[test]
205    fn a_retyped_code_still_reads() {
206        let code = encode(Kind::Level, b"a small beach");
207        let expected = decode(&code).expect("the code as written");
208        for variant in [
209            code.to_lowercase(),
210            code.replace('-', ""),
211            code.replace('-', " "),
212            format!("  {code}  ").replace(' ', ""),
213        ] {
214            assert_eq!(decode(&variant), Some(expected.clone()), "{variant}");
215        }
216        // And the letters the alphabet leaves out map back to what they
217        // looked like, so a code read aloud survives the trip.
218        let muddled: String = code
219            .chars()
220            .map(|ch| match ch {
221                '1' => 'I',
222                '0' => 'O',
223                other => other,
224            })
225            .collect();
226        assert_eq!(decode(&muddled), Some(expected), "{muddled}");
227    }
228
229    /// A code more often arrives on the clipboard than through the
230    /// keyboard, and a clipboard carries whatever wrapped it: the trailing
231    /// newline of a copied line, the CRLF of a Windows mail client, the
232    /// break a textarea folded it at. None of those change what the code
233    /// says, so none of them may refuse it.
234    #[test]
235    fn a_pasted_code_still_reads() {
236        let code = encode(Kind::Level, b"a small beach");
237        let expected = decode(&code).expect("the code as written");
238        for variant in [
239            format!("{code}\n"),
240            format!("{code}\r\n"),
241            format!("\n{code}\n"),
242            code.replace('-', "\n"),
243            code.replace('-', "\r\n"),
244        ] {
245            assert_eq!(decode(&variant), Some(expected.clone()), "{variant:?}");
246        }
247    }
248
249    /// The checksum earns its character: a typo is refused, not half-loaded.
250    ///
251    /// Every position of the code is mistyped as every other letter of the
252    /// alphabet: the body, and the checksum character itself. Testing only
253    /// the last character would have missed that the old checksum let a
254    /// wrong body character through whenever the two bytes sat 32 apart.
255    #[test]
256    fn a_typo_is_refused() {
257        for payload in [b"seed 12345".as_slice(), b"a", b"wrap: on\n"] {
258            let code = encode(Kind::Beach, payload);
259            assert!(decode(&code).is_some(), "the code itself is good");
260            let chars: Vec<char> = code.chars().collect();
261            for (at, &original) in chars.iter().enumerate() {
262                // The `PP<kind>` prefix is not covered by the checksum; it
263                // is checked outright by `decode`.
264                if at < 3 || original == '-' {
265                    continue;
266                }
267                for replacement in ALPHABET.iter().map(|&b| b as char) {
268                    if replacement == original {
269                        continue;
270                    }
271                    let mut typo = chars.clone();
272                    typo[at] = replacement;
273                    let typo: String = typo.into_iter().collect();
274                    assert_eq!(decode(&typo), None, "{original}->{replacement} in {code}");
275                }
276            }
277        }
278    }
279
280    /// A swapped pair, which a plain unweighted sum would wave through:
281    /// every adjacent pair except the documented blind spot, two characters
282    /// exactly 16 apart in the alphabet.
283    #[test]
284    fn a_swapped_pair_is_refused() {
285        let code = encode(Kind::Beach, b"seed 12345");
286        let body: Vec<char> = code.chars().filter(|c| *c != '-').collect();
287        let mut swaps = 0;
288        for at in 3..body.len() - 1 {
289            let (a, b) = (body[at], body[at + 1]);
290            let apart = index_of(a).unwrap().abs_diff(index_of(b).unwrap());
291            if a == b || apart == 16 {
292                continue;
293            }
294            let mut swapped = body.clone();
295            swapped.swap(at, at + 1);
296            let swapped: String = swapped.into_iter().collect();
297            assert_eq!(decode(&swapped), None, "swap at {at} of {code}");
298            swaps += 1;
299        }
300        assert!(swaps > 0, "the sample code had no adjacent pair to swap");
301    }
302
303    /// A character past ASCII whose low byte spells an alphabet letter is
304    /// not that letter: the lookup used to truncate and read it as one.
305    #[test]
306    fn a_character_past_ascii_is_not_in_the_alphabet() {
307        for &letter in ALPHABET {
308            assert!(index_of(char::from(letter)).is_some());
309            let lookalike = char::from_u32(u32::from(letter) + 0x100).expect("a char");
310            assert_eq!(
311                index_of(lookalike),
312                None,
313                "{lookalike:?} read as {letter:?}"
314            );
315        }
316        let code = encode(Kind::Beach, b"seed 12345");
317        let first = code.chars().next().expect("a code");
318        let lookalike = char::from_u32(u32::from(first) + 0x100).expect("a char");
319        let forged = code.replacen(first, &lookalike.to_string(), 1);
320        assert_eq!(decode(&forged), None);
321    }
322
323    #[test]
324    fn what_is_not_a_code_is_refused() {
325        for junk in [
326            "",
327            "PP",
328            "PPX-12345",
329            "hello",
330            "PPB", // no body at all
331            "12345-67890",
332        ] {
333            assert_eq!(decode(junk), None, "{junk}");
334        }
335    }
336
337    /// The point of compressing: a round is thirty kilobytes of mostly the
338    /// same few characters, and a code nobody can carry is not a share code.
339    #[test]
340    fn a_repetitive_payload_shrinks() {
341        let round = "000000 000000 000000 000000\n".repeat(400);
342        let code = encode(Kind::Round, round.as_bytes());
343        assert!(
344            code.len() < round.len() / 8,
345            "{} characters for {} bytes",
346            code.len(),
347            round.len()
348        );
349        assert_eq!(
350            decode(&code).map(|(_, bytes)| bytes),
351            Some(round.into_bytes())
352        );
353    }
354}