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}