1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6use crate::{ByteChunk, EnvironmentVariable, OutputStream, Run, Timestamp};
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
13#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
14#[serde(transparent)]
15pub struct CommandNumber(u64);
16
17impl CommandNumber {
18 pub fn new(number: u64) -> Self {
19 Self(number)
20 }
21
22 pub fn get(self) -> u64 {
23 self.0
24 }
25}
26
27impl fmt::Display for CommandNumber {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 self.0.fmt(f)
30 }
31}
32
33#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
38#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
39#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
40#[serde(untagged)]
41pub enum CommandLine {
42 Argv(Vec<String>),
43 Script(String),
44}
45
46impl From<&str> for CommandLine {
48 fn from(script: &str) -> Self {
49 Self::Script(script.to_owned())
50 }
51}
52
53impl From<String> for CommandLine {
54 fn from(script: String) -> Self {
55 Self::Script(script)
56 }
57}
58
59impl<const N: usize> From<[&str; N]> for CommandLine {
61 fn from(argv: [&str; N]) -> Self {
62 Self::Argv(argv.iter().map(|&argument| argument.to_owned()).collect())
63 }
64}
65
66impl From<&[&str]> for CommandLine {
67 fn from(argv: &[&str]) -> Self {
68 Self::Argv(argv.iter().map(|&argument| argument.to_owned()).collect())
69 }
70}
71
72impl From<Vec<String>> for CommandLine {
73 fn from(argv: Vec<String>) -> Self {
74 Self::Argv(argv)
75 }
76}
77
78impl CommandLine {
79 pub fn text(&self) -> String {
82 match self {
83 Self::Argv(argv) => argv.join(" "),
84 Self::Script(script) => script.clone(),
85 }
86 }
87}
88
89#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
91#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
92#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
93pub struct NewCommand {
94 pub command: CommandLine,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub cwd: Option<String>,
99 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
101 pub env: BTreeMap<String, String>,
102 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
105 pub stdin: bool,
106}
107
108impl NewCommand {
109 pub fn check(&self) -> Result<(), String> {
114 let strings: Vec<&str> = match &self.command {
115 CommandLine::Argv(argv) if argv.is_empty() => {
116 return Err("a command's argv names its program first".to_owned());
117 }
118 CommandLine::Argv(argv) => argv.iter().map(String::as_str).collect(),
119 CommandLine::Script(script) if script.trim().is_empty() => {
120 return Err("a command's script is empty".to_owned());
121 }
122 CommandLine::Script(script) => vec![script],
123 };
124 if strings
125 .iter()
126 .chain(&self.cwd.as_deref())
127 .any(|text| text.contains('\0'))
128 {
129 return Err("a command's strings can't hold a NUL character".to_owned());
130 }
131 for (name, value) in &self.env {
132 if !EnvironmentVariable::is_name(name) {
133 return Err(format!(
134 "{name:?} isn't a variable name: letters, digits and '_', not starting with a digit"
135 ));
136 }
137 if value.contains('\0') {
138 return Err(format!("{name}'s value can't hold a NUL character"));
139 }
140 }
141 Ok(())
142 }
143}
144
145#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
147#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
148#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
149pub struct SandboxCommand {
150 pub number: CommandNumber,
151 pub command: CommandLine,
153 pub cwd: Option<String>,
154 pub env: BTreeMap<String, String>,
155 pub argv: Option<Vec<String>>,
157 pub pid: Option<u64>,
159 pub stdin: CommandStdin,
160 pub state: CommandState,
161 pub created_at: Timestamp,
163 pub started_at: Option<Timestamp>,
164 pub ended_at: Option<Timestamp>,
165}
166
167impl SandboxCommand {
168 pub fn as_of(mut self, run: &Run) -> Self {
171 if self.state.is_active() && !run.state.is_active() {
172 self.state = CommandState::Unfinished;
173 }
174 self
175 }
176
177 pub fn text(&self) -> String {
180 match &self.argv {
181 Some(argv) => argv.join(" "),
182 None => self.command.text(),
183 }
184 }
185}
186
187#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
189#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
190#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
191#[serde(rename_all = "snake_case")]
192pub enum CommandStdin {
193 None,
195 Open,
197 Closed,
199}
200
201#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
205#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
206#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
207pub struct CommandSignal {
208 pub signal: i32,
209}
210
211impl CommandSignal {
212 const SIGNALS: std::ops::RangeInclusive<i32> = 1..=64;
214
215 pub fn check(self) -> Result<(), String> {
217 if Self::SIGNALS.contains(&self.signal) {
218 Ok(())
219 } else {
220 Err(format!("a signal is 1 to 64, not {}", self.signal))
221 }
222 }
223}
224
225#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
233#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
234#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
235#[serde(tag = "kind", rename_all = "snake_case")]
236pub enum CommandState {
237 Starting,
239 Running,
240 Exited {
243 code: i32,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 signal: Option<i32>,
246 },
247 Refused {
250 reason: String,
251 },
252 Unfinished,
254}
255
256impl CommandState {
257 pub fn is_active(&self) -> bool {
259 matches!(self, Self::Starting | Self::Running)
260 }
261}
262
263#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
268#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
269#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
270#[serde(tag = "kind", rename_all = "snake_case")]
271pub enum CommandOutputEvent {
272 Output {
275 stream: OutputStream,
276 offset: u64,
277 chunk: ByteChunk,
278 },
279 Command { command: SandboxCommand },
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn a_command_is_an_argv_or_a_script() {
290 let script: NewCommand = serde_json::from_str(r#"{"command":"echo hi > /f"}"#).unwrap();
291 assert_eq!(
292 script.command,
293 CommandLine::Script("echo hi > /f".to_owned())
294 );
295 assert_eq!(script.cwd, None);
296 let argv: NewCommand =
297 serde_json::from_str(r#"{"command":["cat","/f"],"cwd":"/tmp","env":{"A":"1"}}"#)
298 .unwrap();
299 assert_eq!(
300 argv.command,
301 CommandLine::Argv(vec!["cat".to_owned(), "/f".to_owned()])
302 );
303 assert_eq!(argv.env["A"], "1");
304 assert_eq!(
305 serde_json::to_string(&argv).unwrap(),
306 r#"{"command":["cat","/f"],"cwd":"/tmp","env":{"A":"1"}}"#
307 );
308 assert!(script.check().is_ok() && argv.check().is_ok());
309 }
310
311 #[test]
312 fn a_command_is_checked() {
313 let refused = |json: &str| {
314 let command: NewCommand = serde_json::from_str(json).unwrap();
315 command.check().unwrap_err()
316 };
317 assert!(refused(r#"{"command":[]}"#).contains("program"));
318 assert!(refused(r#"{"command":" "}"#).contains("empty"));
319 assert!(refused(r#"{"command":["a\u0000b"]}"#).contains("NUL"));
320 assert!(refused(r#"{"command":"ls","cwd":"/a\u0000"}"#).contains("NUL"));
321 assert!(refused(r#"{"command":"ls","env":{"1A":"x"}}"#).contains("variable name"));
322 assert!(refused(r#"{"command":"ls","env":{"A":"\u0000"}}"#).contains("NUL"));
323 }
324
325 #[test]
326 fn a_command_takes_stdin_only_if_it_says() {
327 let reads: NewCommand =
328 serde_json::from_str(r#"{"command":["cat"],"stdin":true}"#).unwrap();
329 assert!(reads.stdin);
330 let quiet: NewCommand = serde_json::from_str(r#"{"command":["cat"]}"#).unwrap();
331 assert!(!quiet.stdin);
332 assert_eq!(
333 serde_json::to_string(&quiet).unwrap(),
334 r#"{"command":["cat"]}"#
335 );
336 assert!(CommandSignal { signal: 15 }.check().is_ok());
337 assert!(CommandSignal { signal: 0 }.check().is_err());
338 assert!(CommandSignal { signal: 65 }.check().is_err());
339 }
340
341 #[test]
342 fn a_state_serializes_tagged() {
343 let killed = CommandState::Exited {
344 code: 143,
345 signal: Some(15),
346 };
347 assert_eq!(
348 serde_json::to_string(&killed).unwrap(),
349 r#"{"kind":"exited","code":143,"signal":15}"#
350 );
351 let exited: CommandState = serde_json::from_str(r#"{"kind":"exited","code":0}"#).unwrap();
352 assert_eq!(
353 exited,
354 CommandState::Exited {
355 code: 0,
356 signal: None
357 }
358 );
359 assert!(CommandState::Starting.is_active() && !exited.is_active());
360 }
361}