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) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-19 14:25:11 -04:00
parent 509db707e0
commit a5ddbf2e40
2 changed files with 237 additions and 1 deletions

View File

@@ -114,6 +114,146 @@ pub struct Section {
pub fields: Vec<Field>,
}
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<String>,
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<String>,
#[serde(default)]
pub favorite: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
pub created: i64,
pub modified: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub trashed_at: Option<i64>,
pub core: ItemCore,
#[serde(default)]
pub sections: Vec<Section>,
#[serde(default)]
pub attachments: Vec<AttachmentRef>,
#[serde(default)]
pub field_history: HashMap<FieldId, Vec<FieldHistoryEntry>>,
}
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<Zeroizing<String>> {
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),
}
}
}

View File

@@ -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};