//! 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 32 hex chars of `sha256(plaintext)` (128 bits) — //! content-addressed so identical plaintext blobs deduplicate naturally in git. //! (audit I2/B4: bumped from 8-byte/64-bit format to prevent birthday collisions) 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 } /// Returns true if this ID is valid for filesystem paths. /// Valid ItemIds are 16 lowercase hex chars. pub fn is_valid(&self) -> bool { self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit()) } } 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[..16])) // 16 bytes = 128 bits } pub fn as_str(&self) -> &str { &self.0 } /// Returns true if this ID is valid for filesystem paths. /// Valid AttachmentIds are 32 lowercase hex chars. pub fn is_valid(&self) -> bool { self.0.len() == 32 && self.0.chars().all(|c| c.is_ascii_hexdigit()) } } #[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_32_hex_chars() { let id = AttachmentId::from_plaintext(b"any bytes"); assert_eq!(id.0.len(), 32); // 16 bytes = 32 hex chars = 128 bits assert!(id.0.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn item_id_is_valid_for_normal_ids() { let id = ItemId::new(); assert!(id.is_valid()); } #[test] fn item_id_is_invalid_for_traversal() { let bad = ItemId("../../../etc".to_string()); assert!(!bad.is_valid()); } #[test] fn attachment_id_is_valid_for_normal_ids() { let id = AttachmentId::from_plaintext(b"test"); assert!(id.is_valid()); } #[test] fn attachment_id_is_invalid_for_traversal() { let bad = AttachmentId("../../passwd".to_string()); assert!(!bad.is_valid()); } #[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); } }