feat(core): add ItemId, FieldId, AttachmentId types
16-char hex (64-bit) random IDs for items and fields (audit M8). AttachmentId is sha256(plaintext)[..16] for content-addressing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
108
crates/idfoto-core/src/ids.rs
Normal file
108
crates/idfoto-core/src/ids.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,9 @@
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub use error::{IdfotoError, Result};
|
pub use error::{IdfotoError, Result};
|
||||||
|
|
||||||
|
pub mod ids;
|
||||||
|
pub use ids::{AttachmentId, FieldId, ItemId};
|
||||||
|
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};
|
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user