use serde::{Deserialize, Serialize};
use super::{nanoid, objectid, snowflake, ulid, uuid};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Kind {
Uuid,
Ulid,
NanoId,
ObjectId,
Snowflake,
}
pub(crate) const KIND_NAMES: [&str; 5] = ["uuid", "ulid", "nanoid", "objectid", "snowflake"];
impl Kind {
pub(crate) fn from_name(name: &str) -> Option<Self> {
match name.trim().to_lowercase().as_str() {
"uuid" => Some(Self::Uuid),
"ulid" => Some(Self::Ulid),
"nanoid" => Some(Self::NanoId),
"objectid" => Some(Self::ObjectId),
"snowflake" => Some(Self::Snowflake),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Variant {
Ncs,
Rfc4122,
Microsoft,
Future,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Reason {
AmbiguousKind,
Malformed,
NilOrMax,
VersionClaimMismatch,
TimestampImplausible,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Verdict {
Ignored,
Named(Id),
Refused(Refusal),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Id {
pub(crate) kind: Kind,
pub(crate) version: Option<i64>,
pub(crate) variant: Option<Variant>,
pub(crate) timestamp: Option<String>,
}
impl Id {
pub(crate) const fn of(kind: Kind) -> Self {
Self {
kind,
version: None,
variant: None,
timestamp: None,
}
}
pub(crate) fn at(kind: Kind, timestamp: String) -> Self {
Self {
timestamp: Some(timestamp),
..Self::of(kind)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Refusal {
pub(crate) kind: Option<Kind>,
pub(crate) reason: Reason,
pub(crate) detail: String,
pub(crate) version: Option<i64>,
pub(crate) variant: Option<Variant>,
pub(crate) timestamp: Option<String>,
}
impl Refusal {
pub(crate) fn new(kind: Option<Kind>, reason: Reason, detail: String) -> Self {
Self {
kind,
reason,
detail,
version: None,
variant: None,
timestamp: None,
}
}
}
pub(crate) const EARLIEST_PLAUSIBLE_MS: i64 = 631_152_000_000;
const FUTURE_ALLOWANCE_MS: i64 = 365 * 86_400_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Clock {
pub(crate) now_ms: i64,
}
impl Clock {
pub(crate) const fn at(now_ms: i64) -> Self {
Self { now_ms }
}
pub(crate) fn is_plausible(self, milliseconds: i64) -> bool {
(EARLIEST_PLAUSIBLE_MS..=self.now_ms + FUTURE_ALLOWANCE_MS).contains(&milliseconds)
}
}
pub(crate) fn classify(token: &str, key: Option<&str>, clock: Clock) -> Verdict {
match token.len() {
36 => uuid::classify(token, clock),
26 => ulid::classify(token, key, clock),
24 => objectid::classify(token, key, clock),
21 => nanoid::classify(token, key),
17..=19 if token.bytes().all(|byte| byte.is_ascii_digit()) => {
snowflake::classify(token, key, clock)
}
32 if is_hex(token) => Verdict::Refused(Refusal::new(
None,
Reason::AmbiguousKind,
"32 hex digits are an unhyphenated UUID and an MD5 digest in equal measure; \
nothing in this document chooses between them"
.to_string(),
)),
length if is_near_miss_hex(token) => Verdict::Refused(Refusal::new(
None,
Reason::Malformed,
format!(
"{length} hex digits is not a whole number of bytes and is one digit from \
a hex identifier this tool knows"
),
)),
_ => Verdict::Ignored,
}
}
pub(crate) fn is_hex(token: &str) -> bool {
token.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn is_near_miss_hex(token: &str) -> bool {
let length = token.len();
let near = length.abs_diff(24) == 1 || length.abs_diff(32) == 1;
near && is_hex(token) && token.bytes().any(|byte| byte.is_ascii_alphabetic())
}
pub(crate) fn key_mentions(key: Option<&str>, word: &str) -> bool {
key.is_some_and(|path| flatten_key(path).contains(word))
}
pub(crate) fn flatten_key(key: &str) -> String {
key.chars()
.filter(char::is_ascii_alphanumeric)
.map(|character| character.to_ascii_lowercase())
.collect()
}
pub(crate) fn leaf_key(key: &str) -> &str {
key.rsplit('.').next().unwrap_or(key)
}
pub(crate) fn names_scheme(key: Option<&str>, scheme: &str) -> bool {
key.is_some_and(|path| flatten_key(leaf_key(path)).contains(scheme))
}
pub(crate) fn names_an_id(key: Option<&str>) -> bool {
key.is_some_and(|path| flatten_key(leaf_key(path)).ends_with("id"))
}
#[cfg(test)]
mod tests {
use super::*;
const CLOCK: Clock = Clock::at(1_786_492_800_000);
fn refusal(token: &str) -> Refusal {
match classify(token, None, CLOCK) {
Verdict::Refused(refusal) => refusal,
other => panic!("{token} was {other:?}, not a refusal"),
}
}
#[test]
fn every_kind_name_round_trips_and_the_list_is_complete() {
for name in KIND_NAMES {
let kind = Kind::from_name(name).expect(name);
assert_eq!(serde_json::to_value(kind).expect("serializes"), name);
}
for kind in [
Kind::Uuid,
Kind::Ulid,
Kind::NanoId,
Kind::ObjectId,
Kind::Snowflake,
] {
let name = serde_json::to_value(kind).expect("serializes");
assert!(
KIND_NAMES.contains(&name.as_str().expect("a string")),
"{kind:?} is a kind but is not offered by name"
);
}
}
#[test]
fn an_unknown_kind_name_is_refused() {
assert_eq!(Kind::from_name("guid"), None);
assert_eq!(Kind::from_name(""), None);
}
#[test]
fn a_kind_name_is_normalised() {
assert_eq!(Kind::from_name(" UUID "), Some(Kind::Uuid));
}
#[test]
fn thirty_two_hex_digits_are_ambiguous_rather_than_a_uuid() {
let refusal = refusal("5d41402abc4b2a76b9719d911017c592");
assert_eq!(refusal.reason, Reason::AmbiguousKind);
assert_eq!(refusal.kind, None);
assert!(refusal.detail.contains("MD5"), "{}", refusal.detail);
}
#[test]
fn a_hex_run_one_digit_from_an_identifier_length_is_malformed() {
for token in [
"6a7bb780a1b2c3d4e5f6071", "6a7bb780a1b2c3d4e5f607181", "5d41402abc4b2a76b9719d911017c59", "5d41402abc4b2a76b9719d911017c5921", ] {
let refusal = refusal(token);
assert_eq!(refusal.reason, Reason::Malformed, "{token}");
}
}
#[test]
fn a_run_of_digits_is_never_a_near_miss_hex_identifier() {
assert!(!is_near_miss_hex("1234567890123456789"));
assert!(!is_near_miss_hex("12345678901234567890123"));
}
#[test]
fn an_ordinary_word_is_ignored_rather_than_refused() {
assert_eq!(
classify("configuration_value", None, CLOCK),
Verdict::Ignored
);
}
#[test]
fn the_plausibility_window_opens_in_1990_and_closes_a_year_out() {
assert!(CLOCK.is_plausible(EARLIEST_PLAUSIBLE_MS));
assert!(!CLOCK.is_plausible(EARLIEST_PLAUSIBLE_MS - 1));
assert!(CLOCK.is_plausible(CLOCK.now_ms + FUTURE_ALLOWANCE_MS));
assert!(!CLOCK.is_plausible(CLOCK.now_ms + FUTURE_ALLOWANCE_MS + 1));
}
#[test]
fn key_evidence_ignores_case_and_separators() {
assert!(key_mentions(Some("user_id"), "userid"));
assert!(key_mentions(Some("userId"), "userid"));
assert!(key_mentions(Some("USER-ID"), "userid"));
assert!(key_mentions(Some("orders.[0].userId"), "userid"));
assert!(!key_mentions(Some("username"), "userid"));
assert!(!key_mentions(None, "userid"));
}
#[test]
fn the_leaf_of_a_key_path_is_the_fields_own_name() {
assert_eq!(leaf_key("service.database.id"), "id");
assert_eq!(leaf_key("id"), "id");
}
#[test]
fn a_key_names_an_id_when_its_leaf_ends_in_id() {
for key in [
"id",
"_id",
"$oid",
"userId",
"USER-ID",
"objectId",
"documents.[0]._id",
"service.request_id",
] {
assert!(names_an_id(Some(key)), "{key}");
}
for key in [
"digest",
"commit",
"checksum",
"identifier",
"snowflakes.count",
"ids.total",
] {
assert!(!names_an_id(Some(key)), "{key}");
}
assert!(!names_an_id(None), "a document with no keys names nothing");
}
#[test]
fn only_the_leaf_of_a_path_names_the_scheme() {
assert!(names_scheme(Some("snowflake"), "snowflake"));
assert!(names_scheme(Some("discord.snowflake_value"), "snowflake"));
assert!(!names_scheme(Some("snowflakes.count"), "snowflake"));
assert!(!names_scheme(None, "snowflake"));
assert!(names_scheme(Some("session.ulid"), "ulid"));
assert!(names_scheme(Some("session.nanoId"), "nanoid"));
assert!(!names_scheme(Some("ulids.count"), "ulid"));
}
}