Skip to main content

decdn_protocol/
region.rs

1//! ISO 3166-1 alpha-2 region-code validation.
2//!
3//! `identity.region` (ADR 030) ends up in log fields and metric labels, so
4//! this allowlist is what stops arbitrary bytes (control chars, non-ASCII,
5//! oversized strings) from corrupting them. The config resolver
6//! (`decdn_common::config::normalize_region`) calls [`is_valid_region`] so
7//! every accepted region code is one of this set.
8//!
9//! Accepted set:
10//! - All currently-assigned ISO 3166-1 alpha-2 codes, plus the
11//!   exceptionally-reserved `XK` (Kosovo) which is widely used by EU/IMF.
12//! - The user-assigned reserved ranges (`AA`, `QM`–`QZ`, `XA`–`XZ`, `ZZ`)
13//!   so operators can pick a private code for air-gapped or testnet
14//!   deployments without coordinating with ISO.
15//!
16//! Case-sensitive — callers must uppercase first. The 2-char length is
17//! implicit in the match patterns; any other length is rejected.
18//!
19//! [`Region`] is the parsed form: trim + uppercase + this allowlist, done once
20//! at the boundary so consumers compare values rather than re-normalizing at
21//! every call site (#1348). Use it wherever a validated code is carried around;
22//! [`is_valid_region`] remains for callers that only need the predicate over a
23//! string they already own.
24
25/// True iff `code` is a region the network accepts. See module docs for the
26/// accepted set.
27pub fn is_valid_region(code: &str) -> bool {
28    matches!(
29        code,
30        // Assigned ISO 3166-1 alpha-2 (A–B)
31        "AD" | "AE" | "AF" | "AG" | "AI" | "AL" | "AM" | "AO" | "AQ" | "AR"
32        | "AS" | "AT" | "AU" | "AW" | "AX" | "AZ"
33        | "BA" | "BB" | "BD" | "BE" | "BF" | "BG" | "BH" | "BI" | "BJ" | "BL"
34        | "BM" | "BN" | "BO" | "BQ" | "BR" | "BS" | "BT" | "BV" | "BW" | "BY" | "BZ"
35        // C–F
36        | "CA" | "CC" | "CD" | "CF" | "CG" | "CH" | "CI" | "CK" | "CL" | "CM"
37        | "CN" | "CO" | "CR" | "CU" | "CV" | "CW" | "CX" | "CY" | "CZ"
38        | "DE" | "DJ" | "DK" | "DM" | "DO" | "DZ"
39        | "EC" | "EE" | "EG" | "EH" | "ER" | "ES" | "ET"
40        | "FI" | "FJ" | "FK" | "FM" | "FO" | "FR"
41        // G–J
42        | "GA" | "GB" | "GD" | "GE" | "GF" | "GG" | "GH" | "GI" | "GL" | "GM"
43        | "GN" | "GP" | "GQ" | "GR" | "GS" | "GT" | "GU" | "GW" | "GY"
44        | "HK" | "HM" | "HN" | "HR" | "HT" | "HU"
45        | "ID" | "IE" | "IL" | "IM" | "IN" | "IO" | "IQ" | "IR" | "IS" | "IT"
46        | "JE" | "JM" | "JO" | "JP"
47        // K–N
48        | "KE" | "KG" | "KH" | "KI" | "KM" | "KN" | "KP" | "KR" | "KW" | "KY" | "KZ"
49        | "LA" | "LB" | "LC" | "LI" | "LK" | "LR" | "LS" | "LT" | "LU" | "LV" | "LY"
50        | "MA" | "MC" | "MD" | "ME" | "MF" | "MG" | "MH" | "MK" | "ML" | "MM"
51        | "MN" | "MO" | "MP" | "MQ" | "MR" | "MS" | "MT" | "MU" | "MV" | "MW"
52        | "MX" | "MY" | "MZ"
53        | "NA" | "NC" | "NE" | "NF" | "NG" | "NI" | "NL" | "NO" | "NP" | "NR"
54        | "NU" | "NZ"
55        // O–S
56        | "OM"
57        | "PA" | "PE" | "PF" | "PG" | "PH" | "PK" | "PL" | "PM" | "PN" | "PR"
58        | "PS" | "PT" | "PW" | "PY"
59        | "QA"
60        | "RE" | "RO" | "RS" | "RU" | "RW"
61        | "SA" | "SB" | "SC" | "SD" | "SE" | "SG" | "SH" | "SI" | "SJ" | "SK"
62        | "SL" | "SM" | "SN" | "SO" | "SR" | "SS" | "ST" | "SV" | "SX" | "SY" | "SZ"
63        // T–Z
64        | "TC" | "TD" | "TF" | "TG" | "TH" | "TJ" | "TK" | "TL" | "TM" | "TN"
65        | "TO" | "TR" | "TT" | "TV" | "TW" | "TZ"
66        | "UA" | "UG" | "UM" | "US" | "UY" | "UZ"
67        | "VA" | "VC" | "VE" | "VG" | "VI" | "VN" | "VU"
68        | "WF" | "WS"
69        | "YE" | "YT"
70        | "ZA" | "ZM" | "ZW"
71        // Exceptionally reserved (widely used by EU/IMF/UNMIK).
72        | "XK"
73        // User-assigned reserved ranges (ISO 3166-1 §8.1.3). Operators can
74        // pick any of these for private deployments without coordinating
75        // with ISO.
76        | "AA"
77        | "QM" | "QN" | "QO" | "QP" | "QQ" | "QR" | "QS" | "QT"
78        | "QU" | "QV" | "QW" | "QX" | "QY" | "QZ"
79        | "XA" | "XB" | "XC" | "XD" | "XE" | "XF" | "XG" | "XH"
80        | "XI" | "XJ" | "XL" | "XM" | "XN" | "XO" | "XP" | "XQ"
81        | "XR" | "XS" | "XT" | "XU" | "XV" | "XW" | "XX" | "XY" | "XZ"
82        | "ZZ"
83    )
84}
85
86/// A region code that has already passed [`is_valid_region`], stored in the
87/// normalized (uppercase, untrimmed-of-nothing) form.
88///
89/// The point of the type is that the normalization is done ONCE, at the parse
90/// boundary, instead of at every comparison site. A raw `String` carrying "an
91/// ISO 3166-1 alpha-2 code" by doc comment alone leaves each consumer to
92/// re-derive that: `decdn-client`'s candidate ranking was trimming and
93/// case-folding on every comparison because one side came normalized from
94/// config and the other raw from the chain, and structural `Eq` on the
95/// containing type was wrong as a result — `" us "` and `"US"` compared
96/// unequal (#1348).
97///
98/// Two bytes, `Copy`, and bounded by construction — which also caps what an
99/// operator-submitted `regionHint` can cost on a deserialize path, where the
100/// on-chain source permits any string up to 16 bytes.
101///
102/// Serializes as the plain 2-character string, so persisted forms stay
103/// human-readable and a `Region` field is wire-compatible with the `String` it
104/// replaces in the serialize direction.
105#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
106pub struct Region([u8; 2]);
107
108/// Hand-written so the code renders as text, not as its bytes. A derived
109/// `Debug` over the `[u8; 2]` payload prints `Region([85, 83])`, and this type
110/// IS formatted with `{:?}` on an operator-facing path — `decdn get`'s
111/// "discovered node … (region {:?})" line — as well as into `tracing` fields
112/// and `anyhow` context. The derive turned that line's output from `"US"` into
113/// `Some(Region([85, 83]))`.
114impl core::fmt::Debug for Region {
115    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
116        f.debug_tuple("Region").field(&self.as_str()).finish()
117    }
118}
119
120impl Region {
121    /// Parse `raw` into a `Region`, or `None` if it is not an accepted code.
122    ///
123    /// Surrounding whitespace is trimmed and the code is uppercased before the
124    /// allowlist check, because both sources this reads from are
125    /// operator-submitted: a config file and an on-chain `regionHint` the
126    /// contract does not constrain beyond a length cap.
127    ///
128    /// `None` rather than an error type: for the advisory uses (locality-aware
129    /// node ranking) an unrecognized code means "no locality information", not
130    /// "reject this node". Callers that need it to be fatal — config
131    /// resolution — say so at their own layer.
132    #[must_use]
133    pub fn parse(raw: &str) -> Option<Self> {
134        let upper = raw.trim().to_ascii_uppercase();
135        if !is_valid_region(&upper) {
136            return None;
137        }
138        // `is_valid_region` matches only 2-byte ASCII patterns, so the array
139        // conversion cannot fail; `ok()?` keeps that fact local instead of
140        // asserting it (the workspace denies `expect`/`panic`).
141        let bytes: [u8; 2] = upper.as_bytes().try_into().ok()?;
142        Some(Self(bytes))
143    }
144
145    /// The normalized code.
146    #[must_use]
147    pub fn as_str(&self) -> &str {
148        // Every byte came from `to_ascii_uppercase` over an allowlisted ASCII
149        // code, so this is always valid UTF-8.
150        core::str::from_utf8(&self.0).unwrap_or("")
151    }
152}
153
154impl AsRef<str> for Region {
155    fn as_ref(&self) -> &str {
156        self.as_str()
157    }
158}
159
160impl core::fmt::Display for Region {
161    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
162        f.write_str(self.as_str())
163    }
164}
165
166/// Rejects with the same allowlist [`Region::parse`] applies. The error carries
167/// no input — a region code reaches log fields and metric labels, and this type
168/// exists partly to keep unvalidated bytes out of them. The `Deserialize` impl
169/// below upholds the same property, so it holds for every fallible construction
170/// path, not just this one.
171impl core::str::FromStr for Region {
172    type Err = InvalidRegion;
173
174    fn from_str(s: &str) -> Result<Self, Self::Err> {
175        Self::parse(s).ok_or(InvalidRegion)
176    }
177}
178
179/// [`Region`]'s [`FromStr`](core::str::FromStr) was given something outside
180/// the accepted set.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub struct InvalidRegion;
183
184impl core::fmt::Display for InvalidRegion {
185    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
186        f.write_str(
187            "not an accepted ISO 3166-1 alpha-2 region code \
188             (assigned, or user-reserved AA/QM-QZ/XA-XZ/ZZ)",
189        )
190    }
191}
192
193impl core::error::Error for InvalidRegion {}
194
195impl serde::Serialize for Region {
196    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
197        s.serialize_str(self.as_str())
198    }
199}
200
201/// Deserializing re-runs the allowlist, so the invariant holds for values that
202/// arrive from a file or the wire rather than through [`Region::parse`] — a
203/// derived impl over the byte array would let any two bytes in.
204///
205/// Deserializes an owned `String` rather than a borrowed `&str` so this works
206/// against non-borrowing formats too (a `serde_json` reader, postcard over a
207/// streamed buffer); the allocation is bounded by the caller's own input and
208/// dropped immediately.
209impl<'de> serde::Deserialize<'de> for Region {
210    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
211        let raw = String::deserialize(d)?;
212        // `custom(InvalidRegion)`, NOT `invalid_value(Unexpected::Str(&raw))`:
213        // the rejected input must not travel in the error, for the same reason
214        // `FromStr` refuses to echo it. This path is the one that matters most —
215        // it reads the peer cache, whose contents originate from an on-chain
216        // `regionHint` any operator can set to arbitrary bytes, and the decode
217        // error surfaces to the user through `CacheRead::Unusable`.
218        Self::parse(&raw).ok_or_else(|| serde::de::Error::custom(InvalidRegion))
219    }
220}
221
222#[cfg(test)]
223#[allow(clippy::unwrap_used, clippy::expect_used)]
224mod tests;