cymule_executor_process/
lib.rs1use 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
16pub const DEFAULT_PROCESS_MESSAGE_LIMIT: usize = 8 * 1024 * 1024;
18
19#[derive(Debug, Clone)]
21pub struct ProcessExecutorConfig {
22 pub executable: PathBuf,
24 pub arguments: Vec<String>,
26 pub working_directory: Option<PathBuf>,
28 pub environment: BTreeMap<String, String>,
30 pub timeout: Duration,
32 pub message_limit: usize,
34}
35
36impl ProcessExecutorConfig {
37 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#[derive(Debug, Clone)]
69pub struct ProcessExecutor {
70 config: ProcessExecutorConfig,
71}
72
73impl ProcessExecutor {
74 pub fn new(config: ProcessExecutorConfig) -> RuntimeResult<Self> {
76 config.validate()?;
77 Ok(Self { config })
78 }
79
80 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}