use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use std::time::{Duration, Instant};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use bincode::{Decode, Encode};
use reqwest::Method;
use subtle::ConstantTimeEq;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::Notify;
use tokio::task::JoinSet;
use zeroize::Zeroizing;
use crate::access::{AccessLevel, access_denied, required_access_for};
use crate::error::{Error, Result};
use crate::transport::{BoxFuture, RawResponse, RequestExecutor, RequestSpec};
const MAX_REQUEST_FRAME: u32 = 64 * 1024;
const MAX_RESPONSE_FRAME: u32 = 8 * 1024 * 1024;
const MAX_RESPONSE_BODY: usize = MAX_RESPONSE_FRAME as usize - 4096;
const FRAME_BODY_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_AUTH_ATTEMPTS: u32 = 5;
const MAX_CONNECTIONS: usize = 64;
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_LABEL_LEN: usize = 80;
const TOKEN_BYTES: usize = 32;
const MSG_AUTH_FAILED: &str = "authentication failed: wrong or already-used token";
const MSG_AUTH_REQUIRED: &str =
"authorize first: present the agent's token or request operator approval";
const MSG_NO_TOKEN: &str =
"this agent has no token; request operator approval (RequestAuth) instead";
const MSG_DENIED: &str = "the operator denied this connection";
pub struct AuthToken(Zeroizing<String>);
impl AuthToken {
pub fn generate() -> Result<Self> {
let mut bytes = Zeroizing::new([0u8; TOKEN_BYTES]);
getrandom::fill(bytes.as_mut_slice())
.map_err(|e| Error::agent(format!("could not read OS randomness for token: {e}")))?;
Ok(Self(Zeroizing::new(
URL_SAFE_NO_PAD.encode(bytes.as_slice()),
)))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
fn verify(&self, candidate: &str) -> bool {
self.0.as_bytes().ct_eq(candidate.as_bytes()).into()
}
}
impl std::fmt::Debug for AuthToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("AuthToken").field(&"<redacted>").finish()
}
}
#[derive(Encode, Decode)]
pub enum AgentRequest {
Authenticate(String),
RequestAuth {
label: Option<String>,
requested: AccessLevel,
},
Ping,
Execute(WireRequest),
}
impl std::fmt::Debug for AgentRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Authenticate(_) => f.write_str("Authenticate(<redacted>)"),
Self::RequestAuth { label, requested } => f
.debug_struct("RequestAuth")
.field("label", label)
.field("requested", requested)
.finish(),
Self::Ping => f.write_str("Ping"),
Self::Execute(wire) => f.debug_tuple("Execute").field(wire).finish(),
}
}
}
#[derive(Debug, Encode, Decode)]
pub enum AgentResponse {
Authenticated(Capabilities),
AuthPending,
AuthDenied(String),
Pong,
Executed(WireResponse),
Failed(String),
}
#[derive(Debug, Clone, Encode, Decode)]
pub struct Capabilities {
pub base_url: String,
pub access: AccessLevel,
}
#[derive(Debug, Encode, Decode)]
pub struct WireRequest {
method: String,
path: String,
query: Vec<(String, String)>,
body: Option<Vec<u8>>,
requires_auth: bool,
}
#[derive(Debug, Encode, Decode)]
pub struct WireResponse {
status: u16,
retry_after_millis: Option<u64>,
body: Vec<u8>,
}
#[must_use]
pub fn default_socket_path() -> PathBuf {
std::env::var_os("XDG_RUNTIME_DIR").map_or_else(
|| {
std::env::temp_dir()
.join("revolutx-agent")
.join("agent.sock")
},
|dir| PathBuf::from(dir).join("revolutx-agent.sock"),
)
}
async fn write_frame<T: Encode + Sync>(stream: &mut UnixStream, value: &T) -> std::io::Result<()> {
let bytes = Zeroizing::new(
bincode::encode_to_vec(value, bincode::config::standard())
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?,
);
let len = u32::try_from(bytes.len()).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "frame too large to encode")
})?;
stream.write_all(&len.to_be_bytes()).await?;
stream.write_all(&bytes).await?;
stream.flush().await?;
Ok(())
}
async fn read_frame_bytes(reader: &mut UnixStream, max_len: u32) -> std::io::Result<Vec<u8>> {
let mut len_buf = [0u8; 4];
reader.read_exact(&mut len_buf).await?;
let len = u32::from_be_bytes(len_buf);
if len > max_len {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"frame exceeds the maximum size",
));
}
let mut buf = vec![0u8; len as usize];
tokio::time::timeout(FRAME_BODY_TIMEOUT, reader.read_exact(&mut buf))
.await
.map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "frame body timed out"))??;
Ok(buf)
}
fn decode<T: Decode<()>>(bytes: &[u8]) -> std::io::Result<T> {
bincode::decode_from_slice(bytes, bincode::config::standard())
.map(|(value, _)| value)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
#[derive(Debug)]
pub enum AuthOutcome {
Authorized(Capabilities),
Pending,
Denied(String),
}
#[derive(Debug)]
pub struct AgentExecutor {
conn: tokio::sync::Mutex<UnixStream>,
caps: OnceLock<Capabilities>,
authenticated: AtomicBool,
broken: AtomicBool,
}
impl AgentExecutor {
pub async fn connect(socket_path: impl AsRef<Path>) -> Result<Self> {
let socket_path = socket_path.as_ref();
let stream = UnixStream::connect(socket_path).await.map_err(|e| {
Error::agent(format!(
"cannot connect to agent at {}: {e}",
socket_path.display()
))
})?;
Ok(Self {
conn: tokio::sync::Mutex::new(stream),
caps: OnceLock::new(),
authenticated: AtomicBool::new(false),
broken: AtomicBool::new(false),
})
}
pub async fn authenticate(&self, token: &str) -> Result<()> {
match self
.round_trip(&AgentRequest::Authenticate(token.to_owned()))
.await?
{
AgentResponse::Authenticated(caps) => {
let _ = self.caps.set(caps);
self.authenticated.store(true, Ordering::Release);
Ok(())
}
AgentResponse::Failed(message) | AgentResponse::AuthDenied(message) => {
Err(Error::agent(message))
}
AgentResponse::AuthPending | AgentResponse::Pong | AgentResponse::Executed(_) => Err(
Error::agent("agent did not answer the authentication handshake"),
),
}
}
pub async fn request_authorization(
&self,
label: Option<&str>,
requested: AccessLevel,
) -> Result<AuthOutcome> {
let request = AgentRequest::RequestAuth {
label: label.map(str::to_owned),
requested,
};
match self.round_trip(&request).await? {
AgentResponse::Authenticated(caps) => {
let _ = self.caps.set(caps.clone());
self.authenticated.store(true, Ordering::Release);
Ok(AuthOutcome::Authorized(caps))
}
AgentResponse::AuthPending => Ok(AuthOutcome::Pending),
AgentResponse::AuthDenied(message) => Ok(AuthOutcome::Denied(message)),
AgentResponse::Failed(message) => Err(Error::agent(message)),
AgentResponse::Pong | AgentResponse::Executed(_) => {
Err(Error::agent("unexpected agent response to a RequestAuth"))
}
}
}
#[must_use]
pub fn is_session_authenticated(&self) -> bool {
self.authenticated.load(Ordering::Acquire)
}
#[must_use]
pub fn access(&self) -> AccessLevel {
self.caps
.get()
.map_or(AccessLevel::Market, |caps| caps.access)
}
pub async fn ping(&self) -> Result<()> {
match self.round_trip(&AgentRequest::Ping).await? {
AgentResponse::Pong => Ok(()),
AgentResponse::Failed(message) => Err(Error::agent(message)),
_ => Err(Error::agent("agent did not answer ping with Pong")),
}
}
async fn round_trip(&self, request: &AgentRequest) -> Result<AgentResponse> {
if self.broken.load(Ordering::Acquire) {
return Err(Error::agent_unusable(
"connection was marked broken by an earlier transport error; reconnect to continue",
));
}
let bytes = {
let mut conn = self.conn.lock().await;
if let Err(e) = write_frame(&mut conn, request).await {
self.broken.store(true, Ordering::Release);
return Err(Error::agent_unusable(format!(
"failed to send request to agent: {e}"
)));
}
match read_frame_bytes(&mut conn, MAX_RESPONSE_FRAME).await {
Ok(bytes) => bytes,
Err(e) => {
self.broken.store(true, Ordering::Release);
return Err(Error::agent_unusable(format!(
"failed to read agent response: {e}"
)));
}
}
};
decode(&bytes).map_err(|e| Error::agent(format!("invalid agent response: {e}")))
}
}
impl RequestExecutor for AgentExecutor {
fn execute(&self, request: RequestSpec) -> BoxFuture<'_, Result<RawResponse>> {
let wire = WireRequest {
method: request.method().as_str().to_owned(),
path: request.path().to_owned(),
query: request.query().to_vec(),
body: request.body().map(<[u8]>::to_vec),
requires_auth: request.requires_auth(),
};
Box::pin(async move {
match self.round_trip(&AgentRequest::Execute(wire)).await? {
AgentResponse::Executed(w) => Ok(RawResponse {
status: w.status,
retry_after: w.retry_after_millis.map(Duration::from_millis),
body: w.body,
}),
AgentResponse::Failed(message) => Err(Error::agent(message)),
AgentResponse::Pong
| AgentResponse::Authenticated(_)
| AgentResponse::AuthPending
| AgentResponse::AuthDenied(_) => {
Err(Error::agent("unexpected agent response to an Execute"))
}
}
})
}
fn base_url(&self) -> &str {
self.caps.get().map_or("", |caps| caps.base_url.as_str())
}
fn is_authenticated(&self) -> bool {
self.is_session_authenticated()
}
}
pub type ConnId = u64;
#[derive(Debug, Clone, Copy)]
pub struct PeerCred {
pub uid: u32,
pub gid: u32,
pub pid: Option<i32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthMethod {
Token,
Manual,
}
impl AuthMethod {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Token => "token",
Self::Manual => "manual",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnState {
Connected,
Pending {
requested: AccessLevel,
},
Authorized {
access: AccessLevel,
},
Denied,
}
#[derive(Debug, Clone)]
pub struct ConnectionInfo {
pub id: ConnId,
pub peer: PeerCred,
pub label: Option<String>,
pub method: Option<AuthMethod>,
pub state: ConnState,
pub since: Instant,
}
struct Gate {
token: AuthToken,
consumed: bool,
}
struct Entry {
peer: PeerCred,
label: Option<String>,
method: Option<AuthMethod>,
state: ConnState,
since: Instant,
}
struct Registry {
conns: BTreeMap<ConnId, Entry>,
next_id: ConnId,
ceiling: AccessLevel,
token: Option<Gate>,
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(PoisonError::into_inner)
}
#[derive(Clone)]
pub struct AgentControl {
registry: Arc<Mutex<Registry>>,
shutdown: Arc<Notify>,
}
impl AgentControl {
#[must_use]
pub fn list(&self) -> Vec<ConnectionInfo> {
let reg = lock(&self.registry);
reg.conns
.iter()
.map(|(&id, e)| ConnectionInfo {
id,
peer: e.peer,
label: e.label.clone(),
method: e.method,
state: e.state,
since: e.since,
})
.collect()
}
pub fn grant(&self, id: ConnId, access: Option<AccessLevel>) -> Result<AccessLevel> {
let mut reg = lock(&self.registry);
let ceiling = reg.ceiling;
let entry = reg
.conns
.get_mut(&id)
.ok_or_else(|| Error::agent(format!("no connection #{id}")))?;
let requested = match entry.state {
ConnState::Pending { requested } => Some(requested),
_ => None,
};
let level = access.or(requested).ok_or_else(|| {
Error::agent(format!(
"connection #{id} has not requested an access level; specify one, e.g. `grant {id} market`"
))
})?;
if level > ceiling {
return Err(Error::agent(format!(
"cannot grant `{}`: the agent's ceiling is `{}`",
level.as_str(),
ceiling.as_str()
)));
}
entry.state = ConnState::Authorized { access: level };
entry.method = Some(AuthMethod::Manual);
drop(reg);
Ok(level)
}
pub fn deny(&self, id: ConnId) -> Result<()> {
let mut reg = lock(&self.registry);
let entry = reg
.conns
.get_mut(&id)
.ok_or_else(|| Error::agent(format!("no connection #{id}")))?;
entry.state = ConnState::Denied;
drop(reg);
Ok(())
}
#[must_use]
pub fn active_count(&self) -> usize {
let reg = lock(&self.registry);
reg.conns
.values()
.filter(|e| matches!(e.state, ConnState::Authorized { .. }))
.count()
}
pub fn shutdown(&self) {
self.shutdown.notify_one();
}
}
pub struct AgentServer {
executor: Arc<dyn RequestExecutor>,
registry: Arc<Mutex<Registry>>,
shutdown: Arc<Notify>,
}
impl AgentServer {
#[must_use]
pub fn new(
executor: Arc<dyn RequestExecutor>,
access: AccessLevel,
token: Option<AuthToken>,
) -> (Self, AgentControl) {
let registry = Arc::new(Mutex::new(Registry {
conns: BTreeMap::new(),
next_id: 1,
ceiling: access,
token: token.map(|token| Gate {
token,
consumed: false,
}),
}));
let shutdown = Arc::new(Notify::new());
let control = AgentControl {
registry: Arc::clone(®istry),
shutdown: Arc::clone(&shutdown),
};
(
Self {
executor,
registry,
shutdown,
},
control,
)
}
pub async fn run(self, socket_path: &Path) -> Result<()> {
let listener = bind(socket_path).await?;
let mut sessions = JoinSet::new();
loop {
tokio::select! {
() = self.shutdown.notified() => break,
accepted = listener.accept() => {
while sessions.try_join_next().is_some() {}
let Ok((stream, _addr)) = accepted else { continue };
if sessions.len() >= MAX_CONNECTIONS {
drop(stream);
continue;
}
let peer = read_peer_cred(&stream);
let id = register(&self.registry, peer);
let registry = Arc::clone(&self.registry);
let executor = Arc::clone(&self.executor);
sessions.spawn(async move {
handle_connection(stream, id, ®istry, executor.as_ref()).await;
lock(®istry).conns.remove(&id);
});
}
}
}
Ok(())
}
}
fn read_peer_cred(stream: &UnixStream) -> PeerCred {
stream.peer_cred().map_or(
PeerCred {
uid: u32::MAX,
gid: u32::MAX,
pid: None,
},
|c| PeerCred {
uid: c.uid(),
gid: c.gid(),
pid: c.pid(),
},
)
}
fn register(registry: &Mutex<Registry>, peer: PeerCred) -> ConnId {
let mut reg = lock(registry);
let id = reg.next_id;
reg.next_id += 1;
reg.conns.insert(
id,
Entry {
peer,
label: None,
method: None,
state: ConnState::Connected,
since: Instant::now(),
},
);
id
}
async fn bind(socket_path: &Path) -> Result<UnixListener> {
ensure_parent_dir(socket_path)?;
if socket_path.exists() {
if UnixStream::connect(socket_path).await.is_ok() {
return Err(Error::agent(format!(
"an agent is already listening on {}",
socket_path.display()
)));
}
std::fs::remove_file(socket_path)
.map_err(|e| Error::agent(format!("could not remove stale socket: {e}")))?;
}
let listener = UnixListener::bind(socket_path)
.map_err(|e| Error::agent(format!("could not bind {}: {e}", socket_path.display())))?;
set_socket_permissions(socket_path)?;
Ok(listener)
}
#[cfg(unix)]
fn ensure_parent_dir(socket_path: &Path) -> Result<()> {
use std::os::unix::fs::DirBuilderExt;
let Some(parent) = socket_path.parent() else {
return Ok(());
};
if parent.as_os_str().is_empty() {
return Ok(());
}
if parent.exists() {
return check_dir_is_safe(parent);
}
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o711)
.create(parent)
.map_err(|e| Error::agent(format!("could not create {}: {e}", parent.display())))
}
#[cfg(unix)]
fn check_dir_is_safe(parent: &Path) -> Result<()> {
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(parent)
.map_err(|e| Error::agent(format!("could not stat {}: {e}", parent.display())))?;
let euid = unsafe { libc::geteuid() };
let owner = meta.uid();
if owner != euid && owner != 0 {
return Err(Error::agent(format!(
"refusing to use socket directory {}: owned by uid {owner}, not this agent \
(uid {euid}) or root — another user could replace the socket",
parent.display()
)));
}
let mode = meta.mode();
let other_writable = mode & 0o022 != 0; let sticky = mode & 0o1000 != 0;
if other_writable && !sticky {
return Err(Error::agent(format!(
"refusing to use socket directory {}: group/other-writable (mode {:o}) without \
the sticky bit — another user could replace the socket",
parent.display(),
mode & 0o7777
)));
}
Ok(())
}
#[cfg(not(unix))]
fn ensure_parent_dir(_socket_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn set_socket_permissions(socket_path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o666))
.map_err(|e| Error::agent(format!("could not set socket permissions: {e}")))
}
#[cfg(not(unix))]
fn set_socket_permissions(_socket_path: &Path) -> Result<()> {
Ok(())
}
async fn handle_connection(
mut stream: UnixStream,
id: ConnId,
registry: &Mutex<Registry>,
executor: &dyn RequestExecutor,
) {
let mut token_attempts: u32 = 0;
let mut awaiting_first_frame = true;
loop {
let read = read_frame_bytes(&mut stream, MAX_REQUEST_FRAME);
let bytes = if awaiting_first_frame {
match tokio::time::timeout(HANDSHAKE_TIMEOUT, read).await {
Ok(Ok(bytes)) => bytes,
_ => break,
}
} else {
match read.await {
Ok(bytes) => bytes,
Err(_) => break,
}
};
awaiting_first_frame = false;
let bytes = Zeroizing::new(bytes);
let request: AgentRequest = match decode(&bytes) {
Ok(request) => request,
Err(_) => break,
};
let response = match request {
AgentRequest::Authenticate(candidate) => {
let candidate = Zeroizing::new(candidate);
match authorize_token(registry, id, executor, candidate.as_str()) {
TokenOutcome::Granted(caps) => AgentResponse::Authenticated(caps),
TokenOutcome::NoToken => AgentResponse::Failed(MSG_NO_TOKEN.to_owned()),
TokenOutcome::Bad => {
token_attempts += 1;
if token_attempts >= MAX_AUTH_ATTEMPTS {
let _ = write_frame(
&mut stream,
&AgentResponse::Failed(MSG_AUTH_FAILED.to_owned()),
)
.await;
break;
}
AgentResponse::Failed(MSG_AUTH_FAILED.to_owned())
}
}
}
AgentRequest::RequestAuth { label, requested } => {
request_manual(registry, id, executor, label, requested)
}
AgentRequest::Ping => match authorized_access(registry, id) {
Some(_) => AgentResponse::Pong,
None => AgentResponse::Failed(MSG_AUTH_REQUIRED.to_owned()),
},
AgentRequest::Execute(wire) => match authorized_access(registry, id) {
Some(access) => execute_forwarded(executor, wire, access).await,
None => AgentResponse::Failed(MSG_AUTH_REQUIRED.to_owned()),
},
};
if write_frame(&mut stream, &response).await.is_err() {
break;
}
}
}
fn authorized_access(registry: &Mutex<Registry>, id: ConnId) -> Option<AccessLevel> {
let reg = lock(registry);
match reg.conns.get(&id)?.state {
ConnState::Authorized { access } => Some(access),
_ => None,
}
}
enum TokenOutcome {
Granted(Capabilities),
NoToken,
Bad,
}
fn authorize_token(
registry: &Mutex<Registry>,
id: ConnId,
executor: &dyn RequestExecutor,
candidate: &str,
) -> TokenOutcome {
let ceiling;
{
let mut reg = lock(registry);
ceiling = reg.ceiling;
match reg.token.as_mut() {
None => return TokenOutcome::NoToken,
Some(gate) => {
if gate.consumed || !gate.token.verify(candidate) {
return TokenOutcome::Bad;
}
gate.consumed = true;
}
}
if let Some(entry) = reg.conns.get_mut(&id) {
entry.state = ConnState::Authorized { access: ceiling };
entry.method = Some(AuthMethod::Token);
}
}
TokenOutcome::Granted(Capabilities {
base_url: executor.base_url().to_owned(),
access: ceiling,
})
}
fn request_manual(
registry: &Mutex<Registry>,
id: ConnId,
executor: &dyn RequestExecutor,
label: Option<String>,
requested: AccessLevel,
) -> AgentResponse {
let base_url = executor.base_url().to_owned();
let mut reg = lock(registry);
let ceiling = reg.ceiling;
let response = {
let Some(entry) = reg.conns.get_mut(&id) else {
return AgentResponse::Failed(MSG_AUTH_REQUIRED.to_owned());
};
match entry.state {
ConnState::Authorized { access } => {
AgentResponse::Authenticated(Capabilities { base_url, access })
}
ConnState::Denied => AgentResponse::AuthDenied(MSG_DENIED.to_owned()),
ConnState::Connected | ConnState::Pending { .. } => {
entry.state = ConnState::Pending {
requested: requested.min(ceiling),
};
if let Some(label) = label {
entry.label = Some(sanitize_label(&label));
}
entry.method = Some(AuthMethod::Manual);
AgentResponse::AuthPending
}
}
};
drop(reg);
response
}
fn sanitize_label(label: &str) -> String {
let mut out: String = label
.chars()
.take(MAX_LABEL_LEN)
.map(|c| if c.is_control() { '·' } else { c })
.collect();
if label.chars().count() > MAX_LABEL_LEN {
out.push('…');
}
out
}
async fn execute_forwarded(
executor: &dyn RequestExecutor,
wire: WireRequest,
access: AccessLevel,
) -> AgentResponse {
let Ok(method) = Method::from_bytes(wire.method.as_bytes()) else {
return AgentResponse::Failed(format!("invalid HTTP method '{}'", wire.method));
};
let required = required_access_for(method.as_str(), &wire.path);
if !access.permits(required) {
return AgentResponse::Failed(access_denied(required, access));
}
let spec =
RequestSpec::from_parts(method, wire.path, wire.query, wire.body, wire.requires_auth);
match executor.execute(spec).await {
Ok(raw) if raw.body.len() > MAX_RESPONSE_BODY => AgentResponse::Failed(format!(
"response too large: {} bytes exceeds the agent's {} MiB limit; narrow the request",
raw.body.len(),
MAX_RESPONSE_FRAME / (1024 * 1024),
)),
Ok(raw) => AgentResponse::Executed(WireResponse {
status: raw.status,
retry_after_millis: raw
.retry_after
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)),
body: raw.body,
}),
Err(e) => AgentResponse::Failed(e.to_string()),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn check_dir_is_safe_accepts_owner_managed_and_traversable_dirs() {
use std::os::unix::fs::PermissionsExt;
for mode in [0o700, 0o711, 0o755] {
let dir = tempfile::tempdir().unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(mode)).unwrap();
assert!(
check_dir_is_safe(dir.path()).is_ok(),
"mode {mode:o} should be accepted"
);
}
}
#[test]
fn check_dir_is_safe_rejects_world_writable_without_sticky() {
use std::os::unix::fs::PermissionsExt;
for mode in [0o777, 0o733, 0o770] {
let dir = tempfile::tempdir().unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(mode)).unwrap();
assert!(
check_dir_is_safe(dir.path()).is_err(),
"mode {mode:o} should be rejected"
);
}
}
#[test]
fn check_dir_is_safe_accepts_sticky_world_writable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o1777)).unwrap();
assert!(check_dir_is_safe(dir.path()).is_ok());
}
#[test]
fn sanitize_label_strips_control_chars_and_truncates() {
assert_eq!(
sanitize_label("collector\r\x1b[2Kmalicious"),
"collector··[2Kmalicious"
);
assert_eq!(sanitize_label("revolutx-collector"), "revolutx-collector");
let long = "x".repeat(MAX_LABEL_LEN + 40);
let out = sanitize_label(&long);
assert_eq!(out.chars().count(), MAX_LABEL_LEN + 1);
assert!(out.ends_with('…'));
}
#[derive(Debug)]
struct EchoExecutor;
impl RequestExecutor for EchoExecutor {
fn execute(&self, request: RequestSpec) -> BoxFuture<'_, Result<RawResponse>> {
let body = request.path().as_bytes().to_vec();
Box::pin(async move {
Ok(RawResponse {
status: 200,
retry_after: None,
body,
})
})
}
#[allow(clippy::unnecessary_literal_bound)]
fn base_url(&self) -> &str {
"http://stub/api/1.0"
}
fn is_authenticated(&self) -> bool {
true
}
}
async fn wait_for(path: &Path) {
for _ in 0..200 {
if path.exists() {
return;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("socket never appeared");
}
fn spawn_agent(
socket: PathBuf,
ceiling: AccessLevel,
token: Option<AuthToken>,
) -> (AgentControl, tokio::task::JoinHandle<Result<()>>) {
let (server, control) = AgentServer::new(Arc::new(EchoExecutor), ceiling, token);
let handle = tokio::spawn(async move { server.run(&socket).await });
(control, handle)
}
fn only_id(control: &AgentControl) -> ConnId {
let conns = control.list();
assert_eq!(conns.len(), 1, "expected exactly one connection");
conns[0].id
}
#[test]
fn token_generate_is_unique_and_verifies_in_constant_time() {
let a = AuthToken::generate().unwrap();
let b = AuthToken::generate().unwrap();
assert_ne!(a.as_str(), b.as_str());
assert!(a.verify(a.as_str()));
assert!(!a.verify(b.as_str()));
assert!(!a.verify(""));
assert!(!format!("{a:?}").contains(a.as_str()));
}
#[tokio::test]
async fn token_auth_round_trips_and_reports_capabilities() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let token = AuthToken::generate().unwrap();
let secret = token.as_str().to_owned();
let (control, server) = spawn_agent(socket.clone(), AccessLevel::View, Some(token));
wait_for(&socket).await;
let exec = AgentExecutor::connect(&socket).await.unwrap();
assert!(!exec.is_session_authenticated());
assert!(exec.execute(RequestSpec::get("/tickers")).await.is_err());
exec.authenticate(&secret).await.unwrap();
assert!(exec.is_session_authenticated());
assert_eq!(exec.base_url(), "http://stub/api/1.0");
assert_eq!(exec.access(), AccessLevel::View);
let raw = exec
.execute(RequestSpec::get("/orders/active"))
.await
.unwrap();
assert_eq!(raw.body, b"/orders/active");
let info = &control.list()[0];
assert!(matches!(
info.state,
ConnState::Authorized {
access: AccessLevel::View
}
));
assert_eq!(info.method, Some(AuthMethod::Token));
server.abort();
}
#[tokio::test]
async fn token_is_single_use_across_connections() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let token = AuthToken::generate().unwrap();
let secret = token.as_str().to_owned();
let (_control, server) = spawn_agent(socket.clone(), AccessLevel::View, Some(token));
wait_for(&socket).await;
let first = AgentExecutor::connect(&socket).await.unwrap();
first.authenticate(&secret).await.unwrap();
let second = AgentExecutor::connect(&socket).await.unwrap();
assert!(second.authenticate(&secret).await.is_err());
first.ping().await.unwrap();
server.abort();
}
#[tokio::test]
async fn manual_pending_then_grant_authorizes_and_enforces_access() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let (control, server) = spawn_agent(socket.clone(), AccessLevel::Trading, None);
wait_for(&socket).await;
let exec = AgentExecutor::connect(&socket).await.unwrap();
assert!(exec.authenticate("anything").await.is_err());
let outcome = exec
.request_authorization(Some("collector"), AccessLevel::Market)
.await
.unwrap();
assert!(matches!(outcome, AuthOutcome::Pending));
let info = control.list()[0].clone();
assert_eq!(info.label.as_deref(), Some("collector"));
assert!(matches!(
info.state,
ConnState::Pending {
requested: AccessLevel::Market
}
));
assert_ne!(info.peer.uid, u32::MAX, "peer uid should be readable");
assert_eq!(control.grant(info.id, None).unwrap(), AccessLevel::Market);
let outcome = exec
.request_authorization(Some("collector"), AccessLevel::Market)
.await
.unwrap();
assert!(matches!(outcome, AuthOutcome::Authorized(_)));
assert_eq!(exec.access(), AccessLevel::Market);
assert!(exec.execute(RequestSpec::get("/tickers")).await.is_ok());
assert!(exec.execute(RequestSpec::get("/balances")).await.is_err());
server.abort();
}
#[tokio::test]
async fn manual_deny_is_reported_and_refuses_requests() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let (control, server) = spawn_agent(socket.clone(), AccessLevel::Market, None);
wait_for(&socket).await;
let exec = AgentExecutor::connect(&socket).await.unwrap();
exec.request_authorization(None, AccessLevel::Market)
.await
.unwrap();
control.deny(only_id(&control)).unwrap();
let outcome = exec
.request_authorization(None, AccessLevel::Market)
.await
.unwrap();
assert!(matches!(outcome, AuthOutcome::Denied(_)));
assert!(exec.execute(RequestSpec::get("/tickers")).await.is_err());
server.abort();
}
#[tokio::test]
async fn two_clients_authorized_at_different_levels() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let (control, server) = spawn_agent(socket.clone(), AccessLevel::View, None);
wait_for(&socket).await;
let a = AgentExecutor::connect(&socket).await.unwrap();
let b = AgentExecutor::connect(&socket).await.unwrap();
a.request_authorization(Some("a"), AccessLevel::Market)
.await
.unwrap();
b.request_authorization(Some("b"), AccessLevel::View)
.await
.unwrap();
for info in control.list() {
let level = if info.label.as_deref() == Some("a") {
AccessLevel::Market
} else {
AccessLevel::View
};
control.grant(info.id, Some(level)).unwrap();
}
a.request_authorization(Some("a"), AccessLevel::Market)
.await
.unwrap();
b.request_authorization(Some("b"), AccessLevel::View)
.await
.unwrap();
assert!(a.execute(RequestSpec::get("/balances")).await.is_err());
assert!(b.execute(RequestSpec::get("/balances")).await.is_ok());
assert_eq!(control.active_count(), 2);
server.abort();
}
#[tokio::test]
async fn grant_above_ceiling_is_refused() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let (control, server) = spawn_agent(socket.clone(), AccessLevel::Market, None);
wait_for(&socket).await;
let exec = AgentExecutor::connect(&socket).await.unwrap();
exec.request_authorization(None, AccessLevel::Market)
.await
.unwrap();
let id = only_id(&control);
assert!(control.grant(id, Some(AccessLevel::Trading)).is_err());
assert!(matches!(control.list()[0].state, ConnState::Pending { .. }));
server.abort();
}
#[tokio::test]
async fn active_count_drops_when_the_client_leaves() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let (control, server) = spawn_agent(socket.clone(), AccessLevel::Market, None);
wait_for(&socket).await;
let exec = AgentExecutor::connect(&socket).await.unwrap();
exec.request_authorization(None, AccessLevel::Market)
.await
.unwrap();
control.grant(only_id(&control), None).unwrap();
exec.request_authorization(None, AccessLevel::Market)
.await
.unwrap();
assert_eq!(control.active_count(), 1);
drop(exec);
for _ in 0..200 {
if control.active_count() == 0 {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert_eq!(control.active_count(), 0);
server.abort();
}
#[tokio::test]
async fn shutdown_stops_the_server() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let (control, server) = spawn_agent(socket.clone(), AccessLevel::Market, None);
wait_for(&socket).await;
control.shutdown();
let outcome = tokio::time::timeout(Duration::from_secs(2), server)
.await
.expect("server should return after shutdown");
assert!(outcome.unwrap().is_ok());
}
#[derive(Debug)]
struct BigBodyExecutor;
impl RequestExecutor for BigBodyExecutor {
fn execute(&self, request: RequestSpec) -> BoxFuture<'_, Result<RawResponse>> {
let body = if request.path().starts_with("/candles") {
vec![b'x'; MAX_RESPONSE_BODY + 1]
} else {
request.path().as_bytes().to_vec()
};
Box::pin(async move {
Ok(RawResponse {
status: 200,
retry_after: None,
body,
})
})
}
#[allow(clippy::unnecessary_literal_bound)]
fn base_url(&self) -> &str {
"http://stub/api/1.0"
}
fn is_authenticated(&self) -> bool {
true
}
}
#[tokio::test]
async fn oversized_response_is_refused_and_the_connection_survives() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("agent.sock");
let (server, control) =
AgentServer::new(Arc::new(BigBodyExecutor), AccessLevel::Market, None);
let task = tokio::spawn({
let socket = socket.clone();
async move { server.run(&socket).await }
});
wait_for(&socket).await;
let exec = AgentExecutor::connect(&socket).await.unwrap();
exec.request_authorization(None, AccessLevel::Market)
.await
.unwrap();
control.grant(control.list()[0].id, None).unwrap();
exec.request_authorization(None, AccessLevel::Market)
.await
.unwrap();
let err = exec
.execute(RequestSpec::get("/candles/BTC-USD"))
.await
.unwrap_err();
assert!(err.to_string().contains("too large"), "got: {err}");
let raw = exec.execute(RequestSpec::get("/tickers")).await.unwrap();
assert_eq!(raw.body, b"/tickers");
assert!(exec.execute(RequestSpec::get("/tickers")).await.is_ok());
task.abort();
}
}