1use serde::{Deserialize, Serialize, de::DeserializeOwned};
7
8use crate::{
9 EngineInvocationV1, EngineObservationV1, EnginePolicyDecisionV1, Sha256Digest, ValidationError,
10 receipt_document::digest_bytes, validate_bounded_opaque_identifier,
11};
12
13pub const RUNTIME_EXCHANGE_VERSION: &str = "leanctx.runtime-exchange/v1";
15pub const MAX_RUNTIME_EXCHANGE_BYTES: usize = 1_048_576;
17pub const MAX_RUNTIME_DEADLINE_MS: u64 = 60_000;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct RuntimeExchangeProfile {
31 input: usize,
32 output: usize,
33 request: usize,
34 response: usize,
35}
36
37const VERIFIER_METADATA_BYTES: usize = 16 * 1024;
39
40const fn base64_bytes(bytes: usize) -> usize {
41 bytes.div_ceil(3) * 4
42}
43
44pub const MAX_ARTIFACT_READER_MANIFEST_BYTES: usize = 64 * 1024;
49pub const MAX_ARTIFACT_READER_BODY_BYTES: usize = 4 * 1024 * 1024;
50pub const MAX_ARTIFACT_READER_AUTHORIZATION_BYTES: usize = 16 * 1024;
51pub const ARTIFACT_READER_METADATA_BYTES: usize = 16 * 1024;
53pub const MAX_ARTIFACT_READER_SOURCE_FILES: usize = 4096;
55pub const MAX_ARTIFACT_READER_SOURCE_PATH_BYTES: usize = 1024;
56const JSON_ESCAPE_WIDTH: usize = 6;
58const DIGEST_TEXT_BYTES: usize = 71;
60pub const MAX_ARTIFACT_READER_LOCAL_BYTES: usize = MAX_ARTIFACT_READER_SOURCE_FILES
63 * (JSON_ESCAPE_WIDTH * MAX_ARTIFACT_READER_SOURCE_PATH_BYTES
64 + JSON_ESCAPE_WIDTH * DIGEST_TEXT_BYTES
65 + 128)
66 + ARTIFACT_READER_METADATA_BYTES;
67pub const MAX_ARTIFACT_READER_INPUT_BYTES: usize = JSON_ESCAPE_WIDTH
72 * (base64_bytes(MAX_ARTIFACT_READER_BODY_BYTES)
73 + base64_bytes(MAX_ARTIFACT_READER_MANIFEST_BYTES)
74 + base64_bytes(MAX_ARTIFACT_READER_AUTHORIZATION_BYTES))
75 + MAX_ARTIFACT_READER_LOCAL_BYTES
76 + ARTIFACT_READER_METADATA_BYTES;
77pub const MAX_ARTIFACT_READER_OUTPUT_BYTES: usize = MAX_ARTIFACT_READER_SOURCE_FILES
80 * (JSON_ESCAPE_WIDTH * MAX_ARTIFACT_READER_SOURCE_PATH_BYTES + 3)
81 + ARTIFACT_READER_METADATA_BYTES;
82pub const MAX_ARTIFACT_AUTHOR_CONTENT_BYTES: usize = 1024 * 1024;
84pub const MAX_ARTIFACT_AUTHOR_INPUT_BYTES: usize = JSON_ESCAPE_WIDTH
88 * base64_bytes(MAX_ARTIFACT_AUTHOR_CONTENT_BYTES)
89 + MAX_ARTIFACT_READER_LOCAL_BYTES
90 + ARTIFACT_READER_METADATA_BYTES;
91pub const MAX_ARTIFACT_AUTHOR_OUTPUT_BYTES: usize = base64_bytes(MAX_ARTIFACT_READER_BODY_BYTES)
94 + base64_bytes(MAX_ARTIFACT_READER_MANIFEST_BYTES)
95 + ARTIFACT_READER_METADATA_BYTES;
96const _: () = {
98 assert!(MAX_ARTIFACT_READER_LOCAL_BYTES == 27_451_392);
99 assert!(MAX_ARTIFACT_READER_INPUT_BYTES == 61_677_616);
100 assert!(MAX_ARTIFACT_READER_OUTPUT_BYTES == 25_194_496);
101 assert!(MAX_ARTIFACT_AUTHOR_INPUT_BYTES == 35_856_400);
102 assert!(MAX_ARTIFACT_AUTHOR_OUTPUT_BYTES == 5_696_176);
103};
104
105impl RuntimeExchangeProfile {
106 pub const ORDINARY: Self = Self {
108 input: MAX_RUNTIME_EXCHANGE_BYTES,
109 output: MAX_RUNTIME_EXCHANGE_BYTES,
110 request: MAX_RUNTIME_EXCHANGE_BYTES,
111 response: MAX_RUNTIME_EXCHANGE_BYTES,
112 };
113
114 pub const ORG_POLICY_VERIFIER: Self = {
119 let pack = crate::org_policy_verifier::MAX_ORG_POLICY_VERIFIED_PACK_BYTES;
120 let snapshot = crate::org_policy_verifier::MAX_ORG_REQUIRED_MARKER_BYTES;
121 let artifact = 6 * pack + VERIFIER_METADATA_BYTES;
122 let input = base64_bytes(artifact) + 2 * base64_bytes(snapshot) + VERIFIER_METADATA_BYTES;
123 Self {
124 input,
125 output: artifact,
126 request: input + 2 * VERIFIER_METADATA_BYTES,
127 response: 7 * pack + 3 * VERIFIER_METADATA_BYTES,
128 }
129 };
130
131 pub const ARTIFACT_READER: Self = Self {
138 input: MAX_ARTIFACT_READER_INPUT_BYTES,
139 output: MAX_ARTIFACT_READER_OUTPUT_BYTES,
140 request: MAX_ARTIFACT_READER_INPUT_BYTES
141 + MAX_ARTIFACT_READER_LOCAL_BYTES
142 + 2 * ARTIFACT_READER_METADATA_BYTES,
143 response: 2 * MAX_ARTIFACT_READER_OUTPUT_BYTES + 2 * ARTIFACT_READER_METADATA_BYTES,
144 };
145
146 pub const ARTIFACT_AUTHOR: Self = Self {
150 input: MAX_ARTIFACT_AUTHOR_INPUT_BYTES,
151 output: MAX_ARTIFACT_AUTHOR_OUTPUT_BYTES,
152 request: MAX_ARTIFACT_AUTHOR_INPUT_BYTES
153 + MAX_ARTIFACT_READER_LOCAL_BYTES
154 + 2 * ARTIFACT_READER_METADATA_BYTES,
155 response: 2 * MAX_ARTIFACT_AUTHOR_OUTPUT_BYTES + 2 * ARTIFACT_READER_METADATA_BYTES,
156 };
157
158 #[must_use]
160 pub const fn max_input_bytes(self) -> usize {
161 self.input
162 }
163
164 #[must_use]
166 pub const fn max_output_bytes(self) -> usize {
167 self.output
168 }
169
170 #[must_use]
172 pub const fn max_request_bytes(self) -> usize {
173 self.request
174 }
175
176 #[must_use]
178 pub const fn max_response_bytes(self) -> usize {
179 self.response
180 }
181}
182
183#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct RuntimeRequestV1 {
188 pub protocol_version: String,
189 pub session_id: String,
190 pub request_id: String,
191 pub sequence: u64,
193 pub deadline_unix_ms: u64,
194 pub invocation: EngineInvocationV1,
195 pub input: String,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub lease_b64: Option<String>,
201}
202
203pub const MAX_RUNTIME_LEASE_B64_BYTES: usize = 16 * 1024;
205
206fn validate_lease_b64(lease: &str) -> Result<(), ValidationError> {
207 let padding = lease.bytes().rev().take_while(|b| *b == b'=').count();
208 let body = &lease[..lease.len() - padding];
209 if lease.is_empty()
210 || lease.len() > MAX_RUNTIME_LEASE_B64_BYTES
211 || !lease.len().is_multiple_of(4)
212 || padding > 2
213 || !body
214 .bytes()
215 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/'))
216 {
217 return Err(ValidationError::new(
218 "runtime lease must be padded base64 of at most 16 KiB",
219 ));
220 }
221 Ok(())
222}
223
224impl RuntimeRequestV1 {
225 pub fn validate(&self) -> Result<(), ValidationError> {
227 self.validate_in(RuntimeExchangeProfile::ORDINARY)
228 }
229
230 pub fn validate_in(&self, profile: RuntimeExchangeProfile) -> Result<(), ValidationError> {
232 validate_version(&self.protocol_version)?;
233 validate_bounded_opaque_identifier(&self.session_id, "runtime session_id")?;
234 validate_bounded_opaque_identifier(&self.request_id, "runtime request_id")?;
235 self.invocation.validate()?;
236 if self.sequence == 0 || self.deadline_unix_ms == 0 {
237 return Err(ValidationError::new(
238 "runtime sequence/deadline must be nonzero",
239 ));
240 }
241 if self.invocation.policy_admission.decision != EnginePolicyDecisionV1::Admitted {
242 return Err(ValidationError::new(
243 "runtime input requires host policy admission",
244 ));
245 }
246 if let Some(lease) = &self.lease_b64 {
247 validate_lease_b64(lease)?;
248 }
249 validate_content(
250 self.input.as_bytes(),
251 &self.invocation.input_digest,
252 profile.input,
253 )
254 }
255
256 pub fn validate_at(&self, now_unix_ms: u64) -> Result<(), ValidationError> {
259 self.validate_at_in(now_unix_ms, RuntimeExchangeProfile::ORDINARY)
260 }
261
262 pub fn validate_at_in(
264 &self,
265 now_unix_ms: u64,
266 profile: RuntimeExchangeProfile,
267 ) -> Result<(), ValidationError> {
268 self.validate_in(profile)?;
269 let remaining = self.deadline_unix_ms.saturating_sub(now_unix_ms);
270 if remaining == 0 || remaining > MAX_RUNTIME_DEADLINE_MS {
271 return Err(ValidationError::new(
272 "runtime deadline expired or exceeds budget",
273 ));
274 }
275 Ok(())
276 }
277
278 pub fn to_bytes(&self) -> Result<Vec<u8>, ValidationError> {
280 self.to_bytes_in(RuntimeExchangeProfile::ORDINARY)
281 }
282
283 pub fn to_bytes_in(&self, profile: RuntimeExchangeProfile) -> Result<Vec<u8>, ValidationError> {
286 self.validate_in(profile)?;
287 encode(self, profile.request)
288 }
289
290 pub fn from_bytes(bytes: &[u8], now_unix_ms: u64) -> Result<Self, ValidationError> {
294 Self::from_bytes_in(bytes, now_unix_ms, RuntimeExchangeProfile::ORDINARY)
295 }
296
297 pub fn from_bytes_in(
299 bytes: &[u8],
300 now_unix_ms: u64,
301 profile: RuntimeExchangeProfile,
302 ) -> Result<Self, ValidationError> {
303 let request: Self = decode(bytes, profile.request)?;
304 request.validate_at_in(now_unix_ms, profile)?;
305 Ok(request)
306 }
307
308 pub fn digest(&self) -> Result<Sha256Digest, ValidationError> {
312 self.digest_in(RuntimeExchangeProfile::ORDINARY)
313 }
314
315 pub fn digest_in(
318 &self,
319 profile: RuntimeExchangeProfile,
320 ) -> Result<Sha256Digest, ValidationError> {
321 digest_bytes(&self.to_bytes_in(profile)?)
322 }
323}
324
325#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
327#[serde(deny_unknown_fields)]
328pub struct RuntimeResponseV1 {
329 pub protocol_version: String,
330 pub request_digest: Sha256Digest,
331 pub observation: EngineObservationV1,
332 pub output: Option<String>,
333}
334
335impl RuntimeResponseV1 {
336 pub fn validate_for(&self, request: &RuntimeRequestV1) -> Result<(), ValidationError> {
338 self.validate_for_in(request, RuntimeExchangeProfile::ORDINARY)
339 }
340
341 pub fn validate_for_in(
343 &self,
344 request: &RuntimeRequestV1,
345 profile: RuntimeExchangeProfile,
346 ) -> Result<(), ValidationError> {
347 validate_version(&self.protocol_version)?;
348 if self.request_digest != request.digest_in(profile)? {
349 return Err(ValidationError::new(
350 "runtime response belongs to another request",
351 ));
352 }
353 self.observation.validate_for(&request.invocation)?;
354 if self.observation.receipt_link.is_some() {
355 return Err(ValidationError::new(
356 "runtime peer cannot issue a host receipt",
357 ));
358 }
359 match (&self.output, &self.observation.output_digest) {
360 (Some(output), Some(digest)) => {
361 validate_content(output.as_bytes(), digest, profile.output)
362 }
363 (None, None) => Ok(()),
364 _ => Err(ValidationError::new(
365 "runtime output and digest must be present together",
366 )),
367 }
368 }
369
370 pub fn to_bytes(&self, request: &RuntimeRequestV1) -> Result<Vec<u8>, ValidationError> {
372 self.to_bytes_in(request, RuntimeExchangeProfile::ORDINARY)
373 }
374
375 pub fn to_bytes_in(
377 &self,
378 request: &RuntimeRequestV1,
379 profile: RuntimeExchangeProfile,
380 ) -> Result<Vec<u8>, ValidationError> {
381 self.validate_for_in(request, profile)?;
382 encode(self, profile.response)
383 }
384
385 pub fn from_bytes(
387 bytes: &[u8],
388 request: &RuntimeRequestV1,
389 now_unix_ms: u64,
390 ) -> Result<Self, ValidationError> {
391 Self::from_bytes_in(
392 bytes,
393 request,
394 now_unix_ms,
395 RuntimeExchangeProfile::ORDINARY,
396 )
397 }
398
399 pub fn from_bytes_in(
401 bytes: &[u8],
402 request: &RuntimeRequestV1,
403 now_unix_ms: u64,
404 profile: RuntimeExchangeProfile,
405 ) -> Result<Self, ValidationError> {
406 request.validate_at_in(now_unix_ms, profile)?;
407 let response: Self = decode(bytes, profile.response)?;
408 response.validate_for_in(request, profile)?;
409 Ok(response)
410 }
411}
412
413fn validate_version(version: &str) -> Result<(), ValidationError> {
414 if version != RUNTIME_EXCHANGE_VERSION {
415 return Err(ValidationError::new("unsupported runtime exchange version"));
416 }
417 Ok(())
418}
419
420fn validate_content(
421 bytes: &[u8],
422 digest: &Sha256Digest,
423 limit: usize,
424) -> Result<(), ValidationError> {
425 if bytes.len() > limit || digest_bytes(bytes)? != *digest {
426 return Err(ValidationError::new(
427 "runtime content exceeds limit or differs from digest",
428 ));
429 }
430 Ok(())
431}
432
433struct Bounded {
436 bytes: Vec<u8>,
437 limit: usize,
438}
439
440impl std::io::Write for Bounded {
441 fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
442 if buffer.len() > self.limit - self.bytes.len() {
443 return Err(std::io::ErrorKind::FileTooLarge.into());
444 }
445 self.bytes.extend_from_slice(buffer);
446 Ok(buffer.len())
447 }
448
449 fn flush(&mut self) -> std::io::Result<()> {
450 Ok(())
451 }
452}
453
454fn encode(value: &impl Serialize, limit: usize) -> Result<Vec<u8>, ValidationError> {
455 let mut bounded = Bounded {
456 bytes: Vec::new(),
457 limit,
458 };
459 serde_json::to_writer(&mut bounded, value).map_err(|error| {
460 ValidationError::new(if error.is_io() {
461 "runtime message exceeds encoded limit"
462 } else {
463 "runtime JSON encoding failed"
464 })
465 })?;
466 Ok(bounded.bytes)
467}
468
469fn decode<T: DeserializeOwned>(bytes: &[u8], limit: usize) -> Result<T, ValidationError> {
470 if bytes.is_empty() || bytes.len() > limit {
471 return Err(ValidationError::new("runtime message length is invalid"));
472 }
473 serde_json::from_slice(bytes).map_err(|_| ValidationError::new("invalid runtime JSON"))
474}
475
476#[cfg(test)]
477mod tests;