1pub 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}