Skip to main content

solti_model/domain/kind/
subprocess.rs

1//! # Subprocess execution specification.
2//!
3//! [`SubprocessSpec`] and [`SubprocessMode`] define how OS subprocess tasks are configured and validated.
4
5use base64::Engine;
6use base64::engine::general_purpose::STANDARD as BASE64;
7use serde::{Deserialize, Serialize};
8
9use crate::Runtime;
10use crate::error::{ModelError, ModelResult};
11
12/// Maximum script body size (after base64 decode) accepted by the model.
13pub const MAX_SCRIPT_BODY_BYTES: usize = 2 * 1024 * 1024;
14
15/// Execution strategy for a subprocess task.
16///
17/// | Variant   | What it does                                                               |
18/// |-----------|----------------------------------------------------------------------------|
19/// | `Command` | Direct binary execution via `execve(command, args)`                        |
20/// | `Script`  | Script passed to an interpreter: `execve(runtime, [flag, body, ...args])`  |
21#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub enum SubprocessMode {
24    /// Direct binary execution.
25    Command {
26        /// Binary to execute (e.g. `"ls"`, `"/usr/bin/python"`).
27        command: String,
28        /// Command-line arguments.
29        #[serde(default, skip_serializing_if = "Vec::is_empty")]
30        args: Vec<String>,
31    },
32    /// Script execution via a runtime interpreter.
33    Script {
34        /// Interpreter used to execute the script.
35        runtime: Runtime,
36        /// Base64-encoded (standard alphabet) script body.
37        body: String,
38        /// Additional arguments passed after the script body.
39        #[serde(default, skip_serializing_if = "Vec::is_empty")]
40        args: Vec<String>,
41    },
42}
43
44impl SubprocessMode {
45    /// Decode base64 script body to a UTF-8 string.
46    ///
47    /// Returns `Ok(body)` for `Script` variant, `Err` for `Command` or invalid body.
48    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    /// Validate the mode at the model level.
68    ///
69    /// Checks:
70    /// - `Command`: command must not be empty.
71    /// - `Script`: body must not be empty, must be valid base64, must decode to UTF-8.
72    /// - `Script` + `Custom` runtime: command and flag must not be empty.
73    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}