use super::{
native_json,
native_status::{decode_inspect, inspect_route, InspectOutcome, State},
};
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
type Result<T> = std::result::Result<T, &'static str>;
#[derive(Debug, Clone)]
pub struct ExpectedContainer {
pub id: String,
pub name: String,
pub image_id: String,
pub labels: BTreeMap<String, String>,
pub port: Option<FixedPort>,
pub image_exposed_ports: BTreeSet<String>,
}
#[derive(Debug, Clone)]
pub struct FixedPort {
pub container: u16,
pub host: u16,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Object {
name: String,
image: String,
config: Config,
network_settings: Network,
host_config: Host,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Config {
labels: BTreeMap<String, String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Network {
ports: BTreeMap<String, Option<Vec<Port>>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Host {
port_bindings: BTreeMap<String, Option<Vec<Port>>>,
privileged: bool,
auto_remove: bool,
auto_remove_image: bool,
restart_policy: Restart,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Restart {
name: String,
maximum_retry_count: u32,
}
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct Port {
#[serde(rename = "HostIp")]
host_ip: String,
#[serde(rename = "HostPort")]
host_port: String,
}
impl ExpectedContainer {
pub fn validate(&self) -> Result<()> {
inspect_route(&self.id)?;
validate_exposed(&self.image_exposed_ports)?;
validate_plan(&self.name, &self.image_id, &self.labels, self.port.as_ref())
}
pub fn match_inspect(
&self,
method: &str,
route: &str,
status: u16,
body: &[u8],
) -> Result<State> {
self.validate()?;
let state = match decode_inspect(&self.id, method, route, status, body)? {
InspectOutcome::Present(state) => state,
InspectOutcome::Missing => {
return Err("ownership cannot be proved for missing container")
}
};
let object: Object = serde_json::from_value(native_json::decode(body)?)
.map_err(|_| "native ownership fields rejected")?;
if object.name != self.name
|| object.image != self.image_id
|| object.config.labels != self.labels
{
return Err("native ownership facts mismatch");
}
self.match_network_ports(&object.network_settings.ports)?;
self.match_ports(&object.host_config.port_bindings)?;
let host = object.host_config;
if host.privileged
|| host.auto_remove
|| host.auto_remove_image
|| host.restart_policy.name != "no"
|| host.restart_policy.maximum_retry_count != 0
{
return Err("native host policy mismatch");
}
Ok(state)
}
fn match_network_ports(&self, ports: &BTreeMap<String, Option<Vec<Port>>>) -> Result<()> {
let published = self.port.as_ref().map(|p| format!("{}/tcp", p.container));
let mut expected = self.image_exposed_ports.clone();
if let Some(key) = &published {
expected.insert(key.clone());
}
if ports.keys().cloned().collect::<BTreeSet<_>>() != expected {
return Err("native exposed/published port keys mismatch");
}
let mut published_map = BTreeMap::new();
for (key, value) in ports {
if Some(key) == published.as_ref() {
published_map.insert(key.clone(), value.clone());
} else if value.is_some() {
return Err("image exposed port unexpectedly published");
}
}
self.match_ports(&published_map)
}
fn match_ports(&self, ports: &BTreeMap<String, Option<Vec<Port>>>) -> Result<()> {
match &self.port {
None if ports.is_empty() => Ok(()),
Some(expected) if ports.len() == 1 => {
let key = format!("{}/tcp", expected.container);
let bindings = ports
.get(&key)
.and_then(Option::as_ref)
.ok_or("expected published TCP port missing")?;
if bindings.len() != 1
|| bindings[0].host_ip != "127.0.0.1"
|| bindings[0].host_port != expected.host.to_string()
{
return Err("published port differs from exact plan");
}
Ok(())
}
_ => Err("unexpected native port map"),
}
}
}
pub(super) fn validate_exposed(ports: &BTreeSet<String>) -> Result<()> {
if ports.len() > 64 {
return Err("too many image exposed ports");
}
for key in ports {
let (number, protocol) = key.split_once('/').ok_or("invalid exposed port")?;
let port = number.parse::<u16>().map_err(|_| "invalid exposed port")?;
if port == 0 || port.to_string() != number || !matches!(protocol, "tcp" | "udp" | "sctp") {
return Err("noncanonical exposed port");
}
}
Ok(())
}
pub(super) fn validate_plan(
name: &str,
image_id: &str,
labels: &BTreeMap<String, String>,
port: Option<&FixedPort>,
) -> Result<()> {
inspect_route(image_id)?;
validate_plan_fields(name, labels, port, true)
}
pub(super) fn validate_plan_fields(
name: &str,
labels: &BTreeMap<String, String>,
port: Option<&FixedPort>,
require_labels: bool,
) -> Result<()> {
if name.is_empty()
|| name.len() > 128
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b))
|| !name.as_bytes()[0].is_ascii_alphanumeric()
{
return Err("explicit container name required");
}
if (require_labels && labels.is_empty())
|| labels.len() > 64
|| labels.iter().any(|(k, v)| {
k.is_empty()
|| k.len() > 256
|| v.len() > 4096
|| k.chars().any(char::is_control)
|| v.chars().any(char::is_control)
})
{
return Err("bounded explicit label map required");
}
if port
.as_ref()
.is_some_and(|p| p.container == 0 || p.host == 0)
{
return Err("fixed nonzero ports required");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{json, Value};
fn expected() -> ExpectedContainer {
ExpectedContainer {
id: "a".repeat(64),
name: "gate-fixture".into(),
image_id: "b".repeat(64),
labels: BTreeMap::from([
("io.gatehouse.lease".into(), "fixture-owned".into()),
("image.label".into(), "inherited".into()),
]),
image_exposed_ports: BTreeSet::new(),
port: Some(FixedPort {
container: 8080,
host: 18080,
}),
}
}
fn fixture() -> Value {
let e = expected();
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"}]}}})
}
fn check(e: &ExpectedContainer, v: &Value) -> Result<State> {
e.match_inspect(
"GET",
&inspect_route(&e.id).unwrap(),
200,
&serde_json::to_vec(v).unwrap(),
)
}
#[test]
fn exact_owned_object_and_no_port_profile() {
assert_eq!(check(&expected(), &fixture()).unwrap().status, "running");
let mut e = expected();
e.port = None;
let mut v = fixture();
v["NetworkSettings"]["Ports"] = json!({});
v["HostConfig"]["PortBindings"] = json!({});
assert!(check(&e, &v).is_ok());
}
#[test]
fn exact_name_image_and_full_label_map_required() {
for field in ["Name", "Image"] {
let mut v = fixture();
v[field] = "other".into();
assert!(check(&expected(), &v).is_err());
}
for mode in 0..3 {
let mut v = fixture();
let labels = v["Config"]["Labels"].as_object_mut().unwrap();
match mode {
0 => {
labels.remove("io.gatehouse.lease");
}
1 => {
labels.insert("io.gatehouse.lease".into(), json!("other"));
}
_ => {
labels.insert("unexpected".into(), json!("value"));
}
}
assert!(check(&expected(), &v).is_err());
}
let mut v = fixture();
v["Image"] = format!("sha256:{}", expected().image_id).into();
assert!(check(&expected(), &v).is_err());
}
#[test]
fn missing_null_and_wrong_type_fields_refused() {
for field in ["Name", "Image", "Config", "NetworkSettings"] {
for missing in [true, false] {
let mut v = fixture();
if missing {
v.as_object_mut().unwrap().remove(field);
} else {
v[field] = Value::Null;
}
assert!(check(&expected(), &v).is_err());
}
}
let mut v = fixture();
v["Config"]["Labels"] = Value::Null;
assert!(check(&expected(), &v).is_err());
let mut v = fixture();
v["NetworkSettings"]["Ports"] = Value::Null;
assert!(check(&expected(), &v).is_err());
}
#[test]
fn no_extra_wildcard_udp_noncanonical_or_multiple_bindings() {
for ports in [
json!({}),
json!({"8080/udp":[{"HostIp":"127.0.0.1","HostPort":"18080"}]}),
json!({"8080/tcp":null}),
json!({"8080/tcp":[]}),
json!({"8080/tcp":[{"HostIp":"0.0.0.0","HostPort":"18080"}]}),
json!({"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"018080"}]}),
json!({"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"18081"}]}),
json!({"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"18080"},{"HostIp":"::1","HostPort":"18080"}]}),
json!({"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"18080"}],"90/tcp":null}),
] {
let mut v = fixture();
v["NetworkSettings"]["Ports"] = ports;
assert!(check(&expected(), &v).is_err());
}
}
#[test]
fn invalid_expected_values_and_names_refused() {
for name in ["", "/gate-fixture", "-name", "name/other"] {
let mut e = expected();
e.name = name.into();
assert!(e.validate().is_err());
}
let mut e = expected();
e.labels.clear();
assert!(e.validate().is_err());
let mut e = expected();
e.port.as_mut().unwrap().host = 0;
assert!(e.validate().is_err());
let mut e = expected();
e.image_id = "tag:latest".into();
assert!(e.validate().is_err());
}
#[test]
fn duplicate_fields_wrong_route_and_absence_never_own() {
let e = expected();
let route = inspect_route(&e.id).unwrap();
let raw = serde_json::to_string(&fixture())
.unwrap()
.replace("\"HostIp\":", "\"HostIp\":\"0.0.0.0\",\"HostIp\":");
assert!(e.match_inspect("GET", &route, 200, raw.as_bytes()).is_err());
let body = serde_json::to_vec(&fixture()).unwrap();
assert!(e.match_inspect("POST", &route, 200, &body).is_err());
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();
assert!(e.match_inspect("GET", &route, 404, &absent).is_err());
}
#[test]
fn host_policy_missing_enabled_or_wrong_restart_refused() {
for key in ["Privileged", "AutoRemove", "AutoRemoveImage"] {
let mut v = fixture();
v["HostConfig"][key] = true.into();
assert!(check(&expected(), &v).is_err());
}
for key in [
"Privileged",
"AutoRemove",
"AutoRemoveImage",
"RestartPolicy",
"PortBindings",
] {
let mut v = fixture();
v["HostConfig"].as_object_mut().unwrap().remove(key);
assert!(check(&expected(), &v).is_err());
}
for name in ["", "always", "on-failure", "unless-stopped"] {
let mut v = fixture();
v["HostConfig"]["RestartPolicy"]["Name"] = name.into();
assert!(check(&expected(), &v).is_err());
}
let mut v = fixture();
v["HostConfig"]["RestartPolicy"]["MaximumRetryCount"] = 1.into();
assert!(check(&expected(), &v).is_err());
let mut v = fixture();
v.as_object_mut().unwrap().remove("HostConfig");
assert!(check(&expected(), &v).is_err());
}
#[test]
fn host_port_bindings_cannot_disagree_with_network_ports() {
for ports in [
json!({}),
Value::Null,
json!({"8080/tcp":[{"HostIp":"0.0.0.0","HostPort":"18080"}]}),
json!({"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"18081"}]}),
json!({"8080/tcp":null}),
] {
let mut v = fixture();
v["HostConfig"]["PortBindings"] = ports;
assert!(check(&expected(), &v).is_err());
}
}
#[test]
fn exposed_null_ports_are_not_host_bindings() {
let mut e = expected();
e.image_exposed_ports = BTreeSet::from(["53/udp".into(), "8080/tcp".into()]);
let mut v = fixture();
v["NetworkSettings"]["Ports"]["53/udp"] = Value::Null;
assert!(check(&e, &v).is_ok());
e.port = None;
v["HostConfig"]["PortBindings"] = json!({});
v["NetworkSettings"]["Ports"]["8080/tcp"] = Value::Null;
assert!(check(&e, &v).is_ok());
}
#[test]
fn exposed_keys_must_be_exact_and_unpublished() {
let mut e = expected();
e.image_exposed_ports = BTreeSet::from(["53/udp".into()]);
assert!(check(&e, &fixture()).is_err());
for value in [
json!([]),
json!([{"HostIp":"127.0.0.1","HostPort":"18053"}]),
json!({}),
] {
let mut v = fixture();
v["NetworkSettings"]["Ports"]["53/udp"] = value;
assert!(check(&e, &v).is_err());
}
let mut v = fixture();
v["NetworkSettings"]["Ports"]["53/udp"] = Value::Null;
v["NetworkSettings"]["Ports"]["54/udp"] = Value::Null;
assert!(check(&e, &v).is_err());
}
#[test]
fn exposed_metadata_never_weakens_published_host_policy() {
let mut e = expected();
e.image_exposed_ports = BTreeSet::from(["53/udp".into(), "8080/tcp".into()]);
let mut v = fixture();
v["NetworkSettings"]["Ports"]["53/udp"] = Value::Null;
v["HostConfig"]["PortBindings"]["53/udp"] = Value::Null;
assert!(check(&e, &v).is_err());
v["HostConfig"]["PortBindings"]
.as_object_mut()
.unwrap()
.remove("53/udp");
v["NetworkSettings"]["Ports"]["8080/tcp"] = Value::Null;
assert!(check(&e, &v).is_err());
}
#[test]
fn exposed_keys_bounded_and_canonical() {
for key in [
"0/tcp",
"65536/tcp",
"080/tcp",
"80/TCP",
"80",
"80/tcp/other",
"+80/tcp",
"80/icmp",
] {
assert!(validate_exposed(&BTreeSet::from([key.into()])).is_err());
}
assert!(validate_exposed(&(1..=65).map(|p| format!("{p}/tcp")).collect()).is_err());
assert!(validate_exposed(&BTreeSet::from(["65535/sctp".into()])).is_ok());
}
}