gatehouse_runtime_protocol/
lib.rs1pub mod intent;
4pub mod lifecycle;
5#[cfg(unix)]
6pub mod store;
7pub mod v2;
8pub mod v3;
9mod wire;
10
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeSet;
13
14pub const MAX_MESSAGE_BYTES: usize = 65536;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(try_from = "String")]
18pub enum Protocol {
19 #[serde(rename = "gate-runtime/v1")]
20 V1,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "kebab-case")]
25#[serde(try_from = "String")]
26pub enum Engine {
27 Docker,
28 Podman,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct Namespace {
35 pub engine: Engine,
36 pub endpoint: String,
37 pub context: String,
38 pub principal: String,
39 pub rootless: bool,
40 pub scope: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct Binding {
46 pub protocol: Protocol,
47 pub adapter_sha256: String,
48 pub adapter_version: String,
49 pub invocation: String,
50 pub operation_key: String,
51 pub generation: String,
52 pub namespace: Namespace,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
56#[serde(rename_all = "kebab-case")]
57pub enum Operation {
58 Capabilities,
59 Inspect { object_id: String },
60 Remove { object_id: String },
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct Request {
66 pub binding: Binding,
67 pub operation: Operation,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct Label {
73 pub key: String,
74 pub value: String,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct Object {
80 pub object_id: String,
81 pub labels: Vec<Label>,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
85#[serde(rename_all = "kebab-case")]
86#[serde(try_from = "String")]
87pub enum Capability {
88 Inspect,
89 Remove,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
93#[serde(rename_all = "kebab-case")]
94pub enum Outcome {
95 Capabilities { supported: Vec<Capability> },
96 Present { object: Object },
97 Absent,
98 Unavailable,
99 Completed,
100 RejectedBeforeEffect,
101 Unknown,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct Response {
107 pub binding: Binding,
108 pub operation: Operation,
109 pub outcome: Outcome,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct Invalid(pub &'static str);
115impl std::fmt::Display for Invalid {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 f.write_str(self.0)
118 }
119}
120impl std::error::Error for Invalid {}
121type Result<T> = std::result::Result<T, Invalid>;
122
123fn nonempty(value: &str) -> Result<()> {
124 if value.trim().is_empty() || value.chars().any(char::is_control) {
125 return Err(Invalid("missing or invalid identity"));
126 }
127 Ok(())
128}
129
130fn digest(value: &str) -> Result<()> {
131 if value.len() != 64
132 || !value
133 .bytes()
134 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
135 {
136 return Err(Invalid("expected full lowercase hexadecimal identity"));
137 }
138 Ok(())
139}
140
141impl Binding {
142 pub fn validate(&self) -> Result<()> {
143 digest(&self.adapter_sha256)?;
144 for value in [
145 &self.adapter_version,
146 &self.invocation,
147 &self.operation_key,
148 &self.generation,
149 &self.namespace.endpoint,
150 &self.namespace.context,
151 &self.namespace.principal,
152 &self.namespace.scope,
153 ] {
154 nonempty(value)?;
155 }
156 Ok(())
157 }
158}
159
160impl Request {
161 pub fn validate(&self) -> Result<()> {
162 self.binding.validate()?;
163 match &self.operation {
164 Operation::Capabilities => Ok(()),
165 Operation::Inspect { object_id } | Operation::Remove { object_id } => digest(object_id),
166 }
167 }
168
169 pub fn require_same_intent(&self, previous: &Self) -> Result<()> {
171 self.validate()?;
172 previous.validate()?;
173 if self != previous {
174 return Err(Invalid("request differs from recorded intent"));
175 }
176 Ok(())
177 }
178}
179
180fn parse<T: for<'de> Deserialize<'de>>(bytes: &[u8]) -> Result<T> {
181 if bytes.len() > MAX_MESSAGE_BYTES {
182 return Err(Invalid("message exceeds protocol limit"));
183 }
184 serde_json::from_slice(bytes).map_err(|_| Invalid("invalid protocol message"))
185}
186
187pub fn parse_request(bytes: &[u8]) -> Result<Request> {
188 let request: Request = parse(bytes)?;
189 request.validate()?;
190 Ok(request)
191}
192
193pub fn parse_response(bytes: &[u8], expected: &Request) -> Result<Response> {
194 let response: Response = parse(bytes)?;
195 response.validate(expected)?;
196 Ok(response)
197}
198
199fn validate_labels(labels: &[Label]) -> Result<()> {
200 let mut keys = BTreeSet::new();
201 if labels.is_empty() {
202 return Err(Invalid("missing ownership labels"));
203 }
204 for label in labels {
205 nonempty(&label.key)?;
206 nonempty(&label.value)?;
207 if !keys.insert(&label.key) {
208 return Err(Invalid("duplicate ownership label"));
209 }
210 }
211 Ok(())
212}
213
214impl Response {
215 pub fn validate(&self, expected: &Request) -> Result<()> {
216 expected.validate()?;
217 if self.binding != expected.binding || self.operation != expected.operation {
218 return Err(Invalid("response binding mismatch"));
219 }
220 match (&self.operation, &self.outcome) {
221 (Operation::Capabilities, Outcome::Capabilities { supported }) => {
222 let unique: BTreeSet<_> = supported.iter().collect();
223 if supported.is_empty() || unique.len() != supported.len() {
224 return Err(Invalid("empty or duplicate capabilities"));
225 }
226 }
227 (Operation::Inspect { object_id }, Outcome::Present { object }) => {
228 if object.object_id != *object_id {
229 return Err(Invalid("object identity mismatch"));
230 }
231 validate_labels(&object.labels)?;
232 }
233 (Operation::Inspect { .. }, Outcome::Absent | Outcome::Unavailable) => {}
234 (
235 Operation::Remove { .. },
236 Outcome::Completed | Outcome::RejectedBeforeEffect | Outcome::Unknown,
237 ) => {}
238 _ => return Err(Invalid("outcome incompatible with operation")),
239 }
240 Ok(())
241 }
242
243 pub fn require_ownership(&self, expected: &Request, labels: &[Label]) -> Result<()> {
245 self.validate(expected)?;
246 validate_labels(labels)?;
247 let Outcome::Present { object } = &self.outcome else {
248 return Err(Invalid("ownership requires a present object"));
249 };
250 if !labels.iter().all(|label| object.labels.contains(label)) {
251 return Err(Invalid("ownership labels mismatch"));
252 }
253 Ok(())
254 }
255}
256
257pub fn require_absence(remove: &Request, inspection: &Response) -> Result<()> {
261 remove.validate()?;
262 let Operation::Remove { object_id } = &remove.operation else {
263 return Err(Invalid("reconciliation requires a removal intent"));
264 };
265 let expected = Request {
266 binding: remove.binding.clone(),
267 operation: Operation::Inspect {
268 object_id: object_id.clone(),
269 },
270 };
271 inspection.validate(&expected)?;
272 if inspection.outcome != Outcome::Absent {
273 return Err(Invalid("exact object absence not established"));
274 }
275 Ok(())
276}
277
278#[cfg(test)]
279mod contract;