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:
185
crates/idfoto-core/src/item.rs
Normal file
185
crates/idfoto-core/src/item.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user