use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicU64; use std::sync::{Arc, RwLock}; use dashmap::DashMap; use serde::{Deserialize, Serialize}; use tokio::sync::Semaphore; use tracing::warn; use crate::actors::ActorRegistry; use crate::cache::NclCache; use crate::notifications::NotificationStore; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Role { Admin, Viewer, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KeyEntry { pub role: Role, /// Argon2id PHC string — produced by `ontoref-daemon --hash-password ` pub hash: String, /// Human-readable label identifying which key this is (e.g. "developer", /// "agent", "ci"). Recorded in sessions for audit tracing. Optional — /// defaults to empty string for keys created before this field existed. #[serde(default)] pub label: String, } /// Result of a successful key verification — role and the label of the /// matched key for audit trail purposes. #[derive(Debug, Clone)] pub struct KeyMatch { pub role: Role, pub label: String, } /// Verify `password` against a slice of `KeyEntry`s using argon2id. /// /// Returns the `KeyMatch` (role + label) of the first matching key, or `None` /// if no key matches. Unparseable PHC hashes are logged and skipped. pub fn verify_keys_list(keys: &[KeyEntry], password: &str) -> Option { use argon2::{Argon2, PasswordHash, PasswordVerifier}; for key in keys { let Ok(parsed) = PasswordHash::new(&key.hash) else { warn!(role = ?key.role, label = %key.label, "skipping key entry with unparseable PHC hash — key is inactive"); continue; }; if Argon2::default() .verify_password(password.as_bytes(), &parsed) .is_ok() { return Some(KeyMatch { role: key.role, label: key.label.clone(), }); } } None } /// Entry as it arrives from the JSON config pipe (ADR-004). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegistryEntry { pub slug: String, /// Absolute local path. Empty for push_only projects. #[serde(default)] pub root: PathBuf, /// Import paths for nickel export (local projects only). #[serde(default)] pub nickel_import_paths: Vec, #[serde(default)] pub keys: Vec, /// Git/SSH/HTTPS URL. Informational — daemon never fetches from it. #[serde(default)] pub remote_url: String, /// When true: no local file watch, no NCL import. Project pushes via POST /// /sync. #[serde(default)] pub push_only: bool, } /// Per-project runtime state owned by the registry. pub struct ProjectContext { pub slug: String, pub root: PathBuf, pub import_path: Option, pub cache: Arc, pub actors: Arc, pub notifications: Arc, /// Mutable at runtime via `PUT /projects/{slug}/keys`. pub keys: RwLock>, /// Git/SSH/HTTPS remote URL. Informational. pub remote_url: String, /// No local file watch, no NCL import — project pushes via POST /sync. pub push_only: bool, /// Ensures only one `seed_ontology` runs at a time per project. /// Prevents concurrent re-seeds caused by rapid filesystem events. pub seed_lock: Arc, /// Incremented by 1 after each successful `seed_ontology` completion. /// Clients can compare versions to detect stale local state. pub ontology_version: Arc, /// Per-file change counters. Keyed by canonical absolute path; incremented /// on every cache invalidation for that file. Consumers compare snapshots /// to detect which individual files changed between polls. pub file_versions: Arc>, } impl ProjectContext { pub fn auth_enabled(&self) -> bool { !self .keys .read() .unwrap_or_else(|e| e.into_inner()) .is_empty() } pub fn verify_key(&self, password: &str) -> Option { let keys = self.keys.read().unwrap_or_else(|e| e.into_inner()); verify_keys_list(&keys, password) } /// Replace the current key set. Returns the number of keys now active. pub fn set_keys(&self, new_keys: Vec) -> usize { let mut guard = self.keys.write().unwrap_or_else(|e| e.into_inner()); *guard = new_keys; guard.len() } } pub struct ProjectRegistry { contexts: DashMap>, /// Slug of the primary (local) project — always present in `contexts`. primary_slug: String, stale_actor_timeout: u64, max_notifications: usize, } impl ProjectRegistry { /// Build a registry with a pre-constructed primary project context, plus /// optional additional entries (service mode). The primary context is /// inserted under `primary_slug` and accessible via `primary()`. /// /// Returns `(registry, Arc)` so the caller can extract /// shared `Arc` references (cache, actors, notifications) before consuming /// the primary context into the registry. pub fn with_primary( primary_slug: String, primary_ctx: Arc, additional_entries: Vec, stale_actor_timeout: u64, max_notifications: usize, ) -> Self { let registry = Self { contexts: DashMap::new(), primary_slug: primary_slug.clone(), stale_actor_timeout, max_notifications, }; registry.contexts.insert(primary_slug.clone(), primary_ctx); for entry in additional_entries { if entry.slug == primary_slug { warn!( slug = %entry.slug, "additional registry entry collides with primary slug — skipping" ); continue; } if entry.push_only { let ctx = make_context(ContextSpec { slug: entry.slug.clone(), root: PathBuf::new(), import_path: None, keys: entry.keys, remote_url: entry.remote_url, push_only: true, stale_actor_timeout, max_notifications, ack_required: vec![], }); registry.contexts.insert(entry.slug, Arc::new(ctx)); continue; } let root = match entry.root.canonicalize() { Ok(p) => p, Err(e) => { warn!( slug = %entry.slug, path = %entry.root.display(), error = %e, "project root not found — skipping" ); continue; } }; let import_path = resolve_import_path(&entry.nickel_import_paths, &root); let ctx = make_context(ContextSpec { slug: entry.slug.clone(), root, import_path, keys: entry.keys, remote_url: entry.remote_url, push_only: false, stale_actor_timeout, max_notifications, ack_required: vec![], }); registry.contexts.insert(entry.slug, Arc::new(ctx)); } registry } /// The slug of the primary (local) project. pub fn primary_slug(&self) -> &str { &self.primary_slug } /// The primary project context. Panics if not found — invariant guaranteed /// by `with_primary` constructor. pub fn primary(&self) -> Arc { self.contexts .get(&self.primary_slug) .map(|r| Arc::clone(&*r)) .expect("primary project context missing — registry invariant violated") } /// Add a project at runtime (UI-triggered, not from bootstrap). pub fn add_project(&self, entry: RegistryEntry) -> anyhow::Result<()> { if self.contexts.contains_key(&entry.slug) { anyhow::bail!("project '{}' already exists", entry.slug); } let (root, import_path) = if entry.push_only { (PathBuf::new(), None) } else { let r = entry .root .canonicalize() .map_err(|e| anyhow::anyhow!("project '{}': root path error: {}", entry.slug, e))?; let ip = resolve_import_path(&entry.nickel_import_paths, &r); (r, ip) }; let ctx = make_context(ContextSpec { slug: entry.slug.clone(), root, import_path, keys: entry.keys, remote_url: entry.remote_url, push_only: entry.push_only, stale_actor_timeout: self.stale_actor_timeout, max_notifications: self.max_notifications, ack_required: vec![], }); self.contexts.insert(entry.slug, Arc::new(ctx)); Ok(()) } /// Remove a project by slug. pub fn remove_project(&self, slug: &str) { self.contexts.remove(slug); } pub fn get(&self, slug: &str) -> Option> { self.contexts.get(slug).map(|r| Arc::clone(&*r)) } pub fn all(&self) -> Vec> { let mut list: Vec<_> = self.contexts.iter().map(|r| Arc::clone(&*r)).collect(); list.sort_by(|a, b| a.slug.cmp(&b.slug)); list } pub fn count(&self) -> usize { self.contexts.len() } /// Replace the key set for a registered project. /// Returns `None` if the slug is not found. pub fn update_keys(&self, slug: &str, keys: Vec) -> Option { self.contexts.get(slug).map(|ctx| ctx.set_keys(keys)) } } /// Resolve nickel_import_paths entries into a colon-separated /// NICKEL_IMPORT_PATH string. Relative paths are resolved against the project /// root. fn resolve_import_path(paths: &[String], root: &Path) -> Option { let joined = paths .iter() .map(|p| { let candidate = Path::new(p); if candidate.is_absolute() { p.clone() } else { root.join(candidate).display().to_string() } }) .collect::>() .join(":"); if joined.is_empty() { None } else { Some(joined) } } pub struct ContextSpec { pub slug: String, pub root: PathBuf, pub import_path: Option, pub keys: Vec, pub remote_url: String, pub push_only: bool, pub stale_actor_timeout: u64, pub max_notifications: usize, /// Directories that require notification acknowledgment. pub ack_required: Vec, } pub fn make_context(spec: ContextSpec) -> ProjectContext { let sessions_dir = spec.root.join(".ontoref").join("sessions"); let actors = Arc::new(ActorRegistry::new(spec.stale_actor_timeout).with_persist_dir(sessions_dir)); actors.load_persisted(); let ack_dirs = if spec.ack_required.is_empty() { vec![".ontology".into(), "adrs".into()] } else { spec.ack_required }; ProjectContext { slug: spec.slug, root: spec.root, import_path: spec.import_path, cache: Arc::new(NclCache::new()), actors, notifications: Arc::new(NotificationStore::new(spec.max_notifications, ack_dirs)), keys: RwLock::new(spec.keys), remote_url: spec.remote_url, push_only: spec.push_only, seed_lock: Arc::new(Semaphore::new(1)), ontology_version: Arc::new(AtomicU64::new(0)), file_versions: Arc::new(DashMap::new()), } } #[cfg(test)] mod tests { use std::sync::Arc; use super::*; fn phc_hash(password: &str) -> String { use argon2::password_hash::{rand_core::OsRng, SaltString}; use argon2::{Argon2, PasswordHasher}; let salt = SaltString::generate(&mut OsRng); Argon2::default() .hash_password(password.as_bytes(), &salt) .unwrap() .to_string() } fn admin_entry(password: &str) -> KeyEntry { KeyEntry { role: Role::Admin, hash: phc_hash(password), label: String::new(), } } fn viewer_entry(password: &str) -> KeyEntry { KeyEntry { role: Role::Viewer, hash: phc_hash(password), label: String::new(), } } // ── verify_keys_list ────────────────────────────────────────────────── #[test] fn correct_password_returns_role() { let keys = vec![admin_entry("secret")]; assert_eq!( verify_keys_list(&keys, "secret").map(|m| m.role), Some(Role::Admin) ); } #[test] fn wrong_password_returns_none() { let keys = vec![admin_entry("secret")]; assert!(verify_keys_list(&keys, "wrong").is_none()); } #[test] fn empty_keys_returns_none() { assert!(verify_keys_list(&[], "anything").is_none()); } #[test] fn returns_first_matching_role() { let keys = vec![viewer_entry("pw"), admin_entry("pw")]; // Viewer comes first — that role wins. assert_eq!( verify_keys_list(&keys, "pw").map(|m| m.role), Some(Role::Viewer) ); } #[test] fn invalid_phc_hash_is_skipped_not_panicked() { let keys = vec![ KeyEntry { role: Role::Admin, hash: "not-a-phc-string".to_string(), label: String::new(), }, admin_entry("valid"), ]; // Bad entry skipped; valid entry matches. assert_eq!( verify_keys_list(&keys, "valid").map(|m| m.role), Some(Role::Admin) ); } #[test] fn invalid_phc_only_returns_none() { let keys = vec![KeyEntry { role: Role::Admin, hash: "garbage".to_string(), label: String::new(), }]; assert!(verify_keys_list(&keys, "anything").is_none()); } // ── resolve_import_path ─────────────────────────────────────────────── #[test] fn empty_paths_returns_none() { assert_eq!(resolve_import_path(&[], Path::new("/root")), None); } #[test] fn absolute_path_returned_as_is() { let result = resolve_import_path(&["/abs/path".to_string()], Path::new("/root")); assert_eq!(result, Some("/abs/path".to_string())); } #[test] fn relative_path_joined_with_root() { let result = resolve_import_path(&["lib".to_string()], Path::new("/root")); assert_eq!(result, Some("/root/lib".to_string())); } #[test] fn multiple_paths_colon_separated() { let result = resolve_import_path(&["/abs".to_string(), "rel".to_string()], Path::new("/root")); assert_eq!(result, Some("/abs:/root/rel".to_string())); } // ── with_primary slug collision ─────────────────────────────────────── fn minimal_ctx(slug: &str) -> Arc { Arc::new(make_context(ContextSpec { slug: slug.to_string(), root: PathBuf::new(), import_path: None, keys: vec![], remote_url: String::new(), push_only: true, stale_actor_timeout: 300, max_notifications: 64, ack_required: vec![], })) } #[test] fn primary_is_accessible_after_construction() { let registry = ProjectRegistry::with_primary("main".to_string(), minimal_ctx("main"), vec![], 300, 64); assert_eq!(registry.primary().slug, "main"); assert_eq!(registry.primary_slug(), "main"); } #[test] fn slug_collision_does_not_overwrite_primary() { let primary = minimal_ctx("shared"); let primary_ptr = Arc::as_ptr(&primary); let colliding = RegistryEntry { slug: "shared".to_string(), root: PathBuf::new(), nickel_import_paths: vec![], keys: vec![], remote_url: String::new(), push_only: true, }; let registry = ProjectRegistry::with_primary("shared".to_string(), primary, vec![colliding], 300, 64); // The primary context pointer must survive — not replaced by the colliding // entry. assert_eq!(Arc::as_ptr(®istry.primary()), primary_ptr); assert_eq!(registry.count(), 1); } #[test] fn additional_non_colliding_entry_added() { let registry = ProjectRegistry::with_primary( "primary".to_string(), minimal_ctx("primary"), vec![RegistryEntry { slug: "secondary".to_string(), root: PathBuf::new(), nickel_import_paths: vec![], keys: vec![], remote_url: String::new(), push_only: true, }], 300, 64, ); assert_eq!(registry.count(), 2); assert!(registry.get("secondary").is_some()); } }