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:
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user