gatehouse-runtime-protocol 0.2.0

Strict data contract for pinned container runtime adapters
Documentation
//! Independent read-only image readiness contract. No lease/mutation authority.
pub use crate::v2::{Engine, Observations, ObservedIdentity, Transport};
use crate::{digest, nonempty, Invalid, Result};
use serde::{Deserialize, Deserializer, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String")]
pub enum Protocol {
    #[serde(rename = "gate-runtime/v3")]
    V3,
}
impl TryFrom<String> for Protocol {
    type Error = Invalid;
    fn try_from(s: String) -> Result<Self> {
        if s == "gate-runtime/v3" {
            Ok(Self::V3)
        } else {
            Err(Invalid("unsupported v3 protocol"))
        }
    }
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Binding {
    pub protocol: Protocol,
    pub adapter_sha256: String,
    pub adapter_version: String,
    pub invocation: String,
    pub operation_key: String,
    pub generation: String,
    pub adapter_config_sha256: String,
    pub engine: Engine,
    pub transport: Transport,
    pub socket_path: String,
    pub socket_uid: u32,
    pub principal_uid: u32,
    pub rootless: bool,
    pub api_version: String,
    pub server_version: String,
    pub daemon_id: String,
    pub scope: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Target {
    pub image: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Operation {
    pub readiness: Target,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
    pub binding: Binding,
    pub operation: Operation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Readiness {
    pub image_present: bool,
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "image_id"
    )]
    pub image_id: Option<String>,
}
fn image_id<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Option<String>, D::Error> {
    String::deserialize(d).map(Some)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Outcome {
    Readiness(Readiness),
    Unavailable,
}
impl<'de> Deserialize<'de> for Outcome {
    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
        struct Visitor;
        impl<'de> serde::de::Visitor<'de> for Visitor {
            type Value = Outcome;
            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
                f.write_str("readiness object or unavailable string")
            }
            fn visit_str<E: serde::de::Error>(self, s: &str) -> std::result::Result<Outcome, E> {
                if s == "unavailable" {
                    Ok(Outcome::Unavailable)
                } else {
                    Err(E::custom("invalid v3 outcome"))
                }
            }
            fn visit_map<M: serde::de::MapAccess<'de>>(
                self,
                mut m: M,
            ) -> std::result::Result<Outcome, M::Error> {
                if m.next_key::<String>()?.as_deref() != Some("readiness") {
                    return Err(serde::de::Error::custom("invalid v3 outcome"));
                }
                let value = m.next_value()?;
                if m.next_key::<serde::de::IgnoredAny>()?.is_some() {
                    return Err(serde::de::Error::custom("extra outcome fields"));
                }
                Ok(Outcome::Readiness(value))
            }
        }
        d.deserialize_any(Visitor)
    }
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Response {
    pub binding: Binding,
    pub operation: Operation,
    pub outcome: Outcome,
    #[serde(deserialize_with = "observations")]
    pub observations: Option<Observations>,
}
fn observations<'de, D: Deserializer<'de>>(
    d: D,
) -> std::result::Result<Option<Observations>, D::Error> {
    Option::deserialize(d)
}

pub fn validate_image(image: &str) -> Result<()> {
    if image.is_empty()
        || image.len() > 512
        || !image.is_ascii()
        || image.starts_with(['-', '+'])
        || image
            .bytes()
            .any(|b| b.is_ascii_whitespace() || b.is_ascii_control() || matches!(b, b'?' | b'#'))
        || image.split('/').any(|p| matches!(p, "" | "." | ".."))
    {
        return Err(Invalid("invalid v3 image reference"));
    }
    if let Some((repo, hash)) = image.split_once('@') {
        nonempty(repo)?;
        digest(
            hash.strip_prefix("sha256:")
                .ok_or(Invalid("unsupported image digest"))?,
        )?;
    }
    Ok(())
}
impl Binding {
    pub fn validate(&self) -> Result<()> {
        if self.adapter_version != "0.2.0"
            || self.invocation.len() != 32
            || !self
                .invocation
                .bytes()
                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
        {
            return Err(Invalid("invalid v3 adapter or invocation"));
        }
        for d in [
            &self.adapter_sha256,
            &self.adapter_config_sha256,
            &self.operation_key,
            &self.generation,
        ] {
            digest(d)?;
        }
        for t in [&self.server_version, &self.daemon_id, &self.scope] {
            nonempty(t)?;
        }
        nonempty(&self.socket_path)?;
        if !self.socket_path.starts_with('/')
            || self.socket_path[1..]
                .split('/')
                .any(|p| matches!(p, "" | "." | ".."))
        {
            return Err(Invalid("invalid v3 socket path"));
        }
        let parts: Vec<_> = self.api_version.split('.').collect();
        if parts.len() != 2
            || parts
                .iter()
                .any(|p| !p.parse::<u16>().is_ok_and(|n| n.to_string() == *p))
        {
            return Err(Invalid("invalid v3 API version"));
        }
        Ok(())
    }
}
impl Request {
    pub fn validate(&self) -> Result<()> {
        self.binding.validate()?;
        validate_image(&self.operation.readiness.image)
    }
}
impl Response {
    pub fn validate(&self, expected: &Request) -> Result<()> {
        expected.validate()?;
        if self.binding != expected.binding || self.operation != expected.operation {
            return Err(Invalid("v3 response binding mismatch"));
        }
        match (&self.outcome, &self.observations) {
            (Outcome::Unavailable, None) => Ok(()),
            (Outcome::Readiness(r), Some(pair)) => {
                for o in [&pair.observed_before, &pair.observed_after] {
                    if o.api_version != self.binding.api_version
                        || o.server_version != self.binding.server_version
                        || o.daemon_id != self.binding.daemon_id
                    {
                        return Err(Invalid("v3 observation mismatch"));
                    }
                }
                match (r.image_present, &r.image_id) {
                    (true, Some(id)) => digest(
                        id.strip_prefix("sha256:")
                            .ok_or(Invalid("invalid image ID"))?,
                    ),
                    (false, None) => Ok(()),
                    _ => Err(Invalid("inconsistent image readiness")),
                }
            }
            _ => Err(Invalid("invalid v3 observations")),
        }
    }
}
pub fn parse_request(bytes: &[u8]) -> Result<Request> {
    let r: Request = crate::parse(bytes)?;
    r.validate()?;
    Ok(r)
}
pub fn parse_response(bytes: &[u8], expected: &Request) -> Result<Response> {
    let r: Response = crate::parse(bytes)?;
    r.validate(expected)?;
    Ok(r)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::{json, Value};
    fn request() -> Request {
        serde_json::from_value(json!({"binding":{"protocol":"gate-runtime/v3","adapter_sha256":"a".repeat(64),"adapter_version":"0.2.0","invocation":"1".repeat(32),"operation_key":"b".repeat(64),"generation":"c".repeat(64),"adapter_config_sha256":"d".repeat(64),"engine":"docker","transport":"unix-http","socket_path":"/run/test.sock","socket_uid":1000,"principal_uid":1000,"rootless":false,"api_version":"1.47","server_version":"27","daemon_id":"fixture","scope":"test"},"operation":{"readiness":{"image":"registry:5000/repo:tag"}}})).unwrap()
    }
    fn response() -> Value {
        let r = request();
        let o = json!({"api_version":"1.47","server_version":"27","daemon_id":"fixture"});
        json!({"binding":r.binding,"operation":r.operation,"outcome":{"readiness":{"image_present":true,"image_id":format!("sha256:{}","e".repeat(64))}},"observations":{"observed_before":o,"observed_after":o}})
    }
    fn bytes(v: &Value) -> Vec<u8> {
        serde_json::to_vec(v).unwrap()
    }
    #[test]
    fn readiness_requires_identity_on_present_and_missing_but_not_unavailable() {
        let expected = request();
        let mut value = response();
        assert!(parse_response(&bytes(&value), &expected).is_ok());
        value["outcome"] = json!({"readiness":{"image_present":false}});
        assert!(parse_response(&bytes(&value), &expected).is_ok());
        value["observations"] = Value::Null;
        assert!(parse_response(&bytes(&value), &expected).is_err());
        value["outcome"] = json!("unavailable");
        assert!(parse_response(&bytes(&value), &expected).is_ok());
        value.as_object_mut().unwrap().remove("observations");
        assert!(parse_response(&bytes(&value), &expected).is_err());
    }
    #[test]
    fn inconsistent_image_fields_and_capabilities_are_rejected() {
        for outcome in [
            json!({"readiness":{"image_present":true}}),
            json!({"readiness":{"image_present":true,"image_id":null}}),
            json!({"readiness":{"image_present":false,"image_id":null}}),
            json!({"readiness":{"image_present":false,"image_id":"sha256:abc"}}),
            json!({"readiness":{"image_present":true,"image_id":"bad"}}),
            json!({"capabilities":{"supported":["readiness"]}}),
            json!({"unavailable":null}),
            json!("absent"),
        ] {
            let mut v = response();
            v["outcome"] = outcome;
            assert!(parse_response(&bytes(&v), &request()).is_err());
        }
    }
    #[test]
    fn every_identity_and_exact_image_echo_are_required() {
        for field in [
            "protocol",
            "adapter_sha256",
            "adapter_version",
            "invocation",
            "operation_key",
            "generation",
            "adapter_config_sha256",
            "engine",
            "transport",
            "socket_path",
            "api_version",
            "server_version",
            "daemon_id",
            "scope",
        ] {
            let mut v = response();
            v["binding"][field] = json!("changed");
            assert!(parse_response(&bytes(&v), &request()).is_err(), "{field}");
        }
        for field in ["socket_uid", "principal_uid"] {
            let mut v = response();
            v["binding"][field] = json!(1001);
            assert!(parse_response(&bytes(&v), &request()).is_err());
        }
        let mut v = response();
        v["binding"]["rootless"] = json!(true);
        assert!(parse_response(&bytes(&v), &request()).is_err());
        v = response();
        v["operation"]["readiness"]["image"] = json!("other");
        assert!(parse_response(&bytes(&v), &request()).is_err());
        for side in ["observed_before", "observed_after"] {
            for field in ["api_version", "server_version", "daemon_id"] {
                let mut v = response();
                v["observations"][side][field] = json!("other");
                assert!(parse_response(&bytes(&v), &request()).is_err());
            }
        }
    }
    #[test]
    fn strict_duplicates_unknown_trailing_and_size_boundaries() {
        let r = serde_json::to_string(&request()).unwrap();
        for s in [
            r.replace("\"image\":", "\"image\":\"first\",\"image\":"),
            r.replace(
                "\"invocation\":",
                "\"invocation\":\"first\",\"invocation\":",
            ),
            format!("{r}{{}}"),
            r.replace("\"readiness\":{", "\"readiness\":{\"extra\":true,"),
        ] {
            assert!(parse_request(s.as_bytes()).is_err());
        }
        let response = String::from_utf8(bytes(&response())).unwrap();
        for s in [
            response.replace(
                "\"image_present\":true",
                "\"image_present\":true,\"image_present\":false",
            ),
            response.replace("\"readiness\":{", "\"readiness\":{\"extra\":true,"),
        ] {
            assert!(parse_response(s.as_bytes(), &request()).is_err());
        }
        let mut exact = r.into_bytes();
        exact.resize(crate::MAX_MESSAGE_BYTES, b' ');
        assert!(parse_request(&exact).is_ok());
        exact.push(b' ');
        assert!(parse_request(&exact).is_err());
    }
    #[test]
    fn image_reference_policy_and_v2_separation() {
        for s in [
            "",
            "-x",
            "+x",
            "a b",
            "a\n",
            "a?b",
            "a#b",
            "/a",
            "a//b",
            "a/../b",
            "a/./b",
            "é",
            "a@sha256:bad",
        ] {
            assert!(validate_image(s).is_err(), "{s}");
        }
        assert!(validate_image(&"x".repeat(512)).is_ok());
        assert!(validate_image(&"x".repeat(513)).is_err());
        assert!(validate_image(&format!("registry:5000/repo@sha256:{}", "a".repeat(64))).is_ok());
        let raw = serde_json::to_vec(&request()).unwrap();
        assert!(crate::v2::parse_request(&raw).is_err());
        for op in [
            json!("capabilities"),
            json!({"inspect":{"object_id":"a".repeat(64)}}),
            json!({"remove":{"object_id":"a".repeat(64)}}),
        ] {
            let mut v = serde_json::to_value(request()).unwrap();
            v["operation"] = op;
            assert!(parse_request(&bytes(&v)).is_err());
        }
    }
}