- AttachmentId now uses 16 bytes of SHA-256 (128 bits) instead of 8, requiring ~2^64 work for birthday collision instead of ~2^32. - Added is_valid() to ItemId and AttachmentId for path traversal prevention during backup restore. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
162 lines
4.6 KiB
Rust
162 lines
4.6 KiB
Rust
//! 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);
|
|
}
|
|
}
|