From 23f7cb76b122c8a801e03296789bcc52f1a9522b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 13:14:00 -0400 Subject: [PATCH] feat(core): add Field, FieldKind, FieldValue, Section Parallel kind/value enums with a validate() invariant. Password, Concealed, and Totp kinds are marked history-tracked so the Item setter (next task) can decide whether to capture history on update. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/item.rs | 185 +++++++++++++++++++++++++++++++++ crates/idfoto-core/src/lib.rs | 3 + 2 files changed, 188 insertions(+) create mode 100644 crates/idfoto-core/src/item.rs diff --git a/crates/idfoto-core/src/item.rs b/crates/idfoto-core/src/item.rs new file mode 100644 index 0000000..e52ce2e --- /dev/null +++ b/crates/idfoto-core/src/item.rs @@ -0,0 +1,185 @@ +//! 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::{IdfotoError, 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(IdfotoError::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, +} + +#[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); + } +} diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 00637fe..49f50ad 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -44,6 +44,9 @@ pub use time::{now_unix, MonthYear}; pub mod item_types; pub use item_types::{ItemCore, ItemType}; +pub mod item; +pub use item::{Field, FieldKind, FieldValue, Section}; + pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};