//! Random and content-addressed identifiers for items, fields, and attachments. //! //! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy) //! generated via `OsRng` (audit M8: bumped from the v1 8-char/32-bit format). //! - `AttachmentId` is the first 16 hex chars of `sha256(plaintext)` — //! content-addressed so identical plaintext blobs deduplicate naturally in git. use rand::rngs::OsRng; use rand::RngCore; use sha2::{Digest, Sha256}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct ItemId(pub String); #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct FieldId(pub String); #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct AttachmentId(pub String); impl ItemId { pub fn new() -> Self { let mut bytes = [0u8; 8]; OsRng.fill_bytes(&mut bytes); Self(hex::encode(bytes)) } pub fn as_str(&self) -> &str { &self.0 } } impl Default for ItemId { fn default() -> Self { Self::new() } } impl FieldId { pub fn new() -> Self { let mut bytes = [0u8; 8]; OsRng.fill_bytes(&mut bytes); Self(hex::encode(bytes)) } pub fn as_str(&self) -> &str { &self.0 } } impl Default for FieldId { fn default() -> Self { Self::new() } } impl AttachmentId { pub fn from_plaintext(plaintext: &[u8]) -> Self { let digest = Sha256::digest(plaintext); Self(hex::encode(&digest[..8])) } pub fn as_str(&self) -> &str { &self.0 } } #[cfg(test)] mod tests { use super::*; #[test] fn item_id_is_16_hex_chars() { let id = ItemId::new(); assert_eq!(id.0.len(), 16); assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn item_ids_are_unique() { let mut seen = std::collections::HashSet::new(); for _ in 0..10_000 { assert!(seen.insert(ItemId::new().0)); } } #[test] fn field_id_is_16_hex_chars() { let id = FieldId::new(); assert_eq!(id.0.len(), 16); assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn field_ids_are_unique() { let mut seen = std::collections::HashSet::new(); for _ in 0..10_000 { assert!(seen.insert(FieldId::new().0)); } } #[test] fn attachment_id_is_deterministic() { let plaintext = b"hello world"; let a = AttachmentId::from_plaintext(plaintext); let b = AttachmentId::from_plaintext(plaintext); assert_eq!(a, b); } #[test] fn attachment_id_changes_with_plaintext() { let a = AttachmentId::from_plaintext(b"hello"); let b = AttachmentId::from_plaintext(b"world"); assert_ne!(a, b); } #[test] fn attachment_id_is_16_hex_chars() { let id = AttachmentId::from_plaintext(b"any bytes"); assert_eq!(id.0.len(), 16); assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn ids_serialize_as_bare_strings() { let item = ItemId("abcdef0123456789".to_string()); let json = serde_json::to_string(&item).unwrap(); assert_eq!(json, "\"abcdef0123456789\""); let parsed: ItemId = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, item); } }