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;
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/// Explicit trusted configuration, never filled from the current environment.
31#[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/// Deliberately excludes untrusted input values from diagnostics.
112#[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    /// Reject operation-key reuse with changed content, including generation.
169    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    /// Core supplies the required ownership labels from its trusted lease.
243    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
256/// An adapter's mutation completion claim alone cannot clear an intent. This
257/// checks a separately obtained exact-ID inspection; persistence and locking
258/// remain the caller's responsibility, as does authenticating the adapter.
259pub 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;