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/// A POSIX resource limit to apply to a spawned process.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct ExecRlimit {
53    /// Resource name (lowercase): "nofile", "nproc", "as", "cpu", etc.
54    pub resource: String,
55
56    /// Soft limit (can be raised up to hard limit by the process).
57    pub soft: u64,
58
59    /// Hard limit (ceiling, requires privileges to raise).
60    pub hard: u64,
61}
62
63/// Confirmation that a command has been started.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ExecStarted {
66    /// The PID of the spawned process.
67    pub pid: u32,
68}
69
70/// Stdin data sent to a running command.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ExecStdin {
73    /// The raw input data.
74    #[serde(with = "serde_bytes")]
75    pub data: Vec<u8>,
76}
77
78/// Notification that an `ExecStdin` write to the child's stdin failed.
79/// The most common cause is the child closing its read end (EPIPE);
80/// the session is otherwise alive and may still produce output and an
81/// exit code, so this is delivered as a non-terminal event.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ExecStdinError {
84    /// `errno` from the underlying write, if available.
85    #[serde(default)]
86    pub errno: Option<i32>,
87
88    /// Standard errno name like `"EPIPE"`, populated when `errno` is.
89    #[serde(default)]
90    pub errno_name: Option<String>,
91
92    /// Human-readable description from agentd.
93    pub message: String,
94}
95
96/// Stdout data from a running command.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ExecStdout {
99    /// The raw output data.
100    #[serde(with = "serde_bytes")]
101    pub data: Vec<u8>,
102}
103
104/// Stderr data from a running command.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct ExecStderr {
107    /// The raw error output data.
108    #[serde(with = "serde_bytes")]
109    pub data: Vec<u8>,
110}
111
112/// Notification that a command has exited.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct ExecExited {
115    /// The exit code of the process.
116    pub code: i32,
117}
118
119/// Notification that a command failed to start (the user's program
120/// never got to run). Distinct from `ExecExited`, which means the
121/// process ran and reported an exit code.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ExecFailed {
124    /// Coarse classification used by the CLI/SDK to pick hints.
125    pub kind: ExecFailureKind,
126
127    /// `errno` if the underlying failure was a syscall.
128    #[serde(default)]
129    pub errno: Option<i32>,
130
131    /// Standard errno name like `"ENOENT"`. Easier to grep than the
132    /// raw number; populated by the agentd classifier.
133    #[serde(default)]
134    pub errno_name: Option<String>,
135
136    /// Human-readable description from agentd. Always populated.
137    pub message: String,
138
139    /// Which step failed when the kind alone isn't enough — e.g.
140    /// `"execvp"`, `"setrlimit(RLIMIT_NOFILE)"`, `"posix_openpt"`.
141    #[serde(default)]
142    pub stage: Option<String>,
143}
144
145/// Coarse classification of an `ExecFailed` cause. The CLI's
146/// stage-to-hint mapper keys off this; the SDK exposes it directly
147/// for programmatic consumers.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ExecFailureKind {
151    /// Binary not found on PATH or at the explicit path. ENOENT on
152    /// the binary path itself (not on the cwd — see `BadCwd`).
153    NotFound,
154
155    /// Binary found but not executable: EACCES or EPERM on file.
156    PermissionDenied,
157
158    /// File exists but the kernel can't run it: bad ELF, missing
159    /// interpreter for a shebang script, wrong architecture, etc.
160    /// ENOEXEC.
161    NotExecutable,
162
163    /// Working directory unusable: doesn't exist (ENOENT on cwd),
164    /// not a directory (ENOTDIR), or no permission to chdir.
165    BadCwd,
166
167    /// Argument or env list too large (E2BIG), too many symlinks
168    /// resolving the path (ELOOP), path too long (ENAMETOOLONG),
169    /// or invalid bytes in argv (e.g. interior NUL — EINVAL).
170    BadArgs,
171
172    /// Resource limit prevented the spawn: rejected `setrlimit`
173    /// (EPERM/EINVAL), per-process fork limit (EAGAIN with NPROC),
174    /// fd table exhaustion (EMFILE/ENFILE).
175    ResourceLimit,
176
177    /// User/group setup failed: requested user doesn't exist in the
178    /// sandbox, or `setuid`/`setgid` rejected (EPERM).
179    UserSetupFailed,
180
181    /// Memory pressure: kernel couldn't allocate (ENOMEM, or EAGAIN
182    /// on fork without an explicit rlimit cause).
183    OutOfMemory,
184
185    /// PTY allocation or attachment failed (pty mode only).
186    PtySetupFailed,
187
188    /// Anything else: `errno` is carried verbatim, `message` and
189    /// `stage` describe the specifics.
190    Other,
191}
192
193/// Request to resize the PTY of a running command.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct ExecResize {
196    /// New number of rows.
197    pub rows: u16,
198
199    /// New number of columns.
200    pub cols: u16,
201}
202
203/// Request to send a signal to a running command.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct ExecSignal {
206    /// The signal number to send (e.g. 15 for SIGTERM).
207    pub signal: i32,
208}
209
210//--------------------------------------------------------------------------------------------------
211// Functions
212//--------------------------------------------------------------------------------------------------
213
214fn default_rows() -> u16 {
215    24
216}
217
218fn default_cols() -> u16 {
219    80
220}
221
222impl FromStr for ExecRlimit {
223    type Err = String;
224
225    fn from_str(spec: &str) -> Result<Self, Self::Err> {
226        let (resource, limit) = spec
227            .split_once('=')
228            .ok_or_else(|| "rlimit must be in format RESOURCE=LIMIT".to_string())?;
229
230        let mut parts = limit.split(':');
231        let soft = parts
232            .next()
233            .ok_or_else(|| "missing soft limit".to_string())?
234            .parse::<u64>()
235            .map_err(|err| format!("invalid soft limit: {err}"))?;
236        let hard = match parts.next() {
237            Some(value) => value
238                .parse::<u64>()
239                .map_err(|err| format!("invalid hard limit: {err}"))?,
240            None => soft,
241        };
242
243        if parts.next().is_some() {
244            return Err("too many ':' separators".into());
245        }
246
247        if soft > hard {
248            return Err("soft limit cannot exceed hard limit".into());
249        }
250
251        Ok(Self {
252            resource: resource.to_ascii_lowercase(),
253            soft,
254            hard,
255        })
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::ExecRlimit;
262
263    #[test]
264    fn test_exec_rlimit_from_str_uses_soft_for_hard_when_omitted() {
265        assert_eq!(
266            "NOFILE=65535".parse::<ExecRlimit>().unwrap(),
267            ExecRlimit {
268                resource: "nofile".to_string(),
269                soft: 65_535,
270                hard: 65_535,
271            }
272        );
273    }
274
275    #[test]
276    fn test_exec_rlimit_from_str_parses_soft_and_hard() {
277        assert_eq!(
278            "nofile=4096:65535".parse::<ExecRlimit>().unwrap(),
279            ExecRlimit {
280                resource: "nofile".to_string(),
281                soft: 4_096,
282                hard: 65_535,
283            }
284        );
285    }
286
287    #[test]
288    fn test_exec_rlimit_from_str_rejects_soft_above_hard() {
289        let err = "nofile=65535:4096".parse::<ExecRlimit>().unwrap_err();
290        assert_eq!(err, "soft limit cannot exceed hard limit");
291    }
292}
293
294//--------------------------------------------------------------------------------------------------
295// Re-Exports
296//--------------------------------------------------------------------------------------------------
297
298pub use microsandbox_types::RlimitResource;