1use base64::Engine;
6use base64::engine::general_purpose::STANDARD as BASE64;
7use serde::{Deserialize, Serialize};
8
9use crate::Runtime;
10use crate::error::{ModelError, ModelResult};
11
12pub const MAX_SCRIPT_BODY_BYTES: usize = 2 * 1024 * 1024;
14
15#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub enum SubprocessMode {
24 Command {
26 command: String,
28 #[serde(default, skip_serializing_if = "Vec::is_empty")]
30 args: Vec<String>,
31 },
32 Script {
34 runtime: Runtime,
36 body: String,
38 #[serde(default, skip_serializing_if = "Vec::is_empty")]
40 args: Vec<String>,
41 },
42}
43
44impl SubprocessMode {
45 pub fn decode_body(&self) -> ModelResult<String> {
49 match self {
50 SubprocessMode::Command { .. } => Err(ModelError::Invalid(
51 "decode_body called on Command mode".into(),
52 )),
53 SubprocessMode::Script { body, .. } => {
54 if body.is_empty() {
55 return Err(ModelError::Invalid("script body cannot be empty".into()));
56 }
57 let bytes = BASE64
58 .decode(body)
59 .map_err(|e| ModelError::Invalid(format!("invalid base64 body: {e}").into()))?;
60 String::from_utf8(bytes).map_err(|e| {
61 ModelError::Invalid(format!("script body is not valid UTF-8: {e}").into())
62 })
63 }
64 }
65 }
66
67 pub fn validate(&self) -> ModelResult<()> {
74 match self {
75 SubprocessMode::Command { command, .. } => {
76 if command.trim().is_empty() {
77 return Err(ModelError::Invalid(
78 "subprocess command cannot be empty".into(),
79 ));
80 }
81 }
82 SubprocessMode::Script { runtime, body, .. } => {
83 if body.is_empty() {
84 return Err(ModelError::Invalid("script body cannot be empty".into()));
85 }
86 let bytes = BASE64
87 .decode(body)
88 .map_err(|e| ModelError::Invalid(format!("invalid base64 body: {e}").into()))?;
89 if bytes.len() > MAX_SCRIPT_BODY_BYTES {
90 return Err(ModelError::Invalid(
91 format!(
92 "script body is {} bytes (decoded), maximum allowed is {} bytes",
93 bytes.len(),
94 MAX_SCRIPT_BODY_BYTES
95 )
96 .into(),
97 ));
98 }
99 std::str::from_utf8(&bytes).map_err(|e| {
100 ModelError::Invalid(format!("script body is not valid UTF-8: {e}").into())
101 })?;
102
103 if let Runtime::Custom { command, flag } = runtime {
104 if command.trim().is_empty() {
105 return Err(ModelError::Invalid(
106 "custom runtime command cannot be empty".into(),
107 ));
108 }
109 if flag.trim().is_empty() {
110 return Err(ModelError::Invalid(
111 "custom runtime flag cannot be empty".into(),
112 ));
113 }
114 }
115 }
116 }
117 Ok(())
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 fn encode(s: &str) -> String {
126 BASE64.encode(s.as_bytes())
127 }
128
129 #[test]
130 fn command_valid() {
131 let mode = SubprocessMode::Command {
132 command: "ls".into(),
133 args: vec!["-la".into()],
134 };
135 assert!(mode.validate().is_ok());
136 }
137
138 #[test]
139 fn command_empty_fails() {
140 let mode = SubprocessMode::Command {
141 command: "".into(),
142 args: vec![],
143 };
144 let err = mode.validate().unwrap_err();
145 assert!(err.to_string().contains("command cannot be empty"));
146 }
147
148 #[test]
149 fn command_whitespace_fails() {
150 let mode = SubprocessMode::Command {
151 command: " ".into(),
152 args: vec![],
153 };
154 assert!(mode.validate().is_err());
155 }
156
157 #[test]
158 fn script_valid_bash() {
159 let mode = SubprocessMode::Script {
160 runtime: Runtime::Bash,
161 body: encode("echo hello"),
162 args: vec![],
163 };
164 assert!(mode.validate().is_ok());
165 }
166
167 #[test]
168 fn script_empty_body_fails() {
169 let mode = SubprocessMode::Script {
170 runtime: Runtime::Bash,
171 body: "".into(),
172 args: vec![],
173 };
174 let err = mode.validate().unwrap_err();
175 assert!(err.to_string().contains("body cannot be empty"));
176 }
177
178 #[test]
179 fn script_invalid_base64_fails() {
180 let mode = SubprocessMode::Script {
181 runtime: Runtime::Bash,
182 body: "not-valid-base64!!!".into(),
183 args: vec![],
184 };
185 let err = mode.validate().unwrap_err();
186 assert!(err.to_string().contains("invalid base64"));
187 }
188
189 #[test]
190 fn script_non_utf8_body_fails() {
191 let non_utf8 = BASE64.encode([0xFF, 0xFE, 0x80]);
192 let mode = SubprocessMode::Script {
193 runtime: Runtime::Bash,
194 body: non_utf8,
195 args: vec![],
196 };
197 let err = mode.validate().unwrap_err();
198 assert!(err.to_string().contains("not valid UTF-8"));
199 }
200
201 #[test]
202 fn script_custom_runtime_valid() {
203 let mode = SubprocessMode::Script {
204 runtime: Runtime::Custom {
205 command: "ruby".into(),
206 flag: "-e".into(),
207 },
208 body: encode("puts 'hello'"),
209 args: vec![],
210 };
211 assert!(mode.validate().is_ok());
212 }
213
214 #[test]
215 fn script_custom_empty_command_fails() {
216 let mode = SubprocessMode::Script {
217 runtime: Runtime::Custom {
218 command: "".into(),
219 flag: "-e".into(),
220 },
221 body: encode("puts 'hello'"),
222 args: vec![],
223 };
224 let err = mode.validate().unwrap_err();
225 assert!(
226 err.to_string()
227 .contains("custom runtime command cannot be empty")
228 );
229 }
230
231 #[test]
232 fn script_custom_empty_flag_fails() {
233 let mode = SubprocessMode::Script {
234 runtime: Runtime::Custom {
235 command: "ruby".into(),
236 flag: "".into(),
237 },
238 body: encode("puts 'hello'"),
239 args: vec![],
240 };
241 let err = mode.validate().unwrap_err();
242 assert!(
243 err.to_string()
244 .contains("custom runtime flag cannot be empty")
245 );
246 }
247
248 #[test]
249 fn decode_body_returns_script() {
250 let mode = SubprocessMode::Script {
251 runtime: Runtime::Bash,
252 body: encode("echo hello"),
253 args: vec![],
254 };
255 assert_eq!(mode.decode_body().unwrap(), "echo hello");
256 }
257
258 #[test]
259 fn decode_body_errors_on_command_mode() {
260 let mode = SubprocessMode::Command {
261 command: "ls".into(),
262 args: vec![],
263 };
264 assert!(mode.decode_body().is_err());
265 }
266
267 #[test]
268 fn script_body_within_limit_is_accepted() {
269 let payload = "a".repeat(MAX_SCRIPT_BODY_BYTES);
270 let mode = SubprocessMode::Script {
271 runtime: Runtime::Bash,
272 body: BASE64.encode(payload.as_bytes()),
273 args: vec![],
274 };
275 mode.validate()
276 .expect("body at exactly the limit must pass");
277 }
278
279 #[test]
280 fn script_body_over_limit_is_rejected() {
281 let payload = "a".repeat(MAX_SCRIPT_BODY_BYTES + 1);
282 let mode = SubprocessMode::Script {
283 runtime: Runtime::Bash,
284 body: BASE64.encode(payload.as_bytes()),
285 args: vec![],
286 };
287 let err = mode
288 .validate()
289 .expect_err("over-limit body must be rejected");
290 let msg = err.to_string();
291 assert!(
292 msg.contains(&MAX_SCRIPT_BODY_BYTES.to_string()),
293 "error should mention the limit, got: {msg}"
294 );
295 }
296
297 #[test]
298 fn serde_roundtrip_command() {
299 let mode = SubprocessMode::Command {
300 command: "echo".into(),
301 args: vec!["hello".into()],
302 };
303 let json = serde_json::to_string(&mode).unwrap();
304 let back: SubprocessMode = serde_json::from_str(&json).unwrap();
305 assert_eq!(back, mode);
306 }
307
308 #[test]
309 fn serde_roundtrip_script() {
310 let mode = SubprocessMode::Script {
311 runtime: Runtime::Python,
312 body: encode("print('hello')"),
313 args: vec!["--verbose".into()],
314 };
315 let json = serde_json::to_string(&mode).unwrap();
316 let back: SubprocessMode = serde_json::from_str(&json).unwrap();
317 assert_eq!(back, mode);
318 }
319
320 #[test]
321 fn serde_command_empty_args_skipped() {
322 let mode = SubprocessMode::Command {
323 command: "ls".into(),
324 args: vec![],
325 };
326 let json = serde_json::to_string(&mode).unwrap();
327 assert!(!json.contains("args"));
328 }
329}