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(
116 &self.path,
117 std::fs::Permissions::from_mode(0o600),
118 );
119 }
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 fn registry(tag: &str) -> IdentityRegistry {
128 let dir = std::env::temp_dir().join(format!(
129 "trustee-ident-{tag}-{}",
130 std::process::id()
131 ));
132 let _ = std::fs::remove_dir_all(&dir);
133 std::fs::create_dir_all(&dir).unwrap();
134 IdentityRegistry::from_path(dir.join("identity_map.json"))
135 }
136
137 #[test]
138 fn first_sight_pins_derived_key() {
139 let r = registry("pin1");
140 assert_eq!(r.pin("sub-1", "farzan"), "farzan");
141 assert_eq!(r.pin("sub-1", "anything-else"), "farzan");
142 }
143
144 #[test]
145 fn claim_mutation_cannot_rotate_the_namespace() {
146 let r = registry("mut");
149 let first = r.pin("sub-1", "farzan");
150 assert_eq!(r.pin("sub-1", "sub-1"), first, "claim mutation rotated the namespace");
152 assert_eq!(r.pin("sub-1", "ghost"), first);
154 }
155
156 #[test]
157 fn distinct_principals_pin_independently() {
158 let r = registry("multi");
159 assert_eq!(r.pin("sub-a", "farzan"), "farzan");
160 assert_eq!(r.pin("sub-b", "leo"), "leo");
161 assert_eq!(r.pin("sub-a", "leo"), "farzan");
162 }
163
164 #[test]
165 fn pins_persist_across_reload() {
166 let dir = std::env::temp_dir().join(format!(
167 "trustee-ident-persist-{}",
168 std::process::id()
169 ));
170 let _ = std::fs::remove_dir_all(&dir);
171 std::fs::create_dir_all(&dir).unwrap();
172 let path = dir.join("identity_map.json");
173
174 let r = IdentityRegistry::from_path(path.clone());
175 r.pin("sub-1", "farzan");
176 drop(r);
177
178 let r2 = IdentityRegistry::from_path(path);
179 assert_eq!(r2.pin("sub-1", "different-derived"), "farzan");
180 let _ = std::fs::remove_dir_all(&dir);
181 }
182
183 #[test]
184 fn missing_file_is_an_empty_registry() {
185 let r = registry("empty");
186 assert_eq!(r.get("nobody"), None);
187 assert_eq!(r.pin("nobody", "default"), "default");
188 }
189}