use std::collections::BTreeMap;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::context::ExecutionContext;
use crate::error::Result;
#[async_trait]
pub trait Sandbox: Send + Sync + 'static {
fn backend(&self) -> &str;
async fn run_command(&self, spec: CommandSpec, ctx: &ExecutionContext)
-> Result<CommandOutput>;
async fn run_code(&self, spec: CodeSpec, ctx: &ExecutionContext) -> Result<CommandOutput>;
async fn read_file(&self, path: &str, ctx: &ExecutionContext) -> Result<Vec<u8>>;
async fn write_file(&self, path: &str, bytes: &[u8], ctx: &ExecutionContext) -> Result<()>;
async fn list_dir(&self, path: &str, ctx: &ExecutionContext) -> Result<Vec<DirEntry>>;
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandSpec {
pub argv: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stdin: Option<Vec<u8>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<std::time::Duration>,
}
impl CommandSpec {
#[must_use]
pub fn new(argv: Vec<String>) -> Self {
Self {
argv,
..Self::default()
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeSpec {
pub language: SandboxLanguage,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<std::time::Duration>,
}
impl CodeSpec {
#[must_use]
pub fn new(language: SandboxLanguage, source: impl Into<String>) -> Self {
Self {
language,
source: source.into(),
timeout: None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SandboxLanguage {
Bash,
Python,
TypeScript,
JavaScript,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandOutput {
pub exit_code: i32,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub duration_ms: u64,
}
impl CommandOutput {
#[must_use]
pub const fn succeeded(&self) -> bool {
self.exit_code == 0
}
#[must_use]
pub fn stdout_lossy(&self) -> String {
String::from_utf8_lossy(&self.stdout).into_owned()
}
#[must_use]
pub fn stderr_lossy(&self) -> String {
String::from_utf8_lossy(&self.stderr).into_owned()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DirEntry {
pub name: String,
pub is_dir: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn command_output_helpers_decode_utf8_lossily() {
let output = CommandOutput {
exit_code: 0,
stdout: b"hello".to_vec(),
stderr: vec![0xff, 0x66, 0x6f],
duration_ms: 12,
};
assert!(output.succeeded());
assert_eq!(output.stdout_lossy(), "hello");
assert!(output.stderr_lossy().contains('\u{FFFD}'));
}
#[test]
fn command_spec_default_has_empty_argv() {
let spec = CommandSpec::default();
assert!(spec.argv.is_empty());
assert!(spec.env.is_empty());
assert!(spec.timeout.is_none());
}
#[test]
fn sandbox_language_round_trips_via_serde() {
let langs = [
SandboxLanguage::Bash,
SandboxLanguage::Python,
SandboxLanguage::TypeScript,
SandboxLanguage::JavaScript,
];
for lang in langs {
let json = serde_json::to_string(&lang).expect("serialize");
let back: SandboxLanguage = serde_json::from_str(&json).expect("deserialize");
assert_eq!(lang, back);
}
}
#[test]
fn command_spec_new_seeds_argv_and_defaults() {
let spec = CommandSpec::new(vec!["ls".into(), "-la".into()]);
assert_eq!(spec.argv, vec!["ls".to_owned(), "-la".to_owned()]);
assert!(spec.working_dir.is_none());
}
}