Renames crate directories and sweeps identifiers so Plan 1B can reference
the post-rename names throughout.
- git mv crates/idfoto-{core,cli,wasm} → crates/relicario-{core,cli,wasm}
- sed sweep: idfoto_core/idfoto-core/IdfotoError/IDFOTO_IMAGE/.idfoto/ etc.
- All 128 relicario-core tests pass post-sweep
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
498 lines
17 KiB
Rust
498 lines
17 KiB
Rust
//! 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<String>),
|
|
Concealed(Zeroizing<String>),
|
|
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<String>,
|
|
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(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<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(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);
|
|
}
|
|
}
|