LCOV - code coverage report
Current view: top level - antares-bus/src - subjects.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 100.0 % 52 52
Test Date: 2026-09-21 10:31:06 Functions: 85.7 % 14 12

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Subject encoding: `changes.{tenant}.{type_hash}.{id_hash}`.
       3              : //!
       4              : //! Entity types and ids are IRIs/URNs containing `.` and `:` — illegal or
       5              : //! ambiguous as NATS subject tokens — so both segments are FNV-1a 64 hashes
       6              : //! in hex. Tenant names are validated token-safe at creation (`TenantId`), so
       7              : //! the tenant travels verbatim and consumers can filter `changes.{tenant}.>`.
       8              : //! FNV-1a is spelled out here because it must stay bit-stable across Rust
       9              : //! releases (std's DefaultHasher is not) — a subject is a wire contract.
      10              : //!
      11              : //! The tenant is taken as a `TenantId`, not a `&str`: it is the only segment
      12              : //! that is not hashed, so the validated newtype is what keeps a `.`, a `*` or
      13              : //! a `>` out of the subject.
      14              : 
      15              : use antares_model::TenantId;
      16              : 
      17              : /// FNV-1a 64 (public-domain constants). Stable forever by construction.
      18           32 : pub fn fnv1a64(bytes: &[u8]) -> u64 {
      19           32 :     let mut h: u64 = 0xcbf2_9ce4_8422_2325;
      20          614 :     for b in bytes {
      21          614 :         h ^= u64::from(*b);
      22          614 :         h = h.wrapping_mul(0x0000_0100_0000_01b3);
      23          614 :     }
      24           32 :     h
      25           32 : }
      26              : 
      27              : /// The subject one `ChangeEvent` publishes to.
      28            6 : pub fn change_subject(tenant: &TenantId, first_type: &str, entity_id: &str) -> String {
      29            6 :     format!(
      30              :         "changes.{tenant}.{:016x}.{:016x}",
      31            6 :         fnv1a64(first_type.as_bytes()),
      32            6 :         fnv1a64(entity_id.as_bytes())
      33              :     )
      34            6 : }
      35              : 
      36              : /// Registration CUD deltas (`ANTARES_REGISTRY`): broadcast, per tenant.
      37            2 : pub fn registry_subject(tenant: &TenantId) -> String {
      38            2 :     format!("registry.{tenant}")
      39            2 : }
      40              : 
      41              : #[cfg(test)]
      42              : mod tests {
      43              :     use super::*;
      44              : 
      45              :     #[test]
      46            2 :     fn hash_is_the_published_fnv1a_vector() {
      47              :         // FNV-1a 64 test vectors ("" and "a") from the reference spec
      48            2 :         assert_eq!(fnv1a64(b""), 0xcbf2_9ce4_8422_2325);
      49            2 :         assert_eq!(fnv1a64(b"a"), 0xaf63_dc4c_8601_ec8c);
      50            2 :     }
      51              : 
      52            8 :     fn tenant(raw: &str) -> TenantId {
      53            8 :         TenantId::new(raw).expect("token-safe tenant")
      54            8 :     }
      55              : 
      56              :     #[test]
      57            2 :     fn subject_tokens_never_carry_iri_punctuation() {
      58            2 :         let s = change_subject(
      59            2 :             &tenant("acme"),
      60            2 :             "https://uri.etsi.org/ngsi-ld/default-context/Vehicle",
      61            2 :             "urn:ngsi-ld:Vehicle:A1",
      62              :         );
      63            2 :         let mut parts = s.split('.');
      64            2 :         assert_eq!(parts.next(), Some("changes"));
      65            2 :         assert_eq!(parts.next(), Some("acme"));
      66            4 :         for token in parts {
      67           64 :             assert!(!token.is_empty() && token.chars().all(|c| c.is_ascii_hexdigit()));
      68              :         }
      69            2 :         assert_eq!(s.split('.').count(), 4);
      70            2 :     }
      71              : 
      72              :     /// The tenant is the one segment that travels verbatim, so it must not be
      73              :     /// able to add tokens or a `>`/`*` wildcard to the subject. The subject
      74              :     /// builders take a `TenantId`, which is the only way to construct one, so
      75              :     /// the escape is refused at the type level and again at validation.
      76              :     #[test]
      77            2 :     fn a_hostile_tenant_cannot_escape_the_subject_encoding() {
      78           10 :         for hostile in ["a.b.>", ">", "*", "a b", "a.b"] {
      79           10 :             assert!(TenantId::new(hostile).is_err(), "should reject {hostile:?}");
      80              :         }
      81            2 :         let s = change_subject(&tenant("a-b_1"), "T", "urn:x:1");
      82            2 :         assert_eq!(s.split('.').count(), 4, "tenant must stay one token: {s}");
      83            2 :         assert!(!s.contains('>') && !s.contains('*'), "no wildcards: {s}");
      84            2 :         assert_eq!(registry_subject(&tenant("a-b_1")), "registry.a-b_1");
      85            2 :     }
      86              : 
      87              :     /// Hostile types and ids never reach the wire: both are hashed, so
      88              :     /// separators and wildcards cannot re-shape the subject either.
      89              :     #[test]
      90            2 :     fn hostile_types_and_ids_stay_hashed() {
      91            2 :         let s = change_subject(&tenant("acme"), "*.>", "urn:x:1.>.*\r\n");
      92            2 :         assert_eq!(s.split('.').count(), 4);
      93            2 :         assert!(s.starts_with("changes.acme."));
      94            2 :         assert!(s
      95            2 :             .split('.')
      96            2 :             .skip(2)
      97           64 :             .all(|t| t.len() == 16 && t.chars().all(|c| c.is_ascii_hexdigit())));
      98            2 :     }
      99              : }
        

Generated by: LCOV version 2.0-1