Skip to main content

gatehouse_runtime_protocol/lifecycle/
wire.rs

1//! Strict lifecycle wire facts. No I/O, journal, authorization tokens or effects.
2use super::canonical;
3use crate::{digest, nonempty, Invalid, Result};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6
7pub const PROTOCOL: &str = "gate-runtime/lifecycle-v1";
8const DOMAIN: &[u8] = b"gate-runtime/lifecycle-v1/request\0";
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub struct EngineBinding {
13    pub engine: String,
14    pub transport: String,
15    pub socket_path: String,
16    pub socket_uid: u32,
17    pub principal_uid: u32,
18    pub rootless: bool,
19    pub api_version: String,
20    pub server_version: String,
21    pub daemon_id: String,
22    pub scope: String,
23}
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct Binding {
27    pub installation_sha256: String,
28    pub executable_sha256: String,
29    pub config_sha256: String,
30    pub engine_binding: EngineBinding,
31    pub project_id: String,
32    pub resource_id: String,
33    pub lease_invocation: String,
34    pub invocation: String,
35    pub generation: u64,
36    pub operation_key: String,
37}
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct Entry {
41    pub key: String,
42    pub value: String,
43}
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct Target {
47    pub container_port: u16,
48    pub object_id: String,
49    pub name: String,
50    pub labels: Vec<Entry>,
51}
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
54pub enum Operation {
55    ResolveImage {
56        image: String,
57    },
58    Create {
59        name: String,
60        image_id: String,
61        labels: Vec<Entry>,
62        environment: Vec<Entry>,
63        container_port: u16,
64    },
65    Status {
66        target: Target,
67    },
68    Start {
69        target: Target,
70    },
71    Stop {
72        target: Target,
73        grace_seconds: u8,
74    },
75    Remove {
76        target: Target,
77    },
78    HealthExec {
79        target: Target,
80        argv: Vec<String>,
81        timeout_ms: u32,
82    },
83}
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct Request {
87    pub protocol: String,
88    pub binding: Binding,
89    pub operation: Operation,
90}
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "kebab-case")]
93pub enum ContainerState {
94    Created,
95    Running,
96    Paused,
97    Restarting,
98    Removing,
99    Exited,
100    Dead,
101}
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct Port {
105    pub container_port: u16,
106    pub protocol: String,
107    pub host_ip: String,
108    pub host_port: u16,
109}
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct Object {
113    pub image_id: String,
114    pub object_id: String,
115    pub name: String,
116    pub labels: Vec<Entry>,
117    pub state: ContainerState,
118    pub ports: Vec<Port>,
119}
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct Observation {
123    pub api_version: String,
124    pub server_version: String,
125    pub daemon_id: String,
126}
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct Observations {
130    pub observed_before: Observation,
131    pub observed_after: Observation,
132}
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(deny_unknown_fields)]
135pub struct Absence {
136    pub object_id: String,
137    pub route: String,
138    pub http_status: u16,
139    pub error_message_sha256: String,
140}
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "kebab-case")]
143pub enum Reason {
144    InvalidInput,
145    IdentityMismatch,
146    Transport,
147    Deadline,
148    Cancelled,
149    InvalidResponse,
150    UnsupportedState,
151}
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
154pub enum Outcome {
155    Image {
156        image_id: String,
157        repo_digests: Vec<String>,
158    },
159    Created {
160        object: Object,
161    },
162    Status {
163        object: Object,
164    },
165    Started {
166        object: Object,
167    },
168    Stopped {
169        object: Object,
170    },
171    Removed,
172    Absent {
173        evidence: Absence,
174    },
175    Health {
176        exec_id: String,
177        exit_code: i32,
178        object: Object,
179    },
180    RejectedBeforeEffect {
181        reason: Reason,
182    },
183    Unknown {
184        reason: Reason,
185    },
186}
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(deny_unknown_fields)]
189pub struct Response {
190    pub protocol: String,
191    pub binding: Binding,
192    pub operation: Operation,
193    pub outcome: Outcome,
194    #[serde(deserialize_with = "required_observations")]
195    pub observations: Option<Observations>,
196}
197fn required_observations<'de, D: serde::Deserializer<'de>>(
198    d: D,
199) -> std::result::Result<Option<Observations>, D::Error> {
200    Deserialize::deserialize(d)
201}
202fn hex32(s: &str) -> Result<()> {
203    if s.len() == 32
204        && s.bytes()
205            .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
206    {
207        Ok(())
208    } else {
209        Err(Invalid("invalid lifecycle nonce"))
210    }
211}
212fn name(s: &str) -> Result<()> {
213    if s.is_empty()
214        || s.len() > 128
215        || !s.as_bytes()[0].is_ascii_alphanumeric()
216        || !s
217            .bytes()
218            .all(|c| c.is_ascii_alphanumeric() || b"_.-".contains(&c))
219    {
220        return Err(Invalid("invalid container name"));
221    }
222    Ok(())
223}
224fn image_id(s: &str) -> Result<()> {
225    digest(
226        s.strip_prefix("sha256:")
227            .ok_or(Invalid("invalid image ID"))?,
228    )
229}
230fn entries(items: &[Entry]) -> Result<()> {
231    if items.len() > 128 {
232        return Err(Invalid("too many lifecycle entries"));
233    }
234    let mut last: Option<&str> = None;
235    for e in items {
236        nonempty(&e.key)?;
237        if e.key.len() > 256
238            || e.value.len() > 4096
239            || e.value.contains('\0')
240            || last.is_some_and(|p| p >= e.key.as_str())
241        {
242            return Err(Invalid("invalid lifecycle entries"));
243        }
244        last = Some(&e.key);
245    }
246    Ok(())
247}
248fn ownership(labels: &[Entry], b: &Binding) -> Result<()> {
249    entries(labels)?;
250    for (key, expected) in [
251        (
252            "gatehouse.lifecycle.authority",
253            "container-lifecycle/mutate",
254        ),
255        ("gatehouse.lifecycle.project", b.project_id.as_str()),
256        ("gatehouse.lifecycle.resource", b.resource_id.as_str()),
257        (
258            "gatehouse.lifecycle.invocation",
259            b.lease_invocation.as_str(),
260        ),
261    ] {
262        if !labels.iter().any(|e| e.key == key && e.value == expected) {
263            return Err(Invalid("lifecycle ownership mismatch"));
264        }
265    }
266    Ok(())
267}
268impl Binding {
269    pub fn validate(&self) -> Result<()> {
270        for d in [
271            &self.installation_sha256,
272            &self.executable_sha256,
273            &self.config_sha256,
274            &self.operation_key,
275        ] {
276            digest(d)?;
277        }
278        hex32(&self.lease_invocation)?;
279        hex32(&self.invocation)?;
280        for s in [
281            &self.project_id,
282            &self.resource_id,
283            &self.engine_binding.server_version,
284            &self.engine_binding.daemon_id,
285            &self.engine_binding.scope,
286        ] {
287            nonempty(s)?;
288        }
289        if self.generation == 0 {
290            return Err(Invalid("invalid lifecycle generation"));
291        }
292        self.engine_binding.validate()
293    }
294}
295impl EngineBinding {
296    pub fn validate(&self) -> Result<()> {
297        let e = self;
298        for s in [&e.server_version, &e.daemon_id, &e.scope] {
299            nonempty(s)?;
300        }
301        if e.engine != "docker"
302            || e.transport != "unix-http"
303            || !e.socket_path.starts_with('/')
304            || e.socket_path[1..]
305                .split('/')
306                .any(|s| matches!(s, "" | "." | ".."))
307        {
308            return Err(Invalid("invalid lifecycle endpoint"));
309        }
310        nonempty(&e.socket_path)?;
311        let api: Vec<_> = e.api_version.split('.').collect();
312        if api.len() != 2
313            || api
314                .iter()
315                .any(|v| !v.parse::<u16>().is_ok_and(|n| n.to_string() == *v))
316        {
317            return Err(Invalid("invalid lifecycle API version"));
318        }
319        Ok(())
320    }
321}
322impl Operation {
323    fn target(&self) -> Option<&Target> {
324        match self {
325            Self::Status { target }
326            | Self::Start { target }
327            | Self::Stop { target, .. }
328            | Self::Remove { target }
329            | Self::HealthExec { target, .. } => Some(target),
330            _ => None,
331        }
332    }
333    fn validate(&self, b: &Binding) -> Result<()> {
334        if let Some(t) = self.target() {
335            if t.container_port == 0 {
336                return Err(Invalid("invalid target port"));
337            }
338            digest(&t.object_id)?;
339            name(&t.name)?;
340            ownership(&t.labels, b)?;
341        }
342        match self {
343            Self::ResolveImage { image } => crate::v3::validate_image(image)?,
344            Self::Create {
345                name: n,
346                image_id: id,
347                labels,
348                environment,
349                container_port,
350            } => {
351                name(n)?;
352                image_id(id)?;
353                ownership(labels, b)?;
354                entries(environment)?;
355                if *container_port == 0 || environment.iter().any(|e| e.key.contains('=')) {
356                    return Err(Invalid("invalid create parameters"));
357                }
358            }
359            Self::Stop { grace_seconds, .. } if !(1..=30).contains(grace_seconds) => {
360                return Err(Invalid("invalid stop grace"))
361            }
362            Self::HealthExec {
363                argv, timeout_ms, ..
364            } if argv.is_empty()
365                || argv.len() > 64
366                || !(1..=30000).contains(timeout_ms)
367                || argv.iter().any(|a| a.is_empty() || a.contains('\0'))
368                || argv.iter().map(String::len).sum::<usize>() > 8192 =>
369            {
370                return Err(Invalid("invalid health command"));
371            }
372            _ => {}
373        }
374        Ok(())
375    }
376}
377impl Request {
378    pub fn operation_key(&self) -> Result<String> {
379        let mut value =
380            serde_json::to_value(self).map_err(|_| Invalid("invalid request encoding"))?;
381        value["binding"]
382            .as_object_mut()
383            .ok_or(Invalid("missing binding"))?
384            .remove("operation_key");
385        let mut hash = Sha256::new();
386        hash.update(DOMAIN);
387        hash.update(canonical::encode(&value)?);
388        Ok(format!("{:x}", hash.finalize()))
389    }
390    pub fn validate(&self) -> Result<()> {
391        if self.protocol != PROTOCOL {
392            return Err(Invalid("unsupported lifecycle protocol"));
393        }
394        self.binding.validate()?;
395        self.operation.validate(&self.binding)?;
396        if self.operation_key()? != self.binding.operation_key {
397            return Err(Invalid("lifecycle request digest mismatch"));
398        }
399        Ok(())
400    }
401    /// Canonical bytes for an already minted, validated request. Does not mint
402    /// authority or obtain random nonces on behalf of a caller.
403    pub fn canonical_bytes(&self) -> Result<Vec<u8>> {
404        self.validate()?;
405        canonical::encode(
406            &serde_json::to_value(self).map_err(|_| Invalid("invalid request encoding"))?,
407        )
408    }
409}
410
411pub fn parse_request(bytes: &[u8]) -> Result<Request> {
412    let value = canonical::parse_canonical(bytes)?;
413    let request: Request =
414        serde_json::from_value(value).map_err(|_| Invalid("invalid lifecycle request schema"))?;
415    request.validate()?;
416    if request.canonical_bytes()? != bytes {
417        return Err(Invalid("lifecycle request fields changed during parsing"));
418    }
419    Ok(request)
420}
421/// Both byte inputs must originate from separately bounded host channels. This
422/// validates equality, not the file ownership/provenance of the protected input.
423pub fn parse_protected_request(stdin: &[u8], protected: &[u8]) -> Result<Request> {
424    let expected = parse_request(protected)?;
425    let supplied = parse_request(stdin)?;
426    if stdin != protected || supplied != expected {
427        return Err(Invalid("lifecycle protected input mismatch"));
428    }
429    Ok(expected)
430}
431
432fn object_matches(object: &Object, target: &Target) -> Result<()> {
433    if object.object_id != target.object_id
434        || object.name != target.name
435        || !target.labels.iter().all(|e| object.labels.contains(e))
436    {
437        return Err(Invalid("lifecycle object mismatch"));
438    }
439    image_id(&object.image_id)?;
440    digest(&object.object_id)?;
441    name(&object.name)?;
442    entries(&object.labels)?;
443    if object.ports.len() > 1 {
444        return Err(Invalid("too many port bindings"));
445    }
446    for p in &object.ports {
447        if p.protocol != "tcp"
448            || p.host_ip != "127.0.0.1"
449            || p.container_port != target.container_port
450            || p.host_port == 0
451        {
452            return Err(Invalid("invalid port binding"));
453        }
454    }
455    for (i, p) in object.ports.iter().enumerate() {
456        if object.ports[..i]
457            .iter()
458            .any(|old| old.container_port == p.container_port)
459        {
460            return Err(Invalid("ambiguous port binding"));
461        }
462    }
463    Ok(())
464}
465impl Response {
466    pub fn validate(&self, expected: &Request) -> Result<()> {
467        expected.validate()?;
468        if self.protocol != expected.protocol
469            || self.binding != expected.binding
470            || self.operation != expected.operation
471        {
472            return Err(Invalid("lifecycle response binding mismatch"));
473        }
474        if matches!(
475            self.outcome,
476            Outcome::Unknown { .. } | Outcome::RejectedBeforeEffect { .. }
477        ) {
478            return if self.observations.is_none() {
479                Ok(())
480            } else {
481                Err(Invalid("invalid failure observations"))
482            };
483        }
484        self.validate_observations(expected)?;
485        self.validate_outcome(expected)
486    }
487    fn validate_observations(&self, expected: &Request) -> Result<()> {
488        let obs = self
489            .observations
490            .as_ref()
491            .ok_or(Invalid("missing lifecycle observations"))?;
492        let e = &expected.binding.engine_binding;
493        for o in [&obs.observed_before, &obs.observed_after] {
494            if o.api_version != e.api_version
495                || o.server_version != e.server_version
496                || o.daemon_id != e.daemon_id
497            {
498                return Err(Invalid("lifecycle observation mismatch"));
499            }
500        }
501        Ok(())
502    }
503    fn validate_outcome(&self, expected: &Request) -> Result<()> {
504        let e = &expected.binding.engine_binding;
505        use ContainerState as S;
506        match (&expected.operation, &self.outcome) {
507            (
508                Operation::ResolveImage { image },
509                Outcome::Image {
510                    image_id: id,
511                    repo_digests,
512                },
513            ) => {
514                validate_resolved_image(image, id, repo_digests)?;
515            }
516            (
517                Operation::Create {
518                    name,
519                    labels,
520                    image_id,
521                    container_port,
522                    ..
523                },
524                Outcome::Created { object },
525            ) => {
526                object_matches(
527                    object,
528                    &Target {
529                        container_port: *container_port,
530                        object_id: object.object_id.clone(),
531                        name: name.clone(),
532                        labels: labels.clone(),
533                    },
534                )?;
535                if object.state != S::Created || &object.image_id != image_id {
536                    return Err(Invalid("created state required"));
537                }
538            }
539            (Operation::Status { target }, Outcome::Status { object }) => {
540                object_matches(object, target)?
541            }
542            (Operation::Start { target }, Outcome::Started { object }) => {
543                object_matches(object, target)?;
544                if object.state != S::Running || object.ports.len() != 1 {
545                    return Err(Invalid("running state required"));
546                }
547            }
548            (Operation::Stop { target, .. }, Outcome::Stopped { object }) => {
549                object_matches(object, target)?;
550                if !matches!(object.state, S::Created | S::Exited) {
551                    return Err(Invalid("terminal state required"));
552                }
553            }
554            (Operation::Remove { .. }, Outcome::Removed) => {}
555            (Operation::Status { target }, Outcome::Absent { evidence }) => {
556                let route = format!("/v{}/containers/{}/json", e.api_version, target.object_id);
557                let hash = format!(
558                    "{:x}",
559                    Sha256::digest(format!("No such container: {}", target.object_id).as_bytes())
560                );
561                if evidence.object_id != target.object_id
562                    || evidence.route != route
563                    || evidence.http_status != 404
564                    || evidence.error_message_sha256 != hash
565                {
566                    return Err(Invalid("invalid absence evidence"));
567                }
568            }
569            (
570                Operation::HealthExec { target, .. },
571                Outcome::Health {
572                    exec_id, object, ..
573                },
574            ) => {
575                digest(exec_id)?;
576                object_matches(object, target)?;
577                if object.state != S::Running || object.ports.len() != 1 {
578                    return Err(Invalid("health container not running"));
579                }
580            }
581            _ => return Err(Invalid("lifecycle operation outcome mismatch")),
582        }
583        Ok(())
584    }
585}
586fn validate_resolved_image(image: &str, id: &str, repo_digests: &[String]) -> Result<()> {
587    image_id(id)?;
588    if repo_digests.len() > 128 {
589        return Err(Invalid("too many repo digests"));
590    }
591    for (i, d) in repo_digests.iter().enumerate() {
592        crate::v3::validate_image(d)?;
593        if !d.contains('@') || repo_digests[..i].contains(d) {
594            return Err(Invalid("invalid repo digests"));
595        }
596    }
597    if image.contains('@') && !repo_digests.iter().any(|digest| digest == image) {
598        return Err(Invalid("image digest pin mismatch"));
599    }
600    Ok(())
601}
602/// Only validated responses may subsequently enter the durable journal layer.
603/// Validation does not consume a generation; replay rejection belongs to storage.
604pub fn parse_response(bytes: &[u8], expected: &Request) -> Result<Response> {
605    let value = canonical::parse_canonical(bytes)?;
606    let response: Response =
607        serde_json::from_value(value).map_err(|_| Invalid("invalid lifecycle response schema"))?;
608    response.validate(expected)?;
609    let typed =
610        serde_json::to_value(&response).map_err(|_| Invalid("invalid response encoding"))?;
611    if canonical::encode(&typed)? != bytes {
612        return Err(Invalid("lifecycle response fields changed during parsing"));
613    }
614    Ok(response)
615}
616
617#[cfg(test)]
618mod tests;