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>,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -182,4 +322,100 @@ mod tests {
|
|||||||
assert_eq!(parsed.name.as_deref(), Some("Recovery codes"));
|
assert_eq!(parsed.name.as_deref(), Some("Recovery codes"));
|
||||||
assert_eq!(parsed.fields.len(), 2);
|
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 use item_types::{ItemCore, ItemType};
|
||||||
|
|
||||||
pub mod item;
|
pub mod item;
|
||||||
pub use item::{Field, FieldKind, FieldValue, Section};
|
pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section};
|
||||||
|
|
||||||
pub mod attachment;
|
pub mod attachment;
|
||||||
pub use attachment::{AttachmentRef, AttachmentSummary};
|
pub use attachment::{AttachmentRef, AttachmentSummary};
|
||||||
|
|||||||
Reference in New Issue
Block a user