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