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/// Stdout data from a running command.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ExecStdout {
121 /// The raw output data.
122 #[serde(with = "serde_bytes")]
123 pub data: Vec<u8>,
124}
125
126/// Stderr data from a running command.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct ExecStderr {
129 /// The raw error output data.
130 #[serde(with = "serde_bytes")]
131 pub data: Vec<u8>,
132}
133
134/// Notification that a command has exited.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ExecExited {
137 /// The exit code of the process.
138 pub code: i32,
139}
140
141/// Notification that a command failed to start (the user's program
142/// never got to run). Distinct from `ExecExited`, which means the
143/// process ran and reported an exit code.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct ExecFailed {
146 /// Coarse classification used by the CLI/SDK to pick hints.
147 pub kind: ExecFailureKind,
148
149 /// `errno` if the underlying failure was a syscall.
150 #[serde(default)]
151 pub errno: Option<i32>,
152
153 /// Standard errno name like `"ENOENT"`. Easier to grep than the
154 /// raw number; populated by the agentd classifier.
155 #[serde(default)]
156 pub errno_name: Option<String>,
157
158 /// Human-readable description from agentd. Always populated.
159 pub message: String,
160
161 /// Which step failed when the kind alone isn't enough — e.g.
162 /// `"execvp"`, `"setrlimit(RLIMIT_NOFILE)"`, `"posix_openpt"`.
163 #[serde(default)]
164 pub stage: Option<String>,
165}
166
167/// Coarse classification of an `ExecFailed` cause. The CLI's
168/// stage-to-hint mapper keys off this; the SDK exposes it directly
169/// for programmatic consumers.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum ExecFailureKind {
173 /// Binary not found on PATH or at the explicit path. ENOENT on
174 /// the binary path itself (not on the cwd — see `BadCwd`).
175 NotFound,
176
177 /// Binary found but not executable: EACCES or EPERM on file.
178 PermissionDenied,
179
180 /// File exists but the kernel can't run it: bad ELF, missing
181 /// interpreter for a shebang script, wrong architecture, etc.
182 /// ENOEXEC.
183 NotExecutable,
184
185 /// Working directory unusable: doesn't exist (ENOENT on cwd),
186 /// not a directory (ENOTDIR), or no permission to chdir.
187 BadCwd,
188
189 /// Argument or env list too large (E2BIG), too many symlinks
190 /// resolving the path (ELOOP), path too long (ENAMETOOLONG),
191 /// or invalid bytes in argv (e.g. interior NUL — EINVAL).
192 BadArgs,
193
194 /// Resource limit prevented the spawn: rejected `setrlimit`
195 /// (EPERM/EINVAL), per-process fork limit (EAGAIN with NPROC),
196 /// fd table exhaustion (EMFILE/ENFILE).
197 ResourceLimit,
198
199 /// User/group setup failed: requested user doesn't exist in the
200 /// sandbox, or `setuid`/`setgid` rejected (EPERM).
201 UserSetupFailed,
202
203 /// Memory pressure: kernel couldn't allocate (ENOMEM, or EAGAIN
204 /// on fork without an explicit rlimit cause).
205 OutOfMemory,
206
207 /// PTY allocation or attachment failed (pty mode only).
208 PtySetupFailed,
209
210 /// Anything else: `errno` is carried verbatim, `message` and
211 /// `stage` describe the specifics.
212 Other,
213}
214
215/// Request to resize the PTY of a running command.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct ExecResize {
218 /// New number of rows.
219 pub rows: u16,
220
221 /// New number of columns.
222 pub cols: u16,
223}
224
225/// Request to send a signal to a running command.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct ExecSignal {
228 /// The signal number to send (e.g. 15 for SIGTERM).
229 pub signal: i32,
230}
231
232//--------------------------------------------------------------------------------------------------
233// Functions
234//--------------------------------------------------------------------------------------------------
235
236fn default_rows() -> u16 {
237 24
238}
239
240fn default_cols() -> u16 {
241 80
242}
243
244//--------------------------------------------------------------------------------------------------
245// Methods
246//--------------------------------------------------------------------------------------------------
247
248impl RlimitResource {
249 /// Returns the lowercase string representation used on the wire.
250 pub fn as_str(&self) -> &'static str {
251 match self {
252 Self::Cpu => "cpu",
253 Self::Fsize => "fsize",
254 Self::Data => "data",
255 Self::Stack => "stack",
256 Self::Core => "core",
257 Self::Rss => "rss",
258 Self::Nproc => "nproc",
259 Self::Nofile => "nofile",
260 Self::Memlock => "memlock",
261 Self::As => "as",
262 Self::Locks => "locks",
263 Self::Sigpending => "sigpending",
264 Self::Msgqueue => "msgqueue",
265 Self::Nice => "nice",
266 Self::Rtprio => "rtprio",
267 Self::Rttime => "rttime",
268 }
269 }
270}
271
272//--------------------------------------------------------------------------------------------------
273// Trait Implementations
274//--------------------------------------------------------------------------------------------------
275
276/// Case-insensitive string to [`RlimitResource`] conversion.
277impl TryFrom<&str> for RlimitResource {
278 type Error = String;
279
280 fn try_from(s: &str) -> Result<Self, Self::Error> {
281 match s.to_ascii_lowercase().as_str() {
282 "cpu" => Ok(Self::Cpu),
283 "fsize" => Ok(Self::Fsize),
284 "data" => Ok(Self::Data),
285 "stack" => Ok(Self::Stack),
286 "core" => Ok(Self::Core),
287 "rss" => Ok(Self::Rss),
288 "nproc" => Ok(Self::Nproc),
289 "nofile" => Ok(Self::Nofile),
290 "memlock" => Ok(Self::Memlock),
291 "as" => Ok(Self::As),
292 "locks" => Ok(Self::Locks),
293 "sigpending" => Ok(Self::Sigpending),
294 "msgqueue" => Ok(Self::Msgqueue),
295 "nice" => Ok(Self::Nice),
296 "rtprio" => Ok(Self::Rtprio),
297 "rttime" => Ok(Self::Rttime),
298 _ => Err(format!("unknown rlimit resource: {s}")),
299 }
300 }
301}
302
303impl FromStr for ExecRlimit {
304 type Err = String;
305
306 fn from_str(spec: &str) -> Result<Self, Self::Err> {
307 let (resource, limit) = spec
308 .split_once('=')
309 .ok_or_else(|| "rlimit must be in format RESOURCE=LIMIT".to_string())?;
310
311 let mut parts = limit.split(':');
312 let soft = parts
313 .next()
314 .ok_or_else(|| "missing soft limit".to_string())?
315 .parse::<u64>()
316 .map_err(|err| format!("invalid soft limit: {err}"))?;
317 let hard = match parts.next() {
318 Some(value) => value
319 .parse::<u64>()
320 .map_err(|err| format!("invalid hard limit: {err}"))?,
321 None => soft,
322 };
323
324 if parts.next().is_some() {
325 return Err("too many ':' separators".into());
326 }
327
328 if soft > hard {
329 return Err("soft limit cannot exceed hard limit".into());
330 }
331
332 Ok(Self {
333 resource: resource.to_ascii_lowercase(),
334 soft,
335 hard,
336 })
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::ExecRlimit;
343
344 #[test]
345 fn test_exec_rlimit_from_str_uses_soft_for_hard_when_omitted() {
346 assert_eq!(
347 "NOFILE=65535".parse::<ExecRlimit>().unwrap(),
348 ExecRlimit {
349 resource: "nofile".to_string(),
350 soft: 65_535,
351 hard: 65_535,
352 }
353 );
354 }
355
356 #[test]
357 fn test_exec_rlimit_from_str_parses_soft_and_hard() {
358 assert_eq!(
359 "nofile=4096:65535".parse::<ExecRlimit>().unwrap(),
360 ExecRlimit {
361 resource: "nofile".to_string(),
362 soft: 4_096,
363 hard: 65_535,
364 }
365 );
366 }
367
368 #[test]
369 fn test_exec_rlimit_from_str_rejects_soft_above_hard() {
370 let err = "nofile=65535:4096".parse::<ExecRlimit>().unwrap_err();
371 assert_eq!(err, "soft limit cannot exceed hard limit");
372 }
373}