use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::{ByteChunk, EnvironmentVariable, OutputStream, Run, Timestamp};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
#[serde(transparent)]
pub struct CommandNumber(u64);
impl CommandNumber {
pub fn new(number: u64) -> Self {
Self(number)
}
pub fn get(self) -> u64 {
self.0
}
}
impl fmt::Display for CommandNumber {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
#[serde(untagged)]
pub enum CommandLine {
Argv(Vec<String>),
Script(String),
}
impl From<&str> for CommandLine {
fn from(script: &str) -> Self {
Self::Script(script.to_owned())
}
}
impl From<String> for CommandLine {
fn from(script: String) -> Self {
Self::Script(script)
}
}
impl<const N: usize> From<[&str; N]> for CommandLine {
fn from(argv: [&str; N]) -> Self {
Self::Argv(argv.iter().map(|&argument| argument.to_owned()).collect())
}
}
impl From<&[&str]> for CommandLine {
fn from(argv: &[&str]) -> Self {
Self::Argv(argv.iter().map(|&argument| argument.to_owned()).collect())
}
}
impl From<Vec<String>> for CommandLine {
fn from(argv: Vec<String>) -> Self {
Self::Argv(argv)
}
}
impl CommandLine {
pub fn text(&self) -> String {
match self {
Self::Argv(argv) => argv.join(" "),
Self::Script(script) => script.clone(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
pub struct NewCommand {
pub command: CommandLine,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stdin: bool,
}
impl NewCommand {
pub fn check(&self) -> Result<(), String> {
let strings: Vec<&str> = match &self.command {
CommandLine::Argv(argv) if argv.is_empty() => {
return Err("a command's argv names its program first".to_owned());
}
CommandLine::Argv(argv) => argv.iter().map(String::as_str).collect(),
CommandLine::Script(script) if script.trim().is_empty() => {
return Err("a command's script is empty".to_owned());
}
CommandLine::Script(script) => vec![script],
};
if strings
.iter()
.chain(&self.cwd.as_deref())
.any(|text| text.contains('\0'))
{
return Err("a command's strings can't hold a NUL character".to_owned());
}
for (name, value) in &self.env {
if !EnvironmentVariable::is_name(name) {
return Err(format!(
"{name:?} isn't a variable name: letters, digits and '_', not starting with a digit"
));
}
if value.contains('\0') {
return Err(format!("{name}'s value can't hold a NUL character"));
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
pub struct SandboxCommand {
pub number: CommandNumber,
pub command: CommandLine,
pub cwd: Option<String>,
pub env: BTreeMap<String, String>,
pub argv: Option<Vec<String>>,
pub pid: Option<u64>,
pub stdin: CommandStdin,
pub state: CommandState,
pub created_at: Timestamp,
pub started_at: Option<Timestamp>,
pub ended_at: Option<Timestamp>,
}
impl SandboxCommand {
pub fn as_of(mut self, run: &Run) -> Self {
if self.state.is_active() && !run.state.is_active() {
self.state = CommandState::Unfinished;
}
self
}
pub fn text(&self) -> String {
match &self.argv {
Some(argv) => argv.join(" "),
None => self.command.text(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
#[serde(rename_all = "snake_case")]
pub enum CommandStdin {
None,
Open,
Closed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
pub struct CommandSignal {
pub signal: i32,
}
impl CommandSignal {
const SIGNALS: std::ops::RangeInclusive<i32> = 1..=64;
pub fn check(self) -> Result<(), String> {
if Self::SIGNALS.contains(&self.signal) {
Ok(())
} else {
Err(format!("a signal is 1 to 64, not {}", self.signal))
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CommandState {
Starting,
Running,
Exited {
code: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
signal: Option<i32>,
},
Refused {
reason: String,
},
Unfinished,
}
impl CommandState {
pub fn is_active(&self) -> bool {
matches!(self, Self::Starting | Self::Running)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CommandOutputEvent {
Output {
stream: OutputStream,
offset: u64,
chunk: ByteChunk,
},
Command { command: SandboxCommand },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_command_is_an_argv_or_a_script() {
let script: NewCommand = serde_json::from_str(r#"{"command":"echo hi > /f"}"#).unwrap();
assert_eq!(
script.command,
CommandLine::Script("echo hi > /f".to_owned())
);
assert_eq!(script.cwd, None);
let argv: NewCommand =
serde_json::from_str(r#"{"command":["cat","/f"],"cwd":"/tmp","env":{"A":"1"}}"#)
.unwrap();
assert_eq!(
argv.command,
CommandLine::Argv(vec!["cat".to_owned(), "/f".to_owned()])
);
assert_eq!(argv.env["A"], "1");
assert_eq!(
serde_json::to_string(&argv).unwrap(),
r#"{"command":["cat","/f"],"cwd":"/tmp","env":{"A":"1"}}"#
);
assert!(script.check().is_ok() && argv.check().is_ok());
}
#[test]
fn a_command_is_checked() {
let refused = |json: &str| {
let command: NewCommand = serde_json::from_str(json).unwrap();
command.check().unwrap_err()
};
assert!(refused(r#"{"command":[]}"#).contains("program"));
assert!(refused(r#"{"command":" "}"#).contains("empty"));
assert!(refused(r#"{"command":["a\u0000b"]}"#).contains("NUL"));
assert!(refused(r#"{"command":"ls","cwd":"/a\u0000"}"#).contains("NUL"));
assert!(refused(r#"{"command":"ls","env":{"1A":"x"}}"#).contains("variable name"));
assert!(refused(r#"{"command":"ls","env":{"A":"\u0000"}}"#).contains("NUL"));
}
#[test]
fn a_command_takes_stdin_only_if_it_says() {
let reads: NewCommand =
serde_json::from_str(r#"{"command":["cat"],"stdin":true}"#).unwrap();
assert!(reads.stdin);
let quiet: NewCommand = serde_json::from_str(r#"{"command":["cat"]}"#).unwrap();
assert!(!quiet.stdin);
assert_eq!(
serde_json::to_string(&quiet).unwrap(),
r#"{"command":["cat"]}"#
);
assert!(CommandSignal { signal: 15 }.check().is_ok());
assert!(CommandSignal { signal: 0 }.check().is_err());
assert!(CommandSignal { signal: 65 }.check().is_err());
}
#[test]
fn a_state_serializes_tagged() {
let killed = CommandState::Exited {
code: 143,
signal: Some(15),
};
assert_eq!(
serde_json::to_string(&killed).unwrap(),
r#"{"kind":"exited","code":143,"signal":15}"#
);
let exited: CommandState = serde_json::from_str(r#"{"kind":"exited","code":0}"#).unwrap();
assert_eq!(
exited,
CommandState::Exited {
code: 0,
signal: None
}
);
assert!(CommandState::Starting.is_active() && !exited.is_active());
}
}