Skip to main content

gatehouse_runtime_protocol/lifecycle_v2/
plan_record.rs

1//! Canonical same-context evidence codec. No storage or dispatch authority.
2use super::{
3    native_image::{decode_image, image_route},
4    ownership::FixedPort,
5    profile::{Profile, SlotIdentity},
6    protected_context::ProtectedContext,
7    readonly::CapturedImage,
8};
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use std::collections::BTreeMap;
12type Result<T> = std::result::Result<T, &'static str>;
13const SCHEMA: &str = "gate-runtime/podman-plan-evidence-v1";
14const LIMIT: usize = 16 * 1024 * 1024;
15#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct PlanInputs {
18    pub name: String,
19    pub labels: BTreeMap<String, String>,
20    pub port: Option<PlanPort>,
21}
22impl PlanInputs {
23    /// Checks only caller-known fields. Empty operator labels are legal until
24    /// image inheritance is merged; final plan validation still applies.
25    pub fn validate(&self) -> Result<()> {
26        super::ownership::validate_plan_fields(
27            &self.name,
28            &self.labels,
29            self.port
30                .as_ref()
31                .map(|p| FixedPort {
32                    container: p.container,
33                    host: p.host,
34                })
35                .as_ref(),
36            false,
37        )
38    }
39}
40#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct PlanPort {
43    pub container: u16,
44    pub host: u16,
45}
46#[derive(Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48struct Wire {
49    schema: String,
50    profile: Profile,
51    asserted_installation_sha256: String,
52    asserted_executable_sha256: String,
53    config_json: String,
54    config_sha256: String,
55    operator_epoch: String,
56    image_id: String,
57    image_json: String,
58    image_sha256: String,
59    inputs: PlanInputs,
60    plan_json: String,
61    plan_sha256: String,
62}
63/// Validated saved evidence only. No public raw constructor or serde implementation.
64pub struct PlanRecord {
65    wire: Wire,
66}
67fn hash(bytes: &[u8]) -> String {
68    format!("{:x}", Sha256::digest(bytes))
69}
70impl PlanRecord {
71    pub fn capture(
72        context: &ProtectedContext,
73        captured: &CapturedImage,
74        identity: &SlotIdentity,
75        inputs: PlanInputs,
76    ) -> Result<Self> {
77        inputs.validate()?;
78        captured.match_context(context)?;
79        identity.validate()?;
80        if identity.config_sha256 != hash(captured.config_bytes()) {
81            return Err("slot config mismatch");
82        }
83        let plan = derive(captured.facts(), &inputs)?;
84        let wire = Wire {
85            schema: SCHEMA.into(),
86            profile: identity.profile.clone(),
87            asserted_installation_sha256: identity.installation_sha256.clone(),
88            asserted_executable_sha256: identity.executable_sha256.clone(),
89            config_json: std::str::from_utf8(captured.config_bytes())
90                .map_err(|_| "config UTF8")?
91                .into(),
92            config_sha256: identity.config_sha256.clone(),
93            operator_epoch: captured.operator_epoch().into(),
94            image_id: captured.facts().id().into(),
95            image_json: std::str::from_utf8(captured.image_bytes())
96                .map_err(|_| "image UTF8")?
97                .into(),
98            image_sha256: hash(captured.image_bytes()),
99            inputs,
100            plan_sha256: hash(plan.as_bytes()),
101            plan_json: plan,
102        };
103        let record = Self { wire };
104        record.validate(context)?;
105        Ok(record)
106    }
107    pub fn encode(&self) -> Result<Vec<u8>> {
108        let bytes = serde_json::to_vec(&self.wire).map_err(|_| "record encoding")?;
109        if bytes.len() > LIMIT {
110            return Err("record too large");
111        }
112        Ok(bytes)
113    }
114    pub fn decode(bytes: &[u8], context: &ProtectedContext) -> Result<Self> {
115        if bytes.is_empty() || bytes.len() > LIMIT {
116            return Err("record size");
117        }
118        let wire = serde_json::from_slice(bytes).map_err(|_| "record fields")?;
119        let record = Self { wire };
120        if record.encode()? != bytes {
121            return Err("noncanonical record");
122        }
123        record.validate(context)?;
124        Ok(record)
125    }
126    /// Bind decoded evidence to the exact host request, including operator
127    /// overrides rather than merely accepting an internally consistent plan.
128    pub fn match_capture_request(&self, image_id: &str, inputs: &PlanInputs) -> Result<()> {
129        super::native_image::image_route(image_id)?;
130        inputs.validate()?;
131        if self.wire.image_id != image_id || self.wire.inputs != *inputs {
132            return Err("capture record request mismatch");
133        }
134        Ok(())
135    }
136    pub(super) fn match_installation_snapshot(
137        &self,
138        identity: &SlotIdentity,
139        config: &[u8],
140    ) -> Result<()> {
141        identity.validate()?;
142        if self.wire.profile != identity.profile
143            || self.wire.asserted_installation_sha256 != identity.installation_sha256
144            || self.wire.asserted_executable_sha256 != identity.executable_sha256
145            || self.wire.config_sha256 != identity.config_sha256
146            || self.wire.config_json.as_bytes() != config
147        {
148            return Err("record installation snapshot mismatch");
149        }
150        Ok(())
151    }
152    pub fn plan_bytes(&self) -> &[u8] {
153        self.wire.plan_json.as_bytes()
154    }
155    pub fn installation_verified(&self) -> bool {
156        false
157    }
158    fn validate(&self, context: &ProtectedContext) -> Result<()> {
159        let w = &self.wire;
160        let (expected, bytes) = context.snapshot()?;
161        if w.schema != SCHEMA
162            || w.config_json.len() > 1024 * 1024
163            || w.image_json.len() > 1024 * 1024
164            || bytes != w.config_json.as_bytes()
165            || expected.operator_epoch != w.operator_epoch
166        {
167            return Err("record context mismatch");
168        }
169        SlotIdentity {
170            profile: w.profile.clone(),
171            installation_sha256: w.asserted_installation_sha256.clone(),
172            executable_sha256: w.asserted_executable_sha256.clone(),
173            config_sha256: w.config_sha256.clone(),
174        }
175        .validate()?;
176        if hash(bytes) != w.config_sha256
177            || hash(w.image_json.as_bytes()) != w.image_sha256
178            || hash(w.plan_json.as_bytes()) != w.plan_sha256
179        {
180            return Err("record digest mismatch");
181        }
182        let facts = decode_image(
183            &w.image_id,
184            "GET",
185            &image_route(&w.image_id)?,
186            200,
187            w.image_json.as_bytes(),
188        )?;
189        if derive(&facts, &w.inputs)? != w.plan_json {
190            return Err("record plan mismatch");
191        }
192        context.revalidate()
193    }
194}
195fn derive(facts: &super::native_image::ImageFacts, inputs: &PlanInputs) -> Result<String> {
196    inputs.validate()?;
197    let plan = facts.create_plan(
198        inputs.name.clone(),
199        inputs.labels.clone(),
200        inputs.port.as_ref().map(|p| FixedPort {
201            container: p.container,
202            host: p.host,
203        }),
204    )?;
205    String::from_utf8(plan.body()?).map_err(|_| "plan UTF8")
206}