diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 9cbb180..0e3d512 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -53,6 +53,12 @@ pub use attachment::{AttachmentRef, AttachmentSummary}; pub mod manifest; pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION}; +pub mod settings; +pub use settings::{ + AttachmentCaps, Capitalization, CharClasses, GeneratorRequest, HistoryRetention, + SymbolCharset, TrashRetention, VaultSettings, +}; + pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams}; diff --git a/crates/idfoto-core/src/settings.rs b/crates/idfoto-core/src/settings.rs new file mode 100644 index 0000000..7557252 --- /dev/null +++ b/crates/idfoto-core/src/settings.rs @@ -0,0 +1,173 @@ +//! Vault-level settings: trash retention, history retention, generator +//! defaults, attachment caps, autofill TOFU acks. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VaultSettings { + pub trash_retention: TrashRetention, + pub field_history_retention: HistoryRetention, + pub generator_defaults: GeneratorRequest, + pub attachment_caps: AttachmentCaps, + /// hostname → unix-seconds first-acked + #[serde(default)] + pub autofill_origin_acks: HashMap, +} + +impl Default for VaultSettings { + fn default() -> Self { + Self { + trash_retention: TrashRetention::Days(30), + field_history_retention: HistoryRetention::Forever, + generator_defaults: GeneratorRequest::default(), + attachment_caps: AttachmentCaps::default(), + autofill_origin_acks: HashMap::new(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum TrashRetention { + Days(u32), + Forever, +} + +impl TrashRetention { + pub fn should_purge(&self, trashed_at: i64, now: i64) -> bool { + match self { + TrashRetention::Forever => false, + TrashRetention::Days(d) => now - trashed_at > (*d as i64) * 86_400, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum HistoryRetention { + LastN(u32), + Days(u32), + Forever, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum GeneratorRequest { + Bip39 { + word_count: u32, + separator: String, + capitalization: Capitalization, + }, + Random { + length: u32, + classes: CharClasses, + symbol_charset: SymbolCharset, + }, +} + +impl Default for GeneratorRequest { + fn default() -> Self { + GeneratorRequest::Random { + length: 20, + classes: CharClasses { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset: SymbolCharset::SafeOnly, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Capitalization { + Lower, + Upper, + FirstOfEach, + Title, + Mixed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CharClasses { + pub lower: bool, + pub upper: bool, + pub digits: bool, + pub symbols: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SymbolCharset { + SafeOnly, + Extended, + Custom(String), +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct AttachmentCaps { + pub per_attachment_max_bytes: u64, + pub per_item_max_count: u32, + pub per_vault_soft_cap_bytes: u64, + pub per_vault_hard_cap_bytes: u64, +} + +impl Default for AttachmentCaps { + fn default() -> Self { + Self { + per_attachment_max_bytes: 10 * 1024 * 1024, + per_item_max_count: 20, + per_vault_soft_cap_bytes: 100 * 1024 * 1024, + per_vault_hard_cap_bytes: 500 * 1024 * 1024, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_spec() { + let s = VaultSettings::default(); + assert!(matches!(s.trash_retention, TrashRetention::Days(30))); + assert!(matches!(s.field_history_retention, HistoryRetention::Forever)); + assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024); + assert_eq!(s.attachment_caps.per_item_max_count, 20); + } + + #[test] + fn trash_retention_purges_after_days() { + let r = TrashRetention::Days(30); + let now = 1_000_000_000; + let recently_trashed = now - 29 * 86_400; + let long_trashed = now - 31 * 86_400; + assert!(!r.should_purge(recently_trashed, now)); + assert!(r.should_purge(long_trashed, now)); + } + + #[test] + fn trash_retention_forever_never_purges() { + let r = TrashRetention::Forever; + assert!(!r.should_purge(0, 1_000_000_000)); + } + + #[test] + fn settings_round_trip() { + let s = VaultSettings::default(); + let json = serde_json::to_string(&s).unwrap(); + let parsed: VaultSettings = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.attachment_caps.per_attachment_max_bytes, + s.attachment_caps.per_attachment_max_bytes); + } + + #[test] + fn random_generator_default_is_20_safe() { + match VaultSettings::default().generator_defaults { + GeneratorRequest::Random { length, classes, symbol_charset } => { + assert_eq!(length, 20); + assert!(classes.lower && classes.upper && classes.digits && classes.symbols); + assert!(matches!(symbol_charset, SymbolCharset::SafeOnly)); + } + _ => panic!("expected Random default"), + } + } +}