//! Item envelope, sections, and custom fields. //! //! `FieldKind` and `FieldValue` are kept as parallel enums (rather than collapsing //! to a single tagged enum) so the kind can be queried without inspecting the value. //! Validation invariant: kind and value's discriminants must match — enforced at //! construction (`Field::new`) and during deserialization (`Field::validate`). use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use url::Url; use zeroize::Zeroizing; use crate::error::{RelicarioError, Result}; use crate::ids::{AttachmentId, FieldId}; use crate::item_types::TotpConfig; use crate::time::MonthYear; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FieldKind { Text, Multiline, Password, Concealed, Url, Email, Phone, Date, MonthYear, Totp, Reference, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", content = "value", rename_all = "snake_case")] pub enum FieldValue { Text(String), Multiline(String), Password(Zeroizing), Concealed(Zeroizing), Url(Url), Email(String), Phone(String), Date(NaiveDate), MonthYear(MonthYear), Totp(TotpConfig), Reference(AttachmentId), } impl FieldValue { pub fn kind(&self) -> FieldKind { match self { FieldValue::Text(_) => FieldKind::Text, FieldValue::Multiline(_) => FieldKind::Multiline, FieldValue::Password(_) => FieldKind::Password, FieldValue::Concealed(_) => FieldKind::Concealed, FieldValue::Url(_) => FieldKind::Url, FieldValue::Email(_) => FieldKind::Email, FieldValue::Phone(_) => FieldKind::Phone, FieldValue::Date(_) => FieldKind::Date, FieldValue::MonthYear(_) => FieldKind::MonthYear, FieldValue::Totp(_) => FieldKind::Totp, FieldValue::Reference(_) => FieldKind::Reference, } } /// True if this kind triggers field-history capture on update. pub fn is_history_tracked(&self) -> bool { matches!(self, FieldValue::Password(_) | FieldValue::Concealed(_) | FieldValue::Totp(_)) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Field { pub id: FieldId, pub label: String, pub kind: FieldKind, pub value: FieldValue, #[serde(default)] pub hidden_by_default: bool, } impl Field { /// Construct a field, deriving `kind` from `value`. pub fn new(label: String, value: FieldValue) -> Self { let kind = value.kind(); Self { id: FieldId::new(), label, kind, value, hidden_by_default: matches!(kind, FieldKind::Password | FieldKind::Concealed), } } /// Verify kind/value discriminants match. Called after deserialization. pub fn validate(&self) -> Result<()> { if self.kind != self.value.kind() { return Err(RelicarioError::Format(format!( "field {}: kind {:?} does not match value discriminant {:?}", self.id.as_str(), self.kind, self.value.kind() ))); } Ok(()) } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Section { #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, 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(RelicarioError::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(RelicarioError::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() } pub fn prune_history(&mut self, retention: &crate::settings::HistoryRetention, now: i64) { use crate::settings::HistoryRetention; for history in self.field_history.values_mut() { match retention { HistoryRetention::Forever => {} HistoryRetention::LastN(n) => { let n = *n as usize; if history.len() > n { let drop_count = history.len() - n; history.drain(..drop_count); } } HistoryRetention::Days(d) => { let cutoff = now - (*d as i64) * 86_400; history.retain(|e| e.replaced_at > cutoff); } } } } } /// 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(RelicarioError::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::*; #[test] fn field_value_kind_matches() { let v = FieldValue::Text("hello".into()); assert_eq!(v.kind(), FieldKind::Text); } #[test] fn password_field_marked_history_tracked() { assert!(FieldValue::Password(Zeroizing::new("x".into())).is_history_tracked()); assert!(FieldValue::Concealed(Zeroizing::new("x".into())).is_history_tracked()); assert!(FieldValue::Totp(TotpConfig::default()).is_history_tracked()); assert!(!FieldValue::Text("x".into()).is_history_tracked()); assert!(!FieldValue::Url(Url::parse("https://example.com").unwrap()).is_history_tracked()); } #[test] fn field_new_derives_kind_from_value() { let f = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("x".into()))); assert_eq!(f.kind, FieldKind::Password); assert!(f.hidden_by_default); } #[test] fn field_new_text_not_hidden() { let f = Field::new("Username".into(), FieldValue::Text("alice".into())); assert!(!f.hidden_by_default); } #[test] fn field_validate_catches_kind_value_mismatch() { let f = Field { id: FieldId::new(), label: "x".into(), kind: FieldKind::Password, value: FieldValue::Text("not actually a password".into()), hidden_by_default: false, }; assert!(f.validate().is_err()); } #[test] fn field_round_trips() { let f = Field::new("Recovery code".into(), FieldValue::Concealed(Zeroizing::new("abcd-efgh".into()))); let json = serde_json::to_string(&f).unwrap(); let parsed: Field = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.label, "Recovery code"); assert_eq!(parsed.kind, FieldKind::Concealed); parsed.validate().unwrap(); } #[test] fn section_round_trip() { let s = Section { name: Some("Recovery codes".into()), fields: vec![ Field::new("code1".into(), FieldValue::Concealed(Zeroizing::new("abc".into()))), Field::new("code2".into(), FieldValue::Concealed(Zeroizing::new("def".into()))), ], }; let json = serde_json::to_string(&s).unwrap(); let parsed: Section = serde_json::from_str(&json).unwrap(); 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), } } #[test] fn prune_history_keeps_last_n() { use crate::settings::HistoryRetention; let core = ItemCore::Login(crate::item_types::LoginCore::default()); let mut item = Item::new("x".into(), core); let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); let fid = f.id.clone(); item.sections.push(Section { name: None, fields: vec![f] }); for i in 1..=5 { item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}")))).unwrap(); } assert_eq!(item.field_history[&fid].len(), 5); item.prune_history(&HistoryRetention::LastN(3), 0); assert_eq!(item.field_history[&fid].len(), 3); // Keeps the MOST RECENT 3 assert_eq!(item.field_history[&fid][0].value.as_str(), "v2"); } #[test] fn prune_history_drops_old_entries_by_days() { use crate::settings::HistoryRetention; let core = ItemCore::Login(crate::item_types::LoginCore::default()); let mut item = Item::new("x".into(), core); let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); let fid = f.id.clone(); item.sections.push(Section { name: None, fields: vec![f] }); let now = 1_000_000_000; item.field_history.insert(fid.clone(), vec![ FieldHistoryEntry { value: Zeroizing::new("old".into()), replaced_at: now - 100 * 86_400 }, FieldHistoryEntry { value: Zeroizing::new("recent".into()), replaced_at: now - 86_400 }, ]); item.prune_history(&HistoryRetention::Days(30), now); assert_eq!(item.field_history[&fid].len(), 1); assert_eq!(item.field_history[&fid][0].value.as_str(), "recent"); } #[test] fn prune_history_forever_keeps_all() { use crate::settings::HistoryRetention; let core = ItemCore::Login(crate::item_types::LoginCore::default()); let mut item = Item::new("x".into(), core); item.field_history.insert(FieldId::new(), vec![ FieldHistoryEntry { value: Zeroizing::new("a".into()), replaced_at: 0 }, FieldHistoryEntry { value: Zeroizing::new("b".into()), replaced_at: 0 }, ]); item.prune_history(&HistoryRetention::Forever, 1_000_000_000); assert_eq!(item.field_history.values().next().unwrap().len(), 2); } }