use crate::agent::AgentCore;
use crate::error::AgentError;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant};
use zeroize::Zeroizing;
pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
pub const DEFAULT_MAX_UNLOCK_TTL: Duration = Duration::from_secs(8 * 60 * 60);
pub struct AgentHandle {
core: Arc<Mutex<AgentCore>>,
socket_path: PathBuf,
pid_file: Option<PathBuf>,
running: Arc<AtomicBool>,
last_activity: Arc<Mutex<Instant>>,
idle_timeout: Duration,
unlocked_at: Arc<Mutex<Instant>>,
max_unlock_ttl: Duration,
locked: Arc<AtomicBool>,
}
impl std::fmt::Debug for AgentHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AgentHandle")
.field("socket_path", &self.socket_path)
.field("pid_file", &self.pid_file)
.field("running", &self.is_running())
.field("locked", &self.is_agent_locked())
.field("idle_timeout", &self.idle_timeout)
.field("max_unlock_ttl", &self.max_unlock_ttl)
.finish_non_exhaustive()
}
}
impl AgentHandle {
pub fn new(socket_path: PathBuf) -> Self {
Self::with_timeout(socket_path, DEFAULT_IDLE_TIMEOUT)
}
pub fn with_timeout(socket_path: PathBuf, idle_timeout: Duration) -> Self {
Self::with_unlock_cap(socket_path, idle_timeout, DEFAULT_MAX_UNLOCK_TTL)
}
pub fn with_unlock_cap(
socket_path: PathBuf,
idle_timeout: Duration,
max_unlock_ttl: Duration,
) -> Self {
Self {
core: Arc::new(Mutex::new(AgentCore::default())),
socket_path,
pid_file: None,
running: Arc::new(AtomicBool::new(false)),
last_activity: Arc::new(Mutex::new(Instant::now())),
idle_timeout,
unlocked_at: Arc::new(Mutex::new(Instant::now())),
max_unlock_ttl,
locked: Arc::new(AtomicBool::new(false)),
}
}
pub fn with_pid_file(socket_path: PathBuf, pid_file: PathBuf) -> Self {
Self {
core: Arc::new(Mutex::new(AgentCore::default())),
socket_path,
pid_file: Some(pid_file),
running: Arc::new(AtomicBool::new(false)),
last_activity: Arc::new(Mutex::new(Instant::now())),
idle_timeout: DEFAULT_IDLE_TIMEOUT,
unlocked_at: Arc::new(Mutex::new(Instant::now())),
max_unlock_ttl: DEFAULT_MAX_UNLOCK_TTL,
locked: Arc::new(AtomicBool::new(false)),
}
}
pub fn with_pid_file_and_timeout(
socket_path: PathBuf,
pid_file: PathBuf,
idle_timeout: Duration,
) -> Self {
Self {
core: Arc::new(Mutex::new(AgentCore::default())),
socket_path,
pid_file: Some(pid_file),
running: Arc::new(AtomicBool::new(false)),
last_activity: Arc::new(Mutex::new(Instant::now())),
idle_timeout,
unlocked_at: Arc::new(Mutex::new(Instant::now())),
max_unlock_ttl: DEFAULT_MAX_UNLOCK_TTL,
locked: Arc::new(AtomicBool::new(false)),
}
}
pub fn from_core(core: AgentCore, socket_path: PathBuf) -> Self {
Self {
core: Arc::new(Mutex::new(core)),
socket_path,
pid_file: None,
running: Arc::new(AtomicBool::new(false)),
last_activity: Arc::new(Mutex::new(Instant::now())),
idle_timeout: DEFAULT_IDLE_TIMEOUT,
unlocked_at: Arc::new(Mutex::new(Instant::now())),
max_unlock_ttl: DEFAULT_MAX_UNLOCK_TTL,
locked: Arc::new(AtomicBool::new(false)),
}
}
pub fn socket_path(&self) -> &PathBuf {
&self.socket_path
}
pub fn pid_file(&self) -> Option<&PathBuf> {
self.pid_file.as_ref()
}
pub fn set_pid_file(&mut self, path: PathBuf) {
self.pid_file = Some(path);
}
pub fn lock(&self) -> Result<MutexGuard<'_, AgentCore>, AgentError> {
self.core
.lock()
.map_err(|_| AgentError::MutexError("Agent core mutex poisoned".to_string()))
}
pub fn core_arc(&self) -> Arc<Mutex<AgentCore>> {
Arc::clone(&self.core)
}
pub fn is_running(&self) -> bool {
self.running.load(Ordering::SeqCst)
}
pub fn set_running(&self, running: bool) {
self.running.store(running, Ordering::SeqCst);
}
pub fn idle_timeout(&self) -> Duration {
self.idle_timeout
}
pub fn touch(&self) {
if let Ok(mut last) = self.last_activity.lock() {
*last = Instant::now();
}
}
pub fn idle_duration(&self) -> Duration {
self.last_activity
.lock()
.map(|last| last.elapsed())
.unwrap_or(Duration::ZERO)
}
pub fn is_idle_timed_out(&self) -> bool {
if self.idle_timeout.is_zero() {
return false;
}
self.idle_duration() >= self.idle_timeout
}
pub fn max_unlock_ttl(&self) -> Duration {
self.max_unlock_ttl
}
pub fn unlock_age(&self) -> Duration {
self.unlocked_at
.lock()
.map(|t| t.elapsed())
.unwrap_or(Duration::ZERO)
}
pub fn is_unlock_window_expired(&self) -> bool {
if self.max_unlock_ttl.is_zero() {
return false;
}
self.unlock_age() >= self.max_unlock_ttl
}
pub fn is_agent_locked(&self) -> bool {
self.locked.load(Ordering::SeqCst)
}
pub fn lock_agent(&self) -> Result<(), AgentError> {
log::info!("Locking agent (clearing keys from memory)");
{
let mut core = self.lock()?;
core.clear_keys();
}
self.locked.store(true, Ordering::SeqCst);
log::debug!("Agent locked");
Ok(())
}
pub fn unlock_agent(&self) {
log::info!("Unlocking agent");
self.locked.store(false, Ordering::SeqCst);
self.touch(); if let Ok(mut unlocked) = self.unlocked_at.lock() {
*unlocked = Instant::now(); }
}
pub fn check_idle_timeout(&self) -> Result<bool, AgentError> {
if (self.is_idle_timed_out() || self.is_unlock_window_expired()) && !self.is_agent_locked()
{
log::info!(
"Locking agent: idle for {:?}, unlocked for {:?}",
self.idle_duration(),
self.unlock_age()
);
self.lock_agent()?;
return Ok(true);
}
Ok(false)
}
#[allow(clippy::disallowed_methods)] pub fn shutdown(&self) -> Result<(), AgentError> {
log::info!("Shutting down agent at {:?}", self.socket_path);
{
let mut core = self.lock()?;
core.clear_keys();
log::debug!("Cleared all keys from agent core");
}
self.set_running(false);
if self.socket_path.exists() {
if let Err(e) = std::fs::remove_file(&self.socket_path) {
log::warn!("Failed to remove socket file {:?}: {}", self.socket_path, e);
} else {
log::debug!("Removed socket file {:?}", self.socket_path);
}
}
if let Some(ref pid_file) = self.pid_file
&& pid_file.exists()
{
if let Err(e) = std::fs::remove_file(pid_file) {
log::warn!("Failed to remove PID file {:?}: {}", pid_file, e);
} else {
log::debug!("Removed PID file {:?}", pid_file);
}
}
log::info!("Agent shutdown complete");
Ok(())
}
pub fn key_count(&self) -> Result<usize, AgentError> {
let core = self.lock()?;
Ok(core.key_count())
}
pub fn public_keys(&self) -> Result<Vec<Vec<u8>>, AgentError> {
let core = self.lock()?;
Ok(core.public_keys())
}
pub fn register_key(&self, pkcs8_bytes: Zeroizing<Vec<u8>>) -> Result<(), AgentError> {
{
let mut core = self.lock()?;
core.register_key(pkcs8_bytes)?;
}
if let Ok(mut unlocked) = self.unlocked_at.lock() {
*unlocked = Instant::now(); }
Ok(())
}
pub fn sign(&self, pubkey: &[u8], data: &[u8]) -> Result<Vec<u8>, AgentError> {
if self.is_agent_locked() {
return Err(AgentError::AgentLocked);
}
let core = self.lock()?;
let result = core.sign(pubkey, data);
if result.is_ok() {
self.touch();
}
result
}
}
impl Clone for AgentHandle {
fn clone(&self) -> Self {
Self {
core: Arc::clone(&self.core),
socket_path: self.socket_path.clone(),
pid_file: self.pid_file.clone(),
running: Arc::clone(&self.running),
last_activity: Arc::clone(&self.last_activity),
idle_timeout: self.idle_timeout,
unlocked_at: Arc::clone(&self.unlocked_at),
max_unlock_ttl: self.max_unlock_ttl,
locked: Arc::clone(&self.locked),
}
}
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
use ring::rand::SystemRandom;
use ring::signature::Ed25519KeyPair;
use tempfile::TempDir;
fn generate_test_pkcs8() -> Vec<u8> {
let rng = SystemRandom::new();
let pkcs8_doc = Ed25519KeyPair::generate_pkcs8(&rng).expect("Failed to generate PKCS#8");
pkcs8_doc.as_ref().to_vec()
}
#[test]
fn test_agent_handle_new() {
let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
assert_eq!(handle.socket_path(), &PathBuf::from("/tmp/test.sock"));
assert!(handle.pid_file().is_none());
assert!(!handle.is_running());
}
#[test]
fn test_agent_handle_with_pid_file() {
let handle = AgentHandle::with_pid_file(
PathBuf::from("/tmp/test.sock"),
PathBuf::from("/tmp/test.pid"),
);
assert_eq!(handle.socket_path(), &PathBuf::from("/tmp/test.sock"));
assert_eq!(handle.pid_file(), Some(&PathBuf::from("/tmp/test.pid")));
}
#[test]
fn test_agent_handle_running_state() {
let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
assert!(!handle.is_running());
handle.set_running(true);
assert!(handle.is_running());
handle.set_running(false);
assert!(!handle.is_running());
}
#[test]
fn test_agent_handle_key_operations() {
let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
assert_eq!(handle.key_count().unwrap(), 0);
let pkcs8_bytes = generate_test_pkcs8();
handle
.register_key(Zeroizing::new(pkcs8_bytes))
.expect("Failed to register key");
assert_eq!(handle.key_count().unwrap(), 1);
let pubkeys = handle.public_keys().unwrap();
assert_eq!(pubkeys.len(), 1);
}
#[test]
fn test_agent_handle_clone_shares_state() {
let handle1 = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
let handle2 = handle1.clone();
let pkcs8_bytes = generate_test_pkcs8();
handle1
.register_key(Zeroizing::new(pkcs8_bytes))
.expect("Failed to register key");
assert_eq!(handle1.key_count().unwrap(), 1);
assert_eq!(handle2.key_count().unwrap(), 1);
}
#[test]
fn test_agent_handle_shutdown() {
let temp_dir = TempDir::new().unwrap();
let socket_path = temp_dir.path().join("test.sock");
std::fs::write(&socket_path, "dummy").unwrap();
let handle = AgentHandle::new(socket_path.clone());
let pkcs8_bytes = generate_test_pkcs8();
handle
.register_key(Zeroizing::new(pkcs8_bytes))
.expect("Failed to register key");
handle.set_running(true);
assert_eq!(handle.key_count().unwrap(), 1);
assert!(handle.is_running());
assert!(socket_path.exists());
handle.shutdown().expect("Shutdown failed");
assert_eq!(handle.key_count().unwrap(), 0);
assert!(!handle.is_running());
assert!(!socket_path.exists());
}
#[test]
fn test_multiple_handles_independent() {
let handle1 = AgentHandle::new(PathBuf::from("/tmp/agent1.sock"));
let handle2 = AgentHandle::new(PathBuf::from("/tmp/agent2.sock"));
let pkcs8_bytes = generate_test_pkcs8();
handle1
.register_key(Zeroizing::new(pkcs8_bytes))
.expect("Failed to register key");
assert_eq!(handle1.key_count().unwrap(), 1);
assert_eq!(handle2.key_count().unwrap(), 0);
}
#[test]
fn test_agent_handle_lock_unlock() {
let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
assert!(!handle.is_agent_locked());
let pkcs8_bytes = generate_test_pkcs8();
handle
.register_key(Zeroizing::new(pkcs8_bytes))
.expect("Failed to register key");
assert_eq!(handle.key_count().unwrap(), 1);
handle.lock_agent().expect("Lock failed");
assert!(handle.is_agent_locked());
assert_eq!(handle.key_count().unwrap(), 0);
handle.unlock_agent();
assert!(!handle.is_agent_locked());
}
#[test]
fn test_agent_handle_sign_when_locked() {
let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
let pkcs8_bytes = generate_test_pkcs8();
handle
.register_key(Zeroizing::new(pkcs8_bytes))
.expect("Failed to register key");
let pubkeys = handle.public_keys().unwrap();
let pubkey = &pubkeys[0];
let result = handle.sign(pubkey, b"test data");
assert!(result.is_ok());
handle.lock_agent().expect("Lock failed");
let result = handle.sign(pubkey, b"test data");
assert!(matches!(result, Err(AgentError::AgentLocked)));
}
#[test]
fn test_agent_handle_idle_timeout() {
let handle =
AgentHandle::with_timeout(PathBuf::from("/tmp/test.sock"), Duration::from_millis(10));
assert!(!handle.is_idle_timed_out());
assert!(!handle.is_agent_locked());
std::thread::sleep(Duration::from_millis(20));
assert!(handle.is_idle_timed_out());
handle.touch();
assert!(!handle.is_idle_timed_out());
}
#[test]
fn test_agent_handle_zero_timeout_never_expires() {
let handle = AgentHandle::with_timeout(PathBuf::from("/tmp/test.sock"), Duration::ZERO);
std::thread::sleep(Duration::from_millis(10));
assert!(!handle.is_idle_timed_out());
}
#[test]
fn test_clone_shares_locked_state() {
let handle_a = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
let handle_b = handle_a.clone();
assert!(!handle_b.is_agent_locked());
handle_a.lock_agent().unwrap();
assert!(handle_b.is_agent_locked());
handle_a.unlock_agent();
assert!(!handle_b.is_agent_locked());
}
#[test]
fn test_clone_shares_last_activity() {
let handle_a =
AgentHandle::with_timeout(PathBuf::from("/tmp/test.sock"), Duration::from_millis(50));
let handle_b = handle_a.clone();
std::thread::sleep(Duration::from_millis(60));
assert!(handle_b.is_idle_timed_out());
handle_a.touch();
assert!(!handle_b.is_idle_timed_out());
}
#[test]
fn test_clone_sign_returns_locked_after_other_clone_locks() {
let handle_a = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
let handle_b = handle_a.clone();
let pkcs8_bytes = generate_test_pkcs8();
handle_a.register_key(Zeroizing::new(pkcs8_bytes)).unwrap();
let pubkeys = handle_a.public_keys().unwrap();
let pubkey = &pubkeys[0];
assert!(handle_b.sign(pubkey, b"test data").is_ok());
handle_a.lock_agent().unwrap();
let result = handle_b.sign(pubkey, b"test data");
assert!(matches!(result, Err(AgentError::AgentLocked)));
}
#[test]
fn absolute_cap_locks_despite_continuous_activity() {
let handle = AgentHandle::with_unlock_cap(
PathBuf::from("/tmp/test.sock"),
Duration::from_secs(3600),
Duration::from_millis(80),
);
handle
.register_key(Zeroizing::new(generate_test_pkcs8()))
.unwrap();
for _ in 0..6 {
std::thread::sleep(Duration::from_millis(25));
handle.touch();
}
assert!(
handle.is_unlock_window_expired(),
"unlock window should be expired ~150ms past an 80ms cap"
);
let locked = handle.check_idle_timeout().unwrap();
assert!(
locked,
"the absolute unlock cap must lock the agent regardless of activity"
);
assert!(handle.is_agent_locked());
assert_eq!(handle.key_count().unwrap(), 0);
}
#[test]
fn public_keys_empty_after_lock() {
let handle = AgentHandle::new(PathBuf::from("/tmp/test.sock"));
handle
.register_key(Zeroizing::new(generate_test_pkcs8()))
.unwrap();
assert_eq!(handle.public_keys().unwrap().len(), 1);
handle.lock_agent().unwrap();
assert!(handle.is_agent_locked());
assert!(
handle.public_keys().unwrap().is_empty(),
"locking must clear keys so the key list cannot leak while locked"
);
}
}