Skip to main content

dvm_api/
command.rs

1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6use crate::{ByteChunk, EnvironmentVariable, OutputStream, Run, Timestamp};
7
8/// A command's number in its sandbox's execution, the controller's: 1, 2,
9/// … in the order the commands were sent. A replay's commands have their
10/// recording's numbers.
11#[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/// What a command runs: its argv, as it is, its program found in the
34/// sandbox's `PATH`; or a script, which the sandbox's shell runs,
35/// `/bin/bash -lc` if its image has bash, else `/bin/sh -c` (dvm chooses
36/// once, as the sandbox starts). In JSON, an array of strings, or a string.
37#[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
46/// A script, which the sandbox's shell runs.
47impl 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
59/// An argv, which runs as it is.
60impl<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    /// The command as a person reads it: a script as it is, an argv its
80    /// arguments joined by spaces.
81    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/// A command to run in a sandbox, `POST /api/sandboxes/{id}/commands`' body.
90#[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    /// The directory it starts in, from the image's working directory if
96    /// relative; the image's, unless given.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub cwd: Option<String>,
99    /// Variables it's given, after the sandbox's, or in place of theirs.
100    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
101    pub env: BTreeMap<String, String>,
102    /// Whether it takes stdin, sent to it (`POST …/commands/{n}/stdin`)
103    /// until its close; else it reads `/dev/null`.
104    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
105    pub stdin: bool,
106}
107
108impl NewCommand {
109    /// Whether a sandbox may be asked to run it, and why not: an argv names
110    /// its program, no string holds a NUL, and each variable's name is one a
111    /// shell takes. A sandbox refuses what it can't run besides (a script in
112    /// an image with no shell), as the command's state says.
113    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/// A command sent to a sandbox, or one its replay ran again.
146#[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    /// What was asked: an argv, or a script.
152    pub command: CommandLine,
153    pub cwd: Option<String>,
154    pub env: BTreeMap<String, String>,
155    /// The argv that ran, once it started: a script's, its shell's.
156    pub argv: Option<Vec<String>>,
157    /// Its process's id in the sandbox, once it started.
158    pub pid: Option<u64>,
159    pub stdin: CommandStdin,
160    pub state: CommandState,
161    /// When it was sent; a replay's, when it started.
162    pub created_at: Timestamp,
163    pub started_at: Option<Timestamp>,
164    pub ended_at: Option<Timestamp>,
165}
166
167impl SandboxCommand {
168    /// The command as it stands once its run, `run`, is as it is: one that
169    /// hadn't started or exited as its run ended is unfinished.
170    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    /// The command as a person reads it: the argv it ran, once it started,
178    /// else what was asked.
179    pub fn text(&self) -> String {
180        match &self.argv {
181            Some(argv) => argv.join(" "),
182            None => self.command.text(),
183        }
184    }
185}
186
187/// A command's stdin.
188#[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    /// It reads `/dev/null`.
194    None,
195    /// It takes what's sent to it, until its close.
196    Open,
197    /// Closed: it reads no more than it was sent.
198    Closed,
199}
200
201/// A signal for a command's process, `POST …/commands/{n}/signal`'s body:
202/// 15 for SIGTERM, 9 for SIGKILL. It goes to the command's process alone,
203/// not what that started, and to none once it exited.
204#[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    /// Linux's signals, real-time ones too.
213    const SIGNALS: std::ops::RangeInclusive<i32> = 1..=64;
214
215    /// Whether it's a signal, and why not.
216    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/// Where a command is:
226///
227/// ```text
228/// Starting → Running → Exited
229/// Starting → Refused
230/// Starting | Running → Unfinished, as its sandbox ends
231/// ```
232#[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    /// Sent to its sandbox, and not started yet.
238    Starting,
239    Running,
240    /// It exited with its code; or a signal killed it: its code is 128 and
241    /// the signal, as a shell says, and the signal is given.
242    Exited {
243        code: i32,
244        #[serde(default, skip_serializing_if = "Option::is_none")]
245        signal: Option<i32>,
246    },
247    /// Its sandbox didn't take it, and why: a script in an image with no
248    /// shell, a command too long.
249    Refused {
250        reason: String,
251    },
252    /// Its sandbox ended before it did.
253    Unfinished,
254}
255
256impl CommandState {
257    /// Whether it may still start, or exit.
258    pub fn is_active(&self) -> bool {
259        matches!(self, Self::Starting | Self::Running)
260    }
261}
262
263/// What `GET /api/sandboxes/{id}/commands/{n}/output?follow=true` sends, each
264/// event's data: a command's output as it's written, from the offsets asked
265/// for, and the command each time it changes. The stream ends once the
266/// command ended, after all it wrote.
267#[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    /// What it wrote to `stream`, from `offset`. A stream's chunks come in
273    /// order, with no gap and no overlap.
274    Output {
275        stream: OutputStream,
276        offset: u64,
277        chunk: ByteChunk,
278    },
279    /// The command as it is: after its output so far, and after each change.
280    /// The last event is the command, ended.
281    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}