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