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