1use 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 #[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
158fn 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
171pub 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 "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 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}
492pub 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}
664pub 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;