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