From a5ddbf2e40deef621e7298811adeb40a95d0977a Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 14:25:11 -0400 Subject: [PATCH] feat(core): add Item envelope with field history + soft-delete set_field_value() captures old values for Password, Concealed, and Totp kinds. Soft-delete via trashed_at timestamp; restore clears it. Kind changes on set_field_value are rejected. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/item.rs | 236 +++++++++++++++++++++++++++++++++ crates/idfoto-core/src/lib.rs | 2 +- 2 files changed, 237 insertions(+), 1 deletion(-) diff --git a/crates/idfoto-core/src/item.rs b/crates/idfoto-core/src/item.rs index e52ce2e..3237e03 100644 --- a/crates/idfoto-core/src/item.rs +++ b/crates/idfoto-core/src/item.rs @@ -114,6 +114,146 @@ pub struct Section { pub fields: Vec, } +use std::collections::HashMap; + +use crate::attachment::AttachmentRef; +use crate::ids::ItemId; +use crate::item_types::{ItemCore, ItemType}; +use crate::time::now_unix; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldHistoryEntry { + pub value: Zeroizing, + pub replaced_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Item { + pub id: ItemId, + pub title: String, + pub r#type: ItemType, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub favorite: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + pub created: i64, + pub modified: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub trashed_at: Option, + pub core: ItemCore, + #[serde(default)] + pub sections: Vec
, + #[serde(default)] + pub attachments: Vec, + #[serde(default)] + pub field_history: HashMap>, +} + +impl Item { + /// Construct a new Item from a typed core; auto-fills id, type, timestamps. + pub fn new(title: String, core: ItemCore) -> Self { + let now = now_unix(); + let r#type = core.item_type(); + Self { + id: ItemId::new(), + title, + r#type, + tags: Vec::new(), + favorite: false, + group: None, + notes: None, + created: now, + modified: now, + trashed_at: None, + core, + sections: Vec::new(), + attachments: Vec::new(), + field_history: HashMap::new(), + } + } + + /// Replace a custom field's value, capturing the previous value into + /// field_history if the field's kind is history-tracked. + pub fn set_field_value(&mut self, field_id: &FieldId, new_value: FieldValue) -> Result<()> { + for section in &mut self.sections { + if let Some(field) = section.fields.iter_mut().find(|f| &f.id == field_id) { + if field.value.kind() != new_value.kind() { + return Err(IdfotoError::Format(format!( + "field {}: cannot change kind from {:?} to {:?}", + field.id.as_str(), field.value.kind(), new_value.kind() + ))); + } + if field.value.is_history_tracked() { + let serialized = serialize_history_value(&field.value)?; + self.field_history + .entry(field.id.clone()) + .or_default() + .push(FieldHistoryEntry { value: serialized, replaced_at: now_unix() }); + } + field.value = new_value; + self.modified = now_unix(); + return Ok(()); + } + } + Err(IdfotoError::Format(format!("field {} not found", field_id.as_str()))) + } + + pub fn soft_delete(&mut self) { + self.trashed_at = Some(now_unix()); + self.modified = now_unix(); + } + + pub fn restore(&mut self) { + self.trashed_at = None; + self.modified = now_unix(); + } + + pub fn is_trashed(&self) -> bool { + self.trashed_at.is_some() + } +} + +/// Serialize a FieldValue to the string form stored in field_history. +fn serialize_history_value(value: &FieldValue) -> Result> { + let s = match value { + FieldValue::Password(p) => Zeroizing::new(p.as_str().to_owned()), + FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()), + FieldValue::Totp(cfg) => { + // Store the base32-encoded secret string for human-recognizability. + let s = base32_encode(&cfg.secret); + Zeroizing::new(s) + } + _ => return Err(IdfotoError::Format("not a history-tracked kind".into())), + }; + Ok(s) +} + +/// Minimal RFC 4648 base32 (no padding) for TOTP secret history serialization. +fn base32_encode(bytes: &[u8]) -> String { + const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut out = String::new(); + let mut buffer: u32 = 0; + let mut bits: u32 = 0; + for &b in bytes { + buffer = (buffer << 8) | (b as u32); + bits += 8; + while bits >= 5 { + let idx = ((buffer >> (bits - 5)) & 0x1f) as usize; + out.push(ALPHA[idx] as char); + bits -= 5; + } + } + if bits > 0 { + let idx = ((buffer << (5 - bits)) & 0x1f) as usize; + out.push(ALPHA[idx] as char); + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -182,4 +322,100 @@ mod tests { assert_eq!(parsed.name.as_deref(), Some("Recovery codes")); assert_eq!(parsed.fields.len(), 2); } + + #[test] + fn new_item_has_timestamps_and_id() { + let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default()); + let item = Item::new("note".into(), core); + assert_eq!(item.id.0.len(), 16); + assert_eq!(item.r#type, ItemType::SecureNote); + assert!(item.created > 0); + assert_eq!(item.created, item.modified); + assert!(item.field_history.is_empty()); + } + + #[test] + fn soft_delete_and_restore_round_trip() { + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("login".into(), core); + assert!(!item.is_trashed()); + item.soft_delete(); + assert!(item.is_trashed()); + item.restore(); + assert!(!item.is_trashed()); + } + + #[test] + fn set_field_value_captures_history_for_password() { + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("login".into(), core); + let pw_field = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("old".into()))); + let pw_id = pw_field.id.clone(); + item.sections.push(Section { name: None, fields: vec![pw_field] }); + + item.set_field_value(&pw_id, FieldValue::Password(Zeroizing::new("new".into()))).unwrap(); + let hist = item.field_history.get(&pw_id).expect("history should exist"); + assert_eq!(hist.len(), 1); + assert_eq!(hist[0].value.as_str(), "old"); + } + + #[test] + fn set_field_value_does_not_capture_history_for_text() { + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("login".into(), core); + let f = Field::new("nickname".into(), FieldValue::Text("a".into())); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + + item.set_field_value(&fid, FieldValue::Text("b".into())).unwrap(); + assert!(item.field_history.get(&fid).is_none_or(|v| v.is_empty())); + } + + #[test] + fn set_field_value_rejects_kind_change() { + let core = ItemCore::Login(crate::item_types::LoginCore::default()); + let mut item = Item::new("login".into(), core); + let f = Field::new("x".into(), FieldValue::Text("a".into())); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + + let err = item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("p".into()))); + assert!(err.is_err()); + } + + #[test] + fn item_serializes_with_minimal_optional_fields() { + let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default()); + let item = Item::new("note".into(), core); + let json = serde_json::to_string(&item).unwrap(); + // No "trashed_at" or "group" or "notes" should appear when None + assert!(!json.contains("trashed_at")); + assert!(!json.contains("\"group\"")); + } + + #[test] + fn full_item_round_trip() { + let core = ItemCore::Login(crate::item_types::LoginCore { + username: Some("alice".into()), + password: Some(Zeroizing::new("hunter2".into())), + url: Some(Url::parse("https://github.com").unwrap()), + totp: None, + }); + let mut item = Item::new("GitHub".into(), core); + item.tags = vec!["work".into()]; + item.favorite = true; + item.notes = Some("notes".into()); + + let json = serde_json::to_string(&item).unwrap(); + let parsed: Item = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.title, "GitHub"); + assert_eq!(parsed.tags, vec!["work".to_string()]); + assert!(parsed.favorite); + match parsed.core { + ItemCore::Login(l) => { + assert_eq!(l.username.as_deref(), Some("alice")); + } + other => panic!("expected Login, got {:?}", other), + } + } } diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index bfed8db..f4dd7b7 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -45,7 +45,7 @@ pub mod item_types; pub use item_types::{ItemCore, ItemType}; pub mod item; -pub use item::{Field, FieldKind, FieldValue, Section}; +pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section}; pub mod attachment; pub use attachment::{AttachmentRef, AttachmentSummary};