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;