LCOV - code coverage report
Current view: top level - antares-model/src - id.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 98.7 % 236 233
Test Date: 2026-09-21 10:31:06 Functions: 92.5 % 53 49

            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              : }
        

Generated by: LCOV version 2.0-1