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 },
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 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}
421pub 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}
602pub 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;