1use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
24use serde::Serialize;
25use sha2::Sha256;
26use std::fs;
27use std::path::Path;
28
29const PRUNE_CHUNK: usize = 10_000;
34
35const META_KEY: &str = "uid_registry_key";
38const META_PEPPER: &str = "uid_registry_pepper";
39
40fn rand32() -> [u8; 32] {
41 use rand::RngCore;
42 let mut b = [0u8; 32];
43 rand::thread_rng().fill_bytes(&mut b);
44 b
45}
46
47pub fn ensure_registry_secrets(vault_conn: &Connection) -> Result<([u8; 32], [u8; 32]), String> {
52 let read = |key: &str| -> Result<Option<String>, String> {
53 vault_conn
54 .query_row(
55 "SELECT value FROM vault_meta WHERE key = ?1",
56 params![key],
57 |r| r.get::<_, String>(0),
58 )
59 .optional()
60 .map_err(|e| e.to_string())
61 };
62 let write = |key: &str, value: &str| -> Result<(), String> {
63 vault_conn
64 .execute(
65 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES (?1, ?2)",
66 params![key, value],
67 )
68 .map(|_| ())
69 .map_err(|e| e.to_string())
70 };
71
72 let key = match read(META_KEY)? {
73 Some(hex_str) => {
74 let bytes = hex::decode(&hex_str).map_err(|e| e.to_string())?;
75 bytes
76 .try_into()
77 .map_err(|_| "uid_registry_key is corrupt (wrong length)".to_string())?
78 }
79 None => {
80 let k = rand32();
81 write(META_KEY, &hex::encode(k))?;
82 k
83 }
84 };
85 let pepper = match read(META_PEPPER)? {
86 Some(hex_str) => {
87 let bytes = hex::decode(&hex_str).map_err(|e| e.to_string())?;
88 bytes
89 .try_into()
90 .map_err(|_| "uid_registry_pepper is corrupt (wrong length)".to_string())?
91 }
92 None => {
93 let p = rand32();
94 write(META_PEPPER, &hex::encode(p))?;
95 p
96 }
97 };
98 Ok((key, pepper))
99}
100
101pub fn open_registry(path: &Path, key: &[u8; 32]) -> Result<Connection, String> {
105 if let Some(p) = path.parent() {
106 fs::create_dir_all(p).map_err(|e| e.to_string())?;
107 }
108 let conn = Connection::open_with_flags(
109 path,
110 OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
111 )
112 .map_err(|e| e.to_string())?;
113 conn.execute_batch(&format!("PRAGMA key = \"x'{}'\";", hex::encode(key)))
114 .map_err(|e| e.to_string())?;
115 conn.execute_batch("SELECT count(*) FROM sqlite_master;")
116 .map_err(|_| "Wrong registry key".to_string())?;
117 conn.execute_batch(
118 "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA cache_size=-32000;",
119 )
120 .map_err(|e| e.to_string())?;
121 crate::restrict_to_owner(path)?;
122 for suffix in ["-wal", "-shm"] {
123 let mut side = path.as_os_str().to_owned();
124 side.push(suffix);
125 let side = std::path::PathBuf::from(side);
126 if side.exists() {
127 crate::restrict_to_owner(&side)?;
128 }
129 }
130 init_schema(&conn)?;
131 Ok(conn)
132}
133
134pub fn init_schema(conn: &Connection) -> Result<(), String> {
135 conn.execute_batch(
136 "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;
137 CREATE TABLE IF NOT EXISTS uid (
138 h BLOB PRIMARY KEY,
139 t INTEGER NOT NULL,
140 m INTEGER
141 ) WITHOUT ROWID;
142 CREATE INDEX IF NOT EXISTS uid_t ON uid(t);
143 CREATE TABLE IF NOT EXISTS uid_meta (
144 id INTEGER PRIMARY KEY,
145 namespace TEXT,
146 generator TEXT,
147 params TEXT,
148 normalise TEXT,
149 actor TEXT,
150 source TEXT,
151 entry_ck TEXT,
152 note TEXT,
153 created_at INTEGER NOT NULL
154 );",
155 )
156 .map_err(|e| e.to_string())
157}
158
159#[derive(Clone, Copy, PartialEq, Eq, Debug)]
162pub enum Normalise {
163 Uuid,
164 Ulid,
165 Lower,
166 None,
167}
168
169impl Normalise {
170 pub fn parse(s: &str) -> Option<Self> {
171 match s.to_ascii_lowercase().as_str() {
172 "uuid" => Some(Normalise::Uuid),
173 "ulid" => Some(Normalise::Ulid),
174 "lower" => Some(Normalise::Lower),
175 "none" => Some(Normalise::None),
176 _ => None,
177 }
178 }
179
180 pub fn as_str(self) -> &'static str {
181 match self {
182 Normalise::Uuid => "uuid",
183 Normalise::Ulid => "ulid",
184 Normalise::Lower => "lower",
185 Normalise::None => "none",
186 }
187 }
188}
189
190pub fn normalise(value: &str, mode: Normalise) -> String {
193 match mode {
194 Normalise::Uuid => {
195 let hex_only: String = value
196 .chars()
197 .filter(|c| c.is_ascii_hexdigit())
198 .collect::<String>()
199 .to_ascii_lowercase();
200 if hex_only.len() == 32 {
201 format!(
202 "{}-{}-{}-{}-{}",
203 &hex_only[0..8],
204 &hex_only[8..12],
205 &hex_only[12..16],
206 &hex_only[16..20],
207 &hex_only[20..32]
208 )
209 } else {
210 value.to_ascii_lowercase()
211 }
212 }
213 Normalise::Ulid => value
214 .to_ascii_uppercase()
215 .chars()
216 .map(|c| match c {
217 'I' | 'L' => '1',
218 'O' => '0',
219 other => other,
220 })
221 .collect(),
222 Normalise::Lower => value.to_ascii_lowercase(),
223 Normalise::None => value.to_string(),
224 }
225}
226
227pub fn hash_value(pepper: &[u8; 32], value: &str, mode: Normalise) -> [u8; 16] {
232 use hmac::{Hmac, Mac};
233 type HmacSha256 = Hmac<Sha256>;
234 let normalised = normalise(value, mode);
235 let mut mac = HmacSha256::new_from_slice(pepper).expect("HMAC accepts any key length");
236 mac.update(normalised.as_bytes());
237 let full = mac.finalize().into_bytes();
238 let mut out = [0u8; 16];
239 out.copy_from_slice(&full[..16]);
240 out
241}
242
243fn now_ts() -> i64 {
244 std::time::SystemTime::now()
245 .duration_since(std::time::UNIX_EPOCH)
246 .map(|d| d.as_secs() as i64)
247 .unwrap_or(0)
248}
249
250#[derive(Default, Clone)]
253pub struct BatchMeta<'a> {
254 pub namespace: Option<&'a str>,
255 pub generator: Option<&'a str>,
256 pub params: Option<&'a str>,
257 pub actor: Option<&'a str>,
258 pub source: Option<&'a str>,
259 pub entry_ck: Option<&'a str>,
260 pub note: Option<&'a str>,
261}
262
263fn insert_meta_row(
264 conn: &Connection,
265 meta: &BatchMeta,
266 normalise_mode: Normalise,
267) -> Result<i64, String> {
268 conn.execute(
269 "INSERT INTO uid_meta (namespace, generator, params, normalise, actor, source, entry_ck, note, created_at) \
270 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
271 params![
272 meta.namespace,
273 meta.generator,
274 meta.params,
275 normalise_mode.as_str(),
276 meta.actor,
277 meta.source,
278 meta.entry_ck,
279 meta.note,
280 now_ts(),
281 ],
282 )
283 .map_err(|e| e.to_string())?;
284 Ok(conn.last_insert_rowid())
285}
286
287pub fn check(
292 conn: &Connection,
293 pepper: &[u8; 32],
294 values: &[String],
295 mode: Normalise,
296) -> Result<Vec<(String, bool)>, String> {
297 let mut out = Vec::with_capacity(values.len());
298 for v in values {
299 let h = hash_value(pepper, v, mode);
300 let exists: bool = conn
301 .query_row(
302 "SELECT EXISTS(SELECT 1 FROM uid WHERE h = ?1)",
303 params![h.as_slice()],
304 |r| r.get(0),
305 )
306 .map_err(|e| e.to_string())?;
307 out.push((v.clone(), !exists));
308 }
309 Ok(out)
310}
311
312pub struct RegisterOutcome {
313 pub value: String,
314 pub registered: bool,
315}
316
317pub fn register(
322 conn: &Connection,
323 pepper: &[u8; 32],
324 values: &[String],
325 mode: Normalise,
326 meta: &BatchMeta,
327) -> Result<Vec<RegisterOutcome>, String> {
328 let meta_id = insert_meta_row(conn, meta, mode)?;
329 let t = now_ts();
330 let mut out = Vec::with_capacity(values.len());
331 for v in values {
332 let h = hash_value(pepper, v, mode);
333 let res = conn.execute(
334 "INSERT INTO uid (h, t, m) VALUES (?1, ?2, ?3)",
335 params![h.as_slice(), t, meta_id],
336 );
337 out.push(RegisterOutcome {
338 value: v.clone(),
339 registered: res.is_ok(),
340 });
341 }
342 Ok(out)
343}
344
345pub fn mint(
349 conn: &Connection,
350 pepper: &[u8; 32],
351 mode: Normalise,
352 meta: &BatchMeta,
353 max_attempts: u32,
354 mut generate: impl FnMut() -> String,
355) -> Result<Option<String>, String> {
356 for _ in 0..max_attempts.max(1) {
357 let candidate = generate();
358 let outcome = register(conn, pepper, std::slice::from_ref(&candidate), mode, meta)?;
359 if outcome.first().is_some_and(|o| o.registered) {
360 return Ok(Some(candidate));
361 }
362 }
363 Ok(None)
364}
365
366#[derive(Serialize)]
367pub struct LookupResult {
368 pub issued: bool,
369 pub namespace: Option<String>,
370 pub generator: Option<String>,
371 pub actor: Option<String>,
372 pub source: Option<String>,
373 pub created_at: Option<i64>,
374}
375
376pub fn lookup(
380 conn: &Connection,
381 pepper: &[u8; 32],
382 value: &str,
383 mode: Normalise,
384) -> Result<LookupResult, String> {
385 let h = hash_value(pepper, value, mode);
386 let row = conn
387 .query_row(
388 "SELECT u.t, m.namespace, m.generator, m.actor, m.source \
389 FROM uid u LEFT JOIN uid_meta m ON u.m = m.id \
390 WHERE u.h = ?1",
391 params![h.as_slice()],
392 |r| {
393 Ok((
394 r.get::<_, i64>(0)?,
395 r.get::<_, Option<String>>(1)?,
396 r.get::<_, Option<String>>(2)?,
397 r.get::<_, Option<String>>(3)?,
398 r.get::<_, Option<String>>(4)?,
399 ))
400 },
401 )
402 .optional()
403 .map_err(|e| e.to_string())?;
404 Ok(match row {
405 Some((t, namespace, generator, actor, source)) => LookupResult {
406 issued: true,
407 namespace,
408 generator,
409 actor,
410 source,
411 created_at: Some(t),
412 },
413 None => LookupResult {
414 issued: false,
415 namespace: None,
416 generator: None,
417 actor: None,
418 source: None,
419 created_at: None,
420 },
421 })
422}
423
424pub fn register_external(
427 conn: &Connection,
428 pepper: &[u8; 32],
429 values: &[String],
430 mode: Normalise,
431 meta: &BatchMeta,
432) -> Result<Vec<RegisterOutcome>, String> {
433 register(conn, pepper, values, mode, meta)
434}
435
436#[derive(Serialize, Default)]
439pub struct PruneReport {
440 pub matched: u64,
441 pub deleted: u64,
442 pub chunks: u64,
443 pub dry_run: bool,
444}
445
446pub fn prune(
458 conn: &mut Connection,
459 before_ts: i64,
460 namespace: Option<&str>,
461 generator: Option<&str>,
462 actor: Option<&str>,
463 dry_run: bool,
464) -> Result<PruneReport, String> {
465 let where_clause = "u.t < ?1 AND u.m IN (SELECT id FROM uid_meta m WHERE \
470 (?2 IS NULL OR m.namespace = ?2) AND \
471 (?3 IS NULL OR m.generator = ?3) AND \
472 (?4 IS NULL OR m.actor = ?4))";
473
474 let matched: i64 = conn
476 .query_row(
477 &format!("SELECT COUNT(*) FROM uid u WHERE {where_clause}"),
478 params![before_ts, namespace, generator, actor],
479 |r| r.get(0),
480 )
481 .map_err(|e| e.to_string())?;
482 let matched = matched as u64;
483
484 if dry_run {
485 return Ok(PruneReport {
486 matched,
487 deleted: 0,
488 chunks: 0,
489 dry_run: true,
490 });
491 }
492
493 let mut deleted: u64 = 0;
494 let mut chunks: u64 = 0;
495 loop {
496 let tx = conn.transaction().map_err(|e| e.to_string())?;
497 let n = tx
498 .execute(
499 &format!(
500 "DELETE FROM uid WHERE h IN (SELECT h FROM uid u WHERE {where_clause} LIMIT {PRUNE_CHUNK})"
501 ),
502 params![before_ts, namespace, generator, actor],
503 )
504 .map_err(|e| e.to_string())?;
505 tx.commit().map_err(|e| e.to_string())?;
506 deleted += n as u64;
507 chunks += 1;
508 if n == 0 {
509 break;
510 }
511 }
512 Ok(PruneReport {
513 matched,
514 deleted,
515 chunks,
516 dry_run: false,
517 })
518}
519
520#[derive(Serialize)]
523pub struct RegistryStats {
524 pub count: i64,
525 pub size_bytes: i64,
526 pub oldest_ts: Option<i64>,
527}
528
529pub fn stats(conn: &Connection) -> Result<RegistryStats, String> {
530 let count: i64 = conn
531 .query_row("SELECT COUNT(*) FROM uid", [], |r| r.get(0))
532 .map_err(|e| e.to_string())?;
533 let oldest_ts: Option<i64> = conn
534 .query_row("SELECT MIN(t) FROM uid", [], |r| r.get(0))
535 .map_err(|e| e.to_string())?;
536 let page_count: i64 = conn
537 .query_row("PRAGMA page_count", [], |r| r.get(0))
538 .unwrap_or(0);
539 let page_size: i64 = conn
540 .query_row("PRAGMA page_size", [], |r| r.get(0))
541 .unwrap_or(4096);
542 Ok(RegistryStats {
543 count,
544 size_bytes: page_count * page_size,
545 oldest_ts,
546 })
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 fn mem() -> Connection {
554 let conn = Connection::open_in_memory().unwrap();
555 init_schema(&conn).unwrap();
556 conn
557 }
558
559 #[test]
560 fn uuid_normalisation_folds_case_and_dashes() {
561 let a = normalise("550E8400-E29B-41D4-A716-446655440000", Normalise::Uuid);
562 let b = normalise("550e8400e29b41d4a716446655440000", Normalise::Uuid);
563 assert_eq!(a, b);
564 assert_eq!(a, "550e8400-e29b-41d4-a716-446655440000");
565 }
566
567 #[test]
568 fn ulid_normalisation_maps_ambiguous_letters() {
569 assert_eq!(normalise("01ILOabc", Normalise::Ulid), "01110ABC");
571 }
572
573 #[test]
574 fn registering_the_same_value_twice_reports_a_conflict_not_two_rows() {
575 let conn = mem();
576 let pepper = [7u8; 32];
577 let meta = BatchMeta::default();
578 let first = register(&conn, &pepper, &["abc".to_string()], Normalise::None, &meta).unwrap();
579 assert!(first[0].registered);
580 let second =
581 register(&conn, &pepper, &["abc".to_string()], Normalise::None, &meta).unwrap();
582 assert!(!second[0].registered);
583 let count: i64 = conn
584 .query_row("SELECT COUNT(*) FROM uid", [], |r| r.get(0))
585 .unwrap();
586 assert_eq!(count, 1);
587 }
588
589 #[test]
590 fn check_is_advisory_and_never_reserves() {
591 let conn = mem();
592 let pepper = [1u8; 32];
593 let r1 = check(&conn, &pepper, &["x".to_string()], Normalise::None).unwrap();
594 assert!(r1[0].1, "unique before anything is registered");
595 let r2 = check(&conn, &pepper, &["x".to_string()], Normalise::None).unwrap();
596 assert!(r2[0].1, "check alone must not have reserved it");
597 }
598
599 #[test]
600 fn lookup_reports_provenance_for_a_registered_value() {
601 let conn = mem();
602 let pepper = [3u8; 32];
603 let meta = BatchMeta {
604 namespace: Some("ci"),
605 generator: Some("uuidv4"),
606 actor: Some("runner-1"),
607 ..Default::default()
608 };
609 register(&conn, &pepper, &["v1".to_string()], Normalise::None, &meta).unwrap();
610 let found = lookup(&conn, &pepper, "v1", Normalise::None).unwrap();
611 assert!(found.issued);
612 assert_eq!(found.namespace.as_deref(), Some("ci"));
613 let missing = lookup(&conn, &pepper, "v2", Normalise::None).unwrap();
614 assert!(!missing.issued);
615 }
616
617 #[test]
618 fn prune_deletes_only_rows_older_than_the_cutoff() {
619 let mut conn = mem();
620 let pepper = [9u8; 32];
621 let meta = BatchMeta::default();
622 register(&conn, &pepper, &["old".to_string()], Normalise::None, &meta).unwrap();
623 conn.execute("UPDATE uid SET t = 100", []).unwrap();
624 register(&conn, &pepper, &["new".to_string()], Normalise::None, &meta).unwrap();
625 conn.execute("UPDATE uid SET t = 999999999 WHERE t != 100", [])
626 .unwrap();
627
628 let dry = prune(&mut conn, 500, None, None, None, true).unwrap();
629 assert_eq!(dry.matched, 1);
630 assert_eq!(dry.deleted, 0);
631
632 let real = prune(&mut conn, 500, None, None, None, false).unwrap();
633 assert_eq!(real.deleted, 1);
634 let remaining: i64 = conn
635 .query_row("SELECT COUNT(*) FROM uid", [], |r| r.get(0))
636 .unwrap();
637 assert_eq!(remaining, 1);
638 }
639
640 #[test]
641 fn a_pruned_value_can_be_registered_again() {
642 let mut conn = mem();
645 let pepper = [4u8; 32];
646 let meta = BatchMeta::default();
647 register(
648 &conn,
649 &pepper,
650 &["gone".to_string()],
651 Normalise::None,
652 &meta,
653 )
654 .unwrap();
655 conn.execute("UPDATE uid SET t = 1", []).unwrap();
656 prune(&mut conn, 1000, None, None, None, false).unwrap();
657 let again = register(
658 &conn,
659 &pepper,
660 &["gone".to_string()],
661 Normalise::None,
662 &meta,
663 )
664 .unwrap();
665 assert!(again[0].registered);
666 }
667}