mod handlers;
mod types;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use axum::extract::{Request, State};
use axum::http::StatusCode;
use axum::http::header::AUTHORIZATION;
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post, put};
use axum::{Json, Router};
use clap::Args;
use futures::StreamExt;
use tokio::sync::RwLock;
use tower_http::catch_panic::CatchPanicLayer;
use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
use boxlite::runtime::options::{NetworkConfig, NetworkMode};
use boxlite::{
BoxCommand, BoxInfo, BoxOptions, BoxliteRuntime, ExecStdin, Execution, LiteBox, NetworkSpec,
RootfsSpec,
};
use crate::cli::GlobalFlags;
use crate::defaults::{LOCAL_SERVE_HOST, LOCAL_SERVE_PORT};
use self::types::{BoxResponse, CreateBoxRequest, ErrorBody, ErrorDetail, ExecRequest};
#[derive(Args, Debug)]
pub struct ServeArgs {
#[arg(long, default_value_t = LOCAL_SERVE_PORT)]
pub port: u16,
#[arg(long, default_value_t = LOCAL_SERVE_HOST.to_string())]
pub host: String,
#[arg(long, env = "BOXLITE_SERVE_API_KEY")]
pub api_key: Option<String>,
}
struct AppState {
runtime: BoxliteRuntime,
boxes: RwLock<HashMap<String, Arc<LiteBox>>>,
executions: RwLock<HashMap<String, Arc<ActiveExecution>>>,
api_key: Option<String>,
}
pub(in crate::commands::serve) struct ActiveExecution {
box_id: String,
execution: Execution,
stdin: tokio::sync::Mutex<Option<ExecStdin>>,
stdout_bus: Arc<BacklogBroadcast>,
stderr_bus: Arc<BacklogBroadcast>,
attach: tokio::sync::Mutex<AttachState>,
done: std::sync::atomic::AtomicBool,
done_tx: tokio::sync::watch::Sender<bool>,
exit_code: std::sync::atomic::AtomicI32,
done_at: std::sync::Mutex<Option<Instant>>,
created_at: Instant,
}
struct AttachState {
connected: bool,
last_disconnect_at: Option<Instant>,
signaled_hup: bool,
signaled_term: bool,
reaping_kill: bool,
escalating: bool,
}
const ATTACH_BROADCAST_CAPACITY: usize = 256;
const BACKLOG_BYTE_CAP: usize = 256 * 1024;
struct BacklogBroadcast {
tx: tokio::sync::broadcast::Sender<Vec<u8>>,
state: std::sync::Mutex<BacklogState>,
cap: usize,
}
struct BacklogState {
backlog: std::collections::VecDeque<Vec<u8>>,
total_bytes: usize,
}
impl BacklogBroadcast {
fn new(capacity: usize, backlog_cap: usize) -> Self {
let (tx, _) = tokio::sync::broadcast::channel(capacity);
Self {
tx,
state: std::sync::Mutex::new(BacklogState {
backlog: std::collections::VecDeque::new(),
total_bytes: 0,
}),
cap: backlog_cap,
}
}
fn send(&self, data: Vec<u8>) {
let mut state = self.state.lock().unwrap();
state.total_bytes += data.len();
state.backlog.push_back(data.clone());
while state.total_bytes > self.cap && state.backlog.len() > 1 {
if let Some(old) = state.backlog.pop_front() {
state.total_bytes -= old.len();
} else {
break;
}
}
let _ = self.tx.send(data);
}
fn subscribe(&self) -> BacklogReceiver {
let state = self.state.lock().unwrap();
let replay: std::collections::VecDeque<Vec<u8>> = state.backlog.iter().cloned().collect();
let rx = self.tx.subscribe();
BacklogReceiver { replay, rx }
}
}
struct BacklogReceiver {
replay: std::collections::VecDeque<Vec<u8>>,
rx: tokio::sync::broadcast::Receiver<Vec<u8>>,
}
impl BacklogReceiver {
async fn recv(&mut self) -> Result<Vec<u8>, tokio::sync::broadcast::error::RecvError> {
if let Some(chunk) = self.replay.pop_front() {
return Ok(chunk);
}
self.rx.recv().await
}
fn try_recv(&mut self) -> Result<Vec<u8>, tokio::sync::broadcast::error::TryRecvError> {
if let Some(chunk) = self.replay.pop_front() {
return Ok(chunk);
}
self.rx.try_recv()
}
}
impl ActiveExecution {
fn new(box_id: String, mut execution: Execution, stdin: Option<ExecStdin>) -> Arc<Self> {
let stdout = execution.stdout();
let stderr = execution.stderr();
let stdout_bus = Arc::new(BacklogBroadcast::new(
ATTACH_BROADCAST_CAPACITY,
BACKLOG_BYTE_CAP,
));
let stderr_bus = Arc::new(BacklogBroadcast::new(
ATTACH_BROADCAST_CAPACITY,
BACKLOG_BYTE_CAP,
));
let (done_tx, _) = tokio::sync::watch::channel(false);
let now = Instant::now();
let active = Arc::new(Self {
box_id,
execution,
stdin: tokio::sync::Mutex::new(stdin),
stdout_bus: stdout_bus.clone(),
stderr_bus: stderr_bus.clone(),
attach: tokio::sync::Mutex::new(AttachState {
connected: false,
last_disconnect_at: Some(now),
signaled_hup: false,
signaled_term: false,
reaping_kill: false,
escalating: false,
}),
done: std::sync::atomic::AtomicBool::new(false),
done_tx,
exit_code: std::sync::atomic::AtomicI32::new(-1),
done_at: std::sync::Mutex::new(None),
created_at: now,
});
let stdout_handle = if let Some(mut out) = stdout {
let bus = stdout_bus;
Some(tokio::spawn(async move {
while let Some(line) = out.next().await {
bus.send(line.into_bytes());
}
}))
} else {
None
};
let stderr_handle = if let Some(mut err) = stderr {
let bus = stderr_bus;
Some(tokio::spawn(async move {
while let Some(line) = err.next().await {
bus.send(line.into_bytes());
}
}))
} else {
None
};
{
let active = Arc::clone(&active);
tokio::spawn(async move {
if let Ok(result) = active.execution.wait().await {
active
.exit_code
.store(result.exit_code, std::sync::atomic::Ordering::SeqCst);
}
if let Some(h) = stdout_handle {
let _ = h.await;
}
if let Some(h) = stderr_handle {
let _ = h.await;
}
*active.done_at.lock().unwrap() = Some(Instant::now());
active.done.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = active.done_tx.send(true);
});
}
active
}
pub(in crate::commands::serve) fn box_id(&self) -> &str {
&self.box_id
}
pub(in crate::commands::serve) fn stdout_bus(&self) -> &BacklogBroadcast {
&self.stdout_bus
}
pub(in crate::commands::serve) fn stderr_bus(&self) -> &BacklogBroadcast {
&self.stderr_bus
}
pub(in crate::commands::serve) fn stdin(&self) -> &tokio::sync::Mutex<Option<ExecStdin>> {
&self.stdin
}
pub(in crate::commands::serve) fn execution(&self) -> &Execution {
&self.execution
}
pub(in crate::commands::serve) fn is_done(&self) -> bool {
self.done.load(std::sync::atomic::Ordering::SeqCst)
}
pub(in crate::commands::serve) fn done_rx(&self) -> tokio::sync::watch::Receiver<bool> {
self.done_tx.subscribe()
}
pub(in crate::commands::serve) fn exit_code(&self) -> i32 {
self.exit_code.load(std::sync::atomic::Ordering::SeqCst)
}
pub(in crate::commands::serve) fn created_at(&self) -> Instant {
self.created_at
}
pub(in crate::commands::serve) fn should_retain(&self, now: Instant) -> bool {
if !self.is_done() {
return true;
}
let done_at = self.done_at.lock().unwrap();
match *done_at {
Some(at) => now.duration_since(at) <= COMPLETED_RETENTION_GRACE,
None => true,
}
}
pub(in crate::commands::serve) async fn mark_connected(&self) -> bool {
let mut s = self.attach.lock().await;
if s.connected || s.reaping_kill || s.escalating {
return false;
}
s.connected = true;
s.last_disconnect_at = None;
s.signaled_hup = false;
s.signaled_term = false;
true
}
pub(in crate::commands::serve) async fn mark_disconnected(&self) {
let mut s = self.attach.lock().await;
s.connected = false;
s.last_disconnect_at = Some(Instant::now());
}
pub(in crate::commands::serve) async fn mark_reaping_kill(&self) {
let mut s = self.attach.lock().await;
s.reaping_kill = true;
}
async fn is_reaping_kill(&self) -> bool {
let s = self.attach.lock().await;
s.reaping_kill
}
async fn try_escalate_hup(&self, now: Instant, reconnect_grace: std::time::Duration) -> bool {
let mut s = self.attach.lock().await;
if s.connected || s.signaled_hup || s.reaping_kill || s.escalating {
return false;
}
let Some(disc) = s.last_disconnect_at else {
return false;
};
if now.duration_since(disc) <= reconnect_grace {
return false;
}
s.signaled_hup = true;
s.escalating = true;
s.last_disconnect_at = Some(now);
true
}
async fn try_escalate_term(&self, now: Instant, shutdown_grace: std::time::Duration) -> bool {
let mut s = self.attach.lock().await;
if s.connected || !s.signaled_hup || s.signaled_term || s.reaping_kill || s.escalating {
return false;
}
let Some(disc) = s.last_disconnect_at else {
return false;
};
if now.duration_since(disc) <= shutdown_grace {
return false;
}
s.signaled_term = true;
s.escalating = true;
s.last_disconnect_at = Some(now);
true
}
async fn try_escalate_kill(&self, now: Instant, shutdown_grace: std::time::Duration) -> bool {
let mut s = self.attach.lock().await;
if s.connected || !s.signaled_term || s.reaping_kill {
return false;
}
let Some(disc) = s.last_disconnect_at else {
return false;
};
if now.duration_since(disc) <= shutdown_grace {
return false;
}
s.reaping_kill = true;
true
}
async fn finish_escalation(&self) {
let mut s = self.attach.lock().await;
s.escalating = false;
}
async fn escalation_failed_mark_doomed(&self) {
let mut s = self.attach.lock().await;
s.escalating = false;
s.reaping_kill = true;
}
}
const REAPER_TICK: std::time::Duration = std::time::Duration::from_secs(30);
const REAPER_SIGNAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const DEFAULT_RECONNECT_GRACE: std::time::Duration = std::time::Duration::from_secs(300);
const DEFAULT_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(30);
const DEFAULT_MAX_SESSION_LIFETIME: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
const COMPLETED_RETENTION_GRACE: std::time::Duration = std::time::Duration::from_secs(5 * 60);
fn resolve_duration(var: &str, fallback: std::time::Duration) -> std::time::Duration {
let raw = match std::env::var(var) {
Ok(s) if !s.is_empty() => s,
_ => return fallback,
};
let parsed = if let Some(rest) = raw.strip_suffix('h') {
rest.parse::<u64>()
.ok()
.map(|n| std::time::Duration::from_secs(n * 3600))
} else if let Some(rest) = raw.strip_suffix('m') {
rest.parse::<u64>()
.ok()
.map(|n| std::time::Duration::from_secs(n * 60))
} else if let Some(rest) = raw.strip_suffix('s') {
rest.parse::<u64>().ok().map(std::time::Duration::from_secs)
} else {
raw.parse::<u64>().ok().map(std::time::Duration::from_secs)
};
match parsed {
Some(d) => d,
None => {
tracing::warn!(env = var, value = %raw,
"invalid duration env var (use Ns/Nm/Nh), using default");
fallback
}
}
}
async fn reaper_loop(state: Arc<AppState>) {
let reconnect_grace = resolve_duration("BOXLITE_RECONNECT_GRACE", DEFAULT_RECONNECT_GRACE);
let shutdown_grace = resolve_duration("BOXLITE_SHUTDOWN_GRACE", DEFAULT_SHUTDOWN_GRACE);
let max_lifetime =
resolve_duration("BOXLITE_MAX_SESSION_LIFETIME", DEFAULT_MAX_SESSION_LIFETIME);
let mut ticker = tokio::time::interval(REAPER_TICK);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
run_reap_once(
&state,
Instant::now(),
reconnect_grace,
shutdown_grace,
max_lifetime,
)
.await;
}
}
async fn run_reap_once(
state: &AppState,
now: Instant,
reconnect_grace: std::time::Duration,
shutdown_grace: std::time::Duration,
max_lifetime: std::time::Duration,
) {
let candidates: Vec<(String, Arc<ActiveExecution>)> = {
let map = state.executions.read().await;
map.iter()
.map(|(k, v)| (k.clone(), Arc::clone(v)))
.collect()
};
for (id, active) in candidates {
if active.is_done() {
if !active.should_retain(now) {
state.executions.write().await.remove(&id);
}
continue;
}
if now.duration_since(active.created_at()) > max_lifetime {
active.mark_reaping_kill().await;
tracing::warn!(exec_id = %id, "session lifetime cap reached, killing");
try_kill_and_evict(state, &id, &active).await;
continue;
}
if active.is_reaping_kill().await {
try_kill_and_evict(state, &id, &active).await;
continue;
}
if active.try_escalate_hup(now, reconnect_grace).await {
let sig_result =
tokio::time::timeout(REAPER_SIGNAL_TIMEOUT, active.execution().signal(1)).await;
if matches!(sig_result, Ok(Ok(()))) {
active.finish_escalation().await;
} else {
tracing::warn!(exec_id = %id, "SIGHUP delivery failed or timed out, killing");
active.escalation_failed_mark_doomed().await;
try_kill_and_evict(state, &id, &active).await;
}
} else if active.try_escalate_term(now, shutdown_grace).await {
let sig_result =
tokio::time::timeout(REAPER_SIGNAL_TIMEOUT, active.execution().signal(15)).await;
if matches!(sig_result, Ok(Ok(()))) {
active.finish_escalation().await;
} else {
tracing::warn!(exec_id = %id, "SIGTERM delivery failed or timed out, killing");
active.escalation_failed_mark_doomed().await;
try_kill_and_evict(state, &id, &active).await;
}
} else if active.try_escalate_kill(now, shutdown_grace).await {
tracing::warn!(exec_id = %id, "orphan exec did not exit after SIGTERM, killing");
try_kill_and_evict(state, &id, &active).await;
}
}
}
async fn try_kill_and_evict(state: &AppState, id: &str, active: &Arc<ActiveExecution>) {
let result = tokio::time::timeout(REAPER_SIGNAL_TIMEOUT, active.execution().kill()).await;
match result {
Ok(Ok(())) => {
state.executions.write().await.remove(id);
}
Ok(Err(e)) => {
tracing::warn!(exec_id = %id, err = %e, "kill failed, will retry next tick");
}
Err(_) => {
tracing::warn!(exec_id = %id, "kill timed out, will retry next tick");
}
}
}
fn box_info_to_response(info: &BoxInfo) -> BoxResponse {
BoxResponse {
box_id: info.id.to_string(),
name: info.name.clone(),
status: info.status.as_str().to_string(),
created_at: info.created_at.to_rfc3339(),
updated_at: info.last_updated.to_rfc3339(),
pid: info.pid,
image: info.image.clone(),
cpus: info.cpus,
memory_mib: info.memory_mib,
labels: info.labels.clone(),
}
}
fn build_box_options(req: &CreateBoxRequest) -> Result<BoxOptions, boxlite::BoxliteError> {
let rootfs = if let Some(ref path) = req.rootfs_path {
RootfsSpec::RootfsPath(path.clone())
} else {
RootfsSpec::Image(req.image.clone().unwrap_or_else(|| "alpine:latest".into()))
};
let env: Vec<(String, String)> = req
.env
.as_ref()
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
let network = match &req.network {
Some(network) => NetworkSpec::try_from(NetworkConfig {
mode: network.mode.parse::<NetworkMode>()?,
allow_net: network.allow_net.clone(),
})?,
None => NetworkSpec::default(),
};
Ok(BoxOptions {
rootfs,
cpus: req.cpus,
memory_mib: req.memory_mib,
disk_size_gb: req.disk_size_gb,
working_dir: req.working_dir.clone(),
env,
network,
entrypoint: req.entrypoint.clone(),
cmd: req.cmd.clone(),
user: req.user.clone(),
auto_remove: req.auto_remove.unwrap_or(false),
detach: req.detach.unwrap_or(true),
..Default::default()
})
}
fn build_box_command(req: &ExecRequest) -> BoxCommand {
let mut cmd = BoxCommand::new(&req.command).args(req.args.iter().map(String::as_str));
if let Some(ref env_map) = req.env {
for (k, v) in env_map {
cmd = cmd.env(k, v);
}
}
if let Some(ref wd) = req.working_dir {
cmd = cmd.working_dir(wd);
}
if req.tty {
cmd = cmd.tty(true);
}
if let Some(secs) = req.timeout_seconds {
cmd = cmd.timeout(std::time::Duration::from_secs_f64(secs));
}
cmd
}
fn error_response(
status: StatusCode,
message: impl Into<String>,
error_type: &str,
code: &str,
) -> Response {
let body = ErrorBody {
error: ErrorDetail {
message: message.into(),
error_type: error_type.to_string(),
code: code.to_string(),
request_id: None,
},
};
(status, Json(body)).into_response()
}
fn error_from_boxlite(err: &boxlite::BoxliteError) -> Response {
let (code, etype, ecode) = err.http();
let status = StatusCode::from_u16(code)
.expect("BoxliteError::http() must return a valid HTTP status code");
error_response(status, err.to_string(), etype, ecode)
}
fn handle_panic(err: Box<dyn std::any::Any + Send + 'static>) -> Response {
let detail = err
.downcast_ref::<&'static str>()
.map(|s| s.to_string())
.or_else(|| err.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic in handler".to_string());
tracing::error!(panic = %detail, "handler panicked");
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("internal error: {}", detail),
"InternalError",
"internal",
)
}
fn auth_allows(expected: Option<&str>, path: &str, bearer: Option<&str>) -> bool {
let Some(expected) = expected else {
return true;
};
if path == "/v1/config" {
return true;
}
match bearer {
Some(tok) => constant_time_eq(tok.as_bytes(), expected.as_bytes()),
None => false,
}
}
async fn require_api_key(State(state): State<Arc<AppState>>, req: Request, next: Next) -> Response {
let bearer = req
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| {
v.strip_prefix("Bearer ")
.or_else(|| v.strip_prefix("bearer "))
});
if auth_allows(state.api_key.as_deref(), req.uri().path(), bearer) {
next.run(req).await
} else {
error_response(
StatusCode::UNAUTHORIZED,
"invalid or missing API key",
"AuthError",
"unauthenticated",
)
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
async fn get_or_fetch_box(state: &AppState, box_id: &str) -> Result<Arc<LiteBox>, Response> {
if let Some(b) = state.boxes.read().await.get(box_id) {
return Ok(Arc::clone(b));
}
match state.runtime.get(box_id).await {
Ok(Some(b)) => {
let id = b.info().id.to_string();
let arc = Arc::new(b);
state.boxes.write().await.insert(id, Arc::clone(&arc));
Ok(arc)
}
Ok(None) => Err(error_response(
StatusCode::NOT_FOUND,
format!("box not found: {box_id}"),
"NotFoundError",
"not_found",
)),
Err(e) => Err(error_from_boxlite(&e)),
}
}
fn build_router(state: Arc<AppState>) -> Router {
use handlers::{advanced, boxes, config, executions, files, me, metrics, snapshots};
Router::new()
.route("/v1/me", get(me::get_me))
.route("/v1/config", get(config::get_config))
.route("/v1/metrics", get(metrics::runtime_metrics))
.route("/v1/boxes/import", post(advanced::import_box))
.route(
"/v1/boxes",
post(boxes::create_box).get(boxes::list_boxes),
)
.route(
"/v1/boxes/{box_id}",
get(boxes::get_box)
.delete(boxes::remove_box)
.head(boxes::head_box),
)
.route(
"/v1/boxes/{box_id}/start",
post(boxes::start_box),
)
.route(
"/v1/boxes/{box_id}/stop",
post(boxes::stop_box),
)
.route(
"/v1/boxes/{box_id}/metrics",
get(metrics::box_metrics),
)
.route(
"/v1/boxes/{box_id}/exec",
post(executions::start_execution),
)
.route(
"/v1/boxes/{box_id}/executions/{exec_id}",
get(executions::get_execution).delete(executions::kill_execution),
)
.route(
"/v1/boxes/{box_id}/executions/{exec_id}/attach",
get(executions::attach_execution),
)
.route(
"/v1/boxes/{box_id}/executions/{exec_id}/signal",
post(executions::send_signal),
)
.route(
"/v1/boxes/{box_id}/executions/{exec_id}/resize",
post(executions::resize_tty),
)
.route(
"/v1/boxes/{box_id}/files",
put(files::upload_files).get(files::download_files),
)
.route(
"/v1/boxes/{box_id}/snapshots",
post(snapshots::create_snapshot).get(snapshots::list_snapshots),
)
.route(
"/v1/boxes/{box_id}/snapshots/{name}",
get(snapshots::get_snapshot).delete(snapshots::delete_snapshot),
)
.route(
"/v1/boxes/{box_id}/snapshots/{name}/restore",
post(snapshots::restore_snapshot),
)
.route(
"/v1/boxes/{box_id}/clone",
post(advanced::clone_box),
)
.route(
"/v1/boxes/{box_id}/export",
post(advanced::export_box),
)
.layer(middleware::from_fn_with_state(
state.clone(),
require_api_key,
))
.layer(CatchPanicLayer::custom(handle_panic))
.layer(PropagateRequestIdLayer::x_request_id())
.layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
.with_state(state)
}
pub async fn execute(args: ServeArgs, global: &GlobalFlags) -> anyhow::Result<()> {
let runtime = global.create_runtime()?;
let state = Arc::new(AppState {
runtime,
boxes: RwLock::new(HashMap::new()),
executions: RwLock::new(HashMap::new()),
api_key: args.api_key.clone(),
});
tokio::spawn(reaper_loop(Arc::clone(&state)));
let app = build_router(state.clone());
let addr = format!("{}:{}", args.host, args.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("boxlite serve listening on {}", addr);
eprintln!("BoxLite REST API server listening on http://{addr}");
let shutdown_state = state.clone();
axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("shutting down...");
eprintln!("\nShutting down...");
let _ = shutdown_state.runtime.shutdown(Some(10)).await;
})
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn auth_allows_permissive_when_no_key() {
assert!(auth_allows(None, "/v1/boxes", None));
assert!(auth_allows(None, "/v1/me", Some("anything")));
}
#[test]
fn auth_allows_config_public_even_with_key() {
assert!(auth_allows(Some("k"), "/v1/config", None));
}
#[test]
fn auth_allows_requires_exact_bearer_when_key_set() {
assert!(auth_allows(Some("k"), "/v1/me", Some("k")));
assert!(!auth_allows(Some("k"), "/v1/me", Some("wrong")));
assert!(!auth_allows(Some("k"), "/v1/me", None));
assert!(!auth_allows(Some("k"), "/v1/boxes", Some("")));
}
#[test]
fn constant_time_eq_basic() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"abcd"));
assert!(constant_time_eq(b"", b""));
}
#[test]
fn build_box_options_empty_body_lands_on_server_default_security() {
let json = r#"{"image": "alpine:latest"}"#;
let req: super::types::CreateBoxRequest =
serde_json::from_str(json).expect("body must deserialize");
let opts = build_box_options(&req).expect("build_box_options");
#[cfg(any(target_os = "linux", target_os = "macos"))]
assert!(
opts.advanced.security.jailer_enabled,
"server default must be sandbox-on after the flip"
);
}
#[test]
fn create_box_request_rejects_client_supplied_security_preset() {
let json = r#"{"image": "alpine:latest", "security": "development"}"#;
let msg = match serde_json::from_str::<super::types::CreateBoxRequest>(json) {
Ok(_) => panic!("`security` must be rejected at deserialize"),
Err(e) => e.to_string(),
};
assert!(
msg.contains("unknown field") && msg.contains("security"),
"expected deny-unknown-fields rejection mentioning `security`; got {msg}"
);
}
#[test]
fn create_box_request_rejects_client_supplied_security_settings() {
let json = r#"{
"image": "alpine:latest",
"security_settings": {
"jailer_enabled": false,
"seccomp_enabled": false,
"uid": null,
"gid": null,
"new_pid_ns": false,
"new_net_ns": false,
"chroot_base": "/srv/boxlite",
"chroot_enabled": false,
"close_fds": false,
"sanitize_env": false,
"env_allowlist": [],
"resource_limits": {},
"sandbox_profile": null,
"network_enabled": true
}
}"#;
let msg = match serde_json::from_str::<super::types::CreateBoxRequest>(json) {
Ok(_) => panic!("`security_settings` must be rejected at deserialize"),
Err(e) => e.to_string(),
};
assert!(
msg.contains("unknown field") && msg.contains("security_settings"),
"expected deny-unknown-fields rejection mentioning `security_settings`; got {msg}"
);
}
fn make_test_active() -> (
Arc<ActiveExecution>,
tokio::sync::mpsc::UnboundedSender<String>, // stdout driver
tokio::sync::mpsc::UnboundedSender<String>, // stderr driver
tokio::sync::mpsc::UnboundedSender<boxlite::ExecResult>, // result driver
) {
let (exec, stdout_tx, stderr_tx, _stdin_rx, result_tx) =
boxlite::Execution::stub("test-exec");
let active = ActiveExecution::new("test-box".to_string(), exec, None);
(active, stdout_tx, stderr_tx, result_tx)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn late_attach_subscriber_must_see_prior_output() {
let (active, stdout_tx, _stderr_tx, _result_tx) = make_test_active();
for i in 1..=5 {
stdout_tx.send(format!("line-{i}\n")).unwrap();
}
tokio::time::sleep(Duration::from_millis(50)).await;
let mut rx = active.stdout_bus().subscribe();
stdout_tx.send("line-6\n".to_string()).unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
let mut received = Vec::new();
while let Ok(Ok(data)) = tokio::time::timeout(Duration::from_millis(50), rx.recv()).await {
received.push(String::from_utf8(data).unwrap());
}
assert!(
received.len() >= 6,
"late subscriber must see pre-subscribe output; \
got {} line(s): {:?} (expected >= 6, including the 5 pre-attach lines)",
received.len(),
received,
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn done_signal_must_wait_for_pump_completion() {
let (active, stdout_tx, stderr_tx, result_tx) = make_test_active();
let mut rx = active.stdout_bus().subscribe();
let mut done_rx = active.done_rx();
stdout_tx.send("final-line\n".to_string()).unwrap();
drop(stdout_tx);
drop(stderr_tx);
result_tx
.send(boxlite::ExecResult {
exit_code: 0,
error_message: None,
})
.unwrap();
let _ = tokio::time::timeout(Duration::from_secs(2), done_rx.changed()).await;
let mut all = Vec::new();
while let Ok(bytes) = rx.try_recv() {
all.push(String::from_utf8(bytes).unwrap());
}
assert!(
all.iter().any(|s| s.contains("final-line")),
"after done_rx fires, all output must have been broadcast; \
got: {:?}",
all,
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reaper_retains_completed_exec_for_grace_period() {
let (active, stdout_tx, stderr_tx, result_tx) = make_test_active();
drop(stdout_tx);
drop(stderr_tx);
result_tx
.send(boxlite::ExecResult {
exit_code: 42,
error_message: None,
})
.unwrap();
for _ in 0..20 {
if active.is_done() {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(active.is_done(), "precondition: exec must be done");
let now = Instant::now();
assert!(
active.should_retain(now),
"recently-completed exec must be retained (grace period = {:?})",
COMPLETED_RETENTION_GRACE,
);
let far_future = now + COMPLETED_RETENTION_GRACE + Duration::from_secs(1);
assert!(
!active.should_retain(far_future),
"exec past the retention grace must not be retained",
);
}
}