Skip to main content

solti_model/domain/kind/
subprocess.rs

1//! # Subprocess workload
2//!
3//! [`SubprocessMode`] selects a command or an interpreter-backed script.
4//! [`SubprocessSpec`] adds environment, working directory, and exit-code policy.
5
6use base64::Engine;
7use base64::engine::general_purpose::STANDARD as BASE64;
8use serde::{Deserialize, Serialize};
9
10use crate::error::{ModelError, ModelResult};
11
12/// Maximum decoded script body size.
13pub const MAX_SCRIPT_BODY_BYTES: usize = 2 * 1024 * 1024;
14
15/// Returns the largest padded base64 length for `max_bytes`.
16const fn max_encoded_len(max_bytes: usize) -> usize {
17    max_bytes.div_ceil(3).saturating_mul(4)
18}
19
20/// Decodes a base64 script body.
21///
22/// Encoded size is checked before allocation.
23/// Decoded size is limited by `max_bytes`.
24fn 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/// Execution strategy for a subprocess task.
55///
56/// | Variant   | Fields                              |
57/// |-----------|-------------------------------------|
58/// | `Command` | executable and arguments            |
59/// | `Script`  | interpreter, base64 body, arguments |
60///
61/// ## Example
62///
63/// ```
64/// use base64::Engine;
65/// use base64::engine::general_purpose::STANDARD as BASE64;
66/// use solti_model::SubprocessMode;
67///
68/// let mode = SubprocessMode::Script {
69///     interpreter: "bash".into(),
70///     body: BASE64.encode("echo hello"),
71///     args: vec![],
72/// };
73///
74/// assert_eq!(mode.decode_body().unwrap(), "echo hello");
75/// ```
76#[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    /// Direct binary execution.
82    Command {
83        /// Executable name or path.
84        #[cfg_attr(
85            feature = "schema",
86            schemars(schema_with = "crate::schema::non_empty_string")
87        )]
88        command: String,
89        /// Command-line arguments.
90        #[serde(default, skip_serializing_if = "Vec::is_empty")]
91        args: Vec<String>,
92    },
93    /// Script execution via an explicit interpreter.
94    Script {
95        /// Interpreter executable name or path.
96        #[cfg_attr(
97            feature = "schema",
98            schemars(schema_with = "crate::schema::non_empty_string")
99        )]
100        interpreter: String,
101        /// Standard padded base64 script body.
102        #[cfg_attr(
103            feature = "schema",
104            schemars(schema_with = "crate::schema::script_body")
105        )]
106        body: String,
107        /// Additional arguments passed after the script body.
108        #[serde(default, skip_serializing_if = "Vec::is_empty")]
109        args: Vec<String>,
110    },
111}
112
113impl SubprocessMode {
114    /// Decodes the script body as UTF-8.
115    ///
116    /// Uses [`MAX_SCRIPT_BODY_BYTES`] as the decoded size limit.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`ModelError::Invalid`] for command mode, invalid base64, non-UTF-8 data, an empty body, or an oversized body.
121    ///
122    /// ## Example
123    ///
124    /// ```
125    /// use base64::Engine;
126    /// use base64::engine::general_purpose::STANDARD as BASE64;
127    /// use solti_model::SubprocessMode;
128    ///
129    /// let mode = SubprocessMode::Script {
130    ///     interpreter: "bash".into(),
131    ///     body: BASE64.encode("echo hello"),
132    ///     args: vec![],
133    /// };
134    ///
135    /// assert_eq!(mode.decode_body().unwrap(), "echo hello");
136    /// ```
137    pub fn decode_body(&self) -> ModelResult<String> {
138        self.decode_body_with_limit(MAX_SCRIPT_BODY_BYTES)
139    }
140
141    /// Decodes the script body with a custom size limit.
142    ///
143    /// Encoded size is checked before allocation.
144    ///
145    /// # Errors
146    ///
147    /// Returns [`ModelError::Invalid`] for command mode, invalid base64, non-UTF-8 data, an empty body, or a body larger than `max_bytes`.
148    ///
149    /// ## Example
150    ///
151    /// ```
152    /// use base64::Engine;
153    /// use base64::engine::general_purpose::STANDARD as BASE64;
154    /// use solti_model::SubprocessMode;
155    ///
156    /// let mode = SubprocessMode::Script {
157    ///     interpreter: "bash".into(),
158    ///     body: BASE64.encode("echo hello"),
159    ///     args: vec![],
160    /// };
161    ///
162    /// assert!(mode.decode_body_with_limit(4).is_err());
163    /// ```
164    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    /// Validates the subprocess mode.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`ModelError::Invalid`] for an empty command or interpreter, or for an invalid, non-UTF-8, or oversized script body.
183    ///
184    /// ## Example
185    ///
186    /// ```
187    /// use solti_model::SubprocessMode;
188    ///
189    /// let mode = SubprocessMode::Command {
190    ///     command: "echo".into(),
191    ///     args: vec!["hello".into()],
192    /// };
193    ///
194    /// mode.validate().unwrap();
195    /// ```
196    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}