umsh_core/
region.rs

1//! Region-code encoding for the Region Code packet option.
2//!
3//! A region code is a 2-byte routing-domain tag. It is not an RF band plan:
4//! it scopes flood forwarding to a locally agreed area, and repeaters match
5//! it against their configured region list.
6//!
7//! Codes come from two sources, and the encoding keeps the letters disjoint
8//! from the hashes so a code can be rendered without knowing which one
9//! produced it:
10//!
11//! - **Short codes** encode one to three ASCII letters or digits with
12//!   ARNCE/HAM-16. Three letters are conventionally an IATA airport code and
13//!   two an ISO 3166-1 country or a bare subdivision code.
14//! - **Named regions** take the first two bytes of the SHA-256 of the
15//!   ASCII-case-folded name, transformed away from the letter space if they
16//!   happen to land in it.
17//!
18//! Both derivations ignore ASCII case, so a region is the same region
19//! however its string was capitalized.
20//!
21//! Because the transform vacates every all-letter encoding, a code that
22//! decodes to letters always came from a short code, and
23//! [`Display`](core::fmt::Display) renders it as those letters. A short code
24//! bearing a digit is not vacated: it encodes, but it shares its space with
25//! the hashes and so has no reading anyone can rely on. Everything else
26//! renders as `0xXXXX`.
27
28use core::fmt;
29use core::str::FromStr;
30
31use hamaddr::{HamAddr, HamAddrType};
32use sha2::{Digest, Sha256};
33
34/// Lowest ARNCE index that denotes a letter (`A`).
35const LETTER_MIN: u16 = 1;
36/// Highest ARNCE index that denotes a letter (`Z`).
37const LETTER_MAX: u16 = 26;
38/// How many letters there are to encode.
39const LETTERS: u16 = 26;
40
41/// First code reserved for transformed named regions, which is the first
42/// code whose leading character is not a letter.
43const TRANSFORM_BASE: u16 = 27 * 1600;
44/// Where the transformed three-letter codes give way to the two-letter ones.
45const TWO_LETTER_BASE: u16 = TRANSFORM_BASE + LETTERS * LETTERS * LETTERS;
46/// Where the transformed two-letter codes give way to the one-letter ones.
47const ONE_LETTER_BASE: u16 = TWO_LETTER_BASE + LETTERS * LETTERS;
48
49/// Longest a short code may be, in characters.
50const SHORT_CODE_MAX_LEN: usize = 3;
51
52/// Longest a region's string form may be, in UTF-8 bytes.
53///
54/// Names travel on the wire in identity payloads, one option per region, and
55/// this bound is what lets a list of them fit
56/// (packet-options.md § Region Code Encoding).
57pub const REGION_NAME_MAX_LEN: usize = 24;
58
59/// A 2-byte region identifier.
60///
61/// ```
62/// # use umsh_core::RegionCode;
63/// let sjc: RegionCode = "SJC".parse().unwrap();
64/// assert_eq!(sjc.to_bytes(), [0x78, 0x53]);
65/// assert_eq!(sjc.to_string(), "SJC");
66///
67/// let oregon: RegionCode = "OR".parse().unwrap();
68/// assert_eq!(oregon.to_string(), "OR");
69///
70/// let valley: RegionCode = "Rogue Valley".parse().unwrap();
71/// assert_eq!(valley.to_string(), "0xC0F9");
72/// ```
73#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
74pub struct RegionCode(u16);
75
76impl RegionCode {
77    /// Wrap a raw code.
78    pub const fn from_u16(value: u16) -> Self {
79        Self(value)
80    }
81
82    /// Return the raw code.
83    pub const fn as_u16(self) -> u16 {
84        self.0
85    }
86
87    /// Wrap a code from its two wire bytes.
88    pub const fn from_bytes(bytes: [u8; 2]) -> Self {
89        Self(u16::from_be_bytes(bytes))
90    }
91
92    /// Return the code's two wire bytes.
93    pub const fn to_bytes(self) -> [u8; 2] {
94        self.0.to_be_bytes()
95    }
96
97    /// Encode a short code: one to three ASCII letters or digits.
98    ///
99    /// Case-insensitive. Anything longer, or bearing a character ARNCE
100    /// spells but a region may not use, is rejected, so that named regions
101    /// cannot be mistaken for short codes.
102    ///
103    /// An all-letter code is exclusive — no named region can derive it — and
104    /// so reads back as itself. One bearing a digit encodes just as
105    /// faithfully but shares its space with the hashes, so it does not.
106    pub fn from_short_code(code: &str) -> Result<Self, RegionCodeError> {
107        if code.is_empty()
108            || code.len() > SHORT_CODE_MAX_LEN
109            || !code.bytes().all(|b| b.is_ascii_alphanumeric())
110        {
111            return Err(RegionCodeError::NotShortCode);
112        }
113        let addr = HamAddr::try_from_callsign(code).map_err(|_| RegionCodeError::NotShortCode)?;
114        Ok(Self(addr.chunk(0)))
115    }
116
117    /// Derive the code for a named region.
118    ///
119    /// ASCII letters are folded to lowercase before hashing, so spellings
120    /// that differ only in case are the same region. No other bytes are
121    /// altered; a name containing non-ASCII characters hashes those bytes
122    /// verbatim.
123    pub fn from_name(name: &str) -> Self {
124        let mut hasher = Sha256::new();
125        for byte in name.bytes() {
126            hasher.update([byte.to_ascii_lowercase()]);
127        }
128        let digest = hasher.finalize();
129        Self(transform_letter_chunk(u16::from_be_bytes([
130            digest[0], digest[1],
131        ])))
132    }
133
134    /// Return the letters this code decodes to, if it decodes to letters
135    /// at all.
136    ///
137    /// `Some` means the code came from an all-letter
138    /// [short code](Self::from_short_code): named regions are transformed
139    /// out of this space by construction. A code decoding to anything else,
140    /// digits included, has no reading and returns `None`.
141    pub fn letters(self) -> Option<Letters> {
142        let addr = HamAddr::from_chunks([self.0, 0, 0, 0]);
143        if !matches!(addr.get_type(), HamAddrType::Callsign) {
144            return None;
145        }
146        let mut sink = LetterSink::default();
147        fmt::write(&mut sink, format_args!("{addr}")).ok()?;
148        let text = sink.buf.get(..sink.len)?;
149        if text.is_empty() || !text.iter().all(u8::is_ascii_alphabetic) {
150            return None;
151        }
152        Some(Letters {
153            buf: sink.buf,
154            len: sink.len as u8,
155        })
156    }
157}
158
159/// The one to three letters a region code reads as.
160///
161/// Held inline: a [`RegionCode`] is `Copy`, and this crate has no allocator
162/// to lean on.
163#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
164pub struct Letters {
165    buf: [u8; SHORT_CODE_MAX_LEN],
166    len: u8,
167}
168
169impl Letters {
170    /// Return the letters as text, uppercase however the code was written.
171    pub fn as_str(&self) -> &str {
172        // The bytes came from `letters`, which admits only ASCII letters.
173        core::str::from_utf8(&self.buf[..self.len as usize]).unwrap_or_default()
174    }
175}
176
177impl core::ops::Deref for Letters {
178    type Target = str;
179
180    fn deref(&self) -> &str {
181        self.as_str()
182    }
183}
184
185impl fmt::Display for Letters {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        f.write_str(self.as_str())
188    }
189}
190
191impl fmt::Debug for Letters {
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        fmt::Debug::fmt(self.as_str(), f)
194    }
195}
196
197/// Move an all-letter encoding into the space reserved for named regions.
198///
199/// The three lengths land in three consecutive blocks, longest first, so
200/// that adding the shorter ones left every code already assigned where it
201/// was. Anything that is not all letters — a code bearing a digit among
202/// them, or one already outside the letter space — is returned unchanged.
203fn transform_letter_chunk(encoded: u16) -> u16 {
204    let a = encoded / 1600;
205    let b = (encoded / 40) % 40;
206    let c = encoded % 40;
207
208    let is_letter = |x: u16| (LETTER_MIN..=LETTER_MAX).contains(&x);
209    if !is_letter(a) {
210        return encoded;
211    }
212
213    match (is_letter(b), b, is_letter(c), c) {
214        (true, _, true, _) => {
215            TRANSFORM_BASE + (a - 1) * LETTERS * LETTERS + (b - 1) * LETTERS + (c - 1)
216        }
217        (true, _, false, 0) => TWO_LETTER_BASE + (a - 1) * LETTERS + (b - 1),
218        (false, 0, false, 0) => ONE_LETTER_BASE + (a - 1),
219        _ => encoded,
220    }
221}
222
223/// A fixed-capacity sink for the at-most-three characters a single ARNCE
224/// chunk renders to. Overlong writes leave `len` past the buffer and are
225/// rejected by the caller.
226#[derive(Default)]
227struct LetterSink {
228    buf: [u8; SHORT_CODE_MAX_LEN],
229    len: usize,
230}
231
232impl fmt::Write for LetterSink {
233    fn write_str(&mut self, s: &str) -> fmt::Result {
234        for byte in s.bytes() {
235            if let Some(slot) = self.buf.get_mut(self.len) {
236                *slot = byte;
237            }
238            self.len += 1;
239        }
240        Ok(())
241    }
242}
243
244/// Parse a region code from its textual form.
245///
246/// `0x` followed by exactly four hex digits is the code it spells, one to
247/// three ASCII letters or digits is a short code, and anything else is a
248/// region name. The derivation is total over every string of one to
249/// [`REGION_NAME_MAX_LEN`] bytes: a string that merely looks like a literal
250/// code — `0x12`, `0xzz` — is not one, and is hashed as the name it is
251/// (packet-options.md § Region Code Encoding).
252impl FromStr for RegionCode {
253    type Err = RegionCodeError;
254
255    fn from_str(s: &str) -> Result<Self, Self::Err> {
256        let trimmed = s.trim();
257        if trimmed.is_empty() {
258            return Err(RegionCodeError::Empty);
259        }
260        if trimmed.len() > REGION_NAME_MAX_LEN {
261            return Err(RegionCodeError::TooLong);
262        }
263        if let Some(hex) = trimmed
264            .strip_prefix("0x")
265            .or_else(|| trimmed.strip_prefix("0X"))
266            && hex.len() == 4
267            && hex.bytes().all(|b| b.is_ascii_hexdigit())
268            && let Ok(value) = u16::from_str_radix(hex, 16)
269        {
270            return Ok(Self(value));
271        }
272        Self::from_short_code(trimmed).or_else(|_| Ok(Self::from_name(trimmed)))
273    }
274}
275
276impl fmt::Display for RegionCode {
277    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278        match self.letters() {
279            Some(letters) => f.write_str(&letters),
280            None => write!(f, "0x{:04X}", self.0),
281        }
282    }
283}
284
285impl fmt::Debug for RegionCode {
286    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287        write!(f, "RegionCode({self})")
288    }
289}
290
291impl From<RegionCode> for [u8; 2] {
292    fn from(code: RegionCode) -> Self {
293        code.to_bytes()
294    }
295}
296
297impl From<[u8; 2]> for RegionCode {
298    fn from(bytes: [u8; 2]) -> Self {
299        Self::from_bytes(bytes)
300    }
301}
302
303/// Why a string could not be read as a region code.
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum RegionCodeError {
306    /// The input was empty or only whitespace.
307    Empty,
308    /// The input was longer than [`REGION_NAME_MAX_LEN`] bytes.
309    TooLong,
310    /// The input was not one to three ASCII letters or digits.
311    NotShortCode,
312}
313
314impl fmt::Display for RegionCodeError {
315    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
316        match self {
317            Self::Empty => f.write_str("empty region code"),
318            Self::TooLong => write!(f, "region name longer than {REGION_NAME_MAX_LEN} bytes"),
319            Self::NotShortCode => f.write_str("expected one to three letters or digits"),
320        }
321    }
322}
323
324#[cfg(feature = "std")]
325impl std::error::Error for RegionCodeError {}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn encodes_the_short_codes_from_the_specification() {
333        assert_eq!(RegionCode::from_short_code("SJC").unwrap().as_u16(), 0x7853);
334        assert_eq!(RegionCode::from_short_code("MFR").unwrap().as_u16(), 0x5242);
335        assert_eq!(RegionCode::from_short_code("US").unwrap().as_u16(), 0x8638);
336        assert_eq!(RegionCode::from_short_code("WA").unwrap().as_u16(), 0x8FE8);
337    }
338
339    #[test]
340    fn encodes_short_codes_case_insensitively() {
341        for (lower, upper) in [("sjc", "SJC"), ("us", "US"), ("w7", "W7")] {
342            assert_eq!(
343                RegionCode::from_short_code(lower).unwrap(),
344                RegionCode::from_short_code(upper).unwrap(),
345                "{lower:?} and {upper:?} should be one code"
346            );
347        }
348    }
349
350    #[test]
351    fn rejects_anything_that_is_not_one_to_three_alphanumerics() {
352        // `/` and `-` are ARNCE characters, but a region may not spell one:
353        // the transformed codes are led by them, and a short code that could
354        // reach that space would read as a region it is not.
355        for input in ["SJCA", "SJ-", "", "S J", "SJ/", "^SJ", "Rogue"] {
356            assert_eq!(
357                RegionCode::from_short_code(input),
358                Err(RegionCodeError::NotShortCode),
359                "{input:?} should not parse as a short code"
360            );
361        }
362    }
363
364    #[test]
365    fn encodes_short_codes_bearing_digits_without_making_them_readable() {
366        // These are encodable, injective and stable — but not vacated, so
367        // they share their space with the hashes and never render.
368        for input in ["W7", "5", "0A1"] {
369            let code = RegionCode::from_short_code(input).unwrap();
370            assert_eq!(
371                input.parse::<RegionCode>().unwrap(),
372                code,
373                "{input:?} should parse as the short code it is"
374            );
375            assert_eq!(code.letters(), None, "{input:?} should have no reading");
376            assert_eq!(code.to_string(), format!("0x{:04X}", code.as_u16()));
377        }
378    }
379
380    #[test]
381    fn distinct_short_codes_never_share_a_region_code() {
382        // Injectivity is the whole reason to encode these rather than hash
383        // them: two different short codes are always two different regions.
384        let mut seen = std::collections::HashMap::new();
385        let alphabet: Vec<u8> = (b'A'..=b'Z').chain(b'0'..=b'9').collect();
386        let mut push = |text: String| {
387            let code = RegionCode::from_short_code(&text).unwrap();
388            if let Some(other) = seen.insert(code, text.clone()) {
389                panic!("{text:?} and {other:?} both encode to {code:?}");
390            }
391        };
392        for &a in &alphabet {
393            push(String::from_utf8(vec![a]).unwrap());
394            for &b in &alphabet {
395                push(String::from_utf8(vec![a, b]).unwrap());
396                for &c in &alphabet {
397                    push(String::from_utf8(vec![a, b, c]).unwrap());
398                }
399            }
400        }
401        assert_eq!(seen.len(), 36 + 36 * 36 + 36 * 36 * 36);
402    }
403
404    #[test]
405    fn derives_named_regions_from_the_hash_prefix() {
406        assert_eq!(RegionCode::from_name("Willamette Valley").as_u16(), 0xB02D);
407        assert_eq!(RegionCode::from_name("East Bay").as_u16(), 0x36E2);
408    }
409
410    #[test]
411    fn derives_named_regions_case_insensitively() {
412        for spelling in ["rogue valley", "ROGUE VALLEY", "RoGuE vAlLeY"] {
413            assert_eq!(
414                RegionCode::from_name(spelling),
415                RegionCode::from_name("Rogue Valley"),
416                "{spelling:?} should derive the same region"
417            );
418            assert_eq!(
419                spelling.parse::<RegionCode>().unwrap(),
420                "Rogue Valley".parse::<RegionCode>().unwrap(),
421                "{spelling:?} should parse to the same region"
422            );
423        }
424    }
425
426    #[test]
427    fn transforms_a_named_region_that_lands_on_three_letters() {
428        // SHA-256("rogue valley") begins 0x3F56, which decodes to `JEN`.
429        assert_eq!(transform_letter_chunk(0x3F56), 0xC0F9);
430        assert_eq!(RegionCode::from_name("Rogue Valley").as_u16(), 0xC0F9);
431    }
432
433    #[test]
434    fn transforms_a_named_region_that_lands_on_two_letters() {
435        // SHA-256("wasatch front") begins 0x5FA0, which decodes to `OL` —
436        // vacated now that two letters are a short code of their own
437        // (packet-options.md § Region Code Encoding).
438        assert_eq!(transform_letter_chunk(0x5FA0), 0xEEDF);
439        assert_eq!(RegionCode::from_name("Wasatch Front").as_u16(), 0xEEDF);
440        assert_eq!(RegionCode::from_u16(0xEEDF).to_string(), "0xEEDF");
441    }
442
443    #[test]
444    fn the_transform_blocks_sit_where_the_specification_says() {
445        assert_eq!(TRANSFORM_BASE, 0xA8C0);
446        assert_eq!(TWO_LETTER_BASE, 0xED68);
447        assert_eq!(ONE_LETTER_BASE, 0xF00C);
448        // The highest code the transform can yield, and the count it vacates.
449        assert_eq!(ONE_LETTER_BASE + LETTERS - 1, 0xF025);
450        assert_eq!(0xF025 - TRANSFORM_BASE + 1, 18278);
451    }
452
453    #[test]
454    fn leaves_a_named_region_outside_the_letter_space_alone() {
455        assert_eq!(transform_letter_chunk(0xB02D), 0xB02D);
456        assert_eq!(transform_letter_chunk(0x36E2), 0x36E2);
457    }
458
459    #[test]
460    fn no_named_region_can_collide_with_a_letter_region() {
461        // The transform is what guarantees this, so assert the property
462        // over the whole 16-bit space rather than trusting the examples.
463        for raw in 0..=u16::MAX {
464            let transformed = RegionCode::from_u16(transform_letter_chunk(raw));
465            assert_eq!(
466                transformed.letters(),
467                None,
468                "0x{raw:04X} transformed to a code that reads as letters"
469            );
470        }
471    }
472
473    #[test]
474    fn every_letter_region_round_trips_through_its_text_form() {
475        let mut cases = Vec::new();
476        for a in b'A'..=b'Z' {
477            cases.push(vec![a]);
478            for b in b'A'..=b'Z' {
479                cases.push(vec![a, b]);
480                for c in b'A'..=b'Z' {
481                    cases.push(vec![a, b, c]);
482                }
483            }
484        }
485        assert_eq!(cases.len(), 26 + 26 * 26 + 26 * 26 * 26);
486        for bytes in cases {
487            let text = String::from_utf8(bytes).unwrap();
488            let code = RegionCode::from_short_code(&text).unwrap();
489            assert_eq!(code.letters().as_deref(), Some(text.as_str()));
490            assert_eq!(code.to_string(), text);
491            assert_eq!(text.parse::<RegionCode>().unwrap(), code);
492            // Lowercase is the same region, and still reads back uppercase.
493            assert_eq!(text.to_lowercase().parse::<RegionCode>().unwrap(), code);
494        }
495    }
496
497    #[test]
498    fn displays_codes_without_a_text_form_as_hex() {
499        assert_eq!(RegionCode::from_u16(0xDF6F).to_string(), "0xDF6F");
500        // Decodes to `654`, which is not letters.
501        assert_eq!(RegionCode::from_u16(0xD35F).to_string(), "0xD35F");
502        // Below the chunk range entirely.
503        assert_eq!(RegionCode::from_u16(0x0100).to_string(), "0x0100");
504        assert_eq!(RegionCode::from_u16(0).to_string(), "0x0000");
505    }
506
507    #[test]
508    fn parses_literal_codes_of_exactly_four_hex_digits() {
509        assert_eq!("0x7853".parse::<RegionCode>().unwrap().as_u16(), 0x7853);
510        assert_eq!("0X7853".parse::<RegionCode>().unwrap().as_u16(), 0x7853);
511        assert_eq!("0xdf6f".parse::<RegionCode>().unwrap().as_u16(), 0xDF6F);
512        assert_eq!("0x0001".parse::<RegionCode>().unwrap().as_u16(), 1);
513    }
514
515    #[test]
516    fn hashes_anything_that_only_looks_like_a_literal_code() {
517        // Only `0x` plus exactly four hex digits spells a code. Everything
518        // else is a name, which keeps the derivation total: there is no such
519        // thing as a string with no region.
520        // `0x` and `0x1` are short codes — one to three alphanumerics — so
521        // they are not among these.
522        for input in ["0x12345", "0xzz", "0x 12", "0x+1"] {
523            assert_eq!(
524                input.parse::<RegionCode>().unwrap(),
525                RegionCode::from_name(input),
526                "{input:?} should hash as a name"
527            );
528        }
529    }
530
531    #[test]
532    fn rejects_a_name_longer_than_the_wire_allows() {
533        let long = "R".repeat(REGION_NAME_MAX_LEN + 1);
534        assert_eq!(long.parse::<RegionCode>(), Err(RegionCodeError::TooLong));
535
536        let limit = "R".repeat(REGION_NAME_MAX_LEN);
537        assert_eq!(
538            limit.parse::<RegionCode>().unwrap(),
539            RegionCode::from_name(&limit)
540        );
541    }
542
543    #[test]
544    fn parses_anything_else_as_a_region_name() {
545        assert_eq!(
546            "Rogue Valley".parse::<RegionCode>().unwrap(),
547            RegionCode::from_name("Rogue Valley")
548        );
549        // Four letters is past the short-code bound, so it is a name.
550        assert_eq!(
551            "OHIO".parse::<RegionCode>().unwrap(),
552            RegionCode::from_name("OHIO")
553        );
554    }
555
556    #[test]
557    fn rejects_an_empty_region_code() {
558        assert_eq!("".parse::<RegionCode>(), Err(RegionCodeError::Empty));
559        assert_eq!("   ".parse::<RegionCode>(), Err(RegionCodeError::Empty));
560    }
561
562    #[test]
563    fn round_trips_through_the_wire_bytes() {
564        let code = RegionCode::from_short_code("SJC").unwrap();
565        assert_eq!(code.to_bytes(), [0x78, 0x53]);
566        assert_eq!(RegionCode::from_bytes([0x78, 0x53]), code);
567    }
568}