runmat_execution_artifact/program/
request.rs1use std::collections::BTreeSet;
2
3use runmat_execution::value::{ValueLimits, ValuePayload, ValueRef, ValueRefKind};
4use serde::{Deserialize, Serialize};
5
6use super::{ExecutableForm, ProgramArtifact, ProgramBuildRecipe};
7use crate::{ArtifactError, ArtifactResult};
8
9pub const PROGRAM_EXECUTION_REQUEST_SCHEMA_V1: u16 = 1;
10pub const MAX_PROGRAM_EXECUTION_ARGUMENTS: usize = 4096;
11pub const MAX_PROGRAM_EXECUTION_RESULT_OBJECTS: usize = 65_538;
12
13#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct ProgramExecutionDescriptor {
16 pub schema_version: u16,
17 pub recipe: ProgramBuildRecipe,
18 pub artifact: ProgramArtifact,
19 pub function: usize,
20 pub requested_outputs: u16,
21}
22
23impl ProgramExecutionDescriptor {
24 pub fn validate(&self) -> ArtifactResult<()> {
25 self.artifact.validate_against(&self.recipe)?;
26 if self.schema_version != PROGRAM_EXECUTION_REQUEST_SCHEMA_V1
27 || !entrypoint_matches(self.artifact.form, self.function, &self.recipe.entrypoint)
28 || self.requested_outputs != self.recipe.outputs.requested_outputs
29 {
30 return Err(ArtifactError::Invalid(
31 "program descriptor has an inconsistent callable or output contract".into(),
32 ));
33 }
34 Ok(())
35 }
36
37 pub fn validate_for_portable_host(&self) -> ArtifactResult<()> {
38 self.validate()?;
39 self.artifact.target.validate_for_portable_host()
40 }
41}
42
43#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct ProgramExecutionInputs {
46 pub schema_version: u16,
47 pub arguments: Vec<ValuePayload>,
48}
49
50impl ProgramExecutionInputs {
51 pub fn validate(&self) -> ArtifactResult<()> {
52 if self.schema_version != PROGRAM_EXECUTION_REQUEST_SCHEMA_V1
53 || self.arguments.len() > MAX_PROGRAM_EXECUTION_ARGUMENTS
54 {
55 return Err(ArtifactError::Invalid(
56 "program inputs use an unsupported schema or exceed their bound".into(),
57 ));
58 }
59 for argument in &self.arguments {
60 argument
61 .validate(ValueLimits::default())
62 .map_err(|error| ArtifactError::Invalid(error.to_string()))?;
63 }
64 Ok(())
65 }
66}
67
68#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct ProgramExecutionRequest {
71 pub schema_version: u16,
72 pub recipe: ProgramBuildRecipe,
73 pub artifact: ProgramArtifact,
74 pub function: usize,
75 pub arguments: Vec<ValuePayload>,
76 pub requested_outputs: u16,
77}
78
79impl ProgramExecutionRequest {
80 pub fn from_parts(
81 descriptor: ProgramExecutionDescriptor,
82 inputs: ProgramExecutionInputs,
83 ) -> ArtifactResult<Self> {
84 descriptor.validate()?;
85 inputs.validate()?;
86 let request = Self {
87 schema_version: PROGRAM_EXECUTION_REQUEST_SCHEMA_V1,
88 recipe: descriptor.recipe,
89 artifact: descriptor.artifact,
90 function: descriptor.function,
91 arguments: inputs.arguments,
92 requested_outputs: descriptor.requested_outputs,
93 };
94 request.validate()?;
95 Ok(request)
96 }
97
98 pub fn validate(&self) -> ArtifactResult<()> {
99 if self.schema_version != PROGRAM_EXECUTION_REQUEST_SCHEMA_V1 {
100 return Err(ArtifactError::Invalid(
101 "unsupported program execution request schema".into(),
102 ));
103 }
104 self.artifact.validate_against(&self.recipe)?;
105 if !entrypoint_matches(self.artifact.form, self.function, &self.recipe.entrypoint)
106 || self.requested_outputs != self.recipe.outputs.requested_outputs
107 || self.arguments.len() > MAX_PROGRAM_EXECUTION_ARGUMENTS
108 || (matches!(
109 self.artifact.form,
110 ExecutableForm::InterpreterScriptV1 | ExecutableForm::TestAttemptV1
111 ) && !self.arguments.is_empty())
112 {
113 return Err(ArtifactError::Invalid(
114 "program execution request has an inconsistent callable, output contract, or argument count".into(),
115 ));
116 }
117 if self.artifact.form == ExecutableForm::ExecutableUnitV3 {
118 let envelope = self
119 .artifact
120 .executable_unit()?
121 .expect("executable-unit form returns its validated envelope");
122 if usize::try_from(envelope.manifest.identity.entrypoint_function.0).ok()
123 != Some(self.function)
124 || (envelope.manifest.identity.entrypoint_kind
125 == runmat_execution::ExecutableEntrypointKind::Script
126 && !self.arguments.is_empty())
127 {
128 return Err(ArtifactError::Invalid(
129 "executable unit request does not match its declared entrypoint".into(),
130 ));
131 }
132 }
133 for argument in &self.arguments {
134 argument
135 .validate(ValueLimits::default())
136 .map_err(|error| ArtifactError::Invalid(error.to_string()))?;
137 }
138 Ok(())
139 }
140
141 pub fn validate_for_portable_host(&self) -> ArtifactResult<()> {
142 self.validate()?;
143 self.artifact.target.validate_for_portable_host()
144 }
145}
146
147fn entrypoint_matches(form: ExecutableForm, function: usize, entrypoint: &str) -> bool {
148 match form {
149 ExecutableForm::InterpreterBytecodeV1 => function.to_string() == entrypoint,
150 ExecutableForm::InterpreterScriptV1 => function == 0 && entrypoint == "script",
151 ExecutableForm::TestAttemptV1 => function == 0 && entrypoint == "test_attempt",
152 ExecutableForm::MeshingWorkload => function == 0 && entrypoint == "meshing_workload",
153 ExecutableForm::ExecutableUnitV3 => function.to_string() == entrypoint,
154 ExecutableForm::NativeObjectV1 => function.to_string() == entrypoint,
155 }
156}
157
158#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
159#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
160pub enum ProgramExecutionResponse {
161 Success {
162 value: ValuePayload,
163 },
164 ExternalizedSuccess {
165 outputs: Vec<ValuePayload>,
166 result_objects: Vec<ValueRef>,
167 },
168 Failure {
169 message: String,
170 },
171}
172
173impl ProgramExecutionResponse {
174 pub fn validate_against(&self, request: &ProgramExecutionRequest) -> ArtifactResult<()> {
175 request.validate()?;
176 match self {
177 Self::Success { value } => {
178 if request.requested_outputs != 1 {
179 return Err(ArtifactError::Invalid(
180 "single-value response differs from its output contract".into(),
181 ));
182 }
183 value
184 .validate(ValueLimits::default())
185 .map_err(|error| ArtifactError::Invalid(error.to_string()))
186 }
187 Self::ExternalizedSuccess {
188 outputs,
189 result_objects,
190 } => validate_externalized_success(request, outputs, result_objects),
191 Self::Failure { message } => {
192 if message.is_empty() || message.len() > 1024 * 1024 {
193 return Err(ArtifactError::Limit(
194 "program failure message is empty or exceeds its byte bound".into(),
195 ));
196 }
197 Ok(())
198 }
199 }
200 }
201}
202
203fn validate_externalized_success(
204 request: &ProgramExecutionRequest,
205 outputs: &[ValuePayload],
206 result_objects: &[ValueRef],
207) -> ArtifactResult<()> {
208 if outputs.len() != usize::from(request.requested_outputs)
209 || result_objects.is_empty()
210 || result_objects.len() > MAX_PROGRAM_EXECUTION_RESULT_OBJECTS
211 {
212 return Err(ArtifactError::Limit(
213 "externalized program response exceeds its output or object inventory contract".into(),
214 ));
215 }
216 for output in outputs {
217 output
218 .validate(ValueLimits::default())
219 .map_err(|error| ArtifactError::Invalid(error.to_string()))?;
220 }
221 let mut value_ids = BTreeSet::new();
222 let mut logical_digests = BTreeSet::new();
223 for object in result_objects {
224 ValuePayload::Object(Box::new(object.clone()))
225 .validate(ValueLimits::default())
226 .map_err(|error| ArtifactError::Invalid(error.to_string()))?;
227 if object.kind != ValueRefKind::ResultObject
228 || object.resident_fence.is_some()
229 || !value_ids.insert(object.id)
230 || !logical_digests.insert(object.logical_digest)
231 {
232 return Err(ArtifactError::Invalid(
233 "externalized program response inventory is invalid or duplicated".into(),
234 ));
235 }
236 }
237 if outputs.iter().any(|output| match output {
238 ValuePayload::Object(reference) if reference.kind == ValueRefKind::ResultObject => {
239 !result_objects.contains(reference.as_ref())
240 }
241 _ => false,
242 }) {
243 return Err(ArtifactError::Invalid(
244 "externalized program response inventory omits a result root".into(),
245 ));
246 }
247 Ok(())
248}
249
250#[cfg(test)]
251mod tests {
252 use runmat_execution::identity::ValueId;
253 use runmat_execution::schema::VALUE_PAYLOAD_SCHEMA_V1;
254 use runmat_execution::{Digest, OutputContract, ProgramEnvironment, ProgramRevision};
255
256 use super::*;
257 use crate::ExecutableForm;
258
259 fn request() -> ProgramExecutionRequest {
260 let revision = ProgramRevision::new(
261 Digest::sha256(b"graph"),
262 Digest::sha256(b"source"),
263 ProgramEnvironment::new(
264 1,
265 1,
266 Digest::sha256(b"runtime"),
267 Digest::sha256(b"catalog"),
268 "matlab",
269 )
270 .unwrap(),
271 )
272 .unwrap();
273 let recipe = ProgramBuildRecipe {
274 schema_version: crate::PROGRAM_BUILD_RECIPE_SCHEMA_VERSION,
275 program_revision: revision,
276 entrypoint: "7".into(),
277 outputs: OutputContract {
278 requested_outputs: 1,
279 },
280 execution_mode: "interpreter".into(),
281 target: crate::ProgramTarget::portable("portable-test"),
282 features: Default::default(),
283 compile_options: Default::default(),
284 source_objects: Vec::new(),
285 expected_artifact_id: None,
286 };
287 let artifact = ProgramArtifact::materialize(
288 &recipe,
289 ExecutableForm::InterpreterBytecodeV1,
290 b"program".to_vec(),
291 )
292 .unwrap();
293 ProgramExecutionRequest {
294 schema_version: PROGRAM_EXECUTION_REQUEST_SCHEMA_V1,
295 recipe,
296 artifact,
297 function: 7,
298 arguments: Vec::new(),
299 requested_outputs: 1,
300 }
301 }
302
303 fn result_reference(bytes: &[u8]) -> ValueRef {
304 let logical_digest = Digest::sha256(bytes);
305 ValueRef {
306 schema_version: VALUE_PAYLOAD_SCHEMA_V1,
307 id: ValueId::derive(&[b"program-response-test", logical_digest.bytes()]),
308 logical_digest,
309 encoded_length: bytes.len() as u64,
310 media_type: "application/vnd.runmat.test-object".into(),
311 value_schema: "runmat.test-object.v1".into(),
312 encryption_context: Digest::sha256(b"test-encryption-context"),
313 kind: ValueRefKind::ResultObject,
314 authorization_scope: "test-scope".into(),
315 resident_fence: None,
316 }
317 }
318
319 #[test]
320 fn exact_program_request_validates_every_identity_boundary() {
321 request().validate().unwrap();
322 let mut mismatched = request();
323 mismatched.function = 8;
324 assert!(mismatched.validate().is_err());
325 let mut tampered = request();
326 tampered.artifact.executable_bytes.push(0);
327 assert!(tampered.validate().is_err());
328 }
329
330 #[test]
331 fn exact_program_request_rejects_unknown_schemas_and_output_drift() {
332 let mut unknown = request();
333 unknown.schema_version += 1;
334 assert!(unknown.validate().is_err());
335 let mut outputs = request();
336 outputs.requested_outputs = 2;
337 assert!(outputs.validate().is_err());
338 }
339
340 #[test]
341 fn script_form_has_an_explicit_argument_free_entrypoint() {
342 let mut script = request();
343 script.recipe.entrypoint = "script".into();
344 script.function = 0;
345 script.artifact = ProgramArtifact::materialize(
346 &script.recipe,
347 ExecutableForm::InterpreterScriptV1,
348 b"script-bytecode".to_vec(),
349 )
350 .unwrap();
351 script.validate().unwrap();
352 script
353 .arguments
354 .push(runmat_execution::value::ValuePayload::Inline(Box::new(
355 runmat_execution::value::InlineValue::String("unexpected".into()),
356 )));
357 assert!(script.validate().is_err());
358 }
359
360 #[test]
361 fn externalized_response_requires_a_complete_unique_result_inventory() {
362 let request = request();
363 let root = result_reference(b"root");
364 let response = ProgramExecutionResponse::ExternalizedSuccess {
365 outputs: vec![ValuePayload::Object(Box::new(root.clone()))],
366 result_objects: vec![root.clone(), result_reference(b"chunk")],
367 };
368 response.validate_against(&request).unwrap();
369
370 let missing_root = ProgramExecutionResponse::ExternalizedSuccess {
371 outputs: vec![ValuePayload::Object(Box::new(root.clone()))],
372 result_objects: vec![result_reference(b"chunk")],
373 };
374 assert!(missing_root.validate_against(&request).is_err());
375
376 let duplicate = ProgramExecutionResponse::ExternalizedSuccess {
377 outputs: vec![ValuePayload::Object(Box::new(root.clone()))],
378 result_objects: vec![root.clone(), root],
379 };
380 assert!(duplicate.validate_against(&request).is_err());
381 }
382}