Skip to main content

gatehouse_runtime_protocol/lifecycle_v2/
capture_wire.rs

1//! Canonical read-only wire. Parsing does not establish protected FD provenance.
2use super::{
3    capture_binding::operation_key,
4    native_image::image_route,
5    plan_record::{PlanInputs, PlanRecord},
6    profile::{Profile, SlotIdentity},
7    protected_context::ProtectedContext,
8};
9use base64::{engine::general_purpose::STANDARD, Engine};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12type Result<T> = std::result::Result<T, &'static str>;
13const REQUEST_LIMIT: usize = 64 * 1024;
14const EXPECTED_LIMIT: usize = 16 * 1024;
15const RESPONSE_LIMIT: usize = 24 * 1024 * 1024;
16const RECORD_LIMIT: usize = 16 * 1024 * 1024;
17fn hash(b: &[u8]) -> String {
18    format!("{:x}", Sha256::digest(b))
19}
20fn hex(s: &str, n: usize) -> bool {
21    s.len() == n
22        && s.bytes()
23            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
24}
25fn encode<T: Serialize>(v: &T, limit: usize) -> Result<Vec<u8>> {
26    let b = serde_json::to_vec(v).map_err(|_| "wire encoding")?;
27    if b.len() > limit {
28        return Err("wire limit");
29    }
30    Ok(b)
31}
32fn decode<T: serde::de::DeserializeOwned + Serialize>(b: &[u8], limit: usize) -> Result<T> {
33    if b.is_empty() || b.len() > limit {
34        return Err("wire limit");
35    }
36    let v = serde_json::from_slice(b).map_err(|_| "wire fields")?;
37    if encode(&v, limit)? != b {
38        return Err("noncanonical wire");
39    }
40    Ok(v)
41}
42#[derive(Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct CaptureRequest {
45    pub schema: String,
46    pub profile: Profile,
47    pub invocation: String,
48    pub image_id: String,
49    pub inputs: PlanInputs,
50    pub timeout_ms: u32,
51}
52impl CaptureRequest {
53    pub fn validate(&self) -> Result<()> {
54        if self.schema != "gate-runtime/podman-capture-v1"
55            || !hex(&self.invocation, 32)
56            || !(1..=30000).contains(&self.timeout_ms)
57        {
58            return Err("capture request rejected");
59        }
60        self.profile.validate()?;
61        image_route(&self.image_id)?;
62        self.inputs.validate()
63    }
64    pub fn encode(&self) -> Result<Vec<u8>> {
65        self.validate()?;
66        encode(self, REQUEST_LIMIT)
67    }
68    pub fn decode(b: &[u8]) -> Result<Self> {
69        let r: Self = decode(b, REQUEST_LIMIT)?;
70        r.validate()?;
71        Ok(r)
72    }
73}
74#[derive(Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct CaptureExpected {
77    pub schema: String,
78    pub invocation: String,
79    pub request_sha256: String,
80    pub host_sha256: String,
81    pub installation_sha256: String,
82    pub config_sha256: String,
83    pub executable_sha256: String,
84    pub config_path: String,
85    pub operation_key: String,
86}
87impl CaptureExpected {
88    /// Pure binding constructor; future host must derive arguments from held files.
89    pub fn bind(
90        request: &CaptureRequest,
91        identity: &SlotIdentity,
92        host_sha256: String,
93        config_path: String,
94    ) -> Result<Self> {
95        identity.validate()?;
96        if identity.profile != request.profile {
97            return Err("capture profile mismatch");
98        }
99        let request_sha256 = hash(&request.encode()?);
100        let key = operation_key(
101            &request_sha256,
102            &host_sha256,
103            &identity.installation_sha256,
104            &identity.config_sha256,
105            &identity.executable_sha256,
106        )?;
107        let expected = Self {
108            schema: "gate-runtime/podman-capture-expected-v1".into(),
109            invocation: request.invocation.clone(),
110            request_sha256,
111            host_sha256,
112            installation_sha256: identity.installation_sha256.clone(),
113            config_sha256: identity.config_sha256.clone(),
114            executable_sha256: identity.executable_sha256.clone(),
115            config_path,
116            operation_key: key,
117        };
118        expected.validate(request)?;
119        Ok(expected)
120    }
121    pub fn validate(&self, request: &CaptureRequest) -> Result<()> {
122        let path = &self.config_path;
123        if self.schema != "gate-runtime/podman-capture-expected-v1"
124            || self.invocation != request.invocation
125            || self.request_sha256 != hash(&request.encode()?)
126            || !path.starts_with('/')
127            || path.len() > 4096
128            || path.chars().any(char::is_control)
129            || path
130                .split('/')
131                .skip(1)
132                .any(|s| s.is_empty() || s == "." || s == "..")
133        {
134            return Err("capture expected mismatch");
135        }
136        if self.operation_key
137            != operation_key(
138                &self.request_sha256,
139                &self.host_sha256,
140                &self.installation_sha256,
141                &self.config_sha256,
142                &self.executable_sha256,
143            )?
144        {
145            return Err("capture key mismatch");
146        }
147        Ok(())
148    }
149    pub fn encode(&self, r: &CaptureRequest) -> Result<Vec<u8>> {
150        self.validate(r)?;
151        encode(self, EXPECTED_LIMIT)
152    }
153    pub fn decode(b: &[u8], r: &CaptureRequest) -> Result<Self> {
154        let e: Self = decode(b, EXPECTED_LIMIT)?;
155        e.validate(r)?;
156        Ok(e)
157    }
158    fn identity(&self, r: &CaptureRequest) -> SlotIdentity {
159        SlotIdentity {
160            profile: r.profile.clone(),
161            installation_sha256: self.installation_sha256.clone(),
162            config_sha256: self.config_sha256.clone(),
163            executable_sha256: self.executable_sha256.clone(),
164        }
165    }
166}
167#[derive(Serialize, Deserialize)]
168#[serde(deny_unknown_fields)]
169struct Response {
170    schema: String,
171    invocation: String,
172    operation_key: String,
173    request_sha256: String,
174    record_b64: String,
175    record_sha256: String,
176}
177/// No stderr/exit/deadline checks here: those belong to the future host runner.
178pub fn encode_response(
179    record: &PlanRecord,
180    request: &CaptureRequest,
181    expected: &CaptureExpected,
182    context: &ProtectedContext,
183) -> Result<Vec<u8>> {
184    expected.validate(request)?;
185    record.match_capture_request(&request.image_id, &request.inputs)?;
186    record.match_installation_snapshot(&expected.identity(request), context.snapshot()?.1)?;
187    let bytes = record.encode()?;
188    encode(
189        &Response {
190            schema: "gate-runtime/podman-capture-response-v1".into(),
191            invocation: request.invocation.clone(),
192            operation_key: expected.operation_key.clone(),
193            request_sha256: expected.request_sha256.clone(),
194            record_b64: STANDARD.encode(&bytes),
195            record_sha256: hash(&bytes),
196        },
197        RESPONSE_LIMIT,
198    )
199}
200pub fn decode_response(
201    bytes: &[u8],
202    request: &CaptureRequest,
203    expected: &CaptureExpected,
204    context: &ProtectedContext,
205) -> Result<PlanRecord> {
206    expected.validate(request)?;
207    let response: Response = decode(bytes, RESPONSE_LIMIT)?;
208    if response.schema != "gate-runtime/podman-capture-response-v1"
209        || response.invocation != request.invocation
210        || response.operation_key != expected.operation_key
211        || response.request_sha256 != expected.request_sha256
212        || !hex(&response.record_sha256, 64)
213        || response.record_b64.len() > RECORD_LIMIT.div_ceil(3) * 4
214    {
215        return Err("capture response binding rejected");
216    }
217    let raw = STANDARD
218        .decode(&response.record_b64)
219        .map_err(|_| "record base64")?;
220    if raw.len() > RECORD_LIMIT
221        || STANDARD.encode(&raw) != response.record_b64
222        || hash(&raw) != response.record_sha256
223    {
224        return Err("record bytes rejected");
225    }
226    let record = PlanRecord::decode(&raw, context)?;
227    record.match_capture_request(&request.image_id, &request.inputs)?;
228    record.match_installation_snapshot(&expected.identity(request), context.snapshot()?.1)?;
229    Ok(record)
230}