Skip to main content

gatehouse_runtime_protocol/
lib.rs

1//! Strict runtime data and intent state support. No process execution, artifact
2//! verification, ambient context discovery, or authority to delete a resource.
3pub 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/// Explicit trusted configuration, never filled from the current environment.
30#[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/// Deliberately excludes untrusted input values from diagnostics.
111#[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    /// Reject operation-key reuse with changed content, including generation.
168    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    /// Core supplies the required ownership labels from its trusted lease.
242    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
255/// An adapter's mutation completion claim alone cannot clear an intent. This
256/// checks a separately obtained exact-ID inspection; persistence and locking
257/// remain the caller's responsibility, as does authenticating the adapter.
258pub 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;