Skip to main content

soma_fleet/
command.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4use soma_ops::Timestamp;
5
6use crate::{RequestError, request::validate_absolute_path};
7
8const MAX_PROGRAM_CHARS: usize = 4096;
9const MAX_ARGUMENT_CHARS: usize = 4096;
10// Allows the 256 canonical command arguments plus a bounded typed-launcher prelude.
11const MAX_ARGUMENTS: usize = 320;
12const MAX_OUTPUT_BYTES: usize = 16 * 1024 * 1024;
13const MAX_STDIN_BYTES: usize = 64 * 1024 * 1024;
14
15/// Bounded exec-style command request with no shell interpretation.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct CommandRequest {
18    program: String,
19    args: Vec<String>,
20    working_dir: Option<PathBuf>,
21    stdin: Option<Vec<u8>>,
22    deadline: Timestamp,
23    max_stdout_bytes: usize,
24    max_stderr_bytes: usize,
25}
26
27impl CommandRequest {
28    /// Creates and validates an exec-style request.
29    pub fn new<I, S>(
30        program: impl Into<String>,
31        args: I,
32        deadline: Timestamp,
33    ) -> Result<Self, RequestError>
34    where
35        I: IntoIterator<Item = S>,
36        S: Into<String>,
37    {
38        let program = program.into();
39        validate_program(&program)?;
40        let args = args.into_iter().map(Into::into).collect::<Vec<_>>();
41        if args.len() > MAX_ARGUMENTS {
42            return Err(RequestError::TooManyArguments {
43                count: args.len(),
44                max: MAX_ARGUMENTS,
45            });
46        }
47        for (index, argument) in args.iter().enumerate() {
48            if !valid_text(argument, MAX_ARGUMENT_CHARS) {
49                return Err(RequestError::InvalidArgument { index });
50            }
51        }
52        Ok(Self {
53            program,
54            args,
55            working_dir: None,
56            stdin: None,
57            deadline,
58            max_stdout_bytes: 256 * 1024,
59            max_stderr_bytes: 256 * 1024,
60        })
61    }
62
63    /// Sets an absolute normalized working directory.
64    pub fn with_working_dir(
65        mut self,
66        working_dir: impl Into<PathBuf>,
67    ) -> Result<Self, RequestError> {
68        self.working_dir = Some(validate_absolute_path(working_dir.into())?);
69        Ok(self)
70    }
71
72    /// Sets bounded stdin bytes delivered without shell interpretation.
73    pub fn with_stdin(mut self, stdin: Vec<u8>) -> Result<Self, RequestError> {
74        if stdin.len() > MAX_STDIN_BYTES {
75            return Err(RequestError::InvalidStdinLimit {
76                bytes: stdin.len(),
77                max: MAX_STDIN_BYTES,
78            });
79        }
80        self.stdin = Some(stdin);
81        Ok(self)
82    }
83
84    /// Sets bounded stdout and stderr budgets.
85    pub fn with_output_limits(
86        mut self,
87        stdout_bytes: usize,
88        stderr_bytes: usize,
89    ) -> Result<Self, RequestError> {
90        validate_output_limit("stdout", stdout_bytes)?;
91        validate_output_limit("stderr", stderr_bytes)?;
92        self.max_stdout_bytes = stdout_bytes;
93        self.max_stderr_bytes = stderr_bytes;
94        Ok(self)
95    }
96
97    /// Rejects a request whose deadline has already elapsed.
98    pub fn validate_at(&self, now: Timestamp) -> Result<(), RequestError> {
99        if self.deadline <= now {
100            Err(RequestError::DeadlineElapsed)
101        } else {
102            Ok(())
103        }
104    }
105
106    /// Returns the executable path or name.
107    #[must_use]
108    pub fn program(&self) -> &str {
109        &self.program
110    }
111
112    /// Returns positional arguments without shell interpolation.
113    #[must_use]
114    pub fn args(&self) -> &[String] {
115        &self.args
116    }
117
118    /// Returns the optional absolute working directory.
119    #[must_use]
120    pub fn working_dir(&self) -> Option<&Path> {
121        self.working_dir.as_deref()
122    }
123
124    /// Returns optional bounded stdin bytes.
125    #[must_use]
126    pub fn stdin(&self) -> Option<&[u8]> {
127        self.stdin.as_deref()
128    }
129
130    /// Returns the request deadline.
131    #[must_use]
132    pub const fn deadline(&self) -> Timestamp {
133        self.deadline
134    }
135
136    /// Returns the maximum captured stdout bytes.
137    #[must_use]
138    pub const fn max_stdout_bytes(&self) -> usize {
139        self.max_stdout_bytes
140    }
141
142    /// Returns the maximum captured stderr bytes.
143    #[must_use]
144    pub const fn max_stderr_bytes(&self) -> usize {
145        self.max_stderr_bytes
146    }
147}
148
149/// Bounded command execution output.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct CommandOutput {
152    stdout: Vec<u8>,
153    stderr: Vec<u8>,
154    exit_code: Option<i32>,
155    truncated: bool,
156}
157
158impl CommandOutput {
159    /// Creates a command output record.
160    #[must_use]
161    pub fn new(stdout: Vec<u8>, stderr: Vec<u8>, exit_code: Option<i32>, truncated: bool) -> Self {
162        Self {
163            stdout,
164            stderr,
165            exit_code,
166            truncated,
167        }
168    }
169
170    /// Returns captured stdout bytes.
171    #[must_use]
172    pub fn stdout(&self) -> &[u8] {
173        &self.stdout
174    }
175
176    /// Returns captured stderr bytes.
177    #[must_use]
178    pub fn stderr(&self) -> &[u8] {
179        &self.stderr
180    }
181
182    /// Returns the process exit code when available.
183    #[must_use]
184    pub const fn exit_code(&self) -> Option<i32> {
185        self.exit_code
186    }
187
188    /// Returns whether either output stream was truncated.
189    #[must_use]
190    pub const fn truncated(&self) -> bool {
191        self.truncated
192    }
193}
194
195fn validate_program(program: &str) -> Result<(), RequestError> {
196    if valid_text(program, MAX_PROGRAM_CHARS) {
197        Ok(())
198    } else {
199        Err(RequestError::InvalidProgram)
200    }
201}
202
203fn valid_text(value: &str, max_chars: usize) -> bool {
204    let count = value.chars().count();
205    count > 0 && count <= max_chars && !value.chars().any(char::is_control)
206}
207
208fn validate_output_limit(stream: &'static str, bytes: usize) -> Result<(), RequestError> {
209    if bytes == 0 || bytes > MAX_OUTPUT_BYTES {
210        Err(RequestError::InvalidOutputLimit { stream, bytes })
211    } else {
212        Ok(())
213    }
214}
215#[cfg(test)]
216#[path = "command_tests.rs"]
217mod tests;