Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! Validated id newtypes. Tenant scoping is threaded through the type system:
3 : //! store methods take `&TenantId` as their first parameter.
4 :
5 : use crate::error::NgsiError;
6 : use serde::{Deserialize, Serialize};
7 : use std::fmt;
8 :
9 : /// Tenant identifier from the `NGSILD-Tenant` header.
10 : ///
11 : /// Token-safe by construction (also used as a NATS subject segment):
12 : /// `[A-Za-z0-9_-]{1,64}`. The default tenant is `"default"`.
13 : #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14 : #[serde(try_from = "String", into = "String")]
15 : pub struct TenantId(String);
16 :
17 : impl TenantId {
18 : /// The default tenant name, used when no `NGSILD-Tenant` header is sent.
19 : pub const DEFAULT: &'static str = "default";
20 :
21 : /// Tenant names the broker mints for its own bookkeeping: the snapshot
22 : /// module's reverse index and one tenant per snapshot share a prefix, the
23 : /// distributed-subscription inbound index is a fixed name.
24 : pub const RESERVED_PREFIXES: &'static [&'static str] = &["snap-"];
25 : /// The reserved names that are not a prefix family.
26 : pub const RESERVED_EXACT: &'static [&'static str] = &["distsub-index"];
27 :
28 : /// Whether `raw` names one of the broker's own tenants.
29 : ///
30 : /// Matched literally, never case-folded: a tenant name is a key in the
31 : /// store and the value of `SET LOCAL antares.tenant`, both case-sensitive,
32 : /// so `SNAP-index` shares a keyspace with nothing and is an ordinary
33 : /// client tenant. A case-insensitive guard would take legal names away
34 : /// from clients and protect nothing.
35 48683 : pub fn is_reserved_str(raw: &str) -> bool {
36 48683 : Self::RESERVED_PREFIXES.iter().any(|p| raw.starts_with(p))
37 47486 : || Self::RESERVED_EXACT.contains(&raw)
38 48683 : }
39 :
40 : /// Whether this is one of the broker's own tenants rather than a client's.
41 : /// Internal tenants stay out of the `tenants` inventory and out of
42 : /// `/q/tenants`: that table is the list of customer accounts.
43 8 : pub fn is_internal(&self) -> bool {
44 8 : Self::is_reserved_str(&self.0)
45 8 : }
46 :
47 : /// Validates a CLIENT-supplied tenant name: `[A-Za-z0-9_-]{1,64}` and not
48 : /// one the broker minted for itself, else BadRequestData. A request that
49 : /// named an internal tenant would put request-shaped documents in the
50 : /// keyspace the broker keeps its own state in, and read and delete
51 : /// another tenant's snapshot bookkeeping (6.3.14).
52 23350 : pub fn new(raw: &str) -> Result<Self, NgsiError> {
53 23350 : if Self::is_reserved_str(raw) {
54 50 : return Err(NgsiError::BadRequestData(format!(
55 50 : "invalid NGSILD-Tenant value: {raw:?}"
56 50 : )));
57 23300 : }
58 23300 : Self::new_internal(raw)
59 23350 : }
60 :
61 : /// The same grammar without the reserved-name refusal — for the broker's
62 : /// own tenants, for the paths that legitimately carry one (the snapshot
63 : /// scoping of 6.3.22 rewrites the request's Tenant to a synthetic one,
64 : /// below the wall that refused the client from naming it), and for
65 : /// decoding a Tenant the broker itself wrote (a bus event, a stored
66 : /// marker). Never for a name a client supplied.
67 30669 : pub fn new_internal(raw: &str) -> Result<Self, NgsiError> {
68 30669 : let ok = !raw.is_empty()
69 30659 : && raw.len() <= 64
70 30649 : && raw
71 30649 : .bytes()
72 357571 : .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
73 30669 : if ok {
74 30561 : Ok(Self(raw.to_owned()))
75 : } else {
76 108 : Err(NgsiError::BadRequestData(format!(
77 108 : "invalid NGSILD-Tenant value: {raw:?}"
78 108 : )))
79 : }
80 30669 : }
81 :
82 : /// The tenant name as sent in the header.
83 465654 : pub fn as_str(&self) -> &str {
84 465654 : &self.0
85 465654 : }
86 : }
87 :
88 : impl Default for TenantId {
89 32127 : fn default() -> Self {
90 32127 : Self(Self::DEFAULT.to_owned())
91 32127 : }
92 : }
93 :
94 : impl TryFrom<String> for TenantId {
95 : type Error = NgsiError;
96 : /// Decoding, not admission: what is decoded here was encoded by this
97 : /// broker (a bus event carries the Tenant a write ran under, and a write
98 : /// inside a Snapshot runs under a synthetic one). The client-facing
99 : /// refusal is `new`.
100 10 : fn try_from(s: String) -> Result<Self, NgsiError> {
101 10 : Self::new_internal(&s)
102 10 : }
103 : }
104 :
105 : impl From<TenantId> for String {
106 20 : fn from(t: TenantId) -> String {
107 20 : t.0
108 20 : }
109 : }
110 :
111 : impl fmt::Display for TenantId {
112 8 : fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 8 : f.write_str(&self.0)
114 8 : }
115 : }
116 :
117 : /// One character of an Entity id.
118 : ///
119 : /// RFC 3986 clause 2 fixes the ASCII repertoire a URI is written in:
120 : /// unreserved, reserved (gen-delims + sub-delims) and "%" for percent-encoding.
121 : /// Anything else — controls, DEL, space, `"`, `<`, `>`, `\`, `^`, backtick,
122 : /// braces, pipe — is not a URI character. CIM 009 clause 5.2.1 widens every
123 : /// "URI" in the document to an IRI as mandated by RFC 3987, so the id is not
124 : /// confined to ASCII: RFC 3987 clause 2.2 adds the ucschar and iprivate ranges
125 : /// (`urn:ngsi-ld:Ciudad:París` is a legal id). Admitting exactly those ranges,
126 : /// rather than subtracting known-bad code points, also keeps out the Unicode
127 : /// noncharacters, the C1 controls and the plane-14 tag/variation-selector block
128 : /// that no IRI production covers.
129 : ///
130 : /// The ucschar ranges themselves still admit characters that render as nothing
131 : /// or reorder the text around them — the bidi controls, the zero-width and
132 : /// format characters, and the fillers and blank patterns that Unicode classes
133 : /// as ordinary letters or symbols yet paint no glyph. Two ids can then render
134 : /// identically, and a log line, console or UI can be rewritten by the id it
135 : /// carries, so those are excluded by name on top of the ranges.
136 1942322 : fn is_id_char(c: char) -> bool {
137 : const URI_ASCII: &str = "-._~:/?#[]@!$&'()*+,;=%";
138 1942322 : let code = c as u32;
139 : // RFC 3987 clause 2.2 ucschar %xA0-D7FF / F900-FDCF / FDF0-FFEF and
140 : // iprivate %xE000-F8FF (F900 follows F8FF, so the two are one range here),
141 : // plus every supplementary plane except each plane's two trailing
142 : // noncharacters and the E0000-E0FFF block that ucschar's E1000-EFFFD skips.
143 1942322 : let iri_non_ascii = matches!(c,
144 70 : '\u{a0}'..='\u{d7ff}'
145 20 : | '\u{e000}'..='\u{fdcf}'
146 18 : | '\u{fdf0}'..='\u{ffef}')
147 1942266 : || (code >= 0x1_0000 && code & 0xffff <= 0xfffd && !(0xe_0000..=0xe_0fff).contains(&code));
148 1942322 : let invisible = matches!(c,
149 : '\u{00ad}' // soft hyphen
150 : | '\u{061c}' // arabic letter mark
151 : | '\u{115f}' | '\u{1160}' | '\u{3164}' | '\u{ffa0}' // hangul fillers: no glyph
152 : | '\u{180e}' // mongolian vowel separator
153 38 : | '\u{200b}'..='\u{200f}' // zero-width space/joiners, bidi marks
154 : | '\u{2800}' // braille pattern blank
155 34 : | '\u{2028}'..='\u{202e}' // line/paragraph separator, bidi overrides
156 24 : | '\u{2060}'..='\u{206f}' // word joiner, invisible operators, bidi isolates
157 14 : | '\u{fe00}'..='\u{fe0f}' // variation selectors
158 : | '\u{feff}' // byte-order mark
159 12 : | '\u{fff9}'..='\u{fffb}' // interlinear annotation
160 : );
161 1942322 : (c.is_ascii_alphanumeric() || URI_ASCII.contains(c) || iri_non_ascii)
162 1942182 : && !c.is_whitespace()
163 1942176 : && !invisible
164 1942322 : }
165 :
166 : /// Entity id: a valid URI per CIM 009 clause 4.5.1 and Table 5.2.4-1, where
167 : /// "URI" also means an IRI per clause 5.2.1. Invalid → BadRequestData, which
168 : /// Table 6.3.2-1 maps to 400.
169 : #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
170 : #[serde(try_from = "String", into = "String")]
171 : pub struct EntityId(String);
172 :
173 : impl EntityId {
174 : /// Validates an entity id as a URI/IRI (4.5.1, 5.2.1): a non-empty scheme,
175 : /// only URI-legal characters and no `.`/`..` path segment; else BadRequestData.
176 73978 : pub fn new(raw: &str) -> Result<Self, NgsiError> {
177 : // Lazy URI check: a scheme followed by ':', over characters a URI or
178 : // IRI is allowed to contain. Full IRI validation happens during
179 : // JSON-LD expansion; this guards the id-shaped entry points, so that
180 : // no id reaches storage, a Location header or a downstream log/UI
181 : // carrying something a reader cannot see.
182 73978 : let no_illegal = raw.chars().all(is_id_char);
183 : // An entity id is interpolated into the path of a forwarded request,
184 : // so a dot-segment in it climbs out of /entities/{id} and addresses a
185 : // different resource on the peer. Slashes stay legal (an http-scheme
186 : // id has them); only "." and ".." as whole segments are refused, in
187 : // their percent-encoded spellings too, since the peer decodes the path
188 : // it receives. RFC 3986 clause 3.3.
189 73978 : let no_dot_segment = {
190 73978 : let decoded = raw
191 73978 : .to_ascii_lowercase()
192 73978 : .replace("%2e", ".")
193 73978 : .replace("%2f", "/");
194 84408 : !decoded.split('/').any(|seg| seg == "." || seg == "..")
195 : };
196 73978 : let scheme_ok = no_illegal
197 73798 : && no_dot_segment
198 73784 : && raw.split_once(':').is_some_and(|(s, rest)| {
199 73694 : !s.is_empty()
200 73680 : && !rest.is_empty()
201 73668 : && s.chars()
202 225236 : .all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
203 73694 : });
204 73978 : if scheme_ok {
205 73668 : Ok(Self(raw.to_owned()))
206 : } else {
207 310 : Err(NgsiError::BadRequestData(format!(
208 310 : "entity id is not a valid URI: {raw:?}"
209 310 : )))
210 : }
211 73978 : }
212 :
213 : /// The id as its original URI string.
214 6 : pub fn as_str(&self) -> &str {
215 6 : &self.0
216 6 : }
217 : }
218 :
219 : impl TryFrom<String> for EntityId {
220 : type Error = NgsiError;
221 10 : fn try_from(s: String) -> Result<Self, NgsiError> {
222 10 : Self::new(&s)
223 10 : }
224 : }
225 :
226 : impl From<EntityId> for String {
227 30 : fn from(e: EntityId) -> String {
228 30 : e.0
229 30 : }
230 : }
231 :
232 : impl fmt::Display for EntityId {
233 0 : fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234 0 : f.write_str(&self.0)
235 0 : }
236 : }
237 :
238 : #[cfg(test)]
239 : mod tests {
240 : use super::*;
241 :
242 : #[test]
243 2 : fn tenant_accepts_token_safe() {
244 2 : assert!(TenantId::new("city-01_A").is_ok());
245 2 : assert_eq!(TenantId::default().as_str(), "default");
246 2 : }
247 :
248 : #[test]
249 2 : fn tenant_rejects_unsafe() {
250 10 : for bad in ["", "a.b", "a b", "ü", &"x".repeat(65)] {
251 10 : assert!(TenantId::new(bad).is_err(), "should reject {bad:?}");
252 : }
253 2 : }
254 :
255 : /// 6.3.14: the tenants the broker mints for itself are not names a
256 : /// client may send. The refusal is on the constructor a client's name
257 : /// goes through, so a path that forgets to ask cannot let one in; the
258 : /// broker's own paths build the same names through `new_internal`.
259 : #[test]
260 2 : fn tenant_rejects_the_names_the_broker_minted_for_itself() {
261 6 : for reserved in [
262 2 : "snap-index",
263 2 : "snap-0123456789abcdef0123456789abcdef",
264 2 : "distsub-index",
265 2 : ] {
266 6 : assert!(
267 6 : TenantId::new(reserved).is_err(),
268 : "a client may not name {reserved:?}"
269 : );
270 6 : assert_eq!(
271 6 : TenantId::new_internal(reserved)
272 6 : .expect("the broker's own constructor")
273 6 : .as_str(),
274 : reserved
275 : );
276 6 : assert!(TenantId::is_reserved_str(reserved));
277 6 : assert!(TenantId::new_internal(reserved)
278 6 : .expect("internal")
279 6 : .is_internal());
280 : }
281 : // The prefix and the exact names are matched literally: a tenant name
282 : // is a case-sensitive key in the store and in `SET LOCAL
283 : // antares.tenant`, so these share a keyspace with nothing and taking
284 : // them from clients would protect nothing.
285 8 : for ordinary in ["snap", "snapshot-data", "SNAP-index", "distsub-index-2"] {
286 8 : assert!(
287 8 : TenantId::new(ordinary).is_ok(),
288 : "{ordinary:?} is an ordinary tenant"
289 : );
290 8 : assert!(!TenantId::is_reserved_str(ordinary));
291 : }
292 : // and the grammar still applies to the broker's own names
293 2 : assert!(TenantId::new_internal("snap-a.b").is_err());
294 2 : }
295 :
296 : /// A Tenant decoded from what the broker itself wrote — a bus event
297 : /// carries the Tenant a write ran under, and 5.5.15 lets a write run
298 : /// inside a Snapshot — is not a client's name being admitted.
299 : #[test]
300 2 : fn a_serialized_internal_tenant_round_trips() {
301 2 : let synth = TenantId::new_internal("snap-abc").expect("internal");
302 2 : let json = serde_json::to_string(&synth).expect("serialize");
303 2 : let back: TenantId = serde_json::from_str(&json).expect("deserialize");
304 2 : assert_eq!(back, synth);
305 2 : }
306 :
307 : #[test]
308 2 : fn entity_id_requires_uri() {
309 2 : assert!(EntityId::new("urn:ngsi-ld:Vehicle:A123").is_ok());
310 2 : assert!(EntityId::new("not a uri").is_err());
311 2 : assert!(EntityId::new(":noscheme").is_err());
312 2 : }
313 :
314 : /// The id lands in the path of a forwarded request, so a dot-segment in it
315 : /// reaches a different resource on the peer: an id of
316 : /// `urn:a/../../csourceRegistrations` turns a write on one entity into a
317 : /// write on the peer's registration collection.
318 : #[test]
319 2 : fn entity_id_rejects_dot_segments() {
320 12 : for bad in [
321 2 : "urn:a/../../csourceRegistrations",
322 2 : "urn:a/..",
323 2 : "urn:a/./b",
324 2 : "urn:a/%2e%2e/b",
325 2 : "urn:a/%2E%2E/b",
326 2 : "urn:a%2f..%2fb",
327 2 : ] {
328 12 : assert!(EntityId::new(bad).is_err(), "should reject {bad:?}");
329 : }
330 : // slashes and dots that are not a whole segment stay legal: an
331 : // http-scheme id has a path, and versioned names carry dots
332 6 : for ok in [
333 2 : "http://example.org/entities/1",
334 2 : "urn:ngsi-ld:Vehicle:A1.2",
335 2 : "http://example.org/v1.0/e..1",
336 2 : ] {
337 6 : assert!(EntityId::new(ok).is_ok(), "should accept {ok:?}");
338 : }
339 2 : }
340 :
341 : #[test]
342 2 : fn entity_id_rejects_control_chars_and_space() {
343 2 : assert!(EntityId::new("urn:has space").is_err());
344 2 : assert!(EntityId::new("urn:x\r\nX-Injected:1").is_err());
345 2 : assert!(EntityId::new("urn:x\ttab").is_err());
346 2 : assert!(EntityId::new("urn:x\u{7f}").is_err());
347 2 : assert!(EntityId::new("urn:ngsi-ld:ok-1").is_ok());
348 2 : }
349 :
350 : /// 4.5.1 and Table 5.2.4-1: "id" shall be a valid URI, and clause 5.2.1
351 : /// widens every "URI" in the document to an IRI (RFC 3987). Invisible and
352 : /// bidi-control characters pass a Unicode-category control test (they are
353 : /// Cf/Mn/Zl/Zp/Lo/So, not Cc) and most sit inside the RFC 3987 ucschar ranges,
354 : /// yet a reader cannot see them — an id that renders as another id spoofs
355 : /// logs, UIs and audit trails. Each is rejected by name, with the error
356 : /// type Table 6.3.2-1 mandates: BadRequestData, 400, never a 500.
357 : #[test]
358 2 : fn entity_id_rejects_non_uri_characters() {
359 64 : for (bad, what) in [
360 2 : ("urn:x\u{202e}gpj.exe", "U+202E right-to-left override"),
361 2 : ("urn:x\u{202d}y", "U+202D left-to-right override"),
362 2 : ("urn:x\u{2066}y", "U+2066 left-to-right isolate"),
363 2 : ("urn:x\u{200b}y", "U+200B zero width space"),
364 2 : ("urn:x\u{200e}y", "U+200E left-to-right mark"),
365 2 : ("urn:x\u{feff}y", "U+FEFF byte-order mark"),
366 2 : ("urn:x\u{2028}y", "U+2028 line separator"),
367 2 : ("urn:x\u{2029}y", "U+2029 paragraph separator"),
368 2 : ("urn:x\u{00a0}y", "U+00A0 no-break space"),
369 2 : ("urn:x\u{2060}y", "U+2060 word joiner"),
370 2 : ("urn:x\u{00ad}y", "U+00AD soft hyphen"),
371 2 : ("urn:x\u{061c}y", "U+061C arabic letter mark"),
372 2 : ("urn:x\u{180e}y", "U+180E mongolian vowel separator"),
373 2 : ("urn:x\u{fe0f}y", "U+FE0F variation selector 16"),
374 2 : ("urn:x\u{e0100}y", "U+E0100 variation selector 17"),
375 2 : ("urn:x\u{e0001}y", "U+E0001 language tag"),
376 2 : ("urn:x\u{e0041}y", "U+E0041 tag latin capital A"),
377 2 : ("urn:x\u{115f}y", "U+115F hangul choseong filler"),
378 2 : ("urn:x\u{1160}y", "U+1160 hangul jungseong filler"),
379 2 : ("urn:x\u{3164}y", "U+3164 hangul filler"),
380 2 : ("urn:x\u{ffa0}y", "U+FFA0 halfwidth hangul filler"),
381 2 : ("urn:x\u{2800}y", "U+2800 braille pattern blank"),
382 2 : ("urn:x\u{fdd0}y", "U+FDD0 noncharacter"),
383 2 : ("urn:x\u{fffe}y", "U+FFFE noncharacter"),
384 2 : ("urn:x\u{1fffe}y", "U+1FFFE noncharacter"),
385 2 : ("urn:x<script>", "angle brackets"),
386 2 : ("urn:x\"y", "double quote"),
387 2 : ("urn:x`y", "backtick"),
388 2 : ("urn:x\\y", "backslash"),
389 2 : ("urn:x^y", "caret"),
390 2 : ("urn:x|y", "pipe"),
391 2 : ("urn:x{y}", "braces"),
392 2 : ] {
393 64 : let e = EntityId::new(bad).expect_err(what);
394 64 : assert_eq!(e.kind(), "BadRequestData", "{what} must be 400 data error");
395 64 : assert_eq!(e.status(), 400, "{what}");
396 : }
397 2 : }
398 :
399 : /// The over-tightening guard for the character predicate: RFC 3986 admits
400 : /// its whole ASCII repertoire including percent-encoding, and clause 5.2.1
401 : /// admits the non-ASCII characters of an IRI (RFC 3987 clause 2.2), so none
402 : /// of these may be refused.
403 : #[test]
404 2 : fn entity_id_accepts_uri_and_iri_forms() {
405 18 : for ok in [
406 2 : "urn:ngsi-ld:Vehicle:A123",
407 2 : "urn:ngsi-ld:Vehicle:A%20B", // percent-encoded space
408 2 : "urn:ngsi-ld:Vehicle:%E2%82%AC1", // percent-encoded UTF-8
409 2 : "http://example.org/entities/%E2%82%AC", // percent-encoded in a path
410 2 : "https://ex.org/a-b_c.d~e/f?g=h&i#j%20k[l]@m!$'()*+,;=",
411 2 : // the non-ASCII characters RFC 3987 admits in an IRI: the suite's
412 2 : // own Relationship objects carry them
413 2 : "urn:ngsi-ld:Ciudad:París",
414 2 : "urn:ngsi-ld:城市:1",
415 2 : "urn:ngsi-ld:Δήμος:1",
416 2 : "urn:ngsi-ld:Vehicle:🚗1", // plane 1, inside ucschar
417 2 : ] {
418 18 : assert!(EntityId::new(ok).is_ok(), "should accept {ok:?}");
419 : }
420 2 : }
421 :
422 : /// The rejection message must not echo the id back unescaped — a
423 : /// rejected id lands in logs, so any control byte stays quoted.
424 : #[test]
425 2 : fn entity_id_rejection_message_is_escaped() {
426 2 : let e = EntityId::new("urn:x\r\nX-Injected:1").expect_err("rejected");
427 2 : let msg = e.to_string();
428 2 : assert!(!msg.contains('\r') && !msg.contains('\n'), "{msg}");
429 2 : }
430 :
431 : /// Deserialization is an entry point of its own: change events arriving
432 : /// off the bus are turned into these types by serde, and `try_from`
433 : /// routes that through the same validation the HTTP path uses.
434 : #[test]
435 2 : fn deserialization_validates_and_serialization_stays_a_bare_string() {
436 2 : assert!(serde_json::from_str::<TenantId>("\"a.b\"").is_err());
437 2 : assert!(serde_json::from_str::<TenantId>("\"\"").is_err());
438 2 : assert!(serde_json::from_str::<EntityId>("\"urn:x\\u202ey\"").is_err());
439 2 : assert!(serde_json::from_str::<EntityId>("\"noscheme\"").is_err());
440 2 : let id: EntityId = serde_json::from_str("\"urn:ngsi-ld:Vehicle:A1\"").expect("valid");
441 2 : assert_eq!(
442 2 : serde_json::to_string(&id).expect("serialize"),
443 : "\"urn:ngsi-ld:Vehicle:A1\"",
444 : "the newtype must not add a wrapper object"
445 : );
446 2 : }
447 :
448 : /// A tenant is also the first half of the `file`-mode redb key
449 : /// (`tenant \0 id`, `antares-sql store/mem/redb.rs`), whose split takes
450 : /// the FIRST NUL. A separator or control byte in a tenant name would make
451 : /// two different (tenant, id) pairs one key, and one tenant's document
452 : /// would be written over another's.
453 : #[test]
454 2 : fn tenant_rejects_the_file_mode_key_separator() {
455 12 : for bad in ["a\0b", "\0", "a\nb", "a\tb", "a/b", "a:b"] {
456 12 : assert!(TenantId::new(bad).is_err(), "should reject {bad:?}");
457 : }
458 2 : }
459 :
460 : /// A tenant travels verbatim as a NATS subject token, so the wildcard
461 : /// and separator characters must never pass validation.
462 : #[test]
463 2 : fn tenant_rejects_subject_metacharacters() {
464 10 : for bad in ["a.b", "*", ">", "a>b", "a*"] {
465 10 : assert!(TenantId::new(bad).is_err(), "should reject {bad:?}");
466 : }
467 2 : assert!(TenantId::new(&"x".repeat(64)).is_ok(), "64 is the boundary");
468 2 : }
469 : }
|