diff --git a/crates/idfoto-core/src/ids.rs b/crates/idfoto-core/src/ids.rs new file mode 100644 index 0000000..9da53a0 --- /dev/null +++ b/crates/idfoto-core/src/ids.rs @@ -0,0 +1,108 @@ +//! 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 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 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 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); + } +} diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index bce5854..7e11a53 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -35,6 +35,9 @@ pub mod error; pub use error::{IdfotoError, Result}; +pub mod ids; +pub use ids::{AttachmentId, FieldId, ItemId}; + pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};