Skip to main content

tollgate_core/
ids.rs

1//! Identifier newtypes.
2//!
3//! Tollgate's identifiers are opaque 128-bit values so a store backend may use
4//! UUIDs without this crate depending on a uuid library; 64-bit backends simply
5//! use the low half. [`PolicyRevision`] is the one 256-bit member: it is not
6//! Tollgate's identifier at all but the *consumer's*, carried through
7//! admission and billing and never interpreted here.
8//!
9//! Every one of them shares a single canonical spelling rule — a fixed number
10//! of lowercase hexadecimal digits, no `0x` — enforced by one function so the
11//! two widths cannot drift apart.
12//!
13//! `FencingToken` and `Generation` are ordered u64 sequences, but they serve
14//! different contracts: a fencing token identifies one lease capability, while
15//! a generation rejects older account snapshots.
16
17use core::{fmt, str::FromStr};
18
19/// An opaque identifier was not written in Tollgate's canonical textual form.
20///
21/// The wire form is a fixed number of lowercase hexadecimal digits, without a
22/// `0x` prefix. Keeping the parser strict gives display output, paths, and JSON
23/// one representation rather than a collection of equivalent spellings.
24///
25/// The expected width travels with the error because Tollgate has identifiers
26/// of two widths — 32 digits for the 128-bit types, 64 for
27/// [`PolicyRevision`] — and one rule serving both must be able to say which it
28/// was applying. A single error type stating the width is one rule; a second
29/// error type beside a second parser would be two rules that have to agree.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct ParseIdError {
32    expected_digits: usize,
33}
34
35impl ParseIdError {
36    const fn new(expected_digits: usize) -> Self {
37        Self { expected_digits }
38    }
39
40    /// How many lowercase hexadecimal digits the canonical form has.
41    #[must_use]
42    pub const fn expected_digits(self) -> usize {
43        self.expected_digits
44    }
45}
46
47impl fmt::Display for ParseIdError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        write!(
50            f,
51            "identifier must be exactly {} lowercase hexadecimal digits",
52            self.expected_digits
53        )
54    }
55}
56
57impl std::error::Error for ParseIdError {}
58
59/// The one canonical-spelling rule, shared by every opaque identifier.
60///
61/// Width and character set only; what the digits decode *to* differs by type
62/// and belongs to the caller. Uppercase is rejected, a `0x` prefix is rejected
63/// (because `x` is not a hexadecimal digit), and the length must match
64/// exactly — no padding, no truncation, no surrounding whitespace.
65const fn validate_hex_digits(value: &str, expected_digits: usize) -> Result<(), ParseIdError> {
66    if value.len() != expected_digits {
67        return Err(ParseIdError::new(expected_digits));
68    }
69    let bytes = value.as_bytes();
70    let mut index = 0;
71    while index < bytes.len() {
72        let byte = bytes[index];
73        if !(byte.is_ascii_digit() || (byte >= b'a' && byte <= b'f')) {
74            return Err(ParseIdError::new(expected_digits));
75        }
76        index += 1;
77    }
78    Ok(())
79}
80
81/// Digits per 128-bit identifier, and per 256-bit [`PolicyRevision`].
82const ID_DIGITS: usize = 32;
83const REVISION_DIGITS: usize = 64;
84
85fn parse_id(value: &str) -> Result<u128, ParseIdError> {
86    validate_hex_digits(value, ID_DIGITS)?;
87    u128::from_str_radix(value, 16).map_err(|_| ParseIdError::new(ID_DIGITS))
88}
89
90macro_rules! id128 {
91    ($(#[$doc:meta])* $name:ident) => {
92        $(#[$doc])*
93        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
94        pub struct $name(pub u128);
95
96        impl fmt::Display for $name {
97            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98                write!(f, "{:032x}", self.0)
99            }
100        }
101
102        impl FromStr for $name {
103            type Err = ParseIdError;
104
105            fn from_str(value: &str) -> Result<Self, Self::Err> {
106                parse_id(value).map(Self)
107            }
108        }
109
110        #[cfg(feature = "serde")]
111        impl serde::Serialize for $name {
112            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
113            where
114                S: serde::Serializer,
115            {
116                serializer.collect_str(self)
117            }
118        }
119
120        #[cfg(feature = "serde")]
121        impl<'de> serde::Deserialize<'de> for $name {
122            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
123            where
124                D: serde::Deserializer<'de>,
125            {
126                struct IdVisitor;
127
128                impl serde::de::Visitor<'_> for IdVisitor {
129                    type Value = $name;
130
131                    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
132                        formatter.write_str(
133                            "an identifier containing exactly 32 lowercase hexadecimal digits",
134                        )
135                    }
136
137                    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
138                    where
139                        E: serde::de::Error,
140                    {
141                        value.parse().map_err(E::custom)
142                    }
143                }
144
145                deserializer.deserialize_str(IdVisitor)
146            }
147        }
148    };
149}
150
151id128!(
152    /// An account: the owner of quota, limits, and permissions.
153    AccountId
154);
155id128!(
156    /// A credential within an account; optional in contexts that admit an
157    /// already-verified principal without key attribution.
158    KeyId
159);
160id128!(
161    /// One allocated lease of units to one service instance.
162    LeaseId
163);
164id128!(
165    /// Idempotency key for usage accounting: one request, one charge.
166    RequestId
167);
168id128!(
169    /// The request path's lookup key: an opaque fingerprint of an
170    /// already-verified credential. How it is derived (API-key HMAC,
171    /// capability subject, session id) is the embedding service's concern —
172    /// by the time it reaches admission, verification has happened.
173    ///
174    /// # A caller must not be able to choose these bits
175    ///
176    /// Derive the fingerprint under a secret the caller does not hold, as the
177    /// reference embedding does with a truncated HMAC-SHA256 of the API key.
178    /// **Never key admission by a raw client-supplied token.**
179    ///
180    /// The reason is not subtle. This value selects an account's snapshot, its
181    /// lease and its rate limiter. A caller who can choose it can aim at
182    /// another tenant's entry — spending their quota, drawing on their limiter,
183    /// and being admitted under their permissions. No hashing choice defends
184    /// against that; only the derivation does.
185    ///
186    /// Because the bits are unsteerable, admission hashes them with a fast
187    /// non-cryptographic hasher rather than SipHash (see
188    /// `tollgate_admission`'s `PrincipalHasher`). That is a *consequence* of
189    /// the rule above, not an additional requirement: an embedder who breaks
190    /// it has already lost the tenant isolation SipHash was never protecting,
191    /// and would merely lose it more slowly.
192    Principal
193);
194
195/// One lease's capability token, drawn from a strictly increasing per-account
196/// allocation sequence.
197///
198/// The ordering supplies an audit trail; it is not an account-wide validity
199/// epoch. A newer token does not invalidate an older active lease. Stores
200/// require this token to match the record named by the accompanying lease ID
201/// (and account ID for usage ingest; INVARIANTS.md GL-4).
202#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
203#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
204#[cfg_attr(feature = "serde", serde(transparent))]
205pub struct FencingToken(pub u64);
206
207impl fmt::Display for FencingToken {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        write!(f, "{}", self.0)
210    }
211}
212
213/// Monotonic version of an account's compiled policy snapshot. A snapshot with
214/// a generation older than the newest one an instance has seen is stale and
215/// must not be (re-)installed.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
217#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
218#[cfg_attr(feature = "serde", serde(transparent))]
219pub struct Generation(pub u64);
220
221impl fmt::Display for Generation {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        write!(f, "{}", self.0)
224    }
225}
226
227/// The consuming application's identity for the product policy compiled into a
228/// snapshot — 256 opaque bits Tollgate carries and never interprets.
229///
230/// A service publishes compiled enforcement data (limits, a cost table,
231/// permissions) derived from its own versioned product records. This is how it
232/// says *which* records those were, so a response can report the policy that
233/// priced a request and the billing event can be traced to the same one,
234/// without any product vocabulary — plan, model, schedule, tier — entering
235/// Tollgate. A content hash of the resolved inputs is the natural value; a
236/// consumer with a shorter identifier zero-pads.
237///
238/// # Not a generation
239///
240/// [`Generation`] orders publication: it decides which snapshot is newer and
241/// which is stale, and admission enforces it (INVARIANTS.md GL-15, GL-26). This
242/// identifies the *inputs* compiled into a publication and carries no order at
243/// all. Two generations can share a revision (the same policy republished after
244/// a status change), and one generation carries exactly one revision. Neither
245/// substitutes for the other, which is why this deliberately does **not**
246/// derive `PartialOrd`/`Ord` as the identifier types do: comparing two of them
247/// for order would be asking a question the value cannot answer, and the
248/// answer would look plausible.
249///
250/// # Unstated is a value, not an error
251///
252/// [`Default`] is all zeroes and means "no revision stated". A control plane
253/// that predates the field publishes snapshots without it, and they decode to
254/// this rather than failing — the same fail-open-on-*identity* choice
255/// `enforcement_mode` and `budget` make, and safe for the same reason: nothing
256/// in Tollgate reads it, so an absent revision cannot change an enforcement
257/// outcome.
258///
259/// # Wire form
260///
261/// Exactly 64 lowercase hexadecimal digits, under the same strict rule the
262/// 128-bit identifiers use (INVARIANTS.md GL-21). Strictness matters more here
263/// than elsewhere: the consumer compares these for equality to select its own
264/// metadata, and two spellings of one revision would silently look like two
265/// policies.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
267pub struct PolicyRevision(pub [u8; 32]);
268
269impl PolicyRevision {
270    /// The "no revision stated" value: all zeroes.
271    pub const UNSTATED: Self = Self([0; 32]);
272
273    /// Whether this is the unstated revision.
274    #[must_use]
275    pub const fn is_unstated(self) -> bool {
276        let mut index = 0;
277        while index < self.0.len() {
278            if self.0[index] != 0 {
279                return false;
280            }
281            index += 1;
282        }
283        true
284    }
285
286    /// The raw bytes, for a consumer storing or comparing them.
287    #[must_use]
288    pub const fn as_bytes(&self) -> &[u8; 32] {
289        &self.0
290    }
291}
292
293impl fmt::Display for PolicyRevision {
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        for byte in self.0 {
296            write!(f, "{byte:02x}")?;
297        }
298        Ok(())
299    }
300}
301
302impl FromStr for PolicyRevision {
303    type Err = ParseIdError;
304
305    fn from_str(value: &str) -> Result<Self, Self::Err> {
306        validate_hex_digits(value, REVISION_DIGITS)?;
307        let mut bytes = [0u8; 32];
308        for (index, byte) in bytes.iter_mut().enumerate() {
309            let pair = &value[index * 2..index * 2 + 2];
310            // The charset and width are already proven, so each pair is two
311            // hexadecimal digits and this cannot fail. Delegating the nibble
312            // arithmetic keeps it that way: a hand-rolled `(high << 4) | low`
313            // reads correctly, but its `|` is indistinguishable from `^` and
314            // `+` here because the halves never share a bit — an equivalent
315            // mutant no test could ever kill, standing where a real one
316            // should.
317            *byte = u8::from_str_radix(pair, 16).map_err(|_| ParseIdError::new(REVISION_DIGITS))?;
318        }
319        Ok(Self(bytes))
320    }
321}
322
323#[cfg(feature = "serde")]
324impl serde::Serialize for PolicyRevision {
325    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
326    where
327        S: serde::Serializer,
328    {
329        serializer.collect_str(self)
330    }
331}
332
333#[cfg(feature = "serde")]
334impl<'de> serde::Deserialize<'de> for PolicyRevision {
335    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
336    where
337        D: serde::Deserializer<'de>,
338    {
339        struct RevisionVisitor;
340
341        impl serde::de::Visitor<'_> for RevisionVisitor {
342            type Value = PolicyRevision;
343
344            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
345                formatter.write_str(
346                    "a policy revision containing exactly 64 lowercase hexadecimal digits",
347                )
348            }
349
350            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
351            where
352                E: serde::de::Error,
353            {
354                value.parse().map_err(E::custom)
355            }
356        }
357
358        deserializer.deserialize_str(RevisionVisitor)
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    macro_rules! canonical_id_tests {
367        ($($name:ident),+ $(,)?) => {
368            $(
369                assert_eq!($name(0).to_string(), "00000000000000000000000000000000");
370                assert_eq!($name(u128::MAX).to_string(), "ffffffffffffffffffffffffffffffff");
371                assert_eq!(
372                    "8000000000000000000000000000002a".parse::<$name>(),
373                    Ok($name((1u128 << 127) | 0x2a)),
374                );
375            )+
376        };
377    }
378
379    #[test]
380    fn every_id_uses_the_same_fixed_width_lowercase_hexadecimal_text() {
381        canonical_id_tests!(AccountId, KeyId, LeaseId, RequestId, Principal);
382    }
383
384    #[test]
385    fn noncanonical_spellings_are_rejected() {
386        for value in [
387            "1",
388            "00000000000000000000000000000001 ",
389            "0x00000000000000000000000000000001",
390            "0000000000000000000000000000000A",
391            "gggggggggggggggggggggggggggggggg",
392        ] {
393            assert_eq!(
394                value.parse::<AccountId>(),
395                Err(ParseIdError::new(ID_DIGITS)),
396                "{value:?}"
397            );
398        }
399    }
400
401    /// The same rule at the other width, with the same rejection corpus scaled
402    /// up — uppercase, a prefix, trailing space, a non-hex digit.
403    #[test]
404    fn noncanonical_revision_spellings_are_rejected() {
405        let ok = "8".repeat(64);
406        assert!(
407            ok.parse::<PolicyRevision>().is_ok(),
408            "the corpus baseline parses"
409        );
410        for value in [
411            "1".to_string(),
412            format!("{}{}", "0".repeat(63), "1 "),
413            format!("0x{}", "0".repeat(64)),
414            format!("{}A", "0".repeat(63)),
415            "g".repeat(64),
416        ] {
417            assert_eq!(
418                value.parse::<PolicyRevision>(),
419                Err(ParseIdError::new(REVISION_DIGITS)),
420                "{value:?}"
421            );
422        }
423    }
424
425    /// The two widths are enforced separately, and each rejects the other's
426    /// canonical form. Sharing one rule must not mean sharing one width: a
427    /// 32-digit value is a perfectly good identifier and not a revision at all.
428    #[test]
429    fn the_two_identifier_widths_reject_each_others_canonical_form() {
430        let id_width = "0".repeat(32);
431        let revision_width = "0".repeat(64);
432
433        assert!(id_width.parse::<AccountId>().is_ok());
434        assert_eq!(
435            id_width.parse::<PolicyRevision>(),
436            Err(ParseIdError::new(REVISION_DIGITS))
437        );
438
439        assert!(revision_width.parse::<PolicyRevision>().is_ok());
440        assert_eq!(
441            revision_width.parse::<AccountId>(),
442            Err(ParseIdError::new(ID_DIGITS))
443        );
444    }
445
446    /// The error says which width it was applying, so a caller reading it is
447    /// not told a 64-digit value should have been 32.
448    #[test]
449    fn the_parse_error_names_the_width_it_expected() {
450        let id_error = "".parse::<AccountId>().unwrap_err();
451        assert_eq!(id_error.expected_digits(), 32);
452        assert_eq!(
453            id_error.to_string(),
454            "identifier must be exactly 32 lowercase hexadecimal digits"
455        );
456
457        let revision_error = "".parse::<PolicyRevision>().unwrap_err();
458        assert_eq!(revision_error.expected_digits(), 64);
459        assert_eq!(
460            revision_error.to_string(),
461            "identifier must be exactly 64 lowercase hexadecimal digits"
462        );
463    }
464
465    /// The unstated revision is a value, not an absence: it has a canonical
466    /// spelling, it round-trips, and it reports itself as unstated.
467    #[test]
468    fn the_unstated_revision_is_all_zeroes_and_round_trips() {
469        let unstated = PolicyRevision::default();
470        assert_eq!(unstated, PolicyRevision::UNSTATED);
471        assert!(unstated.is_unstated());
472        assert_eq!(unstated.to_string(), "0".repeat(64));
473        assert_eq!(unstated.to_string().parse::<PolicyRevision>(), Ok(unstated));
474
475        let stated = PolicyRevision([0xab; 32]);
476        assert!(!stated.is_unstated());
477        assert_eq!(stated.to_string(), "ab".repeat(32));
478        assert_eq!(stated.to_string().parse::<PolicyRevision>(), Ok(stated));
479    }
480
481    /// Every byte position survives the text round trip in the right order —
482    /// a transposition would still be 64 valid digits, so the pattern is
483    /// deliberately asymmetric.
484    #[test]
485    fn a_revision_round_trips_every_byte_position_in_order() {
486        let mut bytes = [0u8; 32];
487        for (index, byte) in bytes.iter_mut().enumerate() {
488            *byte = index as u8;
489        }
490        let revision = PolicyRevision(bytes);
491        assert_eq!(
492            revision.to_string(),
493            "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
494        );
495        assert_eq!(revision.to_string().parse::<PolicyRevision>(), Ok(revision));
496    }
497
498    #[cfg(feature = "serde")]
499    #[test]
500    fn human_readable_serde_is_textual_and_strict() {
501        macro_rules! assert_textual {
502            ($name:ident) => {{
503                let value = $name((1u128 << 127) | 0x2a);
504                let encoded = serde_json::to_string(&value).unwrap();
505                assert_eq!(encoded, r#""8000000000000000000000000000002a""#);
506                assert_eq!(serde_json::from_str::<$name>(&encoded).unwrap(), value);
507                assert!(serde_json::from_str::<$name>("42").is_err());
508            }};
509        }
510
511        assert_textual!(AccountId);
512        assert_textual!(KeyId);
513        assert_textual!(LeaseId);
514        assert_textual!(RequestId);
515        assert_textual!(Principal);
516    }
517
518    /// The revision follows the same textual-and-strict Serde contract: a JSON
519    /// string in canonical form, and a number is refused rather than coerced.
520    #[cfg(feature = "serde")]
521    #[test]
522    fn revision_serde_is_textual_and_strict() {
523        let value = PolicyRevision([0x8f; 32]);
524        let encoded = serde_json::to_string(&value).unwrap();
525        assert_eq!(encoded, format!("\"{}\"", "8f".repeat(32)));
526        assert_eq!(
527            serde_json::from_str::<PolicyRevision>(&encoded).unwrap(),
528            value
529        );
530        // A number is refused, and the refusal says what was expected. The
531        // visitor's `expecting` text is the only thing telling a caller what
532        // shape the field wanted, so it is asserted rather than assumed —
533        // otherwise it could return nothing at all and no test would notice.
534        let wrong_type =
535            serde_json::from_str::<PolicyRevision>("42").expect_err("a number is not a revision");
536        assert!(
537            wrong_type
538                .to_string()
539                .contains("exactly 64 lowercase hexadecimal digits"),
540            "the type error must name the expected form, got {wrong_type}"
541        );
542        // An identifier-width string is not a revision either, and that
543        // refusal carries the parse rule's own width.
544        let wrong_width =
545            serde_json::from_str::<PolicyRevision>(&format!("\"{}\"", "0".repeat(32)))
546                .expect_err("32 digits is not a revision");
547        assert!(
548            wrong_width
549                .to_string()
550                .contains("exactly 64 lowercase hexadecimal digits"),
551            "the width error must name the expected width, got {wrong_width}"
552        );
553    }
554}