use std::fmt;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::backend::{Backend, SandboxBackend};
use crate::credential::SecretRef;
use crate::policy::SandboxPolicy;
use crate::scanning::ExternalizationPolicy;
pub type SandboxId = Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SandboxState {
Created,
Running,
Paused,
Stopped,
Destroyed,
}
impl SandboxState {
pub fn valid_transition(&self, to: &SandboxState) -> bool {
matches!(
(self, to),
(Self::Created, Self::Running)
| (Self::Running, Self::Paused)
| (Self::Running, Self::Stopped)
| (Self::Running, Self::Destroyed)
| (Self::Paused, Self::Running)
| (Self::Paused, Self::Stopped)
| (Self::Paused, Self::Destroyed)
| (Self::Stopped, Self::Destroyed)
)
}
}
impl fmt::Display for SandboxState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Created => write!(f, "created"),
Self::Running => write!(f, "running"),
Self::Paused => write!(f, "paused"),
Self::Stopped => write!(f, "stopped"),
Self::Destroyed => write!(f, "destroyed"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxConfig {
pub backend: Backend,
pub policy: SandboxPolicy,
pub secrets: Vec<SecretRef>,
pub timeout_ms: u64,
pub workdir: Option<String>,
pub env: Vec<(String, String)>,
pub agent_id: Option<String>,
pub externalization: Option<ExternalizationPolicy>,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
backend: Backend::Process,
policy: SandboxPolicy::basic(),
secrets: Vec::new(),
timeout_ms: 30_000,
workdir: None,
env: Vec::new(),
agent_id: None,
externalization: None,
}
}
}
impl SandboxConfig {
pub fn builder() -> SandboxConfigBuilder {
SandboxConfigBuilder::default()
}
}
#[derive(Debug, Default)]
pub struct SandboxConfigBuilder {
config: SandboxConfig,
}
impl SandboxConfigBuilder {
pub fn backend(mut self, backend: Backend) -> Self {
self.config.backend = backend;
self
}
pub fn policy(mut self, policy: SandboxPolicy) -> Self {
self.config.policy = policy;
self
}
pub fn policy_seccomp(mut self, profile: &str) -> Self {
self.config.policy.seccomp_enabled = true;
self.config.policy.seccomp_profile = Some(profile.into());
self
}
pub fn network(mut self, enabled: bool) -> Self {
self.config.policy.network.enabled = enabled;
self
}
pub fn timeout_ms(mut self, ms: u64) -> Self {
self.config.timeout_ms = ms;
self
}
pub fn agent_id(mut self, id: impl Into<String>) -> Self {
self.config.agent_id = Some(id.into());
self
}
pub fn externalization(mut self, policy: ExternalizationPolicy) -> Self {
self.config.externalization = Some(policy);
self
}
pub fn build(self) -> SandboxConfig {
self.config
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecResult {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub duration_ms: u64,
pub timed_out: bool,
}
pub struct Sandbox {
pub id: SandboxId,
pub config: SandboxConfig,
pub state: SandboxState,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub stopped_at: Option<DateTime<Utc>>,
backend: Box<dyn SandboxBackend>,
}
impl fmt::Debug for Sandbox {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Sandbox")
.field("id", &self.id)
.field("config", &self.config)
.field("state", &self.state)
.field("created_at", &self.created_at)
.field("started_at", &self.started_at)
.field("stopped_at", &self.stopped_at)
.field("backend", &self.config.backend.to_string())
.finish()
}
}
impl Sandbox {
pub async fn create(config: SandboxConfig) -> crate::Result<Self> {
if !config.backend.is_available() {
return Err(crate::KavachError::BackendUnavailable(
config.backend.to_string(),
));
}
let backend = crate::backend::create_backend(&config)?;
Ok(Self {
id: Uuid::new_v4(),
config,
state: SandboxState::Created,
created_at: Utc::now(),
started_at: None,
stopped_at: None,
backend,
})
}
pub fn transition(&mut self, to: SandboxState) -> crate::Result<()> {
if !self.state.valid_transition(&to) {
return Err(crate::KavachError::InvalidTransition {
state: self.state.to_string(),
target: to.to_string(),
reason: "invalid state transition".into(),
});
}
tracing::debug!(sandbox_id = %self.id, from = %self.state, to = %to, "sandbox state transition");
self.state = to;
match to {
SandboxState::Running => self.started_at = Some(Utc::now()),
SandboxState::Stopped | SandboxState::Destroyed => self.stopped_at = Some(Utc::now()),
_ => {}
}
Ok(())
}
pub async fn exec(&self, command: &str) -> crate::Result<ExecResult> {
if self.state != SandboxState::Running {
return Err(crate::KavachError::ExecFailed(format!(
"sandbox is {}, not running",
self.state
)));
}
let result = self.backend.exec(command, &self.config.policy).await?;
if let Some(ref ext_policy) = self.config.externalization {
let gate = crate::scanning::ExternalizationGate::new();
gate.apply(result, ext_policy)
} else {
Ok(result)
}
}
pub async fn destroy(mut self) -> crate::Result<()> {
self.backend.destroy().await?;
self.transition(SandboxState::Destroyed)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_transitions() {
assert!(SandboxState::Created.valid_transition(&SandboxState::Running));
assert!(SandboxState::Running.valid_transition(&SandboxState::Stopped));
assert!(SandboxState::Running.valid_transition(&SandboxState::Paused));
assert!(SandboxState::Paused.valid_transition(&SandboxState::Running));
assert!(!SandboxState::Destroyed.valid_transition(&SandboxState::Running));
assert!(!SandboxState::Created.valid_transition(&SandboxState::Stopped));
}
#[test]
fn config_builder() {
let config = SandboxConfig::builder()
.backend(Backend::GVisor)
.policy_seccomp("strict")
.network(false)
.timeout_ms(60_000)
.agent_id("agent-123")
.build();
assert_eq!(config.backend, Backend::GVisor);
assert!(config.policy.seccomp_enabled);
assert!(!config.policy.network.enabled);
assert_eq!(config.timeout_ms, 60_000);
assert_eq!(config.agent_id.unwrap(), "agent-123");
}
#[test]
fn config_default() {
let config = SandboxConfig::default();
assert_eq!(config.backend, Backend::Process);
assert!(config.policy.seccomp_enabled); assert_eq!(config.timeout_ms, 30_000);
}
#[tokio::test]
async fn create_sandbox() {
let config = SandboxConfig::builder().backend(Backend::Noop).build();
let sandbox = Sandbox::create(config).await.unwrap();
assert_eq!(sandbox.state, SandboxState::Created);
}
#[tokio::test]
async fn sandbox_lifecycle() {
let config = SandboxConfig::builder().backend(Backend::Noop).build();
let mut sandbox = Sandbox::create(config).await.unwrap();
sandbox.transition(SandboxState::Running).unwrap();
assert!(sandbox.started_at.is_some());
sandbox.transition(SandboxState::Stopped).unwrap();
assert!(sandbox.stopped_at.is_some());
sandbox.transition(SandboxState::Destroyed).unwrap();
assert_eq!(sandbox.state, SandboxState::Destroyed);
}
#[tokio::test]
async fn exec_requires_running() {
let config = SandboxConfig::builder().backend(Backend::Noop).build();
let sandbox = Sandbox::create(config).await.unwrap();
assert!(sandbox.exec("echo hello").await.is_err());
}
#[test]
fn state_display() {
assert_eq!(SandboxState::Running.to_string(), "running");
assert_eq!(SandboxState::Destroyed.to_string(), "destroyed");
}
#[test]
fn exec_result_serde() {
let result = ExecResult {
exit_code: 0,
stdout: "hello".into(),
stderr: String::new(),
duration_ms: 42,
timed_out: false,
};
let json = serde_json::to_string(&result).unwrap();
let back: ExecResult = serde_json::from_str(&json).unwrap();
assert_eq!(back.exit_code, 0);
assert_eq!(back.duration_ms, 42);
}
}