Skip to main content

cymule_executor_process/
lib.rs

1//! Hardened process plugin executor for Cymule.
2
3use std::collections::BTreeMap;
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7use std::thread;
8use std::time::Duration;
9
10use cymule_runtime::{
11    PLUGIN_VERSION, PluginHost, PluginManifest, PluginRequest, PluginResponse, RuntimeError,
12    RuntimeResult,
13};
14use wait_timeout::ChildExt;
15
16/// Default request/output safety bound.
17pub const DEFAULT_PROCESS_MESSAGE_LIMIT: usize = 8 * 1024 * 1024;
18
19/// Explicit process execution policy.
20#[derive(Debug, Clone)]
21pub struct ProcessExecutorConfig {
22    /// Executable path; no shell interpretation is performed.
23    pub executable: PathBuf,
24    /// Exact argument vector.
25    pub arguments: Vec<String>,
26    /// Optional child working directory.
27    pub working_directory: Option<PathBuf>,
28    /// Complete allowed child environment after ambient clearing.
29    pub environment: BTreeMap<String, String>,
30    /// Maximum time for one request process.
31    pub timeout: Duration,
32    /// Maximum encoded request, stdout, or stderr bytes.
33    pub message_limit: usize,
34}
35
36impl ProcessExecutorConfig {
37    /// Construct a minimal policy around one executable.
38    pub fn new(executable: impl AsRef<Path>) -> Self {
39        Self {
40            executable: executable.as_ref().to_path_buf(),
41            arguments: Vec::new(),
42            working_directory: None,
43            environment: BTreeMap::new(),
44            timeout: Duration::from_mins(1),
45            message_limit: DEFAULT_PROCESS_MESSAGE_LIMIT,
46        }
47    }
48
49    fn validate(&self) -> RuntimeResult<()> {
50        if !self.executable.is_absolute()
51            || self.timeout.is_zero()
52            || self.message_limit == 0
53            || self.message_limit > 64 * 1024 * 1024
54            || self
55                .environment
56                .keys()
57                .any(|key| key.is_empty() || key.contains('=') || key.chars().any(char::is_control))
58        {
59            return Err(RuntimeError::Plugin(
60                "process executor configuration is invalid".to_owned(),
61            ));
62        }
63        Ok(())
64    }
65}
66
67/// One-occurrence process plugin transport.
68#[derive(Debug, Clone)]
69pub struct ProcessExecutor {
70    config: ProcessExecutorConfig,
71}
72
73impl ProcessExecutor {
74    /// Validate and construct an executor.
75    pub fn new(config: ProcessExecutorConfig) -> RuntimeResult<Self> {
76        config.validate()?;
77        Ok(Self { config })
78    }
79
80    /// Read-only execution policy.
81    pub const fn config(&self) -> &ProcessExecutorConfig {
82        &self.config
83    }
84}
85
86impl PluginHost for ProcessExecutor {
87    fn invoke(&mut self, request: PluginRequest) -> RuntimeResult<PluginResponse> {
88        self.config.validate()?;
89        let input = serde_json::to_vec(&request)?;
90        if input.len() > self.config.message_limit {
91            return Err(RuntimeError::Plugin(
92                "process plugin request exceeds the configured byte limit".to_owned(),
93            ));
94        }
95        let mut command = Command::new(&self.config.executable);
96        command
97            .args(&self.config.arguments)
98            .env_clear()
99            .envs(&self.config.environment)
100            .stdin(Stdio::piped())
101            .stdout(Stdio::piped())
102            .stderr(Stdio::piped());
103        if let Some(directory) = &self.config.working_directory {
104            command.current_dir(directory);
105        }
106        let mut child = command.spawn().map_err(|error| {
107            RuntimeError::Io(format!(
108                "failed to start process plugin {}: {error}",
109                self.config.executable.display()
110            ))
111        })?;
112        child
113            .stdin
114            .take()
115            .ok_or_else(|| RuntimeError::Io("process stdin was not captured".to_owned()))?
116            .write_all(&input)?;
117        let stdout = child
118            .stdout
119            .take()
120            .ok_or_else(|| RuntimeError::Io("process stdout was not captured".to_owned()))?;
121        let stderr = child
122            .stderr
123            .take()
124            .ok_or_else(|| RuntimeError::Io("process stderr was not captured".to_owned()))?;
125        let limit = self.config.message_limit;
126        let stdout_reader = thread::spawn(move || read_limited(stdout, limit));
127        let stderr_reader = thread::spawn(move || read_limited(stderr, limit));
128        let status = child.wait_timeout(self.config.timeout)?;
129        let timed_out = status.is_none();
130        let status = if let Some(status) = status {
131            status
132        } else {
133            child.kill()?;
134            child.wait()?
135        };
136        let stdout = join_reader(stdout_reader)?;
137        let stderr = join_reader(stderr_reader)?;
138        if timed_out {
139            return Err(RuntimeError::Plugin(format!(
140                "process_timeout_unknown: plugin {} exceeded {:?}",
141                self.config.executable.display(),
142                self.config.timeout
143            )));
144        }
145        if !status.success() {
146            return Err(RuntimeError::Plugin(format!(
147                "process plugin {} exited with {status}: {}",
148                self.config.executable.display(),
149                String::from_utf8_lossy(&stderr).trim()
150            )));
151        }
152        let response: PluginResponse = serde_json::from_slice(&stdout)?;
153        if let PluginResponse::Error { code, message } = &response {
154            return Err(RuntimeError::Plugin(format!("{code}: {message}")));
155        }
156        Ok(response)
157    }
158
159    fn describe(&mut self) -> RuntimeResult<PluginManifest> {
160        let manifest = match self.invoke(PluginRequest::Describe)? {
161            PluginResponse::Manifest { manifest } => manifest,
162            response => {
163                return Err(RuntimeError::Plugin(format!(
164                    "describe returned unexpected response {response:?}"
165                )));
166            }
167        };
168        if manifest.plugin_version != PLUGIN_VERSION || manifest.implementation_id.is_empty() {
169            return Err(RuntimeError::Plugin(
170                "process plugin returned an invalid manifest".to_owned(),
171            ));
172        }
173        Ok(manifest)
174    }
175}
176
177fn read_limited(reader: impl Read, limit: usize) -> std::io::Result<Vec<u8>> {
178    let mut bytes = Vec::new();
179    reader.take(limit as u64 + 1).read_to_end(&mut bytes)?;
180    if bytes.len() > limit {
181        return Err(std::io::Error::new(
182            std::io::ErrorKind::FileTooLarge,
183            "process output exceeded configured limit",
184        ));
185    }
186    Ok(bytes)
187}
188
189fn join_reader(reader: thread::JoinHandle<std::io::Result<Vec<u8>>>) -> RuntimeResult<Vec<u8>> {
190    reader
191        .join()
192        .map_err(|_| RuntimeError::Io("process output reader panicked".to_owned()))?
193        .map_err(RuntimeError::from)
194}