LCOV - code coverage report
Current view: top level - antares-model/src - id.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 100.0 % 264 264
Test Date: 2026-11-02 13:21:27 Functions: 96.4 % 55 53

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

Generated by: LCOV version 2.0-1