qualia_client_core/wellfair/
sanctuary.rs1use std::fs;
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9use super::journal::JournalEntry;
10
11pub const SANCTUARY_PREFS_FILE: &str = "wellfair/sanctuary_prefs.json";
12
13pub const SANCTUARY_PROTECTED_KINDS: &[&str] = &["therapy_note", "sanctuary_note", "welfare_case"];
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct SanctuaryPrefs {
18 pub enabled: bool,
19 pub locked: bool,
20 pub decoy_session: bool,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub real_pin_hash_hex: Option<String>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub decoy_pin_hash_hex: Option<String>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub armed_at_unix: Option<u32>,
27}
28
29impl Default for SanctuaryPrefs {
30 fn default() -> Self {
31 Self {
32 enabled: false,
33 locked: false,
34 decoy_session: false,
35 real_pin_hash_hex: None,
36 decoy_pin_hash_hex: None,
37 armed_at_unix: None,
38 }
39 }
40}
41
42pub fn hash_pin(pin: &str) -> String {
43 hex::encode(Sha256::digest(pin.as_bytes()))
44}
45
46pub fn load_prefs(storage_root: impl AsRef<Path>) -> SanctuaryPrefs {
47 let path = storage_root.as_ref().join(SANCTUARY_PREFS_FILE);
48 if !path.exists() {
49 return SanctuaryPrefs::default();
50 }
51 fs::read_to_string(&path)
52 .ok()
53 .and_then(|s| serde_json::from_str(&s).ok())
54 .unwrap_or_default()
55}
56
57pub fn save_prefs(storage_root: impl AsRef<Path>, prefs: &SanctuaryPrefs) -> std::io::Result<()> {
58 let path = storage_root.as_ref().join(SANCTUARY_PREFS_FILE);
59 if let Some(parent) = path.parent() {
60 fs::create_dir_all(parent)?;
61 }
62 let json =
63 serde_json::to_string_pretty(prefs).map_err(|e| std::io::Error::other(e.to_string()))?;
64 fs::write(&path, json)
65}
66
67pub fn setup_sanctuary(
68 storage_root: impl AsRef<Path>,
69 real_pin: &str,
70 decoy_pin: &str,
71 now_unix: u32,
72) -> Result<SanctuaryPrefs, String> {
73 if real_pin.len() < 4 || decoy_pin.len() < 4 {
74 return Err("PIN must be at least 4 characters".into());
75 }
76 if real_pin == decoy_pin {
77 return Err("Decoy PIN must differ from the real unlock PIN".into());
78 }
79 let prefs = SanctuaryPrefs {
80 enabled: true,
81 locked: false,
82 decoy_session: false,
83 real_pin_hash_hex: Some(hash_pin(real_pin)),
84 decoy_pin_hash_hex: Some(hash_pin(decoy_pin)),
85 armed_at_unix: Some(now_unix),
86 };
87 save_prefs(storage_root, &prefs).map_err(|e| e.to_string())?;
88 Ok(prefs)
89}
90
91pub fn lock_sanctuary(storage_root: impl AsRef<Path>) -> Result<SanctuaryPrefs, String> {
92 let mut prefs = load_prefs(&storage_root);
93 if !prefs.enabled {
94 return Err("Sanctuary is not set up".into());
95 }
96 prefs.locked = true;
97 prefs.decoy_session = false;
98 save_prefs(&storage_root, &prefs).map_err(|e| e.to_string())?;
99 Ok(prefs)
100}
101
102pub fn unlock_sanctuary(
103 storage_root: impl AsRef<Path>,
104 pin: &str,
105) -> Result<SanctuaryPrefs, String> {
106 let mut prefs = load_prefs(&storage_root);
107 if !prefs.enabled {
108 return Err("Sanctuary is not set up".into());
109 }
110 let hash = hash_pin(pin);
111 let real = prefs.real_pin_hash_hex.as_deref();
112 let decoy = prefs.decoy_pin_hash_hex.as_deref();
113 if Some(hash.as_str()) == real {
114 prefs.locked = false;
115 prefs.decoy_session = false;
116 save_prefs(&storage_root, &prefs).map_err(|e| e.to_string())?;
117 return Ok(prefs);
118 }
119 if Some(hash.as_str()) == decoy {
120 prefs.locked = true;
121 prefs.decoy_session = true;
122 save_prefs(&storage_root, &prefs).map_err(|e| e.to_string())?;
123 return Ok(prefs);
124 }
125 Err("Incorrect PIN".into())
126}
127
128pub fn is_sanctuary_protected_kind(kind: &str) -> bool {
129 SANCTUARY_PROTECTED_KINDS.contains(&kind)
130}
131
132pub fn apply_sanctuary_projection(
133 prefs: &SanctuaryPrefs,
134 entries: Vec<JournalEntry>,
135) -> Vec<JournalEntry> {
136 if !prefs.enabled || !prefs.locked {
137 return entries;
138 }
139 entries
140 .into_iter()
141 .filter(|e| !is_sanctuary_protected_kind(&e.kind))
142 .collect()
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use crate::wellfair::journal::JournalEntry;
149
150 fn entry(kind: &str) -> JournalEntry {
151 JournalEntry {
152 id: format!("urn:test:{kind}"),
153 kind: kind.into(),
154 asserted_time_unix: 0,
155 evidence_type: "SelfReported".into(),
156 sensitivity: "Classified".into(),
157 blob_hash: None,
158 source: "test".into(),
159 committed_unix: 0,
160 summary: None,
161 }
162 }
163
164 #[test]
165 fn locked_sanctuary_hides_protected_kinds() {
166 let prefs = SanctuaryPrefs {
167 enabled: true,
168 locked: true,
169 decoy_session: false,
170 ..Default::default()
171 };
172 let rows = vec![entry("weight"), entry("therapy_note"), entry("life_event")];
173 let out = apply_sanctuary_projection(&prefs, rows);
174 assert_eq!(out.len(), 2);
175 assert!(out.iter().all(|e| !is_sanctuary_protected_kind(&e.kind)));
176 }
177
178 #[test]
179 fn decoy_pin_keeps_locked_with_decoy_flag() {
180 let dir = tempfile::tempdir().unwrap();
181 setup_sanctuary(dir.path(), "real-pin-1", "decoy-pin-2", 10).unwrap();
182 lock_sanctuary(dir.path()).unwrap();
183 let prefs = unlock_sanctuary(dir.path(), "decoy-pin-2").unwrap();
184 assert!(prefs.locked);
185 assert!(prefs.decoy_session);
186 }
187
188 #[test]
189 fn real_pin_unlocks() {
190 let dir = tempfile::tempdir().unwrap();
191 setup_sanctuary(dir.path(), "real-pin-1", "decoy-pin-2", 10).unwrap();
192 lock_sanctuary(dir.path()).unwrap();
193 let prefs = unlock_sanctuary(dir.path(), "real-pin-1").unwrap();
194 assert!(!prefs.locked);
195 assert!(!prefs.decoy_session);
196 }
197}