Skip to main content

gatehouse_runtime_protocol/
v3.rs

1//! Independent read-only image readiness contract. No lease/mutation authority.
2pub use crate::v2::{Engine, Observations, ObservedIdentity, Transport};
3use crate::{digest, nonempty, Invalid, Result};
4use serde::{Deserialize, Deserializer, Serialize};
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(try_from = "String")]
8pub enum Protocol {
9    #[serde(rename = "gate-runtime/v3")]
10    V3,
11}
12impl TryFrom<String> for Protocol {
13    type Error = Invalid;
14    fn try_from(s: String) -> Result<Self> {
15        if s == "gate-runtime/v3" {
16            Ok(Self::V3)
17        } else {
18            Err(Invalid("unsupported v3 protocol"))
19        }
20    }
21}
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct Binding {
25    pub protocol: Protocol,
26    pub adapter_sha256: String,
27    pub adapter_version: String,
28    pub invocation: String,
29    pub operation_key: String,
30    pub generation: String,
31    pub adapter_config_sha256: String,
32    pub engine: Engine,
33    pub transport: Transport,
34    pub socket_path: String,
35    pub socket_uid: u32,
36    pub principal_uid: u32,
37    pub rootless: bool,
38    pub api_version: String,
39    pub server_version: String,
40    pub daemon_id: String,
41    pub scope: String,
42}
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct Target {
46    pub image: String,
47}
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct Operation {
51    pub readiness: Target,
52}
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct Request {
56    pub binding: Binding,
57    pub operation: Operation,
58}
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct Readiness {
62    pub image_present: bool,
63    #[serde(
64        default,
65        skip_serializing_if = "Option::is_none",
66        deserialize_with = "image_id"
67    )]
68    pub image_id: Option<String>,
69}
70fn image_id<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Option<String>, D::Error> {
71    String::deserialize(d).map(Some)
72}
73#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
74#[serde(rename_all = "lowercase")]
75pub enum Outcome {
76    Readiness(Readiness),
77    Unavailable,
78}
79impl<'de> Deserialize<'de> for Outcome {
80    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
81        struct Visitor;
82        impl<'de> serde::de::Visitor<'de> for Visitor {
83            type Value = Outcome;
84            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
85                f.write_str("readiness object or unavailable string")
86            }
87            fn visit_str<E: serde::de::Error>(self, s: &str) -> std::result::Result<Outcome, E> {
88                if s == "unavailable" {
89                    Ok(Outcome::Unavailable)
90                } else {
91                    Err(E::custom("invalid v3 outcome"))
92                }
93            }
94            fn visit_map<M: serde::de::MapAccess<'de>>(
95                self,
96                mut m: M,
97            ) -> std::result::Result<Outcome, M::Error> {
98                if m.next_key::<String>()?.as_deref() != Some("readiness") {
99                    return Err(serde::de::Error::custom("invalid v3 outcome"));
100                }
101                let value = m.next_value()?;
102                if m.next_key::<serde::de::IgnoredAny>()?.is_some() {
103                    return Err(serde::de::Error::custom("extra outcome fields"));
104                }
105                Ok(Outcome::Readiness(value))
106            }
107        }
108        d.deserialize_any(Visitor)
109    }
110}
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct Response {
114    pub binding: Binding,
115    pub operation: Operation,
116    pub outcome: Outcome,
117    #[serde(deserialize_with = "observations")]
118    pub observations: Option<Observations>,
119}
120fn observations<'de, D: Deserializer<'de>>(
121    d: D,
122) -> std::result::Result<Option<Observations>, D::Error> {
123    Option::deserialize(d)
124}
125
126pub fn validate_image(image: &str) -> Result<()> {
127    if image.is_empty()
128        || image.len() > 512
129        || !image.is_ascii()
130        || image.starts_with(['-', '+'])
131        || image
132            .bytes()
133            .any(|b| b.is_ascii_whitespace() || b.is_ascii_control() || matches!(b, b'?' | b'#'))
134        || image.split('/').any(|p| matches!(p, "" | "." | ".."))
135    {
136        return Err(Invalid("invalid v3 image reference"));
137    }
138    if let Some((repo, hash)) = image.split_once('@') {
139        nonempty(repo)?;
140        digest(
141            hash.strip_prefix("sha256:")
142                .ok_or(Invalid("unsupported image digest"))?,
143        )?;
144    }
145    Ok(())
146}
147impl Binding {
148    pub fn validate(&self) -> Result<()> {
149        if self.adapter_version != "0.2.0"
150            || self.invocation.len() != 32
151            || !self
152                .invocation
153                .bytes()
154                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
155        {
156            return Err(Invalid("invalid v3 adapter or invocation"));
157        }
158        for d in [
159            &self.adapter_sha256,
160            &self.adapter_config_sha256,
161            &self.operation_key,
162            &self.generation,
163        ] {
164            digest(d)?;
165        }
166        for t in [&self.server_version, &self.daemon_id, &self.scope] {
167            nonempty(t)?;
168        }
169        nonempty(&self.socket_path)?;
170        if !self.socket_path.starts_with('/')
171            || self.socket_path[1..]
172                .split('/')
173                .any(|p| matches!(p, "" | "." | ".."))
174        {
175            return Err(Invalid("invalid v3 socket path"));
176        }
177        let parts: Vec<_> = self.api_version.split('.').collect();
178        if parts.len() != 2
179            || parts
180                .iter()
181                .any(|p| !p.parse::<u16>().is_ok_and(|n| n.to_string() == *p))
182        {
183            return Err(Invalid("invalid v3 API version"));
184        }
185        Ok(())
186    }
187}
188impl Request {
189    pub fn validate(&self) -> Result<()> {
190        self.binding.validate()?;
191        validate_image(&self.operation.readiness.image)
192    }
193}
194impl Response {
195    pub fn validate(&self, expected: &Request) -> Result<()> {
196        expected.validate()?;
197        if self.binding != expected.binding || self.operation != expected.operation {
198            return Err(Invalid("v3 response binding mismatch"));
199        }
200        match (&self.outcome, &self.observations) {
201            (Outcome::Unavailable, None) => Ok(()),
202            (Outcome::Readiness(r), Some(pair)) => {
203                for o in [&pair.observed_before, &pair.observed_after] {
204                    if o.api_version != self.binding.api_version
205                        || o.server_version != self.binding.server_version
206                        || o.daemon_id != self.binding.daemon_id
207                    {
208                        return Err(Invalid("v3 observation mismatch"));
209                    }
210                }
211                match (r.image_present, &r.image_id) {
212                    (true, Some(id)) => digest(
213                        id.strip_prefix("sha256:")
214                            .ok_or(Invalid("invalid image ID"))?,
215                    ),
216                    (false, None) => Ok(()),
217                    _ => Err(Invalid("inconsistent image readiness")),
218                }
219            }
220            _ => Err(Invalid("invalid v3 observations")),
221        }
222    }
223}
224pub fn parse_request(bytes: &[u8]) -> Result<Request> {
225    let r: Request = crate::parse(bytes)?;
226    r.validate()?;
227    Ok(r)
228}
229pub fn parse_response(bytes: &[u8], expected: &Request) -> Result<Response> {
230    let r: Response = crate::parse(bytes)?;
231    r.validate(expected)?;
232    Ok(r)
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use serde_json::{json, Value};
239    fn request() -> Request {
240        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()
241    }
242    fn response() -> Value {
243        let r = request();
244        let o = json!({"api_version":"1.47","server_version":"27","daemon_id":"fixture"});
245        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}})
246    }
247    fn bytes(v: &Value) -> Vec<u8> {
248        serde_json::to_vec(v).unwrap()
249    }
250    #[test]
251    fn readiness_requires_identity_on_present_and_missing_but_not_unavailable() {
252        let expected = request();
253        let mut value = response();
254        assert!(parse_response(&bytes(&value), &expected).is_ok());
255        value["outcome"] = json!({"readiness":{"image_present":false}});
256        assert!(parse_response(&bytes(&value), &expected).is_ok());
257        value["observations"] = Value::Null;
258        assert!(parse_response(&bytes(&value), &expected).is_err());
259        value["outcome"] = json!("unavailable");
260        assert!(parse_response(&bytes(&value), &expected).is_ok());
261        value.as_object_mut().unwrap().remove("observations");
262        assert!(parse_response(&bytes(&value), &expected).is_err());
263    }
264    #[test]
265    fn inconsistent_image_fields_and_capabilities_are_rejected() {
266        for outcome in [
267            json!({"readiness":{"image_present":true}}),
268            json!({"readiness":{"image_present":true,"image_id":null}}),
269            json!({"readiness":{"image_present":false,"image_id":null}}),
270            json!({"readiness":{"image_present":false,"image_id":"sha256:abc"}}),
271            json!({"readiness":{"image_present":true,"image_id":"bad"}}),
272            json!({"capabilities":{"supported":["readiness"]}}),
273            json!({"unavailable":null}),
274            json!("absent"),
275        ] {
276            let mut v = response();
277            v["outcome"] = outcome;
278            assert!(parse_response(&bytes(&v), &request()).is_err());
279        }
280    }
281    #[test]
282    fn every_identity_and_exact_image_echo_are_required() {
283        for field in [
284            "protocol",
285            "adapter_sha256",
286            "adapter_version",
287            "invocation",
288            "operation_key",
289            "generation",
290            "adapter_config_sha256",
291            "engine",
292            "transport",
293            "socket_path",
294            "api_version",
295            "server_version",
296            "daemon_id",
297            "scope",
298        ] {
299            let mut v = response();
300            v["binding"][field] = json!("changed");
301            assert!(parse_response(&bytes(&v), &request()).is_err(), "{field}");
302        }
303        for field in ["socket_uid", "principal_uid"] {
304            let mut v = response();
305            v["binding"][field] = json!(1001);
306            assert!(parse_response(&bytes(&v), &request()).is_err());
307        }
308        let mut v = response();
309        v["binding"]["rootless"] = json!(true);
310        assert!(parse_response(&bytes(&v), &request()).is_err());
311        v = response();
312        v["operation"]["readiness"]["image"] = json!("other");
313        assert!(parse_response(&bytes(&v), &request()).is_err());
314        for side in ["observed_before", "observed_after"] {
315            for field in ["api_version", "server_version", "daemon_id"] {
316                let mut v = response();
317                v["observations"][side][field] = json!("other");
318                assert!(parse_response(&bytes(&v), &request()).is_err());
319            }
320        }
321    }
322    #[test]
323    fn strict_duplicates_unknown_trailing_and_size_boundaries() {
324        let r = serde_json::to_string(&request()).unwrap();
325        for s in [
326            r.replace("\"image\":", "\"image\":\"first\",\"image\":"),
327            r.replace(
328                "\"invocation\":",
329                "\"invocation\":\"first\",\"invocation\":",
330            ),
331            format!("{r}{{}}"),
332            r.replace("\"readiness\":{", "\"readiness\":{\"extra\":true,"),
333        ] {
334            assert!(parse_request(s.as_bytes()).is_err());
335        }
336        let response = String::from_utf8(bytes(&response())).unwrap();
337        for s in [
338            response.replace(
339                "\"image_present\":true",
340                "\"image_present\":true,\"image_present\":false",
341            ),
342            response.replace("\"readiness\":{", "\"readiness\":{\"extra\":true,"),
343        ] {
344            assert!(parse_response(s.as_bytes(), &request()).is_err());
345        }
346        let mut exact = r.into_bytes();
347        exact.resize(crate::MAX_MESSAGE_BYTES, b' ');
348        assert!(parse_request(&exact).is_ok());
349        exact.push(b' ');
350        assert!(parse_request(&exact).is_err());
351    }
352    #[test]
353    fn image_reference_policy_and_v2_separation() {
354        for s in [
355            "",
356            "-x",
357            "+x",
358            "a b",
359            "a\n",
360            "a?b",
361            "a#b",
362            "/a",
363            "a//b",
364            "a/../b",
365            "a/./b",
366            "é",
367            "a@sha256:bad",
368        ] {
369            assert!(validate_image(s).is_err(), "{s}");
370        }
371        assert!(validate_image(&"x".repeat(512)).is_ok());
372        assert!(validate_image(&"x".repeat(513)).is_err());
373        assert!(validate_image(&format!("registry:5000/repo@sha256:{}", "a".repeat(64))).is_ok());
374        let raw = serde_json::to_vec(&request()).unwrap();
375        assert!(crate::v2::parse_request(&raw).is_err());
376        for op in [
377            json!("capabilities"),
378            json!({"inspect":{"object_id":"a".repeat(64)}}),
379            json!({"remove":{"object_id":"a".repeat(64)}}),
380        ] {
381            let mut v = serde_json::to_value(request()).unwrap();
382            v["operation"] = op;
383            assert!(parse_request(&bytes(&v)).is_err());
384        }
385    }
386}