1use crate::agent::AgentCore;
7use crate::error::AgentError;
8use std::path::PathBuf;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::{Arc, Mutex, MutexGuard};
11use std::time::{Duration, Instant};
12use zeroize::Zeroizing;
13
14pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
16
17pub const DEFAULT_MAX_UNLOCK_TTL: Duration = Duration::from_secs(8 * 60 * 60);
20
21pub struct AgentHandle {
32 core: Arc<Mutex<AgentCore>>,
34 socket_path: PathBuf,
36 pid_file: Option<PathBuf>,
38 running: Arc<AtomicBool>,
40 last_activity: Arc<Mutex<Instant>>,
42 idle_timeout: Duration,
44 unlocked_at: Arc<Mutex<Instant>>,
46 max_unlock_ttl: Duration,
48 locked: Arc<AtomicBool>,
50}
51
52impl std::fmt::Debug for AgentHandle {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 f.debug_struct("AgentHandle")
55 .field("socket_path", &self.socket_path)
56 .field("pid_file", &self.pid_file)
57 .field("running", &self.is_running())
58 .field("locked", &self.is_agent_locked())
59 .field("idle_timeout", &self.idle_timeout)
60 .field("max_unlock_ttl", &self.max_unlock_ttl)
61 .finish_non_exhaustive()
62 }
63}
64
65impl AgentHandle {
66 pub fn new(socket_path: PathBuf) -> Self {
68 Self::with_timeout(socket_path, DEFAULT_IDLE_TIMEOUT)
69 }
70
71 pub fn with_timeout(socket_path: PathBuf, idle_timeout: Duration) -> Self {
74 Self::with_unlock_cap(socket_path, idle_timeout, DEFAULT_MAX_UNLOCK_TTL)
75 }
76
77 pub fn with_unlock_cap(
93 socket_path: PathBuf,
94 idle_timeout: Duration,
95 max_unlock_ttl: Duration,
96 ) -> Self {
97 Self {
98 core: Arc::new(Mutex::new(AgentCore::default())),
99 socket_path,
100 pid_file: None,
101 running: Arc::new(AtomicBool::new(false)),
102 last_activity: Arc::new(Mutex::new(Instant::now())),
103 idle_timeout,
104 unlocked_at: Arc::new(Mutex::new(Instant::now())),
105 max_unlock_ttl,
106 locked: Arc::new(AtomicBool::new(false)),
107 }
108 }
109
110 pub fn with_pid_file(socket_path: PathBuf, pid_file: PathBuf) -> Self {
112 Self {
113 core: Arc::new(Mutex::new(AgentCore::default())),
114 socket_path,
115 pid_file: Some(pid_file),
116 running: Arc::new(AtomicBool::new(false)),
117 last_activity: Arc::new(Mutex::new(Instant::now())),
118 idle_timeout: DEFAULT_IDLE_TIMEOUT,
119 unlocked_at: Arc::new(Mutex::new(Instant::now())),
120 max_unlock_ttl: DEFAULT_MAX_UNLOCK_TTL,
121 locked: Arc::new(AtomicBool::new(false)),
122 }
123 }
124
125 pub fn with_pid_file_and_timeout(
127 socket_path: PathBuf,
128 pid_file: PathBuf,
129 idle_timeout: Duration,
130 ) -> Self {
131 Self {
132 core: Arc::new(Mutex::new(AgentCore::default())),
133 socket_path,
134 pid_file: Some(pid_file),
135 running: Arc::new(AtomicBool::new(false)),
136 last_activity: Arc::new(Mutex::new(Instant::now())),
137 idle_timeout,
138 unlocked_at: Arc::new(Mutex::new(Instant::now())),
139 max_unlock_ttl: DEFAULT_MAX_UNLOCK_TTL,
140 locked: Arc::new(AtomicBool::new(false)),
141 }
142 }
143
144 pub fn from_core(core: AgentCore, socket_path: PathBuf) -> Self {
146 Self {
147 core: Arc::new(Mutex::new(core)),
148 socket_path,
149 pid_file: None,
150 running: Arc::new(AtomicBool::new(false)),
151 last_activity: Arc::new(Mutex::new(Instant::now())),
152 idle_timeout: DEFAULT_IDLE_TIMEOUT,
153 unlocked_at: Arc::new(Mutex::new(Instant::now())),
154 max_unlock_ttl: DEFAULT_MAX_UNLOCK_TTL,
155 locked: Arc::new(AtomicBool::new(false)),
156 }
157 }
158
159 pub fn socket_path(&self) -> &PathBuf {
161 &self.socket_path
162 }
163
164 pub fn pid_file(&self) -> Option<&PathBuf> {
166 self.pid_file.as_ref()
167 }
168
169 pub fn set_pid_file(&mut self, path: PathBuf) {
171 self.pid_file = Some(path);
172 }
173
174 pub fn lock(&self) -> Result<MutexGuard<'_, AgentCore>, AgentError> {
179 self.core
180 .lock()
181 .map_err(|_| AgentError::MutexError("Agent core mutex poisoned".to_string()))
182 }
183
184 pub fn core_arc(&self) -> Arc<Mutex<AgentCore>> {
186 Arc::clone(&self.core)
187 }
188
189 pub fn is_running(&self) -> bool {
191 self.running.load(Ordering::SeqCst)
192 }
193
194 pub fn set_running(&self, running: bool) {
196 self.running.store(running, Ordering::SeqCst);
197 }
198
199 pub fn idle_timeout(&self) -> Duration {
203 self.idle_timeout
204 }
205
206 pub fn touch(&self) {
208 if let Ok(mut last) = self.last_activity.lock() {
209 *last = Instant::now();
210 }
211 }
212
213 pub fn idle_duration(&self) -> Duration {
215 self.last_activity
216 .lock()
217 .map(|last| last.elapsed())
218 .unwrap_or(Duration::ZERO)
219 }
220
221 pub fn is_idle_timed_out(&self) -> bool {
223 if self.idle_timeout.is_zero() {
225 return false;
226 }
227 self.idle_duration() >= self.idle_timeout
228 }
229
230 pub fn max_unlock_ttl(&self) -> Duration {
232 self.max_unlock_ttl
233 }
234
235 pub fn unlock_age(&self) -> Duration {
237 self.unlocked_at
238 .lock()
239 .map(|t| t.elapsed())
240 .unwrap_or(Duration::ZERO)
241 }
242
243 pub fn is_unlock_window_expired(&self) -> bool {
249 if self.max_unlock_ttl.is_zero() {
250 return false;
251 }
252 self.unlock_age() >= self.max_unlock_ttl
253 }
254
255 pub fn is_agent_locked(&self) -> bool {
257 self.locked.load(Ordering::SeqCst)
258 }
259
260 pub fn lock_agent(&self) -> Result<(), AgentError> {
264 log::info!("Locking agent (clearing keys from memory)");
265
266 {
268 let mut core = self.lock()?;
269 core.clear_keys();
270 }
271
272 self.locked.store(true, Ordering::SeqCst);
274 log::debug!("Agent locked");
275 Ok(())
276 }
277
278 pub fn unlock_agent(&self) {
283 log::info!("Unlocking agent");
284 self.locked.store(false, Ordering::SeqCst);
285 self.touch(); if let Ok(mut unlocked) = self.unlocked_at.lock() {
287 *unlocked = Instant::now(); }
289 }
290
291 pub fn check_idle_timeout(&self) -> Result<bool, AgentError> {
299 if (self.is_idle_timed_out() || self.is_unlock_window_expired()) && !self.is_agent_locked()
300 {
301 log::info!(
302 "Locking agent: idle for {:?}, unlocked for {:?}",
303 self.idle_duration(),
304 self.unlock_age()
305 );
306 self.lock_agent()?;
307 return Ok(true);
308 }
309 Ok(false)
310 }
311
312 #[allow(clippy::disallowed_methods)] pub fn shutdown(&self) -> Result<(), AgentError> {
320 log::info!("Shutting down agent at {:?}", self.socket_path);
321
322 {
324 let mut core = self.lock()?;
325 core.clear_keys();
326 log::debug!("Cleared all keys from agent core");
327 }
328
329 self.set_running(false);
331
332 if self.socket_path.exists() {
334 if let Err(e) = std::fs::remove_file(&self.socket_path) {
335 log::warn!("Failed to remove socket file {:?}: {}", self.socket_path, e);
336 } else {
337 log::debug!("Removed socket file {:?}", self.socket_path);
338 }
339 }
340
341 if let Some(ref pid_file) = self.pid_file
343 && pid_file.exists()
344 {
345 if let Err(e) = std::fs::remove_file(pid_file) {
346 log::warn!("Failed to remove PID file {:?}: {}", pid_file, e);
347 } else {
348 log::debug!("Removed PID file {:?}", pid_file);
349 }
350 }
351
352 log::info!("Agent shutdown complete");
353 Ok(())
354 }
355
356 pub fn key_count(&self) -> Result<usize, AgentError> {
358 let core = self.lock()?;
359 Ok(core.key_count())
360 }
361
362 pub fn public_keys(&self) -> Result<Vec<Vec<u8>>, AgentError> {
364 let core = self.lock()?;
365 Ok(core.public_keys())
366 }
367
368 pub fn register_key(&self, pkcs8_bytes: Zeroizing<Vec<u8>>) -> Result<(), AgentError> {
370 {
371 let mut core = self.lock()?;
372 core.register_key(pkcs8_bytes)?;
373 }
374 if let Ok(mut unlocked) = self.unlocked_at.lock() {
375 *unlocked = Instant::now(); }
377 Ok(())
378 }
379
380 pub fn sign(&self, pubkey: &[u8], data: &[u8]) -> Result<Vec<u8>, AgentError> {
385 if self.is_agent_locked() {
387 return Err(AgentError::AgentLocked);
388 }
389
390 let core = self.lock()?;
391 let result = core.sign(pubkey, data);
392
393 if result.is_ok() {
395 self.touch();
396 }
397
398 result
399 }
400}
401
402impl Clone for AgentHandle {
403 fn clone(&self) -> Self {
404 Self {
405 core: Arc::clone(&self.core),
406 socket_path: self.socket_path.clone(),
407 pid_file: self.pid_file.clone(),
408 running: Arc::clone(&self.running),
409 last_activity: Arc::clone(&self.last_activity),
410 idle_timeout: self.idle_timeout,
411 unlocked_at: Arc::clone(&self.unlocked_at),
412 max_unlock_ttl: self.max_unlock_ttl,
413 locked: Arc::clone(&self.locked),
414 }
415 }
416}
417
418#[cfg(test)]
419#[allow(clippy::disallowed_methods)]
420mod tests {
421 use super::*;
422 use ring::rand::SystemRandom;
423 use ring::signature::Ed25519KeyPair;
424 use tempfile::TempDir;
425
426 fn generate_test_pkcs8() -> Vec<u8> {
427 let rng = SystemRandom::new();
428 let pkcs8_doc = Ed25519KeyPair::generate_pkcs8(&rng).expect("Failed to generate PKCS#8");
429 pkcs8_doc.as_ref().to_vec()
430 }
431
432 #[test]
433 fn test_agent_handle_new() {
434 let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
435 assert_eq!(handle.socket_path(), &PathBuf::from("/tmp/test.sock"));
436 assert!(handle.pid_file().is_none());
437 assert!(!handle.is_running());
438 }
439
440 #[test]
441 fn test_agent_handle_with_pid_file() {
442 let handle = AgentHandle::with_pid_file(
443 PathBuf::from("/tmp/test.sock"),
444 PathBuf::from("/tmp/test.pid"),
445 );
446 assert_eq!(handle.socket_path(), &PathBuf::from("/tmp/test.sock"));
447 assert_eq!(handle.pid_file(), Some(&PathBuf::from("/tmp/test.pid")));
448 }
449
450 #[test]
451 fn test_agent_handle_running_state() {
452 let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
453 assert!(!handle.is_running());
454
455 handle.set_running(true);
456 assert!(handle.is_running());
457
458 handle.set_running(false);
459 assert!(!handle.is_running());
460 }
461
462 #[test]
463 fn test_agent_handle_key_operations() {
464 let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
465
466 assert_eq!(handle.key_count().unwrap(), 0);
467
468 let pkcs8_bytes = generate_test_pkcs8();
469 handle
470 .register_key(Zeroizing::new(pkcs8_bytes))
471 .expect("Failed to register key");
472
473 assert_eq!(handle.key_count().unwrap(), 1);
474
475 let pubkeys = handle.public_keys().unwrap();
476 assert_eq!(pubkeys.len(), 1);
477 }
478
479 #[test]
480 fn test_agent_handle_clone_shares_state() {
481 let handle1 = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
482 let handle2 = handle1.clone();
483
484 let pkcs8_bytes = generate_test_pkcs8();
485 handle1
486 .register_key(Zeroizing::new(pkcs8_bytes))
487 .expect("Failed to register key");
488
489 assert_eq!(handle1.key_count().unwrap(), 1);
491 assert_eq!(handle2.key_count().unwrap(), 1);
492 }
493
494 #[test]
495 fn test_agent_handle_shutdown() {
496 let temp_dir = TempDir::new().unwrap();
497 let socket_path = temp_dir.path().join("test.sock");
498
499 std::fs::write(&socket_path, "dummy").unwrap();
501
502 let handle = AgentHandle::new(socket_path.clone());
503 let pkcs8_bytes = generate_test_pkcs8();
504 handle
505 .register_key(Zeroizing::new(pkcs8_bytes))
506 .expect("Failed to register key");
507 handle.set_running(true);
508
509 assert_eq!(handle.key_count().unwrap(), 1);
510 assert!(handle.is_running());
511 assert!(socket_path.exists());
512
513 handle.shutdown().expect("Shutdown failed");
514
515 assert_eq!(handle.key_count().unwrap(), 0);
516 assert!(!handle.is_running());
517 assert!(!socket_path.exists());
518 }
519
520 #[test]
521 fn test_multiple_handles_independent() {
522 let handle1 = AgentHandle::new(PathBuf::from("/tmp/agent1.sock"));
523 let handle2 = AgentHandle::new(PathBuf::from("/tmp/agent2.sock"));
524
525 let pkcs8_bytes = generate_test_pkcs8();
526 handle1
527 .register_key(Zeroizing::new(pkcs8_bytes))
528 .expect("Failed to register key");
529
530 assert_eq!(handle1.key_count().unwrap(), 1);
532 assert_eq!(handle2.key_count().unwrap(), 0);
533 }
534
535 #[test]
536 fn test_agent_handle_lock_unlock() {
537 let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
538
539 assert!(!handle.is_agent_locked());
541
542 let pkcs8_bytes = generate_test_pkcs8();
544 handle
545 .register_key(Zeroizing::new(pkcs8_bytes))
546 .expect("Failed to register key");
547 assert_eq!(handle.key_count().unwrap(), 1);
548
549 handle.lock_agent().expect("Lock failed");
551 assert!(handle.is_agent_locked());
552 assert_eq!(handle.key_count().unwrap(), 0); handle.unlock_agent();
556 assert!(!handle.is_agent_locked());
557 }
558
559 #[test]
560 fn test_agent_handle_sign_when_locked() {
561 let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
562
563 let pkcs8_bytes = generate_test_pkcs8();
565 handle
566 .register_key(Zeroizing::new(pkcs8_bytes))
567 .expect("Failed to register key");
568
569 let pubkeys = handle.public_keys().unwrap();
571 let pubkey = &pubkeys[0];
572
573 let result = handle.sign(pubkey, b"test data");
575 assert!(result.is_ok());
576
577 handle.lock_agent().expect("Lock failed");
579 let result = handle.sign(pubkey, b"test data");
580 assert!(matches!(result, Err(AgentError::AgentLocked)));
581 }
582
583 #[test]
584 fn test_agent_handle_idle_timeout() {
585 let handle =
587 AgentHandle::with_timeout(PathBuf::from("/tmp/test.sock"), Duration::from_millis(10));
588
589 assert!(!handle.is_idle_timed_out());
591 assert!(!handle.is_agent_locked());
592
593 std::thread::sleep(Duration::from_millis(20));
595
596 assert!(handle.is_idle_timed_out());
598
599 handle.touch();
601 assert!(!handle.is_idle_timed_out());
602 }
603
604 #[test]
605 fn test_agent_handle_zero_timeout_never_expires() {
606 let handle = AgentHandle::with_timeout(PathBuf::from("/tmp/test.sock"), Duration::ZERO);
608
609 std::thread::sleep(Duration::from_millis(10));
611
612 assert!(!handle.is_idle_timed_out());
614 }
615
616 #[test]
617 fn test_clone_shares_locked_state() {
618 let handle_a = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
619 let handle_b = handle_a.clone();
620
621 assert!(!handle_b.is_agent_locked());
622 handle_a.lock_agent().unwrap();
623 assert!(handle_b.is_agent_locked());
624
625 handle_a.unlock_agent();
626 assert!(!handle_b.is_agent_locked());
627 }
628
629 #[test]
630 fn test_clone_shares_last_activity() {
631 let handle_a =
632 AgentHandle::with_timeout(PathBuf::from("/tmp/test.sock"), Duration::from_millis(50));
633 let handle_b = handle_a.clone();
634
635 std::thread::sleep(Duration::from_millis(60));
636 assert!(handle_b.is_idle_timed_out());
637
638 handle_a.touch();
640 assert!(!handle_b.is_idle_timed_out());
641 }
642
643 #[test]
644 fn test_clone_sign_returns_locked_after_other_clone_locks() {
645 let handle_a = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
646 let handle_b = handle_a.clone();
647
648 let pkcs8_bytes = generate_test_pkcs8();
649 handle_a.register_key(Zeroizing::new(pkcs8_bytes)).unwrap();
650
651 let pubkeys = handle_a.public_keys().unwrap();
652 let pubkey = &pubkeys[0];
653
654 assert!(handle_b.sign(pubkey, b"test data").is_ok());
655
656 handle_a.lock_agent().unwrap();
657 let result = handle_b.sign(pubkey, b"test data");
658 assert!(matches!(result, Err(AgentError::AgentLocked)));
659 }
660
661 #[test]
662 fn absolute_cap_locks_despite_continuous_activity() {
663 let handle = AgentHandle::with_unlock_cap(
665 PathBuf::from("/tmp/test.sock"),
666 Duration::from_secs(3600),
667 Duration::from_millis(80),
668 );
669 handle
670 .register_key(Zeroizing::new(generate_test_pkcs8()))
671 .unwrap();
672
673 for _ in 0..6 {
675 std::thread::sleep(Duration::from_millis(25));
676 handle.touch();
677 }
678
679 assert!(
680 handle.is_unlock_window_expired(),
681 "unlock window should be expired ~150ms past an 80ms cap"
682 );
683 let locked = handle.check_idle_timeout().unwrap();
684 assert!(
685 locked,
686 "the absolute unlock cap must lock the agent regardless of activity"
687 );
688 assert!(handle.is_agent_locked());
689 assert_eq!(handle.key_count().unwrap(), 0);
690 }
691
692 #[test]
693 fn public_keys_empty_after_lock() {
694 let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
695 handle
696 .register_key(Zeroizing::new(generate_test_pkcs8()))
697 .unwrap();
698 assert_eq!(handle.public_keys().unwrap().len(), 1);
699
700 handle.lock_agent().unwrap();
701
702 assert!(handle.is_agent_locked());
705 assert!(
706 handle.public_keys().unwrap().is_empty(),
707 "locking must clear keys so the key list cannot leak while locked"
708 );
709 }
710}