Skip to main content

gatehouse_runtime_protocol/lifecycle_v2/
native_exec.rs

1//! Source-pinned native mutation wire helpers; no dispatch or retry authority.
2//! Callers must durably reserve each mutation before sending, retain ambiguous
3//! outcomes, and never reconstruct/replay create or start after response loss.
4use super::{native_json, native_status::inspect_route, observation::API};
5use serde::{Deserialize, Serialize};
6type Result<T> = std::result::Result<T, &'static str>;
7
8#[derive(Debug)]
9pub struct HealthPlan {
10    container: String,
11    argv: Vec<String>,
12}
13#[derive(Serialize)]
14#[serde(rename_all = "PascalCase")]
15struct ExecCreate<'a> {
16    user: &'a str,
17    privileged: bool,
18    tty: bool,
19    attach_stdin: bool,
20    attach_stderr: bool,
21    attach_stdout: bool,
22    detach_keys: &'a str,
23    env: [&'a str; 0],
24    working_dir: &'a str,
25    cmd: &'a [String],
26}
27impl HealthPlan {
28    pub fn new(container: &str, argv: Vec<String>) -> Result<Self> {
29        inspect_route(container)?;
30        if argv.is_empty()
31            || argv.len() > 128
32            || argv[0].is_empty()
33            || argv.iter().any(|a| a.len() > 8192 || a.contains('\0'))
34            || argv.iter().map(String::len).sum::<usize>() > 32768
35        {
36            return Err("bounded explicit health argv required");
37        }
38        Ok(Self {
39            container: container.into(),
40            argv,
41        })
42    }
43    pub fn create_route(&self) -> String {
44        format!("/v{API}/libpod/containers/{}/exec", self.container)
45    }
46    pub fn create_body(&self) -> Result<Vec<u8>> {
47        serde_json::to_vec(&ExecCreate {
48            user: "",
49            privileged: false,
50            tty: false,
51            attach_stdin: false,
52            attach_stderr: false,
53            attach_stdout: false,
54            detach_keys: "",
55            env: [],
56            working_dir: "",
57            cmd: &self.argv,
58        })
59        .map_err(|_| "exec encoding failed")
60    }
61    pub fn bind_created(&self, status: u16, body: &[u8]) -> Result<String> {
62        let v = decode_created(status, body, false)?;
63        Ok(v)
64    }
65    /// Match exact exec and container, selected process fields and terminal state.
66    /// CanRemove is necessary: an unstarted exec also reports Running=false/0.
67    pub fn inspect(&self, exec: &str, status: u16, body: &[u8]) -> Result<HealthObservation> {
68        exec_route(exec, false)?;
69        if status != 200 {
70            return Err("exec inspect unavailable");
71        }
72        let i: ExecInspect = serde_json::from_value(native_json::decode(body)?)
73            .map_err(|_| "exec inspect fields rejected")?;
74        let p = i.process_config;
75        if i.id != exec
76            || i.container_id != self.container
77            || i.open_stdin
78            || i.open_stdout
79            || i.open_stderr
80            || p.privileged
81            || p.tty
82            || !p.user.is_empty()
83            || p.entrypoint != self.argv[0]
84            || p.arguments.unwrap_or_default() != self.argv[1..]
85        {
86            return Err("exec identity or process mismatch");
87        }
88        if i.running {
89            if i.can_remove {
90                return Err("inconsistent exec state");
91            }
92            return Ok(HealthObservation::Pending);
93        }
94        if !i.can_remove {
95            return Ok(HealthObservation::Pending);
96        }
97        if !(0..=255).contains(&i.exit_code) {
98            return Err("invalid exec exit code");
99        }
100        Ok(HealthObservation::Completed(i.exit_code as u8))
101    }
102}
103#[derive(Debug, PartialEq, Eq)]
104pub enum HealthObservation {
105    Pending,
106    Completed(u8),
107}
108
109#[derive(Deserialize)]
110#[serde(deny_unknown_fields)]
111struct IdResponse {
112    #[serde(rename = "Id")]
113    id: String,
114}
115#[derive(Deserialize)]
116#[serde(deny_unknown_fields)]
117struct CreateResponse {
118    #[serde(rename = "Id")]
119    id: String,
120    #[serde(rename = "Warnings")]
121    warnings: Vec<String>,
122}
123/// Container response only. Nonempty or null/missing warnings fail closed;
124/// native create request, ownership inspect and durable unknown retention remain gates.
125pub fn decode_created(status: u16, body: &[u8], container: bool) -> Result<String> {
126    if status != 201 {
127        return Err("mutation response not created");
128    }
129    let value = native_json::decode(body)?;
130    let id = if container {
131        let r: CreateResponse =
132            serde_json::from_value(value).map_err(|_| "container create fields rejected")?;
133        if !r.warnings.is_empty() {
134            return Err("container create warnings require review");
135        }
136        r.id
137    } else {
138        serde_json::from_value::<IdResponse>(value)
139            .map_err(|_| "exec create fields rejected")?
140            .id
141    };
142    inspect_route(&id)?;
143    Ok(id)
144}
145pub fn exec_route(exec: &str, start: bool) -> Result<String> {
146    inspect_route(exec)?;
147    Ok(format!(
148        "/v{API}/libpod/exec/{exec}/{}",
149        if start { "start" } else { "json" }
150    ))
151}
152pub const DETACHED_START_BODY: &[u8] = br#"{"Detach":true,"Tty":false,"h":0,"w":0}"#;
153/// Empty 200 acknowledges start only, never health completion.
154pub fn check_started(status: u16, body: &[u8]) -> Result<()> {
155    if status == 200 && body.is_empty() {
156        Ok(())
157    } else {
158        Err("exec start outcome unknown")
159    }
160}
161#[derive(Deserialize)]
162#[serde(rename_all = "PascalCase")]
163struct ExecInspect {
164    #[serde(rename = "ID")]
165    id: String,
166    #[serde(rename = "ContainerID")]
167    container_id: String,
168    can_remove: bool,
169    running: bool,
170    exit_code: i32,
171    open_stdin: bool,
172    open_stdout: bool,
173    open_stderr: bool,
174    process_config: Process,
175}
176#[derive(Deserialize)]
177struct Process {
178    entrypoint: String,
179    #[serde(deserialize_with = "required_arguments")]
180    arguments: Option<Vec<String>>,
181    privileged: bool,
182    tty: bool,
183    user: String,
184}
185
186// Native Go emits null for zero arguments. Missing is distinct from explicit null.
187fn required_arguments<'de, D: serde::Deserializer<'de>>(
188    d: D,
189) -> std::result::Result<Option<Vec<String>>, D::Error> {
190    Option::<Vec<String>>::deserialize(d)
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use serde_json::{json, Value};
197    fn cid() -> String {
198        "a".repeat(64)
199    }
200    fn eid() -> String {
201        "b".repeat(64)
202    }
203    fn plan() -> HealthPlan {
204        HealthPlan::new(&cid(), vec!["/bin/check".into(), "arg".into()]).unwrap()
205    }
206    fn fixture() -> Value {
207        json!({"ID":eid(),"ContainerID":cid(),"CanRemove":true,"Running":false,"ExitCode":0,"OpenStdin":false,"OpenStdout":false,"OpenStderr":false,"ProcessConfig":{"entrypoint":"/bin/check","arguments":["arg"],"privileged":false,"tty":false,"user":""}})
208    }
209    fn inspect(v: &Value) -> Result<HealthObservation> {
210        plan().inspect(&eid(), 200, &serde_json::to_vec(v).unwrap())
211    }
212    #[test]
213    fn exact_request_vectors() {
214        assert_eq!(plan().create_body().unwrap(),br#"{"User":"","Privileged":false,"Tty":false,"AttachStdin":false,"AttachStderr":false,"AttachStdout":false,"DetachKeys":"","Env":[],"WorkingDir":"","Cmd":["/bin/check","arg"]}"#);
215        assert_eq!(
216            plan().create_route(),
217            format!("/v6.1.4/libpod/containers/{}/exec", cid())
218        );
219        assert_eq!(
220            exec_route(&eid(), true).unwrap(),
221            format!("/v6.1.4/libpod/exec/{}/start", eid())
222        );
223        assert_eq!(
224            exec_route(&eid(), false).unwrap(),
225            format!("/v6.1.4/libpod/exec/{}/json", eid())
226        );
227        assert_eq!(
228            DETACHED_START_BODY,
229            br#"{"Detach":true,"Tty":false,"h":0,"w":0}"#
230        );
231    }
232    #[test]
233    fn created_identity_and_warning_boundaries() {
234        for container in [false, true] {
235            let mut v = json!({"Id":eid()});
236            if container {
237                v["Warnings"] = json!([]);
238            }
239            assert_eq!(
240                decode_created(201, &serde_json::to_vec(&v).unwrap(), container).unwrap(),
241                eid()
242            );
243            for status in [200, 204, 400, 404, 409, 500] {
244                assert!(
245                    decode_created(status, &serde_json::to_vec(&v).unwrap(), container).is_err()
246                );
247            }
248            for bad in ["name".into(), "b".repeat(63), "B".repeat(64)] {
249                v["Id"] = bad.into();
250                assert!(decode_created(201, &serde_json::to_vec(&v).unwrap(), container).is_err());
251            }
252        }
253        for w in [Value::Null, json!(["warning"]), json!(false)] {
254            assert!(decode_created(
255                201,
256                &serde_json::to_vec(&json!({"Id":eid(),"Warnings":w})).unwrap(),
257                true
258            )
259            .is_err());
260        }
261        assert!(decode_created(
262            201,
263            &serde_json::to_vec(&json!({"Id":eid()})).unwrap(),
264            true
265        )
266        .is_err());
267        assert!(decode_created(
268            201,
269            format!("{{\"Id\":\"{}\",\"Id\":\"{}\"}}", eid(), eid()).as_bytes(),
270            false
271        )
272        .is_err());
273    }
274    #[test]
275    fn start_ack_is_exact_not_health() {
276        assert!(check_started(200, b"").is_ok());
277        for status in [201, 204, 404, 409, 500] {
278            assert!(check_started(status, b"").is_err());
279        }
280        for body in [b"\n".as_slice(), b"{}", b"null"] {
281            assert!(check_started(200, body).is_err());
282        }
283    }
284    #[test]
285    fn unstarted_and_running_are_not_health_success() {
286        let mut v = fixture();
287        v["CanRemove"] = false.into();
288        assert_eq!(inspect(&v).unwrap(), HealthObservation::Pending);
289        v["Running"] = true.into();
290        assert_eq!(inspect(&v).unwrap(), HealthObservation::Pending);
291        v["CanRemove"] = true.into();
292        assert!(inspect(&v).is_err());
293        for code in [0, 1, 7, 255] {
294            let mut v = fixture();
295            v["ExitCode"] = code.into();
296            assert_eq!(
297                inspect(&v).unwrap(),
298                HealthObservation::Completed(code as u8)
299            );
300        }
301    }
302    #[test]
303    fn selected_fields_and_command_identity_are_required() {
304        for key in [
305            "ID",
306            "ContainerID",
307            "CanRemove",
308            "Running",
309            "ExitCode",
310            "OpenStdin",
311            "OpenStdout",
312            "OpenStderr",
313            "ProcessConfig",
314        ] {
315            let mut v = fixture();
316            v.as_object_mut().unwrap().remove(key);
317            assert!(inspect(&v).is_err());
318        }
319        for key in ["ID", "ContainerID"] {
320            let mut v = fixture();
321            v[key] = "c".repeat(64).into();
322            assert!(inspect(&v).is_err());
323        }
324        for key in ["OpenStdin", "OpenStdout", "OpenStderr"] {
325            let mut v = fixture();
326            v[key] = true.into();
327            assert!(inspect(&v).is_err());
328        }
329        for (key, value) in [
330            ("entrypoint", json!("other")),
331            ("arguments", json!([])),
332            ("privileged", json!(true)),
333            ("tty", json!(true)),
334            ("user", json!("root")),
335        ] {
336            let mut v = fixture();
337            v["ProcessConfig"][key] = value;
338            assert!(inspect(&v).is_err());
339        }
340        for code in [-1, 256] {
341            let mut v = fixture();
342            v["ExitCode"] = code.into();
343            assert!(inspect(&v).is_err());
344        }
345    }
346    #[test]
347    fn request_bounds_and_short_ids_refused() {
348        assert!(HealthPlan::new("name", vec!["true".into()]).is_err());
349        for argv in [
350            vec![],
351            vec!["".into()],
352            vec!["bad\0arg".into()],
353            vec!["x".repeat(8193)],
354            vec!["x".into(); 129],
355        ] {
356            assert!(HealthPlan::new(&cid(), argv).is_err());
357        }
358        assert!(exec_route("short", true).is_err());
359    }
360    #[test]
361    fn zero_arguments_accepts_explicit_null_but_not_missing() {
362        let p = HealthPlan::new(&cid(), vec!["/bin/check".into()]).unwrap();
363        let mut v = fixture();
364        v["ProcessConfig"]["arguments"] = Value::Null;
365        assert_eq!(
366            p.inspect(&eid(), 200, &serde_json::to_vec(&v).unwrap())
367                .unwrap(),
368            HealthObservation::Completed(0)
369        );
370        v["ProcessConfig"]
371            .as_object_mut()
372            .unwrap()
373            .remove("arguments");
374        assert!(p
375            .inspect(&eid(), 200, &serde_json::to_vec(&v).unwrap())
376            .is_err());
377    }
378    #[test]
379    fn health_duplicates_status_and_missing_process_fields_refused() {
380        for key in ["entrypoint", "arguments", "privileged", "tty", "user"] {
381            let mut v = fixture();
382            v["ProcessConfig"].as_object_mut().unwrap().remove(key);
383            assert!(inspect(&v).is_err());
384        }
385        let raw = serde_json::to_string(&fixture()).unwrap().replace(
386            "\"privileged\":false",
387            "\"privileged\":false,\"privileged\":true",
388        );
389        assert!(plan().inspect(&eid(), 200, raw.as_bytes()).is_err());
390        for status in [201, 204, 404, 409, 500] {
391            assert!(plan()
392                .inspect(&eid(), status, &serde_json::to_vec(&fixture()).unwrap())
393                .is_err());
394        }
395    }
396}