Skip to main content

microvms_protocol/
exec.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Exec wire types: the bodies, queries, and SSE event payloads of `/v1/exec/*`.
3//!
4//! Every type here derives both halves of serde. The daemon needs only one half of
5//! each and a client needs the other, and a type that carries one half is a type the
6//! other side has to hand-write — which is the drift this crate exists to prevent.
7//! The published `docs/schema.json` is generated from these same attributes under
8//! both contracts, so a shape change is a schema diff rather than a surprise.
9
10use std::borrow::Cow;
11use std::collections::HashMap;
12
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16/// Where an exec sits in its lifecycle. Mirrors `ExecPhase` in the model crate.
17///
18/// `JsonSchema` rides along with `Serialize` on every type from here down that
19/// crosses the wire. schemars reads the same `#[serde(...)]` attributes serde
20/// does, so the published schema describes what the daemon actually emits — the
21/// `rename_all` below is the reason this matters rather than a formality.
22#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
23#[serde(rename_all = "snake_case")]
24pub enum Phase {
25    /// Child spawned, still running (or its pipes still held by a grandchild).
26    Running,
27    /// Child exited and output is buffered and readable.
28    Exited,
29    /// Caller acked; output has been released and the entry awaits collection.
30    Acked,
31}
32
33impl Phase {
34    /// Every phase, in lifecycle order.
35    ///
36    /// Public because a client that publishes the closed set — both bindings do, in
37    /// their `session_constants` — needs the list from the type rather than a spelled-out
38    /// copy that goes stale the first time a phase is added. The round-trip test below
39    /// holds `ALL` complete by exhaustive match.
40    pub const ALL: [Phase; 3] = [Phase::Running, Phase::Exited, Phase::Acked];
41
42    /// The wire spelling — the exact string serde writes under `rename_all` above.
43    ///
44    /// Here rather than in each client because two bindings each grew their own
45    /// three-arm match over this enum; a variant renamed on the wire must change
46    /// exactly one table, and the test below is what keeps this one equal to serde's.
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Phase::Running => "running",
50            Phase::Exited => "exited",
51            Phase::Acked => "acked",
52        }
53    }
54}
55
56/// Captured output and exit status of a finished exec.
57#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)]
58pub struct Outcome {
59    /// Exit code, or `None` when the child died to a signal.
60    pub exit_code: Option<i32>,
61    /// Signal number that killed the child, when one did.
62    pub signal: Option<i32>,
63    /// The daemon execution deadline fired. Absent on older daemons, which cannot
64    /// distinguish deadline expiry from other signal deaths.
65    #[serde(default)]
66    pub timed_out: bool,
67    pub stdout: String,
68    pub stderr: String,
69    /// Set when either stream hit `max_output_bytes` and was cut. An explicit
70    /// flag rather than a sentinel string in the output: a marker inside the
71    /// bytes is indistinguishable from output that happens to contain it.
72    pub truncated: bool,
73    /// Set when the post-exit linger deadline expired with the pipes still open,
74    /// meaning some grandchild is alive and may write more that nobody will see.
75    /// Reported rather than hidden, because a harness that sees empty output from
76    /// a command it knows produced some needs to be able to tell why.
77    pub writers_may_be_alive: bool,
78}
79
80/// Which pipe a streamed chunk came from. Both share one offset space, so a
81/// client holds one cursor rather than two that can disagree about ordering.
82#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum StreamKind {
85    Stdout,
86    Stderr,
87}
88
89impl StreamKind {
90    /// Both kinds, in the order the daemon documents them.
91    ///
92    /// Same reason as [`Phase::ALL`]: the bindings publish this closed set, and a
93    /// list they spell themselves is a list the enum can outgrow.
94    pub const ALL: [StreamKind; 2] = [StreamKind::Stdout, StreamKind::Stderr];
95
96    /// The wire spelling — the exact string serde writes under `rename_all` above.
97    pub const fn as_str(self) -> &'static str {
98        match self {
99            StreamKind::Stdout => "stdout",
100            StreamKind::Stderr => "stderr",
101        }
102    }
103}
104
105/// A user or group as a start request names it: a numeric id, or a name the daemon
106/// resolves against the guest's `/etc/passwd` or `/etc/group`.
107///
108/// Untagged, so the wire carries a bare JSON integer or a bare JSON string and nothing
109/// else. An integer serializes exactly as the `u32` this field used to be, which is what
110/// keeps a client that only ever sends ids byte-compatible with a daemon that predates
111/// names. A string sent to such a daemon is refused as `malformed_request` rather than
112/// misread.
113///
114/// A string that names no row and is all ASCII digits is read as that id, the way
115/// `docker exec -u 1000` reads it: a harness that carries a uid as a string reaches the
116/// same account an integer would.
117#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
118#[serde(untagged)]
119pub enum NameOrId {
120    /// A numeric uid or gid, applied as given.
121    Id(u32),
122    /// A user or group name, resolved in the guest before anything is spawned.
123    Name(String),
124}
125
126impl From<u32> for NameOrId {
127    fn from(id: u32) -> Self {
128        NameOrId::Id(id)
129    }
130}
131
132impl From<&str> for NameOrId {
133    fn from(name: &str) -> Self {
134        NameOrId::Name(name.to_string())
135    }
136}
137
138impl From<String> for NameOrId {
139    fn from(name: String) -> Self {
140        NameOrId::Name(name)
141    }
142}
143
144impl std::str::FromStr for NameOrId {
145    type Err = std::convert::Infallible;
146
147    /// A command-line spelling: all digits is an id, anything else is a name.
148    ///
149    /// An id is sent as an integer rather than as its digits so a daemon that predates
150    /// names still accepts it.
151    fn from_str(raw: &str) -> Result<Self, Self::Err> {
152        Ok(match raw.parse::<u32>() {
153            Ok(id) if raw.bytes().all(|b| b.is_ascii_digit()) => NameOrId::Id(id),
154            _ => NameOrId::Name(raw.to_string()),
155        })
156    }
157}
158
159impl std::fmt::Display for NameOrId {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        match self {
162            NameOrId::Id(id) => write!(f, "{id}"),
163            NameOrId::Name(name) => f.write_str(name),
164        }
165    }
166}
167
168/// How a start request's `command` is run: `false` (an argv), `true` (`/bin/sh -c`), or
169/// the name of a shell the daemon resolves and runs as `<shell> -c`.
170///
171/// Untagged for [`NameOrId`]'s reason: a boolean serializes exactly as the field always
172/// did, so an old daemon keeps accepting it, and a string sent to an old daemon is refused
173/// rather than misread.
174#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
175#[serde(untagged)]
176pub enum Shell {
177    /// `false` execs `command` as an argv; `true` runs it under `/bin/sh -c`.
178    Flag(bool),
179    /// A shell name such as `"bash"` (resolved on the child's `PATH`, the image's `PATH`,
180    /// `/bin` and `/usr/bin`) or an absolute path to one.
181    Named(String),
182}
183
184impl Default for Shell {
185    fn default() -> Self {
186        Shell::Flag(false)
187    }
188}
189
190impl From<bool> for Shell {
191    fn from(flag: bool) -> Self {
192        Shell::Flag(flag)
193    }
194}
195
196impl From<&str> for Shell {
197    fn from(name: &str) -> Self {
198        Shell::Named(name.to_string())
199    }
200}
201
202impl From<String> for Shell {
203    fn from(name: String) -> Self {
204        Shell::Named(name)
205    }
206}
207
208impl Shell {
209    /// Whether `command` is a script for a shell rather than an argv.
210    pub fn is_shell(&self) -> bool {
211        !matches!(self, Shell::Flag(false))
212    }
213}
214
215/// A start request. `command` is either an argv array or, with `shell` set, a single
216/// script string.
217#[derive(Debug, Deserialize, JsonSchema, Serialize)]
218pub struct StartRequest {
219    /// Caller-minted idempotency key. Harbor retries, and a retry must not
220    /// produce a second child.
221    pub exec_id: String,
222    /// argv when `shell` is false, or the script when it is set.
223    pub command: Vec<String>,
224    /// `false`, `true` (`/bin/sh -c`), or a shell name such as `"bash"`. A name the guest
225    /// cannot resolve answers 400 `unknown_shell` before anything is spawned.
226    #[serde(default)]
227    pub shell: Shell,
228    /// Omitted means inherit the daemon's working directory. See the module docs.
229    #[serde(default)]
230    pub cwd: Option<String>,
231    #[serde(default)]
232    pub env: HashMap<String, String>,
233    /// The user to demote to: a numeric uid, or a name resolved against the guest's
234    /// `/etc/passwd`. Optional; omitted means run as the daemon's own user. A name the
235    /// guest does not have answers 400 `unknown_user` before anything is spawned.
236    ///
237    /// A user with a passwd row, however it was given, gets `HOME`, `USER` and `LOGNAME`
238    /// from the row, beneath the launch environment and `env`. A user given by name also
239    /// gets the row's primary gid when `group` is omitted; a numeric uid keeps the
240    /// daemon's group, as it always did.
241    #[serde(default)]
242    pub user: Option<NameOrId>,
243    /// The group to demote to: a numeric gid, or a name resolved against `/etc/group`. A
244    /// name the guest does not have answers 400 `unknown_group`.
245    #[serde(default)]
246    pub group: Option<NameOrId>,
247    /// Wall-clock budget. Validated before the child spawns — the predecessor
248    /// raised on a bad value inside the waiter thread, by which point the child
249    /// was already running and became an orphan.
250    #[serde(default)]
251    pub timeout_sec: Option<f64>,
252    /// Whether to give the child a writable stdin pipe. Defaults to false, which
253    /// keeps `Stdio::null()`.
254    ///
255    /// Opt-in rather than always-on, and not only for tidiness: a child holding an
256    /// open stdin pipe nobody will ever write to is a child that blocks forever
257    /// the first time it reads. `/bin/sh` reading a script from stdin, `git`
258    /// deciding it can prompt, any tool that probes for input — all of them behave
259    /// differently against a pipe than against `/dev/null`. Every existing caller
260    /// gets today's behavior by not setting this.
261    #[serde(default)]
262    pub stdin: bool,
263    /// Whether to signal the exec's whole process group once its own child exits.
264    ///
265    /// Off by default, and the default is the contract this daemon has always had:
266    /// a backgrounded grandchild that inherited the output pipe keeps running and
267    /// keeps writing, and the linger deadline is what bounds how long the daemon
268    /// waits on it. That is the "backgrounded server keeps logging" guarantee, and
269    /// callers rely on it. Set to `true`, the daemon runs the same SIGTERM-then-
270    /// SIGKILL escalation `POST /v1/exec/{id}/kill` uses against the group as soon
271    /// as the child's exit is observed, so nothing the command left behind outlives
272    /// it and the linger sees EOF rather than `writers_may_be_alive`. A timed-out
273    /// exec is already escalated and is not escalated twice.
274    #[serde(default)]
275    pub reap_group_on_exit: bool,
276    /// Whether the child's environment starts from the image's `ENV`.
277    ///
278    /// Off by default, which keeps the property the daemon has always had: nothing from
279    /// its own environment reaches a child. Set, the environment the daemon inherited as
280    /// the container `CMD`, minus every `AGENTD_*` variable, is the lowest layer, under
281    /// the passwd identity, the launch environment and `env`. The agent token is never in
282    /// that snapshot: it arrives in the run hook, after the snapshot is taken.
283    ///
284    /// A daemon that predates this field ignores it, as serde ignores any unknown key; a
285    /// caller that depends on it can read `image_env_keys` on `GET /v1/health`, which such
286    /// a daemon does not report.
287    #[serde(default)]
288    pub inherit_image_env: bool,
289}
290
291/// `POST /v1/exec/{id}/stdin` body.
292///
293/// Both fields optional and both meaningful together: a final chunk plus EOF in
294/// one request is the common case for feeding a prompt, and forcing two round
295/// trips would leave a window where the child has the bytes but not the EOF that
296/// tells it the input is complete.
297#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
298pub struct StdinRequest {
299    /// Base64 so arbitrary bytes survive JSON. A JSON string cannot carry
300    /// non-UTF-8, and stdin is bytes.
301    #[serde(default)]
302    pub data_b64: Option<String>,
303    /// `"eof"` closes the pipe after any `data_b64` is written. Named rather than
304    /// a bare boolean so the field has somewhere to grow.
305    #[serde(default)]
306    pub signal: Option<String>,
307}
308
309/// `POST /v1/exec/{id}/stdin` response.
310///
311/// `pub` rather than private, like every other type in this module the schema
312/// route publishes: the generator names them by type, so a response shape that
313/// stays private is a shape a consumer cannot be told about.
314#[derive(Debug, Deserialize, JsonSchema, Serialize)]
315pub struct StdinResponse {
316    pub exec_id: String,
317    pub written: usize,
318    pub eof: bool,
319}
320
321/// `GET /v1/exec/{id}/stream` query.
322#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
323pub struct StreamQuery {
324    /// Byte offset to resume from. Absent means 0, i.e. everything still in the
325    /// replay window.
326    #[serde(default)]
327    pub offset: Option<u64>,
328}
329
330/// One `output` SSE event.
331#[derive(Debug, Deserialize, JsonSchema, Serialize)]
332pub struct OutputEvent {
333    pub offset: u64,
334    pub stream: StreamKind,
335    pub output: String,
336}
337
338/// One `gap` SSE event: the byte range a lagging or late subscriber lost.
339#[derive(Debug, Deserialize, JsonSchema, Serialize)]
340pub struct GapEvent {
341    pub from: u64,
342    pub to: u64,
343}
344
345/// The terminal `exit` SSE event. Emitted before the stream ends, so a client
346/// that sees the body close without one knows the connection failed rather than
347/// the command finishing.
348#[derive(Debug, Deserialize, JsonSchema, Serialize)]
349pub struct ExitEvent {
350    pub exit_code: Option<i32>,
351    pub signal: Option<i32>,
352    /// The daemon execution deadline fired. Absent on older daemons, which cannot
353    /// distinguish deadline expiry from other signal deaths.
354    #[serde(default)]
355    pub timed_out: bool,
356    pub truncated: bool,
357    pub writers_may_be_alive: bool,
358    /// Total bytes published, so a client can assert it saw all of them.
359    pub offset: u64,
360}
361
362#[derive(Debug, Deserialize, JsonSchema, Serialize)]
363pub struct StartResponse {
364    pub exec_id: String,
365    pub phase: Phase,
366}
367
368/// `POST /v1/exec/{id}/kill` response.
369///
370/// A named type rather than the `serde_json::json!` literal this used to be: an
371/// ad-hoc `Value` has no schema to derive, so the one route whose body a client
372/// most needs to branch on — `killed` distinguishes "signalled" from "the group
373/// was already gone", and both are 200 — would have been the one route the
374/// published document could not describe.
375#[derive(Debug, Deserialize, JsonSchema, Serialize)]
376pub struct KillResponse {
377    pub exec_id: String,
378    /// Whether a signal was actually delivered. `false` with a 200 means the
379    /// process group had already exited, which is the outcome a kill wanted.
380    pub killed: bool,
381}
382
383/// One entry of `GET /v1/procs`: an exec's process group as the daemon sees it.
384///
385/// The exec registry knows the group (`pgid`) and whether the direct child has
386/// exited; `/proc` knows which pids are still in that group. Both halves are here
387/// because the interesting case is their disagreement — `child_exited: true` with a
388/// non-empty `pids` is a command that finished while something it started did not,
389/// which is exactly what a caller asking "did this step leave anything behind"
390/// needs to see and could not before.
391#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
392pub struct ProcGroup {
393    pub exec_id: String,
394    /// The process group id captured at spawn. `None` when the child was reaped
395    /// before it could be read, in which case `pids` is empty because there is no
396    /// group to scan for.
397    pub pgid: Option<u32>,
398    /// Seconds since the epoch on the daemon's clock when the child was spawned.
399    /// The same convention as a hook observation's `fired_at`.
400    pub started_at: u64,
401    /// Whether the exec's own child has exited. Read from the terminal marker an
402    /// ack cannot take, so an acked exec still reads as exited here.
403    pub child_exited: bool,
404    /// Whether the exec was started with `reap_group_on_exit`.
405    pub reap: bool,
406    /// Live pids whose process group is `pgid`, read from `/proc`. Zombies are not
407    /// live and are not listed. Empty once the group is gone, and empty for an
408    /// entry whose `pgid` is `None`.
409    pub pids: Vec<u32>,
410}
411
412/// `GET /v1/procs` response.
413#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
414pub struct ProcsResponse {
415    /// Every registered exec, in registry order, whether or not anything in its
416    /// group is still alive. An exec that exited cleanly with nothing left behind
417    /// is listed with `child_exited: true` and `pids: []`.
418    pub procs: Vec<ProcGroup>,
419}
420
421#[derive(Debug, Deserialize, JsonSchema, Serialize)]
422pub struct PollResponse {
423    pub exec_id: String,
424    pub phase: Phase,
425    #[serde(skip_serializing_if = "Option::is_none")]
426    #[serde(flatten)]
427    pub result: Option<Outcome>,
428}
429
430/// The JSON error body every failing control route returns.
431///
432/// `error` is a stable machine-readable slug and `detail` is prose for a human
433/// reading a log. A client branches on `error` and the status code, never on
434/// `detail` — which is why the slug is one of the constants below, chosen at each
435/// call site, rather than a formatted string. `Cow` so the daemon keeps naming a
436/// `&'static str` while a client deserializes into an owned one.
437#[derive(Debug, Deserialize, JsonSchema, Serialize)]
438pub struct ErrorBody {
439    pub error: Cow<'static, str>,
440    pub detail: String,
441}
442
443/// The `event:` names on `GET /v1/exec/{id}/stream`.
444///
445/// Named rather than spelled at each `Event::default().event(..)` call: the daemon
446/// writes these and a client matches on them, and a typo on either side is a
447/// stream that silently carries events nobody dispatches.
448pub const EVENT_OUTPUT: &str = "output";
449pub const EVENT_GAP: &str = "gap";
450pub const EVENT_EXIT: &str = "exit";
451
452// The `ErrorBody::error` slugs. Every failing control route returns one of these,
453// they are the part of the surface a client branches on, and they are `&'static
454// str` for the same reason the status code is a number: the pairing of the two is
455// the contract, and neither is derivable from the other.
456
457/// The body is not a valid request for this route. Always 400, never 404.
458pub const ERROR_MALFORMED_REQUEST: &str = "malformed_request";
459/// No exec is registered under this id, or its entry was collected after an ack.
460pub const ERROR_UNKNOWN_EXEC: &str = "unknown_exec";
461/// The child could not be spawned. 500, deliberately not 404.
462pub const ERROR_SPAWN_FAILED: &str = "spawn_failed";
463/// The exec has not exited, so acking would drop output still being written.
464pub const ERROR_STILL_RUNNING: &str = "still_running";
465/// An earlier ack already released the output.
466pub const ERROR_ALREADY_ACKED: &str = "already_acked";
467/// The exec was started without `stdin: true`. Fixable at start time, hence 409.
468pub const ERROR_STDIN_NOT_REQUESTED: &str = "stdin_not_requested";
469/// stdin was closed by an earlier eof, or the child stopped reading. 410: a retry
470/// will never succeed.
471pub const ERROR_STDIN_CLOSED: &str = "stdin_closed";
472/// The child did not read within the configured window. Retryable, and some bytes
473/// may already have been written.
474pub const ERROR_STDIN_WRITE_TIMEOUT: &str = "stdin_write_timeout";
475/// The decoded write exceeds the configured per-write cap.
476pub const ERROR_STDIN_WRITE_TOO_LARGE: &str = "stdin_write_too_large";
477/// The write to the pipe failed for a reason other than a broken pipe.
478pub const ERROR_STDIN_WRITE_FAILED: &str = "stdin_write_failed";
479/// A start request named a user the guest's `/etc/passwd` does not contain. 400, and
480/// nothing was spawned; `detail` names the user.
481pub const ERROR_UNKNOWN_USER: &str = "unknown_user";
482/// A start request named a group the guest's `/etc/group` does not contain. 400, and
483/// nothing was spawned; `detail` names the group.
484pub const ERROR_UNKNOWN_GROUP: &str = "unknown_group";
485/// A start request named a shell that no searched directory holds as an executable
486/// file. 400, and nothing was spawned; `detail` names the shell and where it looked.
487pub const ERROR_UNKNOWN_SHELL: &str = "unknown_shell";
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    /// The `rename_all` is the reason these types are shared rather than mirrored:
494    /// a client that spelled the variants from the Rust identifiers would send
495    /// `Running` where the daemon emits `running`.
496    #[test]
497    fn phase_and_stream_kind_round_trip_through_their_serde_names() {
498        assert_eq!(
499            serde_json::to_string(&Phase::Running).expect("serializes"),
500            "\"running\""
501        );
502        assert_eq!(
503            serde_json::from_str::<Phase>("\"acked\"").expect("deserializes"),
504            Phase::Acked
505        );
506        assert_eq!(
507            serde_json::to_string(&StreamKind::Stderr).expect("serializes"),
508            "\"stderr\""
509        );
510    }
511
512    /// `as_str` is a second spelling of what serde already writes, so the two must be
513    /// equal for every variant or the bindings publish names the wire never carries.
514    ///
515    /// `ALL` is held complete by exhaustion rather than by a length check: the matches
516    /// below have no wildcard arm, so a variant added to either enum fails to compile
517    /// here until it is added to its `ALL` too.
518    #[test]
519    fn as_str_agrees_with_serde_for_every_phase_and_stream_kind() {
520        for phase in Phase::ALL {
521            assert_eq!(
522                serde_json::to_string(&phase).expect("serializes"),
523                format!("\"{}\"", phase.as_str()),
524                "{phase:?} spells differently through as_str and serde"
525            );
526            match phase {
527                Phase::Running | Phase::Exited | Phase::Acked => {}
528            }
529        }
530        for kind in StreamKind::ALL {
531            assert_eq!(
532                serde_json::to_string(&kind).expect("serializes"),
533                format!("\"{}\"", kind.as_str()),
534                "{kind:?} spells differently through as_str and serde"
535            );
536            match kind {
537                StreamKind::Stdout | StreamKind::Stderr => {}
538            }
539        }
540    }
541
542    /// Both halves of serde on one type is the whole point of the crate: what the
543    /// daemon writes is what a client reads, checked rather than assumed.
544    #[test]
545    fn a_response_the_daemon_writes_deserializes_on_the_client_side() {
546        let written = serde_json::to_string(&StartResponse {
547            exec_id: "e1".into(),
548            phase: Phase::Running,
549        })
550        .expect("serializes");
551        let read: StartResponse = serde_json::from_str(&written).expect("deserializes");
552        assert_eq!(read.exec_id, "e1");
553        assert_eq!(read.phase, Phase::Running);
554    }
555
556    /// A request the client writes is one the daemon's extractor accepts, including
557    /// every serde default — the fields a caller omits are the ones most likely to
558    /// disagree across a hand-written mirror.
559    #[test]
560    fn a_start_request_omitting_every_defaulted_field_deserializes() {
561        let request: StartRequest =
562            serde_json::from_str(r#"{"exec_id":"e1","command":["true"]}"#).expect("deserializes");
563        assert_eq!(request.shell, Shell::Flag(false));
564        assert!(!request.shell.is_shell());
565        assert!(request.user.is_none() && request.group.is_none());
566        assert!(
567            !request.inherit_image_env,
568            "inheriting the image env is opt-in: an old client keeps the exact launch map"
569        );
570        assert!(request.cwd.is_none());
571        assert!(request.env.is_empty());
572        assert!(request.timeout_sec.is_none());
573        assert!(!request.stdin);
574        assert!(
575            !request.reap_group_on_exit,
576            "reaping is opt-in: an old client that never heard of the flag keeps today's \
577             grandchild-output guarantee"
578        );
579    }
580
581    /// The union fields read both spellings and write each back as it came, so a client
582    /// that only ever sends integers and booleans sends the bytes it always did.
583    #[test]
584    fn user_group_and_shell_read_both_spellings_and_write_them_back() {
585        let request: StartRequest = serde_json::from_str(
586            r#"{"exec_id":"e1","command":["id"],"user":1000,"group":"staff","shell":"bash","inherit_image_env":true}"#,
587        )
588        .expect("deserializes");
589        assert_eq!(request.user, Some(NameOrId::Id(1000)));
590        assert_eq!(request.group, Some(NameOrId::Name("staff".into())));
591        assert_eq!(request.shell, Shell::Named("bash".into()));
592        assert!(request.inherit_image_env);
593
594        let written = serde_json::to_value(&request).expect("serializes");
595        assert_eq!(written["user"], 1000);
596        assert_eq!(written["group"], "staff");
597        assert_eq!(written["shell"], "bash");
598
599        let legacy: StartRequest =
600            serde_json::from_str(r#"{"exec_id":"e1","command":["id"],"shell":true,"user":0}"#)
601                .expect("deserializes");
602        assert_eq!(legacy.shell, Shell::Flag(true));
603        let written = serde_json::to_value(&legacy).expect("serializes");
604        assert_eq!(written["shell"], true);
605        assert_eq!(written["user"], 0);
606    }
607
608    /// Anything but an integer or a string for a user, or a boolean or a string for a
609    /// shell, is a malformed body rather than a coerced value.
610    #[test]
611    fn other_json_types_for_the_union_fields_are_refused() {
612        for body in [
613            r#"{"exec_id":"e1","command":["id"],"user":-1}"#,
614            r#"{"exec_id":"e1","command":["id"],"user":1.5}"#,
615            r#"{"exec_id":"e1","command":["id"],"user":["root"]}"#,
616            r#"{"exec_id":"e1","command":["id"],"group":{"name":"x"}}"#,
617            r#"{"exec_id":"e1","command":["id"],"shell":1}"#,
618            r#"{"exec_id":"e1","command":["id"],"shell":null}"#,
619        ] {
620            assert!(
621                serde_json::from_str::<StartRequest>(body).is_err(),
622                "accepted {body}"
623            );
624        }
625    }
626
627    /// The command-line spelling: digits are an id and go out as an integer.
628    #[test]
629    fn a_flag_of_digits_is_an_id_and_anything_else_a_name() {
630        assert_eq!("1000".parse::<NameOrId>(), Ok(NameOrId::Id(1000)));
631        assert_eq!("0".parse::<NameOrId>(), Ok(NameOrId::Id(0)));
632        assert_eq!(
633            "agent".parse::<NameOrId>(),
634            Ok(NameOrId::Name("agent".into()))
635        );
636        assert_eq!("+1".parse::<NameOrId>(), Ok(NameOrId::Name("+1".into())));
637        assert_eq!(
638            "99999999999".parse::<NameOrId>(),
639            Ok(NameOrId::Name("99999999999".into()))
640        );
641    }
642
643    /// The procs body round-trips with every field present, `pgid: null` included, so a
644    /// client can tell "no group was ever captured" from "the group is empty".
645    #[test]
646    fn a_procs_response_round_trips_with_a_missing_pgid() {
647        let written = serde_json::to_string(&ProcsResponse {
648            procs: vec![ProcGroup {
649                exec_id: "e1".into(),
650                pgid: None,
651                started_at: 1_756_500_000,
652                child_exited: true,
653                reap: false,
654                pids: Vec::new(),
655            }],
656        })
657        .expect("serializes");
658        assert_eq!(
659            written,
660            r#"{"procs":[{"exec_id":"e1","pgid":null,"started_at":1756500000,"child_exited":true,"reap":false,"pids":[]}]}"#
661        );
662        let read: ProcsResponse = serde_json::from_str(&written).expect("deserializes");
663        assert_eq!(read.procs.len(), 1);
664        assert_eq!(read.procs[0].pgid, None);
665        assert!(read.procs[0].child_exited);
666    }
667
668    /// The flatten-plus-skip on `PollResponse.result` is the one shape whose two
669    /// serde contracts genuinely differ — absent on the way out while running, and
670    /// the same absence has to read back as `None` on the way in. Both directions,
671    /// because a client that read a running exec's missing outcome as an error would
672    /// fail on every poll before the first one that mattered.
673    #[test]
674    fn a_poll_response_round_trips_with_and_without_an_outcome() {
675        let running = serde_json::to_string(&PollResponse {
676            exec_id: "e1".into(),
677            phase: Phase::Running,
678            result: None,
679        })
680        .expect("serializes");
681        assert_eq!(running, r#"{"exec_id":"e1","phase":"running"}"#);
682        let read: PollResponse = serde_json::from_str(&running).expect("deserializes");
683        assert!(read.result.is_none());
684
685        let exited = serde_json::to_string(&PollResponse {
686            exec_id: "e1".into(),
687            phase: Phase::Exited,
688            result: Some(Outcome {
689                exit_code: Some(0),
690                stdout: "hi".into(),
691                ..Outcome::default()
692            }),
693        })
694        .expect("serializes");
695        let read: PollResponse = serde_json::from_str(&exited).expect("deserializes");
696        let outcome = read.result.expect("the outcome is flattened into the body");
697        assert_eq!(outcome.exit_code, Some(0));
698        assert_eq!(outcome.stdout, "hi");
699    }
700
701    /// `error` is a `Cow`, so the daemon's borrowed slug and a client's owned string
702    /// are the same type on the wire.
703    #[test]
704    fn an_error_body_carries_a_borrowed_slug_and_reads_back_owned() {
705        let written = serde_json::to_string(&ErrorBody {
706            error: Cow::Borrowed(ERROR_UNKNOWN_EXEC),
707            detail: "e1".into(),
708        })
709        .expect("serializes");
710        assert_eq!(written, r#"{"error":"unknown_exec","detail":"e1"}"#);
711        let read: ErrorBody = serde_json::from_str(&written).expect("deserializes");
712        assert_eq!(read.error, ERROR_UNKNOWN_EXEC);
713    }
714}