| 1 | use garcard_ipc::{AuthSummary, PROTOCOL_VERSION, StatusData, VersionData}; |
| 2 | use std::collections::VecDeque; |
| 3 | use std::fmt; |
| 4 | use std::sync::Arc; |
| 5 | use std::sync::RwLock; |
| 6 | use std::sync::atomic::{AtomicUsize, Ordering}; |
| 7 | use std::time::Instant; |
| 8 | |
| 9 | /// Typed phases for auth flow transitions. |
| 10 | #[allow(dead_code)] |
| 11 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 12 | pub enum AuthPhase { |
| 13 | Idle, |
| 14 | PendingPrompt, |
| 15 | Verifying, |
| 16 | Success, |
| 17 | Failure, |
| 18 | Canceled, |
| 19 | Timeout, |
| 20 | } |
| 21 | |
| 22 | impl AuthPhase { |
| 23 | pub const fn as_str(self) -> &'static str { |
| 24 | match self { |
| 25 | Self::Idle => "idle", |
| 26 | Self::PendingPrompt => "pending_prompt", |
| 27 | Self::Verifying => "verifying", |
| 28 | Self::Success => "success", |
| 29 | Self::Failure => "failure", |
| 30 | Self::Canceled => "canceled", |
| 31 | Self::Timeout => "timeout", |
| 32 | } |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | impl fmt::Display for AuthPhase { |
| 37 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 38 | write!(f, "{}", self.as_str()) |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | #[allow(dead_code)] |
| 43 | fn can_transition(current: AuthPhase, next: AuthPhase) -> bool { |
| 44 | if current == next { |
| 45 | return true; |
| 46 | } |
| 47 | |
| 48 | match current { |
| 49 | AuthPhase::Idle => matches!(next, AuthPhase::PendingPrompt), |
| 50 | AuthPhase::PendingPrompt => matches!( |
| 51 | next, |
| 52 | AuthPhase::Verifying | AuthPhase::Canceled | AuthPhase::Timeout |
| 53 | ), |
| 54 | AuthPhase::Verifying => matches!( |
| 55 | next, |
| 56 | AuthPhase::Success | AuthPhase::Failure | AuthPhase::Canceled | AuthPhase::Timeout |
| 57 | ), |
| 58 | AuthPhase::Success | AuthPhase::Failure | AuthPhase::Canceled | AuthPhase::Timeout => { |
| 59 | matches!(next, AuthPhase::Idle | AuthPhase::PendingPrompt) |
| 60 | } |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | /// Tracks auth workflow state with no sensitive data. |
| 65 | #[derive(Debug)] |
| 66 | pub struct AuthState { |
| 67 | current_phase: RwLock<AuthPhase>, |
| 68 | active_requests: AtomicUsize, |
| 69 | queued_requests: AtomicUsize, |
| 70 | } |
| 71 | |
| 72 | impl Default for AuthState { |
| 73 | fn default() -> Self { |
| 74 | Self { |
| 75 | current_phase: RwLock::new(AuthPhase::Idle), |
| 76 | active_requests: AtomicUsize::new(0), |
| 77 | queued_requests: AtomicUsize::new(0), |
| 78 | } |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | impl AuthState { |
| 83 | pub fn phase(&self) -> AuthPhase { |
| 84 | self.current_phase |
| 85 | .read() |
| 86 | .map(|phase| *phase) |
| 87 | .unwrap_or(AuthPhase::Idle) |
| 88 | } |
| 89 | |
| 90 | pub fn set_phase(&self, next: AuthPhase) { |
| 91 | if let Ok(mut phase) = self.current_phase.write() { |
| 92 | *phase = next; |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | #[allow(dead_code)] |
| 97 | pub fn transition(&self, next: AuthPhase) -> bool { |
| 98 | if let Ok(mut phase) = self.current_phase.write() { |
| 99 | if can_transition(*phase, next) { |
| 100 | *phase = next; |
| 101 | return true; |
| 102 | } |
| 103 | return false; |
| 104 | } |
| 105 | |
| 106 | false |
| 107 | } |
| 108 | |
| 109 | pub fn set_active_requests(&self, count: usize) { |
| 110 | self.active_requests.store(count, Ordering::Relaxed); |
| 111 | } |
| 112 | |
| 113 | pub fn set_queued_requests(&self, count: usize) { |
| 114 | self.queued_requests.store(count, Ordering::Relaxed); |
| 115 | } |
| 116 | |
| 117 | pub fn sync_queue_counts(&self, active: usize, queued: usize) { |
| 118 | self.set_active_requests(active); |
| 119 | self.set_queued_requests(queued); |
| 120 | } |
| 121 | |
| 122 | pub fn summary(&self) -> AuthSummary { |
| 123 | AuthSummary { |
| 124 | state: self.phase().to_string(), |
| 125 | active_requests: self.active_requests.load(Ordering::Relaxed), |
| 126 | queued_requests: self.queued_requests.load(Ordering::Relaxed), |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | /// Queue policy for concurrent auth requests: one active request and FIFO backlog. |
| 132 | #[derive(Debug, Clone)] |
| 133 | pub struct AuthQueue<T> { |
| 134 | active: Option<T>, |
| 135 | queued: VecDeque<T>, |
| 136 | } |
| 137 | |
| 138 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 139 | pub enum QueueInsert { |
| 140 | Activated, |
| 141 | Queued { position: usize }, |
| 142 | } |
| 143 | |
| 144 | impl<T> Default for AuthQueue<T> { |
| 145 | fn default() -> Self { |
| 146 | Self { |
| 147 | active: None, |
| 148 | queued: VecDeque::new(), |
| 149 | } |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | impl<T> AuthQueue<T> { |
| 154 | pub fn push(&mut self, request: T) -> QueueInsert { |
| 155 | if self.active.is_none() { |
| 156 | self.active = Some(request); |
| 157 | QueueInsert::Activated |
| 158 | } else { |
| 159 | self.queued.push_back(request); |
| 160 | QueueInsert::Queued { |
| 161 | position: self.queued.len(), |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | pub fn active(&self) -> Option<&T> { |
| 167 | self.active.as_ref() |
| 168 | } |
| 169 | |
| 170 | pub fn take_active_if<F>(&mut self, mut predicate: F) -> Option<T> |
| 171 | where |
| 172 | F: FnMut(&T) -> bool, |
| 173 | { |
| 174 | if self.active.as_ref().is_some_and(&mut predicate) { |
| 175 | return self.complete_active(); |
| 176 | } |
| 177 | |
| 178 | None |
| 179 | } |
| 180 | |
| 181 | pub fn complete_active_if<F>(&mut self, mut predicate: F) -> Option<T> |
| 182 | where |
| 183 | F: FnMut(&T) -> bool, |
| 184 | { |
| 185 | if self.active.as_ref().is_some_and(&mut predicate) { |
| 186 | return self.complete_active(); |
| 187 | } |
| 188 | |
| 189 | None |
| 190 | } |
| 191 | |
| 192 | pub fn remove_queued_if<F>(&mut self, mut predicate: F) -> bool |
| 193 | where |
| 194 | F: FnMut(&T) -> bool, |
| 195 | { |
| 196 | if let Some(index) = self.queued.iter().position(&mut predicate) { |
| 197 | self.queued.remove(index); |
| 198 | return true; |
| 199 | } |
| 200 | |
| 201 | false |
| 202 | } |
| 203 | |
| 204 | pub fn complete_active(&mut self) -> Option<T> { |
| 205 | let finished = self.active.take(); |
| 206 | self.promote_next(); |
| 207 | finished |
| 208 | } |
| 209 | |
| 210 | pub fn active_len(&self) -> usize { |
| 211 | usize::from(self.active.is_some()) |
| 212 | } |
| 213 | |
| 214 | pub fn queued_len(&self) -> usize { |
| 215 | self.queued.len() |
| 216 | } |
| 217 | |
| 218 | #[allow(dead_code)] |
| 219 | pub fn is_empty(&self) -> bool { |
| 220 | self.active.is_none() && self.queued.is_empty() |
| 221 | } |
| 222 | |
| 223 | pub fn clear(&mut self) { |
| 224 | self.active = None; |
| 225 | self.queued.clear(); |
| 226 | } |
| 227 | |
| 228 | pub fn counts(&self) -> (usize, usize) { |
| 229 | (self.active_len(), self.queued_len()) |
| 230 | } |
| 231 | |
| 232 | fn promote_next(&mut self) { |
| 233 | if self.active.is_none() { |
| 234 | self.active = self.queued.pop_front(); |
| 235 | } |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | /// Immutable process/runtime metadata for IPC status. |
| 240 | #[derive(Debug)] |
| 241 | pub struct RuntimeState { |
| 242 | started_at: Instant, |
| 243 | pid: u32, |
| 244 | socket_path: String, |
| 245 | backend_name: &'static str, |
| 246 | auth: Arc<AuthState>, |
| 247 | } |
| 248 | |
| 249 | impl RuntimeState { |
| 250 | #[cfg(test)] |
| 251 | pub fn new(socket_path: String, backend_name: &'static str) -> Self { |
| 252 | Self::with_auth(socket_path, backend_name, Arc::new(AuthState::default())) |
| 253 | } |
| 254 | |
| 255 | pub fn with_auth( |
| 256 | socket_path: String, |
| 257 | backend_name: &'static str, |
| 258 | auth: Arc<AuthState>, |
| 259 | ) -> Self { |
| 260 | auth.set_phase(AuthPhase::Idle); |
| 261 | auth.set_active_requests(0); |
| 262 | auth.set_queued_requests(0); |
| 263 | Self { |
| 264 | started_at: Instant::now(), |
| 265 | pid: std::process::id(), |
| 266 | socket_path, |
| 267 | backend_name, |
| 268 | auth, |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | pub fn status(&self) -> StatusData { |
| 273 | StatusData { |
| 274 | running: true, |
| 275 | pid: self.pid, |
| 276 | uptime_secs: self.started_at.elapsed().as_secs(), |
| 277 | version: env!("CARGO_PKG_VERSION").to_string(), |
| 278 | protocol_version: PROTOCOL_VERSION, |
| 279 | socket_path: self.socket_path.clone(), |
| 280 | agent_backend: self.backend_name.to_string(), |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | pub fn version(&self) -> VersionData { |
| 285 | VersionData { |
| 286 | component: "garcard".to_string(), |
| 287 | version: env!("CARGO_PKG_VERSION").to_string(), |
| 288 | protocol_version: PROTOCOL_VERSION, |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | pub fn auth_summary(&self) -> AuthSummary { |
| 293 | self.auth.summary() |
| 294 | } |
| 295 | |
| 296 | #[allow(dead_code)] |
| 297 | pub fn auth_mutation(&self) -> &Arc<AuthState> { |
| 298 | &self.auth |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | #[cfg(test)] |
| 303 | mod tests { |
| 304 | use super::*; |
| 305 | |
| 306 | #[test] |
| 307 | fn auth_state_defaults_to_idle_phase() { |
| 308 | let state = AuthState::default(); |
| 309 | let summary = state.summary(); |
| 310 | assert_eq!(summary.state, "idle"); |
| 311 | assert_eq!(summary.active_requests, 0); |
| 312 | assert_eq!(summary.queued_requests, 0); |
| 313 | } |
| 314 | |
| 315 | #[test] |
| 316 | fn auth_state_updates_summary() { |
| 317 | let state = AuthState::default(); |
| 318 | state.set_phase(AuthPhase::Verifying); |
| 319 | state.set_active_requests(2); |
| 320 | state.set_queued_requests(3); |
| 321 | |
| 322 | let summary = state.summary(); |
| 323 | assert_eq!(summary.state, "verifying"); |
| 324 | assert_eq!(summary.active_requests, 2); |
| 325 | assert_eq!(summary.queued_requests, 3); |
| 326 | } |
| 327 | |
| 328 | #[test] |
| 329 | fn auth_phase_transition_rules_are_enforced() { |
| 330 | let state = AuthState::default(); |
| 331 | assert!(state.transition(AuthPhase::PendingPrompt)); |
| 332 | assert!(state.transition(AuthPhase::Verifying)); |
| 333 | assert!(state.transition(AuthPhase::Failure)); |
| 334 | assert!(state.transition(AuthPhase::Idle)); |
| 335 | assert!(!state.transition(AuthPhase::Verifying)); |
| 336 | assert_eq!(state.phase(), AuthPhase::Idle); |
| 337 | } |
| 338 | |
| 339 | #[test] |
| 340 | fn auth_queue_activates_then_queues() { |
| 341 | let mut queue = AuthQueue::default(); |
| 342 | assert_eq!(queue.push(10), QueueInsert::Activated); |
| 343 | assert_eq!(queue.push(20), QueueInsert::Queued { position: 1 }); |
| 344 | assert_eq!(queue.push(30), QueueInsert::Queued { position: 2 }); |
| 345 | assert_eq!(queue.active(), Some(&10)); |
| 346 | assert_eq!(queue.counts(), (1, 2)); |
| 347 | } |
| 348 | |
| 349 | #[test] |
| 350 | fn auth_queue_completion_promotes_next_request() { |
| 351 | let mut queue = AuthQueue::default(); |
| 352 | queue.push("first"); |
| 353 | queue.push("second"); |
| 354 | queue.push("third"); |
| 355 | |
| 356 | assert_eq!(queue.complete_active(), Some("first")); |
| 357 | assert_eq!(queue.active(), Some(&"second")); |
| 358 | assert_eq!(queue.complete_active(), Some("second")); |
| 359 | assert_eq!(queue.active(), Some(&"third")); |
| 360 | assert_eq!(queue.complete_active(), Some("third")); |
| 361 | assert!(queue.is_empty()); |
| 362 | } |
| 363 | |
| 364 | #[test] |
| 365 | fn auth_queue_take_active_if_promotes_next() { |
| 366 | let mut queue = AuthQueue::default(); |
| 367 | queue.push("cookie-a"); |
| 368 | queue.push("cookie-b"); |
| 369 | |
| 370 | let removed = queue.take_active_if(|value| *value == "cookie-a"); |
| 371 | assert_eq!(removed, Some("cookie-a")); |
| 372 | assert_eq!(queue.active(), Some(&"cookie-b")); |
| 373 | } |
| 374 | |
| 375 | #[test] |
| 376 | fn auth_queue_remove_queued_if_removes_specific_item() { |
| 377 | let mut queue = AuthQueue::default(); |
| 378 | queue.push("cookie-a"); |
| 379 | queue.push("cookie-b"); |
| 380 | queue.push("cookie-c"); |
| 381 | |
| 382 | assert!(queue.remove_queued_if(|value| *value == "cookie-b")); |
| 383 | assert_eq!(queue.counts(), (1, 1)); |
| 384 | assert!(!queue.remove_queued_if(|value| *value == "missing")); |
| 385 | } |
| 386 | } |
| 387 |