Skip to main content

gatehouse_runtime_protocol/lifecycle_v2/
ownership.rs

1//! Exact selected ownership facts from native inspect, not journal authority.
2//! Expected values must eventually come from a protected durable create plan.
3use super::{
4    native_json,
5    native_status::{decode_inspect, inspect_route, InspectOutcome, State},
6};
7use serde::Deserialize;
8use std::collections::{BTreeMap, BTreeSet};
9type Result<T> = std::result::Result<T, &'static str>;
10
11#[derive(Debug, Clone)]
12pub struct ExpectedContainer {
13    pub id: String,
14    pub name: String,
15    /// Native RootfsImageID, full lowercase hex without Docker sha256: prefix.
16    pub image_id: String,
17    /// Exact full merged label map, including any inherited image labels.
18    pub labels: BTreeMap<String, String>,
19    /// Fixed single TCP loopback mapping, or no published ports.
20    pub port: Option<FixedPort>,
21    /// Exact image-declared ports; not host publishing authority.
22    pub image_exposed_ports: BTreeSet<String>,
23}
24#[derive(Debug, Clone)]
25pub struct FixedPort {
26    pub container: u16,
27    pub host: u16,
28}
29#[derive(Deserialize)]
30#[serde(rename_all = "PascalCase")]
31struct Object {
32    name: String,
33    image: String,
34    config: Config,
35    network_settings: Network,
36    host_config: Host,
37}
38#[derive(Deserialize)]
39#[serde(rename_all = "PascalCase")]
40struct Config {
41    labels: BTreeMap<String, String>,
42}
43#[derive(Deserialize)]
44#[serde(rename_all = "PascalCase")]
45struct Network {
46    ports: BTreeMap<String, Option<Vec<Port>>>,
47}
48#[derive(Deserialize)]
49#[serde(rename_all = "PascalCase")]
50struct Host {
51    port_bindings: BTreeMap<String, Option<Vec<Port>>>,
52    privileged: bool,
53    auto_remove: bool,
54    auto_remove_image: bool,
55    restart_policy: Restart,
56}
57#[derive(Deserialize)]
58#[serde(rename_all = "PascalCase")]
59struct Restart {
60    name: String,
61    maximum_retry_count: u32,
62}
63#[derive(Clone, Deserialize)]
64#[serde(deny_unknown_fields)]
65struct Port {
66    #[serde(rename = "HostIp")]
67    host_ip: String,
68    #[serde(rename = "HostPort")]
69    host_port: String,
70}
71impl ExpectedContainer {
72    pub fn validate(&self) -> Result<()> {
73        inspect_route(&self.id)?;
74        validate_exposed(&self.image_exposed_ports)?;
75        validate_plan(&self.name, &self.image_id, &self.labels, self.port.as_ref())
76    }
77    /// Present only: never convert a missing container into an owned object.
78    /// Uses the same response bytes for state and ownership checks. It does not
79    /// validate mounts, namespaces, env or all other create-plan fields.
80    pub fn match_inspect(
81        &self,
82        method: &str,
83        route: &str,
84        status: u16,
85        body: &[u8],
86    ) -> Result<State> {
87        self.validate()?;
88        let state = match decode_inspect(&self.id, method, route, status, body)? {
89            InspectOutcome::Present(state) => state,
90            InspectOutcome::Missing => {
91                return Err("ownership cannot be proved for missing container")
92            }
93        };
94        let object: Object = serde_json::from_value(native_json::decode(body)?)
95            .map_err(|_| "native ownership fields rejected")?;
96        if object.name != self.name
97            || object.image != self.image_id
98            || object.config.labels != self.labels
99        {
100            return Err("native ownership facts mismatch");
101        }
102        self.match_network_ports(&object.network_settings.ports)?;
103        self.match_ports(&object.host_config.port_bindings)?;
104        let host = object.host_config;
105        if host.privileged
106            || host.auto_remove
107            || host.auto_remove_image
108            || host.restart_policy.name != "no"
109            || host.restart_policy.maximum_retry_count != 0
110        {
111            return Err("native host policy mismatch");
112        }
113        Ok(state)
114    }
115    fn match_network_ports(&self, ports: &BTreeMap<String, Option<Vec<Port>>>) -> Result<()> {
116        let published = self.port.as_ref().map(|p| format!("{}/tcp", p.container));
117        let mut expected = self.image_exposed_ports.clone();
118        if let Some(key) = &published {
119            expected.insert(key.clone());
120        }
121        if ports.keys().cloned().collect::<BTreeSet<_>>() != expected {
122            return Err("native exposed/published port keys mismatch");
123        }
124        let mut published_map = BTreeMap::new();
125        for (key, value) in ports {
126            if Some(key) == published.as_ref() {
127                published_map.insert(key.clone(), value.clone());
128            } else if value.is_some() {
129                return Err("image exposed port unexpectedly published");
130            }
131        }
132        self.match_ports(&published_map)
133    }
134    fn match_ports(&self, ports: &BTreeMap<String, Option<Vec<Port>>>) -> Result<()> {
135        match &self.port {
136            None if ports.is_empty() => Ok(()),
137            Some(expected) if ports.len() == 1 => {
138                let key = format!("{}/tcp", expected.container);
139                let bindings = ports
140                    .get(&key)
141                    .and_then(Option::as_ref)
142                    .ok_or("expected published TCP port missing")?;
143                if bindings.len() != 1
144                    || bindings[0].host_ip != "127.0.0.1"
145                    || bindings[0].host_port != expected.host.to_string()
146                {
147                    return Err("published port differs from exact plan");
148                }
149                Ok(())
150            }
151            _ => Err("unexpected native port map"),
152        }
153    }
154}
155
156pub(super) fn validate_exposed(ports: &BTreeSet<String>) -> Result<()> {
157    if ports.len() > 64 {
158        return Err("too many image exposed ports");
159    }
160    for key in ports {
161        let (number, protocol) = key.split_once('/').ok_or("invalid exposed port")?;
162        let port = number.parse::<u16>().map_err(|_| "invalid exposed port")?;
163        if port == 0 || port.to_string() != number || !matches!(protocol, "tcp" | "udp" | "sctp") {
164            return Err("noncanonical exposed port");
165        }
166    }
167    Ok(())
168}
169
170pub(super) fn validate_plan(
171    name: &str,
172    image_id: &str,
173    labels: &BTreeMap<String, String>,
174    port: Option<&FixedPort>,
175) -> Result<()> {
176    inspect_route(image_id)?;
177    validate_plan_fields(name, labels, port, true)
178}
179/// Operator preflight allows no labels; the merged image plan must not be empty.
180pub(super) fn validate_plan_fields(
181    name: &str,
182    labels: &BTreeMap<String, String>,
183    port: Option<&FixedPort>,
184    require_labels: bool,
185) -> Result<()> {
186    if name.is_empty()
187        || name.len() > 128
188        || !name
189            .bytes()
190            .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b))
191        || !name.as_bytes()[0].is_ascii_alphanumeric()
192    {
193        return Err("explicit container name required");
194    }
195    if (require_labels && labels.is_empty())
196        || labels.len() > 64
197        || labels.iter().any(|(k, v)| {
198            k.is_empty()
199                || k.len() > 256
200                || v.len() > 4096
201                || k.chars().any(char::is_control)
202                || v.chars().any(char::is_control)
203        })
204    {
205        return Err("bounded explicit label map required");
206    }
207    if port
208        .as_ref()
209        .is_some_and(|p| p.container == 0 || p.host == 0)
210    {
211        return Err("fixed nonzero ports required");
212    }
213    Ok(())
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use serde_json::{json, Value};
220    fn expected() -> ExpectedContainer {
221        ExpectedContainer {
222            id: "a".repeat(64),
223            name: "gate-fixture".into(),
224            image_id: "b".repeat(64),
225            labels: BTreeMap::from([
226                ("io.gatehouse.lease".into(), "fixture-owned".into()),
227                ("image.label".into(), "inherited".into()),
228            ]),
229            image_exposed_ports: BTreeSet::new(),
230            port: Some(FixedPort {
231                container: 8080,
232                host: 18080,
233            }),
234        }
235    }
236    fn fixture() -> Value {
237        let e = expected();
238        json!({"Id":e.id,"Name":e.name,"Image":e.image_id,"State":{"Status":"running","Running":true,"Paused":false,"Restarting":false,"Dead":false,"ExitCode":0},"Config":{"Labels":e.labels},"HostConfig":{"PortBindings":{"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"18080"}]},"Privileged":false,"AutoRemove":false,"AutoRemoveImage":false,"RestartPolicy":{"Name":"no","MaximumRetryCount":0}},"NetworkSettings":{"Ports":{"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"18080"}]}}})
239    }
240    fn check(e: &ExpectedContainer, v: &Value) -> Result<State> {
241        e.match_inspect(
242            "GET",
243            &inspect_route(&e.id).unwrap(),
244            200,
245            &serde_json::to_vec(v).unwrap(),
246        )
247    }
248    #[test]
249    fn exact_owned_object_and_no_port_profile() {
250        assert_eq!(check(&expected(), &fixture()).unwrap().status, "running");
251        let mut e = expected();
252        e.port = None;
253        let mut v = fixture();
254        v["NetworkSettings"]["Ports"] = json!({});
255        v["HostConfig"]["PortBindings"] = json!({});
256        assert!(check(&e, &v).is_ok());
257    }
258    #[test]
259    fn exact_name_image_and_full_label_map_required() {
260        for field in ["Name", "Image"] {
261            let mut v = fixture();
262            v[field] = "other".into();
263            assert!(check(&expected(), &v).is_err());
264        }
265        for mode in 0..3 {
266            let mut v = fixture();
267            let labels = v["Config"]["Labels"].as_object_mut().unwrap();
268            match mode {
269                0 => {
270                    labels.remove("io.gatehouse.lease");
271                }
272                1 => {
273                    labels.insert("io.gatehouse.lease".into(), json!("other"));
274                }
275                _ => {
276                    labels.insert("unexpected".into(), json!("value"));
277                }
278            }
279            assert!(check(&expected(), &v).is_err());
280        }
281        let mut v = fixture();
282        v["Image"] = format!("sha256:{}", expected().image_id).into();
283        assert!(check(&expected(), &v).is_err());
284    }
285    #[test]
286    fn missing_null_and_wrong_type_fields_refused() {
287        for field in ["Name", "Image", "Config", "NetworkSettings"] {
288            for missing in [true, false] {
289                let mut v = fixture();
290                if missing {
291                    v.as_object_mut().unwrap().remove(field);
292                } else {
293                    v[field] = Value::Null;
294                }
295                assert!(check(&expected(), &v).is_err());
296            }
297        }
298        let mut v = fixture();
299        v["Config"]["Labels"] = Value::Null;
300        assert!(check(&expected(), &v).is_err());
301        let mut v = fixture();
302        v["NetworkSettings"]["Ports"] = Value::Null;
303        assert!(check(&expected(), &v).is_err());
304    }
305    #[test]
306    fn no_extra_wildcard_udp_noncanonical_or_multiple_bindings() {
307        for ports in [
308            json!({}),
309            json!({"8080/udp":[{"HostIp":"127.0.0.1","HostPort":"18080"}]}),
310            json!({"8080/tcp":null}),
311            json!({"8080/tcp":[]}),
312            json!({"8080/tcp":[{"HostIp":"0.0.0.0","HostPort":"18080"}]}),
313            json!({"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"018080"}]}),
314            json!({"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"18081"}]}),
315            json!({"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"18080"},{"HostIp":"::1","HostPort":"18080"}]}),
316            json!({"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"18080"}],"90/tcp":null}),
317        ] {
318            let mut v = fixture();
319            v["NetworkSettings"]["Ports"] = ports;
320            assert!(check(&expected(), &v).is_err());
321        }
322    }
323    #[test]
324    fn invalid_expected_values_and_names_refused() {
325        for name in ["", "/gate-fixture", "-name", "name/other"] {
326            let mut e = expected();
327            e.name = name.into();
328            assert!(e.validate().is_err());
329        }
330        let mut e = expected();
331        e.labels.clear();
332        assert!(e.validate().is_err());
333        let mut e = expected();
334        e.port.as_mut().unwrap().host = 0;
335        assert!(e.validate().is_err());
336        let mut e = expected();
337        e.image_id = "tag:latest".into();
338        assert!(e.validate().is_err());
339    }
340    #[test]
341    fn duplicate_fields_wrong_route_and_absence_never_own() {
342        let e = expected();
343        let route = inspect_route(&e.id).unwrap();
344        let raw = serde_json::to_string(&fixture())
345            .unwrap()
346            .replace("\"HostIp\":", "\"HostIp\":\"0.0.0.0\",\"HostIp\":");
347        assert!(e.match_inspect("GET", &route, 200, raw.as_bytes()).is_err());
348        let body = serde_json::to_vec(&fixture()).unwrap();
349        assert!(e.match_inspect("POST", &route, 200, &body).is_err());
350        let absent=serde_json::to_vec(&json!({"cause":"no such container","message":format!("no container with name or ID \"{}\" found: no such container",e.id),"response":404})).unwrap();
351        assert!(e.match_inspect("GET", &route, 404, &absent).is_err());
352    }
353    #[test]
354    fn host_policy_missing_enabled_or_wrong_restart_refused() {
355        for key in ["Privileged", "AutoRemove", "AutoRemoveImage"] {
356            let mut v = fixture();
357            v["HostConfig"][key] = true.into();
358            assert!(check(&expected(), &v).is_err());
359        }
360        for key in [
361            "Privileged",
362            "AutoRemove",
363            "AutoRemoveImage",
364            "RestartPolicy",
365            "PortBindings",
366        ] {
367            let mut v = fixture();
368            v["HostConfig"].as_object_mut().unwrap().remove(key);
369            assert!(check(&expected(), &v).is_err());
370        }
371        for name in ["", "always", "on-failure", "unless-stopped"] {
372            let mut v = fixture();
373            v["HostConfig"]["RestartPolicy"]["Name"] = name.into();
374            assert!(check(&expected(), &v).is_err());
375        }
376        let mut v = fixture();
377        v["HostConfig"]["RestartPolicy"]["MaximumRetryCount"] = 1.into();
378        assert!(check(&expected(), &v).is_err());
379        let mut v = fixture();
380        v.as_object_mut().unwrap().remove("HostConfig");
381        assert!(check(&expected(), &v).is_err());
382    }
383    #[test]
384    fn host_port_bindings_cannot_disagree_with_network_ports() {
385        for ports in [
386            json!({}),
387            Value::Null,
388            json!({"8080/tcp":[{"HostIp":"0.0.0.0","HostPort":"18080"}]}),
389            json!({"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"18081"}]}),
390            json!({"8080/tcp":null}),
391        ] {
392            let mut v = fixture();
393            v["HostConfig"]["PortBindings"] = ports;
394            assert!(check(&expected(), &v).is_err());
395        }
396    }
397    #[test]
398    fn exposed_null_ports_are_not_host_bindings() {
399        let mut e = expected();
400        e.image_exposed_ports = BTreeSet::from(["53/udp".into(), "8080/tcp".into()]);
401        let mut v = fixture();
402        v["NetworkSettings"]["Ports"]["53/udp"] = Value::Null;
403        assert!(check(&e, &v).is_ok());
404        e.port = None;
405        v["HostConfig"]["PortBindings"] = json!({});
406        v["NetworkSettings"]["Ports"]["8080/tcp"] = Value::Null;
407        assert!(check(&e, &v).is_ok());
408    }
409    #[test]
410    fn exposed_keys_must_be_exact_and_unpublished() {
411        let mut e = expected();
412        e.image_exposed_ports = BTreeSet::from(["53/udp".into()]);
413        assert!(check(&e, &fixture()).is_err());
414        for value in [
415            json!([]),
416            json!([{"HostIp":"127.0.0.1","HostPort":"18053"}]),
417            json!({}),
418        ] {
419            let mut v = fixture();
420            v["NetworkSettings"]["Ports"]["53/udp"] = value;
421            assert!(check(&e, &v).is_err());
422        }
423        let mut v = fixture();
424        v["NetworkSettings"]["Ports"]["53/udp"] = Value::Null;
425        v["NetworkSettings"]["Ports"]["54/udp"] = Value::Null;
426        assert!(check(&e, &v).is_err());
427    }
428    #[test]
429    fn exposed_metadata_never_weakens_published_host_policy() {
430        let mut e = expected();
431        e.image_exposed_ports = BTreeSet::from(["53/udp".into(), "8080/tcp".into()]);
432        let mut v = fixture();
433        v["NetworkSettings"]["Ports"]["53/udp"] = Value::Null;
434        v["HostConfig"]["PortBindings"]["53/udp"] = Value::Null;
435        assert!(check(&e, &v).is_err());
436        v["HostConfig"]["PortBindings"]
437            .as_object_mut()
438            .unwrap()
439            .remove("53/udp");
440        v["NetworkSettings"]["Ports"]["8080/tcp"] = Value::Null;
441        assert!(check(&e, &v).is_err());
442    }
443    #[test]
444    fn exposed_keys_bounded_and_canonical() {
445        for key in [
446            "0/tcp",
447            "65536/tcp",
448            "080/tcp",
449            "80/TCP",
450            "80",
451            "80/tcp/other",
452            "+80/tcp",
453            "80/icmp",
454        ] {
455            assert!(validate_exposed(&BTreeSet::from([key.into()])).is_err());
456        }
457        assert!(validate_exposed(&(1..=65).map(|p| format!("{p}/tcp")).collect()).is_err());
458        assert!(validate_exposed(&BTreeSet::from(["65535/sctp".into()])).is_ok());
459    }
460}