//! 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", content = "value", 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"), } } #[test] fn symbol_charset_custom_round_trips() { let c = SymbolCharset::Custom("!@#".into()); let json = serde_json::to_string(&c).unwrap(); let parsed: SymbolCharset = serde_json::from_str(&json).unwrap(); match parsed { SymbolCharset::Custom(s) => assert_eq!(s, "!@#"), other => panic!("expected Custom, got {:?}", other), } } }