1use std::collections::BTreeMap;
26use std::path::PathBuf;
27use std::sync::Mutex;
28
29#[derive(Clone)]
30pub struct IdentityRegistry {
31 path: PathBuf,
32 map: std::sync::Arc<Mutex<BTreeMap<String, String>>>,
33}
34
35impl IdentityRegistry {
36 pub fn from_default_home() -> Self {
40 let path = dirs::home_dir()
41 .map(|h| h.join(".trustee").join("users").join("identity_map.json"))
42 .unwrap_or_else(|| PathBuf::from("identity_map.json"));
43 Self::from_path(path)
44 }
45
46 pub fn from_path(path: PathBuf) -> Self {
47 let map = std::fs::read_to_string(&path)
48 .ok()
49 .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
50 .and_then(|v| {
51 v.get("pins").and_then(|p| p.as_object()).map(|obj| {
52 obj.iter()
53 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
54 .collect::<BTreeMap<String, String>>()
55 })
56 })
57 .unwrap_or_default();
58 Self {
59 path,
60 map: std::sync::Arc::new(Mutex::new(map)),
61 }
62 }
63
64 pub fn get(&self, sub: &str) -> Option<String> {
66 self.map.lock().ok()?.get(sub).cloned()
67 }
68
69 pub fn pin(&self, sub: &str, derived: &str) -> String {
74 if let Ok(map) = self.map.lock() {
75 if let Some(pinned) = map.get(sub) {
76 return pinned.clone();
77 }
78 }
79 let mut map = self.map.lock().expect("identity registry poisoned");
80 if let Some(pinned) = map.get(sub) {
82 return pinned.clone();
83 }
84 map.insert(sub.to_string(), derived.to_string());
85 self.save(&map);
86 tracing::info!(
87 "identity registry: pinned user_key for sub {sub} (first sighting) — \
88 claims can no longer rotate this namespace"
89 );
90 derived.to_string()
91 }
92
93 fn save(&self, map: &BTreeMap<String, String>) {
94 let dir = self
95 .path
96 .parent()
97 .map(|p| p.to_path_buf())
98 .unwrap_or_default();
99 let _ = std::fs::create_dir_all(&dir);
100 let doc = serde_json::json!({ "pins": map });
101 match serde_json::to_vec_pretty(&doc) {
102 Ok(bytes) => {
103 if let Err(e) = std::fs::write(&self.path, bytes) {
104 tracing::warn!(
105 "identity registry: failed to persist {}: {e} — pin is process-local only",
106 self.path.display()
107 );
108 }
109 }
110 Err(e) => tracing::warn!("identity registry: serialize failed: {e}"),
111 }
112 #[cfg(unix)]
113 {
114 use std::os::unix::fs::PermissionsExt;
115 let _ = std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600));
116 }
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 fn registry(tag: &str) -> IdentityRegistry {
125 let dir = std::env::temp_dir().join(format!("trustee-ident-{tag}-{}", std::process::id()));
126 let _ = std::fs::remove_dir_all(&dir);
127 std::fs::create_dir_all(&dir).unwrap();
128 IdentityRegistry::from_path(dir.join("identity_map.json"))
129 }
130
131 #[test]
132 fn first_sight_pins_derived_key() {
133 let r = registry("pin1");
134 assert_eq!(r.pin("sub-1", "farzan"), "farzan");
135 assert_eq!(r.pin("sub-1", "anything-else"), "farzan");
136 }
137
138 #[test]
139 fn claim_mutation_cannot_rotate_the_namespace() {
140 let r = registry("mut");
143 let first = r.pin("sub-1", "farzan");
144 assert_eq!(
146 r.pin("sub-1", "sub-1"),
147 first,
148 "claim mutation rotated the namespace"
149 );
150 assert_eq!(r.pin("sub-1", "ghost"), first);
152 }
153
154 #[test]
155 fn distinct_principals_pin_independently() {
156 let r = registry("multi");
157 assert_eq!(r.pin("sub-a", "farzan"), "farzan");
158 assert_eq!(r.pin("sub-b", "leo"), "leo");
159 assert_eq!(r.pin("sub-a", "leo"), "farzan");
160 }
161
162 #[test]
163 fn pins_persist_across_reload() {
164 let dir =
165 std::env::temp_dir().join(format!("trustee-ident-persist-{}", std::process::id()));
166 let _ = std::fs::remove_dir_all(&dir);
167 std::fs::create_dir_all(&dir).unwrap();
168 let path = dir.join("identity_map.json");
169
170 let r = IdentityRegistry::from_path(path.clone());
171 r.pin("sub-1", "farzan");
172 drop(r);
173
174 let r2 = IdentityRegistry::from_path(path);
175 assert_eq!(r2.pin("sub-1", "different-derived"), "farzan");
176 let _ = std::fs::remove_dir_all(&dir);
177 }
178
179 #[test]
180 fn missing_file_is_an_empty_registry() {
181 let r = registry("empty");
182 assert_eq!(r.get("nobody"), None);
183 assert_eq!(r.pin("nobody", "default"), "default");
184 }
185}