1use 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 pub image_id: String,
17 pub labels: BTreeMap<String, String>,
19 pub port: Option<FixedPort>,
21 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 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}
179pub(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}