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}