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