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