Skip to main content

gatehouse_runtime_protocol/lifecycle_v2/
native_status.rs

1//! Pure native v6.1.4 inspect response checks. No transport or lease authority.
2//! The caller must bind observations, protected configuration and the held socket.
3use super::{native_json, observation::API};
4use serde::Deserialize;
5
6#[derive(Debug, PartialEq, Eq)]
7pub enum InspectOutcome {
8    Present(State),
9    /// Exact SQLite lookup error only; not permission to release a lease.
10    Missing,
11}
12
13#[derive(Debug, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "PascalCase")]
15pub struct State {
16    pub status: String,
17    pub running: bool,
18    pub paused: bool,
19    pub restarting: bool,
20    pub dead: bool,
21    pub exit_code: i32,
22}
23
24#[derive(Deserialize)]
25#[serde(rename_all = "PascalCase")]
26struct Inspect {
27    id: String,
28    state: State,
29}
30#[derive(Deserialize)]
31#[serde(deny_unknown_fields)]
32struct Failure {
33    cause: String,
34    message: String,
35    response: u16,
36}
37
38/// Only full lowercase IDs: no names, prefixes, escaping, queries or discovery.
39pub fn inspect_route(id: &str) -> Result<String, &'static str> {
40    if id.len() != 64
41        || !id
42            .bytes()
43            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
44    {
45        return Err("full container ID required");
46    }
47    Ok(format!("/v{API}/libpod/containers/{id}/json"))
48}
49
50/// Validates selected state fields only. Image/labels/ports and mutation policy
51/// remain separate integration gates; Present does not establish ownership.
52pub fn decode_inspect(
53    id: &str,
54    method: &str,
55    route: &str,
56    status: u16,
57    bytes: &[u8],
58) -> Result<InspectOutcome, &'static str> {
59    if method != "GET" || route != inspect_route(id)? {
60        return Err("exact native inspect request required");
61    }
62    let value = native_json::decode(bytes)?;
63    match status {
64        200 => {
65            let inspect: Inspect =
66                serde_json::from_value(value).map_err(|_| "inspect fields rejected")?;
67            if inspect.id != id {
68                return Err("inspect target mismatch");
69            }
70            let s = inspect.state;
71            // These supported states are deliberately narrower than every Libpod
72            // state. Unknown/removing/bad states are unavailable, never absent.
73            if !matches!(
74                s.status.as_str(),
75                "initialized" | "created" | "running" | "stopped" | "exited" | "paused"
76            ) || s.running != (s.status == "running")
77                || s.paused != (s.status == "paused")
78                || s.restarting
79                || s.dead
80            {
81                return Err("unsupported or inconsistent native state");
82            }
83            Ok(InspectOutcome::Present(s))
84        }
85        404 => {
86            let error: Failure =
87                serde_json::from_value(value).map_err(|_| "inspect error fields rejected")?;
88            let expected =
89                format!("no container with name or ID \"{id}\" found: no such container");
90            if error.response != 404
91                || error.cause != "no such container"
92                || error.message != expected
93            {
94                return Err("inspect absence not proved");
95            }
96            Ok(InspectOutcome::Missing)
97        }
98        _ => Err("native inspect unavailable"),
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use serde_json::{json, Value};
106    fn id() -> String {
107        "a".repeat(64)
108    }
109    fn present(state: &str) -> Value {
110        json!({"Id":id(),"State":{"Status":state,"Running":state=="running","Paused":state=="paused","Restarting":false,"Dead":false,"ExitCode":0},"Metric":{"float":1.25}})
111    }
112    fn missing() -> Value {
113        json!({"cause":"no such container","message":format!("no container with name or ID \"{}\" found: no such container",id()),"response":404})
114    }
115    fn parse(status: u16, v: &Value) -> Result<InspectOutcome, &'static str> {
116        decode_inspect(
117            &id(),
118            "GET",
119            &inspect_route(&id()).unwrap(),
120            status,
121            &serde_json::to_vec(v).unwrap(),
122        )
123    }
124    #[test]
125    fn full_id_native_route_only() {
126        for bad in [
127            "fixture".into(),
128            "a".repeat(63),
129            "A".repeat(64),
130            "g".repeat(64),
131            format!("{}/", id()),
132        ] {
133            assert!(inspect_route(&bad).is_err());
134        }
135        let body = serde_json::to_vec(&missing()).unwrap();
136        let route = inspect_route(&id()).unwrap();
137        for wrong in [
138            route.replace("/libpod", ""),
139            format!("{route}?size=true"),
140            route.replace("6.1.4", "6.1.3"),
141            route.replace(&id(), &"b".repeat(64)),
142        ] {
143            assert!(decode_inspect(&id(), "GET", &wrong, 404, &body).is_err());
144        }
145        assert!(decode_inspect(&id(), "DELETE", &route, 404, &body).is_err());
146    }
147    #[test]
148    fn supported_present_states_and_exact_missing() {
149        for state in [
150            "initialized",
151            "created",
152            "running",
153            "paused",
154            "stopped",
155            "exited",
156        ] {
157            assert!(matches!(
158                parse(200, &present(state)),
159                Ok(InspectOutcome::Present(_))
160            ));
161        }
162        assert_eq!(parse(404, &missing()).unwrap(), InspectOutcome::Missing);
163    }
164    #[test]
165    fn missing_fields_wrong_target_and_inconsistent_state_refused() {
166        let mut v = present("running");
167        v["Id"] = "b".repeat(64).into();
168        assert!(parse(200, &v).is_err());
169        for field in [
170            "Status",
171            "Running",
172            "Paused",
173            "Restarting",
174            "Dead",
175            "ExitCode",
176        ] {
177            let mut v = present("running");
178            v["State"].as_object_mut().unwrap().remove(field);
179            assert!(parse(200, &v).is_err());
180        }
181        for field in ["Running", "Paused", "Restarting", "Dead"] {
182            let mut v = present("running");
183            v["State"][field] = (!v["State"][field].as_bool().unwrap()).into();
184            assert!(parse(200, &v).is_err());
185        }
186        for state in [
187            "configured",
188            "unknown",
189            "bad state",
190            "removing",
191            "stopping",
192            "",
193        ] {
194            assert!(parse(200, &present(state)).is_err());
195        }
196    }
197    #[test]
198    fn generic_docker_ambiguous_wrong_id_and_status_errors_are_not_absence() {
199        for field in ["cause", "message", "response"] {
200            let mut v = missing();
201            v.as_object_mut().unwrap().remove(field);
202            assert!(parse(404, &v).is_err());
203            let mut v = missing();
204            v[field] = Value::Null;
205            assert!(parse(404, &v).is_err());
206        }
207        for message in [
208            "not found".into(),
209            format!("No such container: {}", id()),
210            missing()["message"]
211                .as_str()
212                .unwrap()
213                .replace(&id(), &"b".repeat(64)),
214        ] {
215            let mut v = missing();
216            v["message"] = message.into();
217            assert!(parse(404, &v).is_err());
218        }
219        let mut v = missing();
220        v["cause"] = "container already exists".into();
221        assert!(parse(404, &v).is_err());
222        let mut v = missing();
223        v["response"] = 500.into();
224        assert!(parse(404, &v).is_err());
225        let mut v = missing();
226        v["extra"] = true.into();
227        assert!(parse(404, &v).is_err());
228        for status in [200, 204, 301, 400, 401, 403, 409, 500, 503] {
229            assert!(parse(status, &missing()).is_err());
230        }
231        assert!(parse(404, &present("running")).is_err());
232    }
233    #[test]
234    fn duplicate_trailing_oversize_and_deep_bodies_refused() {
235        let good = serde_json::to_string(&present("running")).unwrap();
236        let bodies = vec![
237            good.replace("\"Running\":true", "\"Running\":true,\"Running\":true")
238                .into_bytes(),
239            good.replace("1.25", "1.25,\"float\":2").into_bytes(),
240            format!("{good} {{}}").into_bytes(),
241            vec![b' '; native_json::LIMIT + 1],
242            format!("{}0{}", "[".repeat(200), "]".repeat(200)).into_bytes(),
243            Vec::new(),
244        ];
245        for bytes in bodies {
246            assert!(
247                decode_inspect(&id(), "GET", &inspect_route(&id()).unwrap(), 200, &bytes).is_err()
248            );
249        }
250    }
251}