use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Mutex;
#[derive(Clone)]
pub struct IdentityRegistry {
path: PathBuf,
map: std::sync::Arc<Mutex<BTreeMap<String, String>>>,
}
impl IdentityRegistry {
pub fn from_default_home() -> Self {
let path = dirs::home_dir()
.map(|h| h.join(".trustee").join("users").join("identity_map.json"))
.unwrap_or_else(|| PathBuf::from("identity_map.json"));
Self::from_path(path)
}
pub fn from_path(path: PathBuf) -> Self {
let map = std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| {
v.get("pins").and_then(|p| p.as_object()).map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect::<BTreeMap<String, String>>()
})
})
.unwrap_or_default();
Self {
path,
map: std::sync::Arc::new(Mutex::new(map)),
}
}
pub fn get(&self, sub: &str) -> Option<String> {
self.map.lock().ok()?.get(sub).cloned()
}
pub fn pin(&self, sub: &str, derived: &str) -> String {
if let Ok(map) = self.map.lock() {
if let Some(pinned) = map.get(sub) {
return pinned.clone();
}
}
let mut map = self.map.lock().expect("identity registry poisoned");
if let Some(pinned) = map.get(sub) {
return pinned.clone();
}
map.insert(sub.to_string(), derived.to_string());
self.save(&map);
tracing::info!(
"identity registry: pinned user_key for sub {sub} (first sighting) — \
claims can no longer rotate this namespace"
);
derived.to_string()
}
fn save(&self, map: &BTreeMap<String, String>) {
let dir = self
.path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default();
let _ = std::fs::create_dir_all(&dir);
let doc = serde_json::json!({ "pins": map });
match serde_json::to_vec_pretty(&doc) {
Ok(bytes) => {
if let Err(e) = std::fs::write(&self.path, bytes) {
tracing::warn!(
"identity registry: failed to persist {}: {e} — pin is process-local only",
self.path.display()
);
}
}
Err(e) => tracing::warn!("identity registry: serialize failed: {e}"),
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(
&self.path,
std::fs::Permissions::from_mode(0o600),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn registry(tag: &str) -> IdentityRegistry {
let dir = std::env::temp_dir().join(format!(
"trustee-ident-{tag}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
IdentityRegistry::from_path(dir.join("identity_map.json"))
}
#[test]
fn first_sight_pins_derived_key() {
let r = registry("pin1");
assert_eq!(r.pin("sub-1", "farzan"), "farzan");
assert_eq!(r.pin("sub-1", "anything-else"), "farzan");
}
#[test]
fn claim_mutation_cannot_rotate_the_namespace() {
let r = registry("mut");
let first = r.pin("sub-1", "farzan");
assert_eq!(r.pin("sub-1", "sub-1"), first, "claim mutation rotated the namespace");
assert_eq!(r.pin("sub-1", "ghost"), first);
}
#[test]
fn distinct_principals_pin_independently() {
let r = registry("multi");
assert_eq!(r.pin("sub-a", "farzan"), "farzan");
assert_eq!(r.pin("sub-b", "leo"), "leo");
assert_eq!(r.pin("sub-a", "leo"), "farzan");
}
#[test]
fn pins_persist_across_reload() {
let dir = std::env::temp_dir().join(format!(
"trustee-ident-persist-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("identity_map.json");
let r = IdentityRegistry::from_path(path.clone());
r.pin("sub-1", "farzan");
drop(r);
let r2 = IdentityRegistry::from_path(path);
assert_eq!(r2.pin("sub-1", "different-derived"), "farzan");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn missing_file_is_an_empty_registry() {
let r = registry("empty");
assert_eq!(r.get("nobody"), None);
assert_eq!(r.pin("nobody", "default"), "default");
}
}