Skip to main content

acme_proxy/sqlite/
id.rs

1//! Row ids: minting them, and reading one that arrived from outside.
2//!
3//! Every id this server mints is a **UUID version 7** (RFC 9562 §5.7): a
4//! 48-bit big-endian millisecond timestamp, then random bits. Two properties
5//! follow, and both are why the version moved from 4.
6//!
7//! The ids of rows created close together share a prefix, so an index over
8//! them is written at its right-hand edge rather than at a fresh random leaf
9//! per insert. SQLite feels that only mildly — an id column is a secondary
10//! index over the rowid, and one writer at a time dominates — but the
11//! PostgreSQL backend `TODO.md` plans is where a random primary key costs a
12//! page split and a full-page WAL write per row.
13//!
14//! And they sort by creation. `ORDER BY created_at, id` is the tie-break seven
15//! paged listings use on a whole-second `created_at`, so that tie-break is now
16//! chronological where a v4 gave a fresh random permutation per pair. Only
17//! among rows minted since the change: the ids already in a table were
18//! converted in place and keep the v4 bits they were minted with, so a v4 and a
19//! v7 in the same second still tie arbitrarily, for ever.
20//!
21//! Ids are stored as the 16 bytes themselves, not as text — `sqlx`'s `uuid`
22//! feature encodes a [`Uuid`] as a SQLite BLOB and decodes it with
23//! `Uuid::from_slice`, and maps the same type to Postgres's native `uuid`. So
24//! there is no decode helper here: `row.try_get::<Uuid, _>("id")` is the whole
25//! of it. What there is instead is [`parse`], for the one direction that can
26//! fail.
27//!
28//! Three mints in this crate are deliberately **not** row ids and do not come
29//! through [`mint`]: the `x-request-id` fallback
30//! ([`crate::middlewares::access`]), the job runner's lease-owner id
31//! ([`crate::jobs::runner`]) and a notification's `delivery_id`
32//! ([`crate::notify`]). None is the identity of a row, and reaching into the
33//! storage layer for a value that never reaches storage would be backwards.
34
35use uuid::Uuid;
36
37/// A fresh row id.
38///
39/// The single place the version is chosen, which is what lets
40/// `declared_id_widths_match_a_minted_id` (`crate::sqlite::db`) pin it and what
41/// would make a move to some later version one line plus one test.
42#[must_use]
43pub fn mint() -> Uuid {
44    Uuid::now_v7()
45}
46
47/// Parses an id that arrived from outside the process.
48///
49/// `None` for anything an id column could not hold — a `kid` a client invented,
50/// a path segment an operator mistyped, a scanner probing `/acct/../..`. Every
51/// caller turns that into the same answer an unknown-but-well-formed id gets,
52/// so no request path grows a "malformed id" case RFC 8555 has no code for.
53///
54/// Deliberately narrower than [`Uuid::try_parse`], which also accepts the
55/// 32-character simple form, `{braced}` and `urn:uuid:` spellings, and
56/// upper-case hex. Every id this server has ever written came from
57/// `Uuid::to_string()`, so 36 hyphenated characters is the only shape a column
58/// holds and the only one a URL this server minted can carry. Accepting the
59/// others would start resolving URLs that answer "not found" today, which is a
60/// change of behaviour wearing a refactor's clothes.
61#[must_use]
62pub fn parse(value: &str) -> Option<Uuid> {
63    if value.len() != 36 {
64        return None;
65    }
66    Uuid::try_parse(value).ok()
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn mint_is_a_version_7_uuid_in_the_stored_spelling() {
75        let id = mint();
76
77        assert_eq!(id.get_version_num(), 7, "RFC 9562 §5.7");
78        assert_eq!(
79            id.to_string().len(),
80            36,
81            "hyphenated, which is what a column holds"
82        );
83        assert_ne!(mint(), mint());
84    }
85
86    /// The property the whole change rests on, and the one nothing else states.
87    ///
88    /// The second assertion is the load-bearing one: `ORDER BY id` compares the
89    /// *stored* value, so a version that sorted correctly as an integer but not
90    /// in its own encoding would buy the listings nothing.
91    #[test]
92    fn two_mints_sort_the_way_they_were_created() {
93        let first = mint();
94        let second = mint();
95
96        assert!(first < second, "{first} should sort before {second}");
97        assert!(
98            first.as_bytes() < second.as_bytes(),
99            "and so should the bytes a column holds"
100        );
101    }
102
103    #[test]
104    fn parse_takes_the_stored_spelling_and_nothing_else() {
105        let id = mint();
106        assert_eq!(parse(&id.to_string()), Some(id));
107
108        // Every shape `Uuid::try_parse` would have taken, and which resolved to
109        // "not found" before this function existed.
110        assert_eq!(
111            parse(&id.simple().to_string()),
112            None,
113            "32-character simple form"
114        );
115        assert_eq!(parse(&format!("{{{id}}}")), None, "braced");
116        assert_eq!(parse(&id.urn().to_string()), None, "urn:uuid:");
117
118        assert_eq!(parse(""), None);
119        assert_eq!(parse("nope"), None);
120        assert_eq!(parse("../../etc/passwd"), None);
121        // Right length, wrong alphabet.
122        assert_eq!(parse("zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz"), None);
123    }
124
125    /// A v4 minted before the switch still parses.
126    ///
127    /// Nothing was backfilled — an account id is a foreign key, a `kid` is a
128    /// credential a client stored, and an order id is inside a URL a client
129    /// polls for weeks — so the tables hold both versions and will for ever.
130    #[test]
131    fn an_id_minted_before_the_switch_still_parses() {
132        let v4 = "550e8400-e29b-41d4-a716-446655440000";
133
134        let parsed = parse(v4).expect("a v4 is still an id");
135        assert_eq!(parsed.get_version_num(), 4);
136        assert_eq!(parsed.to_string(), v4);
137    }
138}