Skip to main content

microsandbox_protocol/
exec.rs

1//! Exec-related protocol message payloads.
2
3use std::str::FromStr;
4
5use serde::{Deserialize, Serialize};
6
7//--------------------------------------------------------------------------------------------------
8// Types
9//--------------------------------------------------------------------------------------------------
10
11/// Request to execute a command in the guest.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ExecRequest {
14    /// The command to execute (program path).
15    pub cmd: String,
16
17    /// Arguments to the command.
18    #[serde(default)]
19    pub args: Vec<String>,
20
21    /// Environment variables as key=value pairs.
22    #[serde(default)]
23    pub env: Vec<String>,
24
25    /// Working directory for the command.
26    #[serde(default)]
27    pub cwd: Option<String>,
28
29    /// Optional guest user override for the command.
30    #[serde(default)]
31    pub user: Option<String>,
32
33    /// Whether to allocate a PTY for the command.
34    #[serde(default)]
35    pub tty: bool,
36
37    /// Initial terminal rows (only used when `tty` is true).
38    #[serde(default = "default_rows")]
39    pub rows: u16,
40
41    /// Initial terminal columns (only used when `tty` is true).
42    #[serde(default = "default_cols")]
43    pub cols: u16,
44
45    /// POSIX resource limits to apply to the spawned process via `setrlimit()`.
46    #[serde(default)]
47    pub rlimits: Vec<ExecRlimit>,
48}
49
50/// POSIX resource limit identifiers (maps to `RLIMIT_*` constants).
51///
52/// This is the canonical set of resource names understood by the protocol;
53/// both the host-side builders and the guest-side parser agree on it.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55pub enum RlimitResource {
56    /// Max CPU time in seconds (`RLIMIT_CPU`).
57    Cpu,
58    /// Max file size in bytes (`RLIMIT_FSIZE`).
59    Fsize,
60    /// Max data segment size (`RLIMIT_DATA`).
61    Data,
62    /// Max stack size (`RLIMIT_STACK`).
63    Stack,
64    /// Max core file size (`RLIMIT_CORE`).
65    Core,
66    /// Max resident set size (`RLIMIT_RSS`).
67    Rss,
68    /// Max number of processes (`RLIMIT_NPROC`).
69    Nproc,
70    /// Max open file descriptors (`RLIMIT_NOFILE`).
71    Nofile,
72    /// Max locked memory (`RLIMIT_MEMLOCK`).
73    Memlock,
74    /// Max address space size (`RLIMIT_AS`).
75    As,
76    /// Max file locks (`RLIMIT_LOCKS`).
77    Locks,
78    /// Max pending signals (`RLIMIT_SIGPENDING`).
79    Sigpending,
80    /// Max bytes in POSIX message queues (`RLIMIT_MSGQUEUE`).
81    Msgqueue,
82    /// Max nice priority (`RLIMIT_NICE`).
83    Nice,
84    /// Max real-time priority (`RLIMIT_RTPRIO`).
85    Rtprio,
86    /// Max real-time timeout (`RLIMIT_RTTIME`).
87    Rttime,
88}
89
90/// A POSIX resource limit to apply to a spawned process.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ExecRlimit {
93    /// Resource name (lowercase): "nofile", "nproc", "as", "cpu", etc.
94    pub resource: String,
95
96    /// Soft limit (can be raised up to hard limit by the process).
97    pub soft: u64,
98
99    /// Hard limit (ceiling, requires privileges to raise).
100    pub hard: u64,
101}
102
103/// Confirmation that a command has been started.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ExecStarted {
106    /// The PID of the spawned process.
107    pub pid: u32,
108}
109
110/// Stdin data sent to a running command.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct ExecStdin {
113    /// The raw input data.
114    #[serde(with = "serde_bytes")]
115    pub data: Vec<u8>,
116}
117
118/// Notification that an `ExecStdin` write to the child's stdin failed.
119/// The most common cause is the child closing its read end (EPIPE);
120/// the session is otherwise alive and may still produce output and an
121/// exit code, so this is delivered as a non-terminal event.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ExecStdinError {
124    /// `errno` from the underlying write, if available.
125    #[serde(default)]
126    pub errno: Option<i32>,
127
128    /// Standard errno name like `"EPIPE"`, populated when `errno` is.
129    #[serde(default)]
130    pub errno_name: Option<String>,
131
132    /// Human-readable description from agentd.
133    pub message: String,
134}
135
136/// Stdout data from a running command.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ExecStdout {
139    /// The raw output data.
140    #[serde(with = "serde_bytes")]
141    pub data: Vec<u8>,
142}
143
144/// Stderr data from a running command.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct ExecStderr {
147    /// The raw error output data.
148    #[serde(with = "serde_bytes")]
149    pub data: Vec<u8>,
150}
151
152/// Notification that a command has exited.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct ExecExited {
155    /// The exit code of the process.
156    pub code: i32,
157}
158
159/// Notification that a command failed to start (the user's program
160/// never got to run). Distinct from `ExecExited`, which means the
161/// process ran and reported an exit code.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct ExecFailed {
164    /// Coarse classification used by the CLI/SDK to pick hints.
165    pub kind: ExecFailureKind,
166
167    /// `errno` if the underlying failure was a syscall.
168    #[serde(default)]
169    pub errno: Option<i32>,
170
171    /// Standard errno name like `"ENOENT"`. Easier to grep than the
172    /// raw number; populated by the agentd classifier.
173    #[serde(default)]
174    pub errno_name: Option<String>,
175
176    /// Human-readable description from agentd. Always populated.
177    pub message: String,
178
179    /// Which step failed when the kind alone isn't enough — e.g.
180    /// `"execvp"`, `"setrlimit(RLIMIT_NOFILE)"`, `"posix_openpt"`.
181    #[serde(default)]
182    pub stage: Option<String>,
183}
184
185/// Coarse classification of an `ExecFailed` cause. The CLI's
186/// stage-to-hint mapper keys off this; the SDK exposes it directly
187/// for programmatic consumers.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "snake_case")]
190pub enum ExecFailureKind {
191    /// Binary not found on PATH or at the explicit path. ENOENT on
192    /// the binary path itself (not on the cwd — see `BadCwd`).
193    NotFound,
194
195    /// Binary found but not executable: EACCES or EPERM on file.
196    PermissionDenied,
197
198    /// File exists but the kernel can't run it: bad ELF, missing
199    /// interpreter for a shebang script, wrong architecture, etc.
200    /// ENOEXEC.
201    NotExecutable,
202
203    /// Working directory unusable: doesn't exist (ENOENT on cwd),
204    /// not a directory (ENOTDIR), or no permission to chdir.
205    BadCwd,
206
207    /// Argument or env list too large (E2BIG), too many symlinks
208    /// resolving the path (ELOOP), path too long (ENAMETOOLONG),
209    /// or invalid bytes in argv (e.g. interior NUL — EINVAL).
210    BadArgs,
211
212    /// Resource limit prevented the spawn: rejected `setrlimit`
213    /// (EPERM/EINVAL), per-process fork limit (EAGAIN with NPROC),
214    /// fd table exhaustion (EMFILE/ENFILE).
215    ResourceLimit,
216
217    /// User/group setup failed: requested user doesn't exist in the
218    /// sandbox, or `setuid`/`setgid` rejected (EPERM).
219    UserSetupFailed,
220
221    /// Memory pressure: kernel couldn't allocate (ENOMEM, or EAGAIN
222    /// on fork without an explicit rlimit cause).
223    OutOfMemory,
224
225    /// PTY allocation or attachment failed (pty mode only).
226    PtySetupFailed,
227
228    /// Anything else: `errno` is carried verbatim, `message` and
229    /// `stage` describe the specifics.
230    Other,
231}
232
233/// Request to resize the PTY of a running command.
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct ExecResize {
236    /// New number of rows.
237    pub rows: u16,
238
239    /// New number of columns.
240    pub cols: u16,
241}
242
243/// Request to send a signal to a running command.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct ExecSignal {
246    /// The signal number to send (e.g. 15 for SIGTERM).
247    pub signal: i32,
248}
249
250//--------------------------------------------------------------------------------------------------
251// Functions
252//--------------------------------------------------------------------------------------------------
253
254fn default_rows() -> u16 {
255    24
256}
257
258fn default_cols() -> u16 {
259    80
260}
261
262//--------------------------------------------------------------------------------------------------
263// Methods
264//--------------------------------------------------------------------------------------------------
265
266impl RlimitResource {
267    /// Returns the lowercase string representation used on the wire.
268    pub fn as_str(&self) -> &'static str {
269        match self {
270            Self::Cpu => "cpu",
271            Self::Fsize => "fsize",
272            Self::Data => "data",
273            Self::Stack => "stack",
274            Self::Core => "core",
275            Self::Rss => "rss",
276            Self::Nproc => "nproc",
277            Self::Nofile => "nofile",
278            Self::Memlock => "memlock",
279            Self::As => "as",
280            Self::Locks => "locks",
281            Self::Sigpending => "sigpending",
282            Self::Msgqueue => "msgqueue",
283            Self::Nice => "nice",
284            Self::Rtprio => "rtprio",
285            Self::Rttime => "rttime",
286        }
287    }
288}
289
290//--------------------------------------------------------------------------------------------------
291// Trait Implementations
292//--------------------------------------------------------------------------------------------------
293
294/// Case-insensitive string to [`RlimitResource`] conversion.
295impl TryFrom<&str> for RlimitResource {
296    type Error = String;
297
298    fn try_from(s: &str) -> Result<Self, Self::Error> {
299        match s.to_ascii_lowercase().as_str() {
300            "cpu" => Ok(Self::Cpu),
301            "fsize" => Ok(Self::Fsize),
302            "data" => Ok(Self::Data),
303            "stack" => Ok(Self::Stack),
304            "core" => Ok(Self::Core),
305            "rss" => Ok(Self::Rss),
306            "nproc" => Ok(Self::Nproc),
307            "nofile" => Ok(Self::Nofile),
308            "memlock" => Ok(Self::Memlock),
309            "as" => Ok(Self::As),
310            "locks" => Ok(Self::Locks),
311            "sigpending" => Ok(Self::Sigpending),
312            "msgqueue" => Ok(Self::Msgqueue),
313            "nice" => Ok(Self::Nice),
314            "rtprio" => Ok(Self::Rtprio),
315            "rttime" => Ok(Self::Rttime),
316            _ => Err(format!("unknown rlimit resource: {s}")),
317        }
318    }
319}
320
321impl FromStr for ExecRlimit {
322    type Err = String;
323
324    fn from_str(spec: &str) -> Result<Self, Self::Err> {
325        let (resource, limit) = spec
326            .split_once('=')
327            .ok_or_else(|| "rlimit must be in format RESOURCE=LIMIT".to_string())?;
328
329        let mut parts = limit.split(':');
330        let soft = parts
331            .next()
332            .ok_or_else(|| "missing soft limit".to_string())?
333            .parse::<u64>()
334            .map_err(|err| format!("invalid soft limit: {err}"))?;
335        let hard = match parts.next() {
336            Some(value) => value
337                .parse::<u64>()
338                .map_err(|err| format!("invalid hard limit: {err}"))?,
339            None => soft,
340        };
341
342        if parts.next().is_some() {
343            return Err("too many ':' separators".into());
344        }
345
346        if soft > hard {
347            return Err("soft limit cannot exceed hard limit".into());
348        }
349
350        Ok(Self {
351            resource: resource.to_ascii_lowercase(),
352            soft,
353            hard,
354        })
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::ExecRlimit;
361
362    #[test]
363    fn test_exec_rlimit_from_str_uses_soft_for_hard_when_omitted() {
364        assert_eq!(
365            "NOFILE=65535".parse::<ExecRlimit>().unwrap(),
366            ExecRlimit {
367                resource: "nofile".to_string(),
368                soft: 65_535,
369                hard: 65_535,
370            }
371        );
372    }
373
374    #[test]
375    fn test_exec_rlimit_from_str_parses_soft_and_hard() {
376        assert_eq!(
377            "nofile=4096:65535".parse::<ExecRlimit>().unwrap(),
378            ExecRlimit {
379                resource: "nofile".to_string(),
380                soft: 4_096,
381                hard: 65_535,
382            }
383        );
384    }
385
386    #[test]
387    fn test_exec_rlimit_from_str_rejects_soft_above_hard() {
388        let err = "nofile=65535:4096".parse::<ExecRlimit>().unwrap_err();
389        assert_eq!(err, "soft limit cannot exceed hard limit");
390    }
391}