use std::{
collections::HashSet,
io::{BufRead, BufReader, Read, Write},
net::{SocketAddr, TcpListener},
path::Path,
time::Duration as StdDuration,
};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use chrono::Duration;
use serde_json::Value;
use crate::{
audit::{AuditEvent, AuditLog},
error::{GlovesError, Result},
fs_secure::ensure_private_dir,
paths::SecretsPaths,
reaper::TtlReaper,
types::{AgentId, Owner, SecretId, SecretValue},
vault::gocryptfs::GocryptfsDriver,
};
use super::{
output::{self, OutputStatus},
runtime, secret_input, DEFAULT_AGENT_ID, DEFAULT_TTL_DAYS,
};
const DAEMON_ACTION_INTERFACE: &str = "daemon";
const DAEMON_TOKEN_ENV_VAR: &str = "GLOVES_DAEMON_TOKEN";
#[derive(Debug, serde::Deserialize)]
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
enum DaemonRequest {
Ping,
List,
Verify,
Status {
name: String,
},
Get {
name: String,
},
Set {
name: String,
#[serde(default)]
generate: bool,
value: Option<String>,
ttl_days: Option<Value>,
},
Revoke {
name: String,
},
Request {
name: String,
reason: String,
},
Approve {
request_id: String,
},
Deny {
request_id: String,
},
}
#[derive(Debug)]
struct DaemonEnvelope {
agent: Option<String>,
token: Option<String>,
request: DaemonRequest,
}
#[derive(Debug, serde::Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
enum DaemonResponse {
Ok {
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<serde_json::Value>,
},
Error {
error: String,
},
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct DaemonRuntimeOptions {
pub io_timeout_seconds: u64,
pub request_limit_bytes: usize,
}
pub(crate) fn run_daemon(
paths: &SecretsPaths,
bind: &str,
options: DaemonRuntimeOptions,
check: bool,
max_requests: usize,
) -> Result<()> {
runtime::init_layout(paths)?;
enforce_daemon_root_strictness(paths)?;
run_daemon_tcp(paths, bind, options, check, max_requests)
}
#[cfg(unix)]
fn assert_private_directory(path: &Path, label: &str) -> Result<()> {
let mode = std::fs::metadata(path)?.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(GlovesError::InvalidInput(format!(
"{label} must not be group/world accessible: {}",
path.display()
)));
}
Ok(())
}
#[cfg(not(unix))]
fn assert_private_directory(path: &Path, _label: &str) -> Result<()> {
let _ = path;
Ok(())
}
fn enforce_daemon_root_strictness(paths: &SecretsPaths) -> Result<()> {
ensure_private_dir(paths.root())?;
assert_private_directory(paths.root(), "secrets root")?;
Ok(())
}
fn parse_daemon_bind(bind: &str) -> Result<SocketAddr> {
let bind_addr = bind.parse::<SocketAddr>().map_err(|error| {
GlovesError::InvalidInput(format!("invalid daemon bind address: {error}"))
})?;
if bind_addr.port() == 0 {
return Err(GlovesError::InvalidInput(
"daemon bind port must be non-zero".to_owned(),
));
}
if !bind_addr.ip().is_loopback() {
return Err(GlovesError::InvalidInput(
"daemon bind address must be loopback (127.0.0.1 or ::1)".to_owned(),
));
}
Ok(bind_addr)
}
fn run_daemon_tcp(
paths: &SecretsPaths,
bind: &str,
options: DaemonRuntimeOptions,
check: bool,
max_requests: usize,
) -> Result<()> {
let bind_addr = parse_daemon_bind(bind)?;
if check {
let listener = TcpListener::bind(bind_addr)?;
drop(listener);
emit_stdout_line("ok")?;
return Ok(());
}
let listener = TcpListener::bind(bind_addr)?;
let listening_addr = listener.local_addr()?;
emit_stdout_line(&format!("listening: {listening_addr}"))?;
let mut handled_requests = 0_usize;
for stream in listener.incoming() {
let mut stream = match stream {
Ok(stream) => stream,
Err(error) => {
let _ = emit_stderr_line(&format!("error: daemon accept failed: {error}"));
continue;
}
};
let io_timeout = Some(StdDuration::from_secs(options.io_timeout_seconds));
if let Err(error) = stream.set_read_timeout(io_timeout) {
let _ = emit_stderr_line(&format!("error: daemon read-timeout setup failed: {error}"));
continue;
}
if let Err(error) = stream.set_write_timeout(io_timeout) {
let _ = emit_stderr_line(&format!(
"error: daemon write-timeout setup failed: {error}"
));
continue;
}
if let Err(error) =
handle_daemon_connection(paths, &mut stream, options.request_limit_bytes)
{
let _ = write_daemon_response(
&mut stream,
&DaemonResponse::Error {
error: error.to_string(),
},
);
}
handled_requests += 1;
if max_requests > 0 && handled_requests >= max_requests {
break;
}
}
Ok(())
}
fn emit_stdout_line(line: &str) -> Result<()> {
normalize_stdout_result(output::stdout_line(line))
}
fn emit_stderr_line(line: &str) -> std::io::Result<()> {
normalize_stderr_result(output::stderr_line(line))
}
fn normalize_stdout_result(result: std::io::Result<OutputStatus>) -> Result<()> {
match result {
Ok(OutputStatus::Written | OutputStatus::BrokenPipe) => Ok(()),
Err(error) => Err(GlovesError::Io(error)),
}
}
fn normalize_stderr_result(result: std::io::Result<OutputStatus>) -> std::io::Result<()> {
match result {
Ok(OutputStatus::Written | OutputStatus::BrokenPipe) => Ok(()),
Err(error) => Err(error),
}
}
fn handle_daemon_connection<S>(
paths: &SecretsPaths,
stream: &mut S,
request_limit_bytes: usize,
) -> Result<()>
where
S: Read + Write,
{
let request = read_daemon_request(stream, request_limit_bytes)?;
let response = execute_daemon_envelope_request(paths, request);
write_daemon_response(stream, &response)
}
fn read_daemon_request<R>(stream: &mut R, request_limit_bytes: usize) -> Result<DaemonEnvelope>
where
R: Read,
{
let mut reader = BufReader::new(stream.take((request_limit_bytes + 1) as u64));
let mut bytes = Vec::new();
reader.read_until(b'\n', &mut bytes)?;
if bytes.is_empty() {
return Err(GlovesError::InvalidInput("empty daemon request".to_owned()));
}
if bytes.len() > request_limit_bytes {
return Err(GlovesError::InvalidInput(format!(
"daemon request too large (max {} bytes)",
request_limit_bytes
)));
}
while matches!(bytes.last(), Some(b'\n' | b'\r')) {
bytes.pop();
}
if bytes.is_empty() {
return Err(GlovesError::InvalidInput("empty daemon request".to_owned()));
}
let mut payload = serde_json::from_slice::<serde_json::Value>(&bytes)
.map_err(|error| GlovesError::InvalidInput(format!("invalid daemon request: {error}")))?;
let object = payload.as_object_mut().ok_or_else(|| {
GlovesError::InvalidInput("invalid daemon request: expected JSON object".to_owned())
})?;
let agent = parse_daemon_optional_string_field(object.remove("agent"), "agent")?;
let token = parse_daemon_optional_string_field(object.remove("token"), "token")?;
let request = serde_json::from_value::<DaemonRequest>(payload)
.map_err(|error| GlovesError::InvalidInput(format!("invalid daemon request: {error}")))?;
Ok(DaemonEnvelope {
agent,
token,
request,
})
}
fn parse_daemon_optional_string_field(
value: Option<serde_json::Value>,
field_name: &str,
) -> Result<Option<String>> {
match value {
None | Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::String(value)) => Ok(Some(value)),
Some(_) => Err(GlovesError::InvalidInput(format!(
"invalid daemon request: `{field_name}` must be a string"
))),
}
}
fn write_daemon_response<W>(stream: &mut W, response: &DaemonResponse) -> Result<()>
where
W: Write,
{
let payload = serde_json::to_vec(response)?;
stream.write_all(&payload)?;
stream.write_all(b"\n")?;
stream.flush()?;
Ok(())
}
#[cfg(test)]
fn execute_daemon_request(paths: &SecretsPaths, request: DaemonRequest) -> DaemonResponse {
let envelope = DaemonEnvelope {
agent: None,
token: None,
request,
};
execute_daemon_envelope_request(paths, envelope)
}
fn execute_daemon_envelope_request(
paths: &SecretsPaths,
request: DaemonEnvelope,
) -> DaemonResponse {
match execute_daemon_envelope_inner(paths, request) {
Ok((message, data)) => DaemonResponse::Ok { message, data },
Err(error) => DaemonResponse::Error {
error: error.to_string(),
},
}
}
fn log_daemon_command_executed(
paths: &SecretsPaths,
actor: &AgentId,
command: &str,
target: Option<String>,
) {
let audit_log = match AuditLog::new(paths.audit_file()) {
Ok(log) => log,
Err(_) => return,
};
let _ = audit_log.log(AuditEvent::CommandExecuted {
by: actor.clone(),
interface: DAEMON_ACTION_INTERFACE.to_owned(),
command: command.to_owned(),
target,
});
}
fn required_daemon_token_from_env() -> Result<Option<String>> {
match std::env::var(DAEMON_TOKEN_ENV_VAR) {
Ok(token) if token.trim().is_empty() => Err(GlovesError::InvalidInput(format!(
"{DAEMON_TOKEN_ENV_VAR} must not be empty when set"
))),
Ok(token) => Ok(Some(token)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(GlovesError::InvalidInput(format!(
"{DAEMON_TOKEN_ENV_VAR} must be valid UTF-8"
))),
}
}
fn resolve_daemon_actor(requested_agent: Option<&str>) -> Result<AgentId> {
match requested_agent {
Some(agent) if agent.trim().is_empty() => Err(GlovesError::InvalidInput(
"daemon agent must not be empty".to_owned(),
)),
Some(agent) => Ok(AgentId::new(agent.trim())?),
None => Ok(AgentId::new(DEFAULT_AGENT_ID)?),
}
}
fn validate_daemon_token(requested_token: Option<&str>) -> Result<()> {
let required_token = required_daemon_token_from_env()?;
if let Some(required_token) = required_token {
if requested_token != Some(required_token.as_str()) {
return Err(GlovesError::InvalidInput("invalid daemon token".to_owned()));
}
}
Ok(())
}
fn execute_daemon_envelope_inner(
paths: &SecretsPaths,
envelope: DaemonEnvelope,
) -> Result<(String, Option<serde_json::Value>)> {
validate_daemon_token(envelope.token.as_deref())?;
let actor = resolve_daemon_actor(envelope.agent.as_deref())?;
execute_daemon_request_with_actor(paths, envelope.request, &actor)
}
#[cfg(test)]
fn execute_daemon_request_inner(
paths: &SecretsPaths,
request: DaemonRequest,
) -> Result<(String, Option<serde_json::Value>)> {
let envelope = DaemonEnvelope {
agent: None,
token: None,
request,
};
execute_daemon_envelope_inner(paths, envelope)
}
fn execute_daemon_request_with_actor(
paths: &SecretsPaths,
request: DaemonRequest,
actor: &AgentId,
) -> Result<(String, Option<serde_json::Value>)> {
match request {
DaemonRequest::Ping => {
log_daemon_command_executed(paths, actor, "ping", None);
Ok(("pong".to_owned(), None))
}
DaemonRequest::List => {
let manager = runtime::manager_for_paths(paths)?;
let entries = manager.list_all()?;
log_daemon_command_executed(paths, actor, "list", None);
Ok(("ok".to_owned(), Some(serde_json::to_value(entries)?)))
}
DaemonRequest::Verify => {
let manager = runtime::manager_for_paths(paths)?;
TtlReaper::reap(
&manager.agent_backend,
&manager.metadata_store,
&manager.audit_log,
)?;
TtlReaper::reap_vault_sessions(&GocryptfsDriver::new(), paths, &manager.audit_log)?;
log_daemon_command_executed(paths, actor, "verify", None);
Ok(("ok".to_owned(), None))
}
DaemonRequest::Status { name } => {
let manager = runtime::manager_for_paths(paths)?;
let pending = manager.pending_store.load_all()?;
let status = pending
.into_iter()
.find(|request| request.secret_name.as_str() == name)
.map(|request| request.status)
.unwrap_or(crate::types::RequestStatus::Fulfilled);
log_daemon_command_executed(paths, actor, "status", Some(name));
Ok(("ok".to_owned(), Some(serde_json::to_value(status)?)))
}
DaemonRequest::Get { name } => {
let manager = runtime::manager_for_paths(paths)?;
let secret_id = SecretId::new(&name)?;
let caller = actor.clone();
let identity_file = runtime::load_or_create_identity_for_agent(paths, &caller)?;
let secret_value = manager.get(&secret_id, &caller, Some(identity_file.as_path()))?;
let value = secret_value.expose(|bytes| String::from_utf8_lossy(bytes).to_string());
log_daemon_command_executed(paths, actor, "get", Some(name));
Ok((
"ok".to_owned(),
Some(serde_json::json!({ "secret": value })),
))
}
DaemonRequest::Set {
name,
generate,
value,
ttl_days,
} => {
let manager = runtime::manager_for_paths(paths)?;
let secret_id = SecretId::new(&name)?;
let creator = actor.clone();
let recipient = runtime::load_or_create_recipient_for_agent(paths, &creator)?;
let mut recipients = HashSet::new();
recipients.insert(creator.clone());
let ttl = runtime::parse_secret_ttl_json_value(
ttl_days.as_ref(),
DEFAULT_TTL_DAYS,
"ttl_days",
)?;
let secret_value =
SecretValue::new(secret_input::resolve_daemon_secret_input(generate, value)?);
manager.set(
secret_id.clone(),
secret_value,
crate::manager::SetSecretOptions {
owner: Owner::Agent,
ttl: ttl.duration(),
created_by: creator,
recipients,
recipient_keys: vec![recipient],
},
)?;
let expires_at = manager.metadata_store.load(&secret_id)?.expires_at;
log_daemon_command_executed(paths, actor, "set", Some(secret_id.as_str().to_owned()));
Ok((
"ok".to_owned(),
Some(serde_json::json!({
"id": secret_id.as_str(),
"ttl_days": ttl.ttl_days(),
"expires_at": expires_at,
"never_expires": ttl.never_expires(),
})),
))
}
DaemonRequest::Revoke { name } => {
let manager = runtime::manager_for_paths(paths)?;
let secret_id = SecretId::new(&name)?;
let caller = actor.clone();
manager.revoke(&secret_id, &caller)?;
log_daemon_command_executed(paths, actor, "revoke", Some(name));
Ok(("revoked".to_owned(), None))
}
DaemonRequest::Request { name, reason } => {
let manager = runtime::manager_for_paths(paths)?;
let secret_id = SecretId::new(&name)?;
let requester = actor.clone();
let signing_key = runtime::load_or_create_signing_key_for_agent(paths, &requester)?;
let request = manager.request(
secret_id,
requester,
reason,
Duration::days(DEFAULT_TTL_DAYS),
&signing_key,
)?;
log_daemon_command_executed(paths, actor, "request", Some(name));
Ok((
"pending".to_owned(),
Some(serde_json::json!({ "request_id": request.id })),
))
}
DaemonRequest::Approve { request_id } => {
let manager = runtime::manager_for_paths(paths)?;
let request_id = runtime::parse_request_uuid(&request_id)?;
let reviewer = actor.clone();
let request = manager.approve_request(request_id, reviewer)?;
log_daemon_command_executed(paths, actor, "approve", Some(request_id.to_string()));
Ok((
"approved".to_owned(),
Some(serde_json::json!({
"request_id": request.id,
"secret_name": request.secret_name,
"requested_by": request.requested_by,
"reason": request.reason,
"requested_at": request.requested_at,
"expires_at": request.expires_at,
"status": request.status,
"pending": request.pending,
"approved_at": request.approved_at,
"approved_by": request.approved_by,
"denied_at": request.denied_at,
"denied_by": request.denied_by,
})),
))
}
DaemonRequest::Deny { request_id } => {
let manager = runtime::manager_for_paths(paths)?;
let request_id = runtime::parse_request_uuid(&request_id)?;
let reviewer = actor.clone();
let request = manager.deny_request(request_id, reviewer)?;
log_daemon_command_executed(paths, actor, "deny", Some(request_id.to_string()));
Ok((
"denied".to_owned(),
Some(serde_json::json!({
"request_id": request.id,
"secret_name": request.secret_name,
"requested_by": request.requested_by,
"reason": request.reason,
"requested_at": request.requested_at,
"expires_at": request.expires_at,
"status": request.status,
"pending": request.pending,
"approved_at": request.approved_at,
"approved_by": request.approved_by,
"denied_at": request.denied_at,
"denied_by": request.denied_by,
})),
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
use std::{
ffi::OsString,
io::Cursor,
sync::{Mutex, OnceLock},
};
static DAEMON_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn clear_daemon_token_env() {
std::env::remove_var(DAEMON_TOKEN_ENV_VAR);
}
struct MemoryStream {
reader: Cursor<Vec<u8>>,
written: Vec<u8>,
}
impl MemoryStream {
fn new(input: impl Into<Vec<u8>>) -> Self {
Self {
reader: Cursor::new(input.into()),
written: Vec::new(),
}
}
fn response_value(&self) -> Value {
let text = String::from_utf8(self.written.clone()).expect("utf8");
serde_json::from_str(text.trim_end()).expect("json")
}
}
impl Read for MemoryStream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.reader.read(buf)
}
}
impl Write for MemoryStream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.written.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn setup_paths() -> (tempfile::TempDir, SecretsPaths) {
let temp_dir = tempfile::tempdir().expect("temp dir");
let paths = SecretsPaths::new(temp_dir.path());
runtime::init_layout(&paths).expect("layout");
(temp_dir, paths)
}
fn expect_data(data: Option<Value>) -> Value {
data.expect("expected response data")
}
#[test]
fn parse_daemon_bind_rejects_invalid_address() {
let error = parse_daemon_bind("not-a-socket-address").expect_err("must fail");
assert!(error.to_string().contains("invalid daemon bind address"));
}
#[test]
fn parse_daemon_bind_accepts_loopback_and_rejects_unsafe_values() {
assert_eq!(
parse_daemon_bind("127.0.0.1:7373").unwrap(),
"127.0.0.1:7373".parse().unwrap()
);
let zero_port = parse_daemon_bind("127.0.0.1:0").expect_err("must fail");
assert!(zero_port.to_string().contains("must be non-zero"));
let non_loopback = parse_daemon_bind("10.0.0.2:7373").expect_err("must fail");
assert!(non_loopback.to_string().contains("must be loopback"));
}
#[cfg(unix)]
#[test]
fn assert_private_directory_rejects_group_world_access() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = tempfile::tempdir().expect("temp dir");
let mut permissions = std::fs::metadata(temp_dir.path())
.expect("metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(temp_dir.path(), permissions).expect("permissions");
let error = assert_private_directory(temp_dir.path(), "secrets root").expect_err("error");
assert!(error
.to_string()
.contains("secrets root must not be group/world accessible"));
}
#[test]
fn read_daemon_request_validates_empty_and_size_limits() {
let mut empty_input: &[u8] = b"";
let error = read_daemon_request(&mut empty_input, 64).expect_err("must fail");
assert!(error.to_string().contains("empty daemon request"));
let mut blank_line: &[u8] = b"\n";
let error = read_daemon_request(&mut blank_line, 64).expect_err("must fail");
assert!(error.to_string().contains("empty daemon request"));
let mut oversized: &[u8] = br#"{"action":"ping"}"#;
let error = read_daemon_request(&mut oversized, 4).expect_err("must fail");
assert!(error.to_string().contains("daemon request too large"));
}
#[test]
fn read_daemon_request_rejects_invalid_json_and_accepts_ping() {
let mut invalid: &[u8] = br#"{"action":"ping""#;
let error = read_daemon_request(&mut invalid, 64).expect_err("must fail");
assert!(error.to_string().contains("invalid daemon request"));
let mut valid: &[u8] = br#"{"action":"ping"}"#;
let request = read_daemon_request(&mut valid, 64).expect("request");
assert!(matches!(request.request, DaemonRequest::Ping));
assert!(request.agent.is_none());
assert!(request.token.is_none());
}
#[test]
fn read_daemon_request_rejects_non_object_and_accepts_null_optional_fields() {
let mut non_object: &[u8] = br#"["ping"]"#;
let error = read_daemon_request(&mut non_object, 64).expect_err("must fail");
assert!(error
.to_string()
.contains("invalid daemon request: expected JSON object"));
let mut valid: &[u8] = br#"{"action":"ping","agent":null,"token":null}"#;
let request = read_daemon_request(&mut valid, 128).expect("request");
assert!(matches!(request.request, DaemonRequest::Ping));
assert!(request.agent.is_none());
assert!(request.token.is_none());
}
#[test]
fn read_daemon_request_accepts_agent_and_token_fields() {
let mut valid: &[u8] = br#"{"action":"ping","agent":"agent-a","token":"abc"}"#;
let request = read_daemon_request(&mut valid, 128).expect("request");
assert!(matches!(request.request, DaemonRequest::Ping));
assert_eq!(request.agent.as_deref(), Some("agent-a"));
assert_eq!(request.token.as_deref(), Some("abc"));
}
#[test]
fn read_daemon_request_rejects_non_string_agent_and_token_fields() {
let mut invalid_agent: &[u8] = br#"{"action":"ping","agent":true}"#;
let error = read_daemon_request(&mut invalid_agent, 128).expect_err("must fail");
assert!(error.to_string().contains("`agent` must be a string"));
let mut invalid_token: &[u8] = br#"{"action":"ping","token":123}"#;
let error = read_daemon_request(&mut invalid_token, 128).expect_err("must fail");
assert!(error.to_string().contains("`token` must be a string"));
}
#[test]
fn write_daemon_response_outputs_json_line() {
let mut buffer = Vec::new();
write_daemon_response(
&mut buffer,
&DaemonResponse::Ok {
message: "ok".to_owned(),
data: Some(serde_json::json!({ "value": 1 })),
},
)
.expect("response");
let text = String::from_utf8(buffer).expect("utf8");
assert!(text.ends_with('\n'));
let payload = text.trim_end_matches('\n');
let value: Value = serde_json::from_str(payload).expect("json");
assert_eq!(value["status"], "ok");
assert_eq!(value["message"], "ok");
assert_eq!(value["data"]["value"], 1);
}
#[test]
fn output_helpers_accept_written_and_broken_pipe_results() {
assert!(normalize_stdout_result(Ok(OutputStatus::Written)).is_ok());
assert!(normalize_stdout_result(Ok(OutputStatus::BrokenPipe)).is_ok());
assert!(normalize_stderr_result(Ok(OutputStatus::Written)).is_ok());
assert!(normalize_stderr_result(Ok(OutputStatus::BrokenPipe)).is_ok());
let stdout_error = normalize_stdout_result(Err(std::io::Error::from(
std::io::ErrorKind::PermissionDenied,
)))
.expect_err("must fail");
assert!(matches!(stdout_error, GlovesError::Io(_)));
let stderr_error = normalize_stderr_result(Err(std::io::Error::from(
std::io::ErrorKind::PermissionDenied,
)))
.expect_err("must fail");
assert_eq!(stderr_error.kind(), std::io::ErrorKind::PermissionDenied);
}
#[test]
fn daemon_token_helpers_validate_environment_requirements() {
let _lock = DAEMON_ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap();
clear_daemon_token_env();
assert!(required_daemon_token_from_env().unwrap().is_none());
assert!(validate_daemon_token(None).is_ok());
std::env::set_var(DAEMON_TOKEN_ENV_VAR, "daemon-token");
assert_eq!(
required_daemon_token_from_env().unwrap().as_deref(),
Some("daemon-token")
);
let error = validate_daemon_token(Some("wrong")).expect_err("must fail");
assert!(error.to_string().contains("invalid daemon token"));
assert!(validate_daemon_token(Some("daemon-token")).is_ok());
std::env::set_var(DAEMON_TOKEN_ENV_VAR, " ");
let error = required_daemon_token_from_env().expect_err("must fail");
assert!(error
.to_string()
.contains("GLOVES_DAEMON_TOKEN must not be empty"));
clear_daemon_token_env();
}
#[cfg(unix)]
#[test]
fn daemon_token_helpers_reject_non_utf8_environment_values() {
let _lock = DAEMON_ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap();
clear_daemon_token_env();
std::env::set_var(
DAEMON_TOKEN_ENV_VAR,
OsString::from_vec(vec![0xf0, 0x28, 0x8c, 0x28]),
);
let error = required_daemon_token_from_env().expect_err("must fail");
assert!(error
.to_string()
.contains("GLOVES_DAEMON_TOKEN must be valid UTF-8"));
clear_daemon_token_env();
}
#[test]
fn resolve_daemon_actor_defaults_and_rejects_blank_values() {
assert_eq!(
resolve_daemon_actor(None).unwrap().as_str(),
DEFAULT_AGENT_ID
);
assert_eq!(
resolve_daemon_actor(Some(" agent-a ")).unwrap().as_str(),
"agent-a"
);
let error = resolve_daemon_actor(Some(" ")).expect_err("must fail");
assert!(error.to_string().contains("daemon agent must not be empty"));
}
#[test]
fn handle_daemon_connection_roundtrip_honors_agent_and_token() {
let _lock = DAEMON_ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap();
std::env::set_var(DAEMON_TOKEN_ENV_VAR, "daemon-token");
let (_temp_dir, paths) = setup_paths();
let mut set_stream = MemoryStream::new(
br#"{"action":"set","agent":"agent-main","token":"daemon-token","name":"alpha","value":"secret-value"}"#,
);
handle_daemon_connection(&paths, &mut set_stream, 512).expect("set response");
let set_response = set_stream.response_value();
assert_eq!(set_response["status"], "ok");
assert_eq!(set_response["message"], "ok");
assert_eq!(set_response["data"]["id"], "alpha");
let mut denied_stream = MemoryStream::new(
br#"{"action":"get","agent":"agent-other","token":"daemon-token","name":"alpha"}"#,
);
handle_daemon_connection(&paths, &mut denied_stream, 512).expect("denied response");
let denied_response = denied_stream.response_value();
assert_eq!(denied_response["status"], "error");
assert!(!denied_response["error"].as_str().unwrap().is_empty());
clear_daemon_token_env();
}
#[test]
fn execute_daemon_envelope_request_reports_blank_agent_and_invalid_token() {
let _lock = DAEMON_ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap();
std::env::set_var(DAEMON_TOKEN_ENV_VAR, "daemon-token");
let (_temp_dir, paths) = setup_paths();
let invalid_token = execute_daemon_envelope_request(
&paths,
DaemonEnvelope {
agent: None,
token: Some("wrong".to_owned()),
request: DaemonRequest::Ping,
},
);
assert!(matches!(invalid_token, DaemonResponse::Error { .. }));
let invalid_actor = execute_daemon_envelope_request(
&paths,
DaemonEnvelope {
agent: Some(" ".to_owned()),
token: Some("daemon-token".to_owned()),
request: DaemonRequest::Ping,
},
);
assert!(matches!(invalid_actor, DaemonResponse::Error { .. }));
let DaemonResponse::Error { error } = invalid_actor else {
unreachable!("validated above");
};
assert!(error.contains("daemon agent must not be empty"));
clear_daemon_token_env();
}
#[test]
fn execute_daemon_request_inner_supports_ping() {
let _lock = DAEMON_ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap();
clear_daemon_token_env();
let (_temp_dir, paths) = setup_paths();
let (message, data) =
execute_daemon_request_inner(&paths, DaemonRequest::Ping).expect("ping");
assert_eq!(message, "pong");
assert!(data.is_none());
clear_daemon_token_env();
}
#[test]
fn execute_daemon_request_inner_supports_set_get_list_status_and_revoke() {
let _lock = DAEMON_ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap();
clear_daemon_token_env();
let (_temp_dir, paths) = setup_paths();
let (message, data) = execute_daemon_request_inner(
&paths,
DaemonRequest::Set {
name: "alpha".to_owned(),
generate: false,
value: Some("secret-value".to_owned()),
ttl_days: None,
},
)
.expect("set");
assert_eq!(message, "ok");
assert_eq!(expect_data(data)["id"], "alpha");
let (message, data) =
execute_daemon_request_inner(&paths, DaemonRequest::List).expect("list");
assert_eq!(message, "ok");
assert!(expect_data(data).to_string().contains("alpha"));
let (message, data) = execute_daemon_request_inner(
&paths,
DaemonRequest::Status {
name: "alpha".to_owned(),
},
)
.expect("status");
assert_eq!(message, "ok");
assert_eq!(expect_data(data), serde_json::json!("fulfilled"));
let (message, data) = execute_daemon_request_inner(
&paths,
DaemonRequest::Get {
name: "alpha".to_owned(),
},
)
.expect("get");
assert_eq!(message, "ok");
assert_eq!(expect_data(data)["secret"], "secret-value");
let (message, data) = execute_daemon_request_inner(
&paths,
DaemonRequest::Revoke {
name: "alpha".to_owned(),
},
)
.expect("revoke");
assert_eq!(message, "revoked");
assert!(data.is_none());
clear_daemon_token_env();
}
#[test]
fn execute_daemon_request_inner_supports_request_approve_deny_and_verify() {
let _lock = DAEMON_ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap();
clear_daemon_token_env();
let (_temp_dir, paths) = setup_paths();
let (message, data) = execute_daemon_request_inner(
&paths,
DaemonRequest::Request {
name: "alpha".to_owned(),
reason: "need access".to_owned(),
},
)
.expect("request");
assert_eq!(message, "pending");
let request_id = expect_data(data)["request_id"]
.as_str()
.expect("request id")
.to_owned();
let (message, data) = execute_daemon_request_inner(
&paths,
DaemonRequest::Status {
name: "alpha".to_owned(),
},
)
.expect("status");
assert_eq!(message, "ok");
assert_eq!(expect_data(data), serde_json::json!("pending"));
let (message, data) =
execute_daemon_request_inner(&paths, DaemonRequest::Approve { request_id })
.expect("approve");
assert_eq!(message, "approved");
let approved_payload = expect_data(data);
assert_eq!(approved_payload["status"], "fulfilled");
assert_eq!(approved_payload["pending"], false);
assert_eq!(approved_payload["approved_by"], "default-agent");
assert!(approved_payload["approved_at"].is_string());
assert!(approved_payload["denied_by"].is_null());
let (message, data) = execute_daemon_request_inner(
&paths,
DaemonRequest::Request {
name: "bravo".to_owned(),
reason: "need access".to_owned(),
},
)
.expect("request");
assert_eq!(message, "pending");
let deny_request_id = expect_data(data)["request_id"]
.as_str()
.expect("request id")
.to_owned();
let (message, data) = execute_daemon_request_inner(
&paths,
DaemonRequest::Deny {
request_id: deny_request_id,
},
)
.expect("deny");
assert_eq!(message, "denied");
let denied_payload = expect_data(data);
assert_eq!(denied_payload["status"], "denied");
assert_eq!(denied_payload["pending"], false);
assert_eq!(denied_payload["denied_by"], "default-agent");
assert!(denied_payload["denied_at"].is_string());
assert!(denied_payload["approved_by"].is_null());
let (message, data) =
execute_daemon_request_inner(&paths, DaemonRequest::Verify).expect("verify");
assert_eq!(message, "ok");
assert!(data.is_none());
clear_daemon_token_env();
}
#[test]
fn execute_daemon_request_wraps_errors() {
let _lock = DAEMON_ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap();
clear_daemon_token_env();
let (_temp_dir, paths) = setup_paths();
let response = execute_daemon_request(
&paths,
DaemonRequest::Approve {
request_id: "not-a-uuid".to_owned(),
},
);
assert!(matches!(response, DaemonResponse::Error { .. }));
let DaemonResponse::Error { error } = response else {
unreachable!("validated above");
};
assert!(error.contains("invalid request id `not-a-uuid`"));
assert!(error.contains("gloves requests list"));
clear_daemon_token_env();
}
#[test]
fn execute_daemon_request_inner_honors_actor_override() {
let (_temp_dir, paths) = setup_paths();
let actor_main = AgentId::new("agent-main").expect("actor");
let actor_other = AgentId::new("agent-other").expect("actor");
let (message, data) = execute_daemon_request_with_actor(
&paths,
DaemonRequest::Set {
name: "alpha".to_owned(),
generate: false,
value: Some("secret-value".to_owned()),
ttl_days: None,
},
&actor_main,
)
.expect("set");
assert_eq!(message, "ok");
assert_eq!(expect_data(data)["id"], "alpha");
let error = execute_daemon_request_with_actor(
&paths,
DaemonRequest::Get {
name: "alpha".to_owned(),
},
&actor_other,
)
.expect_err("other agent should not decrypt");
assert!(matches!(
error,
GlovesError::Unauthorized | GlovesError::Crypto(_)
));
let (message, data) = execute_daemon_request_with_actor(
&paths,
DaemonRequest::Get {
name: "alpha".to_owned(),
},
&actor_main,
)
.expect("main get");
assert_eq!(message, "ok");
assert_eq!(expect_data(data)["secret"], "secret-value");
}
}