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