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