#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(unsafe_code)]
#![deny(clippy::unwrap_used)]
#![deny(clippy::indexing_slicing)]
#![deny(rustdoc::broken_intra_doc_links)]
#![warn(missing_docs)]
use std::marker::PhantomData;
mod data_encoding;
pub mod dynamic_tnid;
#[cfg(feature = "encryption")]
pub mod encryption;
#[cfg(feature = "filter")]
pub mod filter;
mod name_encoding;
#[cfg(feature = "serde")]
mod serde_impl;
#[cfg(any(
feature = "sqlx-postgres",
feature = "sqlx-mysql",
feature = "sqlx-sqlite"
))]
mod sqlx_impl;
mod tnid_variant;
mod utils;
#[cfg(feature = "uuid")]
mod uuid;
mod uuidlike;
mod v0;
mod v1;
pub use data_encoding::DataEncodingError;
pub use dynamic_tnid::DynamicTnid;
pub use name_encoding::{NameBitsValidation, NameError, NameStr};
pub use tnid_variant::TnidVariant;
pub use uuidlike::Case;
pub use uuidlike::{ParseUuidStringError, UuidLike};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ParseTnidError {
InvalidLength(usize),
MissingSeparator,
InvalidName(NameError),
NameMismatch {
expected: &'static str,
found: String,
},
InvalidDataEncoding(data_encoding::DataEncodingError),
InvalidUuidFormat(ParseUuidStringError),
InvalidUuidBits,
InvalidNameBits,
}
impl std::fmt::Display for ParseTnidError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidLength(len) => {
write!(
f,
"TNID string length {len} is invalid; expected 19-22 characters"
)
}
Self::MissingSeparator => {
write!(
f,
"TNID string missing dot separator; expected format: <name>.<data>"
)
}
Self::InvalidName(e) => write!(f, "invalid TNID name: {e}"),
Self::NameMismatch { expected, found } => {
write!(f, "name mismatch: expected '{expected}', found '{found}'")
}
Self::InvalidDataEncoding(e) => write!(f, "invalid TNID data encoding: {e}"),
Self::InvalidUuidFormat(e) => write!(f, "invalid UUID format: {e}"),
Self::InvalidUuidBits => {
write!(f, "invalid UUID version/variant bits; expected UUIDv8")
}
Self::InvalidNameBits => {
write!(f, "invalid name encoding in bits (empty or malformed)")
}
}
}
}
impl std::error::Error for ParseTnidError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::InvalidName(e) => Some(e),
Self::InvalidDataEncoding(e) => Some(e),
Self::InvalidUuidFormat(e) => Some(e),
_ => None,
}
}
}
pub trait TnidName {
const ID_NAME: NameStr<'static>;
}
#[derive(PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Tnid<Name: TnidName> {
id_name: PhantomData<Name>,
id: u128,
}
impl<Name: TnidName> Copy for Tnid<Name> {}
impl<Name: TnidName> Clone for Tnid<Name> {
fn clone(&self) -> Self {
*self
}
}
impl<Name: TnidName> Tnid<Name> {
pub fn name(&self) -> &'static str {
Name::ID_NAME.as_str()
}
pub fn name_hex(&self) -> String {
name_encoding::name_bits_to_hex(self.id)
}
pub fn as_u128(&self) -> u128 {
self.id
}
pub fn to_bytes(&self) -> [u8; 16] {
self.id.to_be_bytes()
}
pub fn from_bytes(bytes: [u8; 16]) -> Result<Self, ParseTnidError> {
Self::from_u128(u128::from_be_bytes(bytes))
}
#[cfg(all(feature = "time", feature = "rand"))]
pub fn new_time_ordered() -> Self {
Self::new_v0()
}
#[cfg(all(feature = "time", feature = "rand"))]
pub fn new_v0() -> Self {
Self::new_v0_with_time(time::OffsetDateTime::now_utc())
}
#[cfg(feature = "rand")]
pub fn new_high_entropy() -> Self {
Self::new_v1()
}
#[cfg(feature = "rand")]
pub fn new_v1() -> Self {
Self::new_v1_with_random(rand::random())
}
pub fn new_v1_with_random(random_bits: u128) -> Self {
let id_name = Name::ID_NAME;
let id = v1::make_from_parts(id_name, random_bits);
Self {
id_name: PhantomData,
id,
}
}
#[cfg(all(feature = "rand", feature = "time"))]
pub fn new_v0_with_time(time: time::OffsetDateTime) -> Self {
let id_name = Name::ID_NAME;
let epoch_millis = (time.unix_timestamp_nanos() / 1000 / 1000) as u64;
let random_bits: u64 = rand::random();
let id = v0::make_from_parts(id_name, epoch_millis, random_bits);
Self {
id_name: PhantomData,
id,
}
}
pub fn new_v0_with_parts(epoch_millis: u64, random: u64) -> Self {
Self {
id_name: PhantomData,
id: v0::make_from_parts(Name::ID_NAME, epoch_millis, random),
}
}
pub fn to_tnid_string(&self) -> String {
format!(
"{}.{}",
self.name(),
data_encoding::id_data_to_string(self.id)
)
}
pub fn data_string(&self) -> String {
data_encoding::id_data_to_string(self.id)
}
pub fn variant(&self) -> TnidVariant {
TnidVariant::from_id(self.id)
}
pub fn to_uuid_string(&self, case: Case) -> String {
utils::u128_to_uuid_string(self.id, case)
}
pub fn parse_uuid_string(uuid_string: &str) -> Result<Self, ParseTnidError> {
let id = UuidLike::parse_uuid_string(uuid_string)
.map_err(ParseTnidError::InvalidUuidFormat)?
.as_u128();
Self::from_u128(id)
}
pub fn parse_tnid_string(tnid_string: &str) -> Result<Self, ParseTnidError> {
const MIN_LEN: usize =
name_encoding::NAME_MIN_CHARS + 1 + data_encoding::DATA_CHAR_ENCODING_LEN as usize;
const MAX_LEN: usize =
name_encoding::NAME_MAX_CHARS + 1 + data_encoding::DATA_CHAR_ENCODING_LEN as usize;
if tnid_string.len() < MIN_LEN || tnid_string.len() > MAX_LEN {
return Err(ParseTnidError::InvalidLength(tnid_string.len()));
}
let (name, data_str) = tnid_string
.split_once('.')
.ok_or(ParseTnidError::MissingSeparator)?;
if name != Name::ID_NAME.as_str() {
return Err(ParseTnidError::NameMismatch {
expected: Name::ID_NAME.as_str(),
found: name.to_string(),
});
}
let compact_data = data_encoding::string_to_id_data(data_str)
.map_err(ParseTnidError::InvalidDataEncoding)?;
let data_bits = data_encoding::expand_data_bits(compact_data);
let name_bits = name_encoding::name_mask(Name::ID_NAME);
let id = name_bits | utils::UUID_V8_MASK | data_bits;
Self::from_u128(id)
}
pub fn from_u128(id: u128) -> Result<Self, ParseTnidError> {
if (id & utils::UUID_V8_MASK) != utils::UUID_V8_MASK {
return Err(ParseTnidError::InvalidUuidBits);
}
if name_encoding::validate_name_bits(id) == name_encoding::NameBitsValidation::Invalid {
return Err(ParseTnidError::InvalidNameBits);
}
let name_bits_mask = 0xFFFFF_u128 << 108; let actual_name_bits = id & name_bits_mask;
let expected_name_bits = name_encoding::name_mask(Name::ID_NAME);
if actual_name_bits != expected_name_bits {
let found = name_encoding::extract_name_string(id)
.expect("name bits validated, extraction should succeed");
return Err(ParseTnidError::NameMismatch {
expected: Name::ID_NAME.as_str(),
found,
});
}
Ok(Self {
id,
id_name: PhantomData,
})
}
#[cfg(feature = "encryption")]
pub fn encrypt_v0_to_v1(
&self,
key: &encryption::EncryptionKey,
) -> Result<Self, encryption::EncryptionError> {
let id = encryption::encrypt_id_v0_to_v1(self.id, key)?;
Ok(Self {
id_name: PhantomData,
id,
})
}
#[cfg(feature = "encryption")]
pub fn decrypt_v1_to_v0(
&self,
key: &encryption::EncryptionKey,
) -> Result<Self, encryption::EncryptionError> {
let id = encryption::decrypt_id_v1_to_v0(self.id, key)?;
Ok(Self {
id_name: PhantomData,
id,
})
}
#[cfg(feature = "filter")]
pub fn new_v0_filtered(blocklist: &filter::Blocklist) -> Result<Self, filter::FilterError> {
let mut timestamp = blocklist.get_starting_timestamp();
let max_iterations = blocklist.limits().max_v0_iterations;
for _ in 0..max_iterations {
let random: u64 = rand::random();
let id = Self::new_v0_with_parts(timestamp, random);
let data = id.data_string();
match filter::find_first_match(blocklist, &data) {
None => {
blocklist.record_safe_timestamp(timestamp);
return Ok(id);
}
Some((start, len)) => {
if !filter::match_touches_random_portion(start, len) {
let rightmost_char = start + len - 1;
timestamp += filter::timestamp_bump_for_char(rightmost_char);
}
}
}
}
Err(filter::FilterError::MaxIterationsExceeded {
iterations: max_iterations,
})
}
#[cfg(feature = "filter")]
pub fn new_v1_filtered(blocklist: &filter::Blocklist) -> Result<Self, filter::FilterError> {
let max_iterations = blocklist.limits().max_v1_iterations;
for _ in 0..max_iterations {
let id = Self::new_v1();
let data = id.data_string();
if !blocklist.contains_match(&data) {
return Ok(id);
}
}
Err(filter::FilterError::MaxIterationsExceeded {
iterations: max_iterations,
})
}
#[cfg(all(feature = "filter", feature = "encryption"))]
pub fn new_v0_filtered_for_encryption(
key: &encryption::EncryptionKey,
blocklist: &filter::Blocklist,
) -> Result<Self, filter::FilterError> {
let mut timestamp = blocklist.get_starting_timestamp();
let max_iterations = blocklist.limits().max_encryption_iterations;
for _ in 0..max_iterations {
let random: u64 = rand::random();
let v0 = Self::new_v0_with_parts(timestamp, random);
let v0_data = v0.data_string();
if let Some((start, len)) = filter::find_first_match(blocklist, &v0_data) {
if !filter::match_touches_random_portion(start, len) {
let rightmost_char = start + len - 1;
timestamp += filter::timestamp_bump_for_char(rightmost_char);
}
continue;
}
let v1 = v0.encrypt_v0_to_v1(key)?;
let v1_data = v1.data_string();
if !blocklist.contains_match(&v1_data) {
blocklist.record_safe_timestamp(timestamp);
return Ok(v0);
}
}
Err(filter::FilterError::MaxIterationsExceeded {
iterations: max_iterations,
})
}
}
#[cfg(feature = "internals")]
#[cfg_attr(docsrs, doc(cfg(feature = "internals")))]
pub mod internals {
pub use crate::data_encoding::{
CHAR_BIT_LENGTH as DATA_CHAR_BIT_LENGTH, CHAR_MAPPING as DATA_CHAR_MAPPING, DATA_BIT_NUM,
DATA_CHAR_ENCODING_LEN, LEFT_DATA_SECTION_MASK, MIDDLE_DATA_SECTION_MASK,
RIGHT_DATA_SECTION_MASK, expand_data_bits, extract_data_bits, id_data_to_string,
string_to_id_data,
};
#[cfg(feature = "encryption")]
pub use crate::encryption::{
COMPLETE_SECRET_DATA_MASK, LEFT_SECRET_DATA_SECTION_MASK, MIDDLE_SECRET_DATA_SECTION_MASK,
RIGHT_SECRET_DATA_SECTION_MASK, decrypt, decrypt_id_v1_to_v0, encrypt, encrypt_id_v0_to_v1,
expand_secret_data_bits, extract_secret_data_bits,
};
pub use crate::name_encoding::{
CHAR_BIT_LENGTH as NAME_CHAR_BIT_LENGTH, CHAR_MAPPING as NAME_CHAR_MAPPING,
CHAR_MASK as NAME_CHAR_MASK, NAME_MAX_CHARS, NAME_MIN_CHARS, NON_NAME_BITS,
extract_name_string, name_bits_to_hex, name_mask, validate_name_bits,
};
pub use crate::utils::{
UUID_V8_MASK, change_variant, hex_char_to_nibble, u128_to_uuid_string,
uuid_and_variant_mask,
};
pub use crate::v0::{
RANDOM_MASK as V0_RANDOM_MASK, TIMESTAMP_FIRST_28_MASK as V0_TIMESTAMP_FIRST_28_MASK,
TIMESTAMP_LAST_3_MASK as V0_TIMESTAMP_LAST_3_MASK,
TIMESTAMP_SECOND_12_MASK as V0_TIMESTAMP_SECOND_12_MASK,
make_from_parts as v0_make_from_parts, millis_mask as v0_millis_mask,
random_bits_mask as v0_random_bits_mask,
};
pub use crate::v1::{
RANDOM_MASK as V1_RANDOM_MASK, make_from_parts as v1_make_from_parts,
random_bits_mask as v1_random_bits_mask,
};
}
impl<Name: TnidName> std::fmt::Display for Tnid<Name> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_tnid_string())
}
}
impl<Name: TnidName> std::fmt::Debug for Tnid<Name> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_tnid_string())
}
}
impl<Name: TnidName> std::str::FromStr for Tnid<Name> {
type Err = ParseTnidError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
const MIN_TNID_LEN: usize =
name_encoding::NAME_MIN_CHARS + 1 + data_encoding::DATA_CHAR_ENCODING_LEN as usize;
const MAX_TNID_LEN: usize =
name_encoding::NAME_MAX_CHARS + 1 + data_encoding::DATA_CHAR_ENCODING_LEN as usize;
const UUID_LEN: usize = 36;
match s.len() {
MIN_TNID_LEN..=MAX_TNID_LEN if s.contains('.') => Self::parse_tnid_string(s),
MIN_TNID_LEN..=MAX_TNID_LEN => Err(ParseTnidError::MissingSeparator),
UUID_LEN => Self::parse_uuid_string(s),
len => Err(ParseTnidError::InvalidLength(len)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestId;
impl TnidName for TestId {
const ID_NAME: NameStr<'static> = NameStr::new_const("test");
}
#[test]
fn variant0_is_k_sortable() {
let mut epoch_ms: u64 = 1_700_000_000_000;
let random: u64 = 42;
let mut last_id: Tnid<TestId> = Tnid::new_v0_with_parts(epoch_ms, random);
for _ in 1..10_000 {
epoch_ms += 1;
let id: Tnid<TestId> = Tnid::new_v0_with_parts(epoch_ms, random);
assert!(last_id.as_u128() < id.as_u128());
assert!(last_id.to_tnid_string() < id.to_tnid_string());
last_id = id;
}
}
#[test]
fn tnid_variant_returns_v0() {
let id: Tnid<TestId> = Tnid::new_v0_with_parts(1_700_000_000_000, 42);
assert_eq!(id.variant(), TnidVariant::V0);
}
#[cfg(feature = "encryption")]
#[test]
fn encryption_bidirectional() {
let key =
encryption::EncryptionKey::new([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
let original: Tnid<TestId> = Tnid::new_v0_with_parts(1_700_000_000_000, 42);
assert_eq!(original.variant(), TnidVariant::V0);
let encrypted = original
.encrypt_v0_to_v1(&key)
.expect("encryption should succeed");
assert_eq!(encrypted.variant(), TnidVariant::V1);
dbg!(encrypted, original);
let decrypted = encrypted
.decrypt_v1_to_v0(&key)
.expect("decryption should succeed");
assert_eq!(decrypted.variant(), TnidVariant::V0);
assert_eq!(decrypted.as_u128(), original.as_u128());
}
#[test]
fn parse_tnid_string_roundtrip() {
let original: Tnid<TestId> = Tnid::new_v0_with_parts(1_700_000_000_000, 42);
let tnid_string = original.to_tnid_string();
let parsed = Tnid::<TestId>::parse_tnid_string(&tnid_string)
.expect("generated TNID string should parse");
assert_eq!(parsed.as_u128(), original.as_u128());
}
#[test]
fn parse_tnid_string_invalid_name() {
let result = Tnid::<TestId>::parse_tnid_string("wrong.abc123xyz");
assert!(result.is_err());
}
#[test]
fn parse_tnid_string_no_separator() {
let result = Tnid::<TestId>::parse_tnid_string("testabc123xyz");
assert!(result.is_err());
}
#[test]
fn from_u128_malformed_name_bits_returns_invalid_name_bits() {
let a: u128 = 6; let b: u128 = 7; let c: u128 = 8;
let name_bits = (a << 123) | (b << 113) | (c << 108);
let id = name_bits | utils::UUID_V8_MASK;
let result = Tnid::<TestId>::from_u128(id);
assert!(
matches!(result, Err(ParseTnidError::InvalidNameBits)),
"expected InvalidNameBits, got {:?}",
result
);
}
#[test]
fn from_u128_name_mismatch_returns_name_mismatch() {
let u: u128 = 26; let s: u128 = 24; let e: u128 = 10; let r: u128 = 23;
let name_bits = (u << 123) | (s << 118) | (e << 113) | (r << 108);
let id = name_bits | utils::UUID_V8_MASK;
let result = Tnid::<TestId>::from_u128(id);
match result {
Err(ParseTnidError::NameMismatch { expected, found }) => {
assert_eq!(expected, "test");
assert_eq!(found, "user");
}
other => panic!("expected NameMismatch, got {:?}", other),
}
}
#[test]
fn from_u128_empty_name_bits_returns_invalid_name_bits() {
let name_bits: u128 = 0;
let id = name_bits | utils::UUID_V8_MASK;
let result = Tnid::<TestId>::from_u128(id);
assert!(
matches!(result, Err(ParseTnidError::InvalidNameBits)),
"expected InvalidNameBits, got {:?}",
result
);
}
#[cfg(all(feature = "time", feature = "rand"))]
#[test]
fn new_v0_with_time_pre_epoch_wraps() {
use time::OffsetDateTime;
let pre_epoch =
OffsetDateTime::from_unix_timestamp(-14182980).expect("valid unix timestamp");
let id: Tnid<TestId> = Tnid::new_v0_with_time(pre_epoch);
assert_eq!(id.name(), "test");
let normal_time =
OffsetDateTime::from_unix_timestamp(1704067200).expect("valid unix timestamp"); let normal_id: Tnid<TestId> = Tnid::new_v0_with_time(normal_time);
assert!(
id.as_u128() > normal_id.as_u128(),
"pre-epoch ID should sort after normal ID due to wrapping"
);
}
#[cfg(feature = "filter")]
#[test]
fn new_v0_filtered_produces_clean_ids() {
let blocklist = filter::Blocklist::new(&["TACO", "FOO", "BAR"]).unwrap();
for _ in 0..100 {
let id = Tnid::<TestId>::new_v0_filtered(&blocklist).unwrap();
let data = id.data_string();
assert!(
!blocklist.contains_match(&data),
"filtered ID should not contain blocklist words: {data}"
);
}
}
#[cfg(feature = "filter")]
#[test]
fn new_v1_filtered_produces_clean_ids() {
let blocklist = filter::Blocklist::new(&["TACO", "FOO", "BAR"]).unwrap();
for _ in 0..100 {
let id = Tnid::<TestId>::new_v1_filtered(&blocklist).unwrap();
let data = id.data_string();
assert!(
!blocklist.contains_match(&data),
"filtered ID should not contain blocklist words: {data}"
);
}
}
#[cfg(all(feature = "filter", feature = "encryption"))]
#[test]
fn new_v0_filtered_for_encryption_produces_clean_ids() {
let key = encryption::EncryptionKey::new([1u8; 16]);
let blocklist = filter::Blocklist::new(&["TACO", "FOO", "BAR"]).unwrap();
for _ in 0..50 {
let v0 = Tnid::<TestId>::new_v0_filtered_for_encryption(&key, &blocklist).unwrap();
let v0_data = v0.data_string();
assert!(
!blocklist.contains_match(&v0_data),
"V0 should not contain blocklist words: {v0_data}"
);
let v1 = v0.encrypt_v0_to_v1(&key).unwrap();
let v1_data = v1.data_string();
assert!(
!blocklist.contains_match(&v1_data),
"encrypted V1 should not contain blocklist words: {v1_data}"
);
}
}
#[cfg(feature = "filter")]
#[test]
fn filtered_with_empty_blocklist_succeeds() {
let blocklist = filter::Blocklist::new(&[]).unwrap();
let v0 = Tnid::<TestId>::new_v0_filtered(&blocklist);
assert!(v0.is_ok());
let v1 = Tnid::<TestId>::new_v1_filtered(&blocklist);
assert!(v1.is_ok());
}
#[cfg(feature = "filter")]
#[test]
fn data_string_returns_17_chars() {
let id: Tnid<TestId> = Tnid::new_v0_with_parts(1_700_000_000_000, 42);
let data = id.data_string();
assert_eq!(data.len(), 17);
}
}