gatehouse_runtime_protocol/lifecycle_v2/
capture_wire.rs1use 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 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}
177pub 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}