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) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-19 13:14:00 -04:00
parent a95f92fe71
commit 23f7cb76b1
2 changed files with 188 additions and 0 deletions

View File

@@ -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<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(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<String>,
pub fields: Vec<Field>,
}
#[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);
}
}

View File

@@ -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};