1use base64::Engine;
7use base64::engine::general_purpose::STANDARD as BASE64;
8use serde::{Deserialize, Serialize};
9
10use crate::error::{ModelError, ModelResult};
11
12pub const MAX_SCRIPT_BODY_BYTES: usize = 2 * 1024 * 1024;
14
15const fn max_encoded_len(max_bytes: usize) -> usize {
17 max_bytes.div_ceil(3).saturating_mul(4)
18}
19
20fn decode_script_body(body: &str, max_bytes: usize) -> ModelResult<Vec<u8>> {
25 if body.is_empty() {
26 return Err(ModelError::Invalid("script body cannot be empty".into()));
27 }
28 if body.len() > max_encoded_len(max_bytes) {
29 return Err(ModelError::Invalid(
30 format!(
31 "script body is {} bytes (base64-encoded), maximum allowed is {} bytes (decoded)",
32 body.len(),
33 max_bytes
34 )
35 .into(),
36 ));
37 }
38 let bytes = BASE64
39 .decode(body)
40 .map_err(|e| ModelError::Invalid(format!("invalid base64 body: {e}").into()))?;
41 if bytes.len() > max_bytes {
42 return Err(ModelError::Invalid(
43 format!(
44 "script body is {} bytes (decoded), maximum allowed is {} bytes",
45 bytes.len(),
46 max_bytes
47 )
48 .into(),
49 ));
50 }
51 Ok(bytes)
52}
53
54#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
77#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
78#[serde(rename_all = "camelCase", deny_unknown_fields)]
79#[non_exhaustive]
80pub enum SubprocessMode {
81 Command {
83 #[cfg_attr(
85 feature = "schema",
86 schemars(schema_with = "crate::schema::non_empty_string")
87 )]
88 command: String,
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 args: Vec<String>,
92 },
93 Script {
95 #[cfg_attr(
97 feature = "schema",
98 schemars(schema_with = "crate::schema::non_empty_string")
99 )]
100 interpreter: String,
101 #[cfg_attr(
103 feature = "schema",
104 schemars(schema_with = "crate::schema::script_body")
105 )]
106 body: String,
107 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 args: Vec<String>,
110 },
111}
112
113impl SubprocessMode {
114 pub fn decode_body(&self) -> ModelResult<String> {
138 self.decode_body_with_limit(MAX_SCRIPT_BODY_BYTES)
139 }
140
141 pub fn decode_body_with_limit(&self, max_bytes: usize) -> ModelResult<String> {
165 match self {
166 SubprocessMode::Command { .. } => Err(ModelError::Invalid(
167 "decode_body called on Command mode".into(),
168 )),
169 SubprocessMode::Script { body, .. } => {
170 let bytes = decode_script_body(body, max_bytes)?;
171 String::from_utf8(bytes).map_err(|e| {
172 ModelError::Invalid(format!("script body is not valid UTF-8: {e}").into())
173 })
174 }
175 }
176 }
177
178 pub fn validate(&self) -> ModelResult<()> {
197 match self {
198 SubprocessMode::Command { command, .. } => {
199 if command.trim().is_empty() {
200 return Err(ModelError::Invalid(
201 "subprocess command cannot be empty".into(),
202 ));
203 }
204 }
205 SubprocessMode::Script {
206 interpreter, body, ..
207 } => {
208 if interpreter.trim().is_empty() {
209 return Err(ModelError::Invalid(
210 "script interpreter cannot be empty".into(),
211 ));
212 }
213 let bytes = decode_script_body(body, MAX_SCRIPT_BODY_BYTES)?;
214 std::str::from_utf8(&bytes).map_err(|e| {
215 ModelError::Invalid(format!("script body is not valid UTF-8: {e}").into())
216 })?;
217 }
218 }
219 Ok(())
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn encode(s: &str) -> String {
228 BASE64.encode(s.as_bytes())
229 }
230
231 fn script(interpreter: &str, body: String) -> SubprocessMode {
232 SubprocessMode::Script {
233 interpreter: interpreter.into(),
234 body,
235 args: vec![],
236 }
237 }
238
239 #[test]
240 fn command_validation_accepts_content_and_rejects_empty_values() {
241 SubprocessMode::Command {
242 command: "ls".into(),
243 args: vec!["-la".into()],
244 }
245 .validate()
246 .unwrap();
247
248 for command in ["", " "] {
249 let mode = SubprocessMode::Command {
250 command: command.into(),
251 args: vec![],
252 };
253 let error = mode.validate().unwrap_err();
254 assert!(
255 error.to_string().contains("command cannot be empty"),
256 "got: {error}"
257 );
258 }
259 }
260
261 #[test]
262 fn script_validation_accepts_interpreters_and_rejects_invalid_fields() {
263 script("bash", encode("echo hello")).validate().unwrap();
264 script("ruby", encode("puts 'hello'")).validate().unwrap();
265
266 for (mode, expected) in [
267 (
268 script("", encode("echo hello")),
269 "interpreter cannot be empty",
270 ),
271 (script("bash", String::new()), "body cannot be empty"),
272 (
273 script("bash", "not-valid-base64!!!".into()),
274 "invalid base64",
275 ),
276 (
277 script("bash", BASE64.encode([0xFF, 0xFE, 0x80])),
278 "not valid UTF-8",
279 ),
280 ] {
281 let error = mode.validate().unwrap_err();
282 assert!(error.to_string().contains(expected), "got: {error}");
283 }
284 }
285
286 #[test]
287 fn decode_body_returns_script_and_rejects_command_mode() {
288 assert_eq!(
289 script("bash", encode("echo hello")).decode_body().unwrap(),
290 "echo hello"
291 );
292
293 let mode = SubprocessMode::Command {
294 command: "ls".into(),
295 args: vec![],
296 };
297 assert!(mode.decode_body().is_err());
298 }
299
300 #[test]
301 fn configurable_decode_limit_accepts_boundary_and_rejects_overflow() {
302 let exact = script("bash", encode("12345"));
303 assert_eq!(exact.decode_body_with_limit(5).unwrap(), "12345");
304 assert_eq!(exact.decode_body_with_limit(64).unwrap(), "12345");
305
306 let over = script("bash", encode("123456"));
307 let error = over.decode_body_with_limit(5).unwrap_err();
308 assert!(
309 error.to_string().contains('5') || error.to_string().contains("maximum"),
310 "got: {error}"
311 );
312 }
313
314 #[test]
315 fn default_limit_accepts_boundary_and_rejects_overflow() {
316 let payload = "a".repeat(MAX_SCRIPT_BODY_BYTES);
317 let exact = script("bash", BASE64.encode(payload.as_bytes()));
318 exact.validate().expect("body at the limit must pass");
319 assert_eq!(exact.decode_body().unwrap().len(), MAX_SCRIPT_BODY_BYTES);
320
321 let payload = "a".repeat(MAX_SCRIPT_BODY_BYTES + 1);
322 let over = script("bash", BASE64.encode(payload.as_bytes()));
323 for error in [
324 over.validate().unwrap_err(),
325 over.decode_body().unwrap_err(),
326 ] {
327 assert!(
328 error
329 .to_string()
330 .contains(&MAX_SCRIPT_BODY_BYTES.to_string()),
331 "got: {error}"
332 );
333 }
334 }
335
336 #[test]
337 fn encoded_size_precheck_has_an_exact_boundary() {
338 let threshold = MAX_SCRIPT_BODY_BYTES.div_ceil(3) * 4;
339 let at_threshold = script("bash", "A".repeat(threshold));
340 let error = at_threshold
341 .validate()
342 .expect_err("decoded body over the limit must be rejected");
343 assert!(
344 error.to_string().contains("(decoded)"),
345 "decoded-size check must reject the boundary: {error}"
346 );
347
348 let above_threshold = script("bash", "A".repeat(threshold + 1));
349 let error = above_threshold
350 .validate()
351 .expect_err("body over the encoded threshold must be rejected");
352 assert!(
353 error
354 .to_string()
355 .contains(&MAX_SCRIPT_BODY_BYTES.to_string()),
356 "got: {error}"
357 );
358 assert!(
359 !error.to_string().contains("invalid base64"),
360 "precheck must run before decoding: {error}"
361 );
362 }
363
364 #[test]
365 fn serde_roundtrips_command_and_script_modes() {
366 for mode in [
367 SubprocessMode::Command {
368 command: "echo".into(),
369 args: vec!["hello".into()],
370 },
371 SubprocessMode::Script {
372 interpreter: "python3".into(),
373 body: encode("print('hello')"),
374 args: vec!["--verbose".into()],
375 },
376 ] {
377 let json = serde_json::to_string(&mode).unwrap();
378 assert_eq!(serde_json::from_str::<SubprocessMode>(&json).unwrap(), mode);
379 }
380 }
381
382 #[test]
383 fn serde_omits_empty_command_args() {
384 let mode = SubprocessMode::Command {
385 command: "ls".into(),
386 args: vec![],
387 };
388 let json = serde_json::to_string(&mode).unwrap();
389 assert!(!json.contains("args"));
390 }
391}