1use std::io;
4use std::path::Path;
5use std::path::PathBuf;
6use std::sync::Arc;
7
8use bytes::Bytes;
9use ferrin_spec::BoxFuture;
10use futures_util::StreamExt;
11use futures_util::future::Either;
12use tokio::io::AsyncRead;
13use tokio::io::AsyncReadExt;
14use tokio::io::AsyncWriteExt;
15use tokio::process::Child;
16use tokio::process::Command;
17use tokio::task::JoinSet;
18use tokio_util::sync::CancellationToken;
19
20use super::ByteStream;
21use super::ProcessOptions;
22use super::ProcessResult;
23use super::ReadFileOptions;
24use super::ReadTextFileOptions;
25use super::Sandbox;
26use super::SandboxProcess;
27use super::WriteFileOptions;
28
29#[derive(Debug, Clone)]
34pub struct LocalProcessSandbox {
35 root: PathBuf,
36 shell: Vec<String>,
37 description: String,
38}
39
40impl LocalProcessSandbox {
41 #[must_use]
44 pub fn new(root: impl Into<PathBuf>) -> Self {
45 let root = root.into();
46 let shell = if cfg!(windows) {
47 vec!["cmd".to_owned(), "/C".to_owned()]
48 } else {
49 vec!["/bin/sh".to_owned(), "-c".to_owned()]
50 };
51 Self {
52 description: format!(
53 "Local process sandbox (no isolation). Root directory: {}",
54 root.display()
55 ),
56 root,
57 shell,
58 }
59 }
60
61 #[must_use]
64 pub fn with_shell(
65 mut self,
66 program: impl Into<String>,
67 args: impl IntoIterator<Item = String>,
68 ) -> Self {
69 self.shell = std::iter::once(program.into()).chain(args).collect();
70 self
71 }
72
73 #[must_use]
75 pub fn with_description(mut self, description: impl Into<String>) -> Self {
76 self.description = description.into();
77 self
78 }
79
80 #[must_use]
82 pub fn root(&self) -> &Path {
83 &self.root
84 }
85
86 fn resolve(&self, path: &str) -> PathBuf {
87 self.root.join(path)
88 }
89
90 fn command(&self, options: &ProcessOptions) -> Command {
91 let mut command = Command::new(&self.shell[0]);
92 command.args(&self.shell[1..]).arg(&options.command);
93 let directory = options
94 .working_directory
95 .as_deref()
96 .map_or_else(|| self.root.clone(), |dir| self.resolve(dir));
97 command
98 .current_dir(directory)
99 .envs(&options.env)
100 .stdin(std::process::Stdio::null())
101 .stdout(std::process::Stdio::piped())
102 .stderr(std::process::Stdio::piped())
103 .kill_on_drop(true);
104 command
105 }
106}
107
108fn reader_stream(
109 reader: impl AsyncRead + Send + Unpin + 'static,
110 cancellation: CancellationToken,
111) -> ByteStream {
112 Box::pin(futures_util::stream::unfold(
113 Some((reader, cancellation)),
114 |state| async move {
115 let (mut reader, cancellation) = state?;
116 let mut buffer = vec![0u8; 8 * 1024];
117 match with_cancellation(&cancellation, reader.read(&mut buffer)).await {
118 Ok(0) => None,
119 Ok(read) => {
120 buffer.truncate(read);
121 Some((Ok(Bytes::from(buffer)), Some((reader, cancellation))))
122 }
123 Err(error) => Some((Err(error), None)),
124 }
125 },
126 ))
127}
128
129fn cancelled() -> io::Error {
130 io::Error::new(io::ErrorKind::Interrupted, "cancelled")
131}
132
133async fn with_cancellation<T>(
134 cancellation: &CancellationToken,
135 future: impl Future<Output = io::Result<T>>,
136) -> io::Result<T> {
137 match futures_util::future::select(Box::pin(cancellation.cancelled()), Box::pin(future)).await {
138 Either::Left(((), _)) => Err(cancelled()),
139 Either::Right((result, _)) => result,
140 }
141}
142
143fn decode_text(bytes: &[u8], encoding: Option<&str>) -> io::Result<String> {
144 match encoding.map(str::to_ascii_lowercase).as_deref() {
145 None | Some("utf-8" | "utf8") => Ok(String::from_utf8_lossy(bytes).into_owned()),
146 Some(other) => Err(io::Error::new(
147 io::ErrorKind::Unsupported,
148 format!("unsupported text encoding \"{other}\""),
149 )),
150 }
151}
152
153fn select_lines(text: &str, start_line: Option<usize>, end_line: Option<usize>) -> String {
154 if start_line.is_none() && end_line.is_none() {
155 return text.to_owned();
156 }
157 let start = start_line.unwrap_or(1).max(1);
158 let lines: Vec<&str> = text.split('\n').collect();
159 let end = end_line.unwrap_or(lines.len()).min(lines.len());
160 if start > end {
161 return String::new();
162 }
163 lines[start - 1..end].join("\n")
164}
165
166async fn open_optional(path: &Path) -> io::Result<Option<tokio::fs::File>> {
167 match tokio::fs::File::open(path).await {
168 Ok(file) => Ok(Some(file)),
169 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
170 Err(error) => Err(error),
171 }
172}
173
174async fn create_with_parents(path: &Path) -> io::Result<tokio::fs::File> {
175 if let Some(parent) = path.parent() {
176 tokio::fs::create_dir_all(parent).await?;
177 }
178 tokio::fs::File::create(path).await
179}
180
181struct LocalProcess {
182 pid: Option<u32>,
183 tasks: JoinSet<io::Result<i32>>,
184 completion: Option<Result<i32, Arc<io::Error>>>,
185 stdout: Option<ByteStream>,
186 stderr: Option<ByteStream>,
187 kill: CancellationToken,
188}
189
190async fn supervise_process(
191 mut child: Child,
192 cancellation: CancellationToken,
193 kill: CancellationToken,
194) -> io::Result<i32> {
195 let waited = with_cancellation(&cancellation, with_cancellation(&kill, child.wait())).await;
196 match waited {
197 Ok(status) => Ok(status.code().unwrap_or(-1)),
198 Err(error) if error.kind() == io::ErrorKind::Interrupted => {
199 match child.kill().await {
200 Ok(()) => {}
201 Err(error) if error.kind() == io::ErrorKind::InvalidInput => {}
202 Err(error) => return Err(error),
203 }
204 if cancellation.is_cancelled() {
205 Err(error)
206 } else {
207 child.wait().await.map(|status| status.code().unwrap_or(-1))
208 }
209 }
210 Err(error) => Err(error),
211 }
212}
213
214fn process_result(result: &Result<i32, Arc<io::Error>>) -> io::Result<i32> {
215 result
216 .clone()
217 .map_err(|error| io::Error::new(error.kind(), error))
218}
219
220impl SandboxProcess for LocalProcess {
221 fn pid(&self) -> Option<u32> {
222 self.pid
223 }
224
225 fn take_stdout(&mut self) -> Option<ByteStream> {
226 self.stdout.take()
227 }
228
229 fn take_stderr(&mut self) -> Option<ByteStream> {
230 self.stderr.take()
231 }
232
233 fn wait(&mut self) -> BoxFuture<'_, io::Result<i32>> {
234 Box::pin(async move {
235 if let Some(completion) = &self.completion {
236 return process_result(completion);
237 }
238 let result = match self.tasks.join_next().await {
239 Some(Ok(result)) => result,
240 Some(Err(error)) => Err(io::Error::other(error)),
241 None => Err(io::Error::other(
242 "process supervisor ended without a result",
243 )),
244 };
245 self.pid = None;
246 let completion = result.map_err(Arc::new);
247 let result = process_result(&completion);
248 self.completion = Some(completion);
249 result
250 })
251 }
252
253 fn kill(&mut self) -> BoxFuture<'_, io::Result<()>> {
254 Box::pin(async move {
255 self.kill.cancel();
256 match self.wait().await {
257 Ok(_) => Ok(()),
258 Err(error) if error.kind() == io::ErrorKind::Interrupted => Ok(()),
259 Err(error) => Err(error),
260 }
261 })
262 }
263}
264
265impl Sandbox for LocalProcessSandbox {
266 fn description(&self) -> &str {
267 &self.description
268 }
269
270 fn read_file(&self, options: ReadFileOptions) -> BoxFuture<'_, io::Result<Option<ByteStream>>> {
271 Box::pin(async move {
272 let path = self.resolve(&options.path);
273 let file = with_cancellation(&options.cancellation, open_optional(&path)).await?;
274 Ok(file.map(|file| reader_stream(file, options.cancellation)))
275 })
276 }
277
278 fn read_binary_file(
279 &self,
280 options: ReadFileOptions,
281 ) -> BoxFuture<'_, io::Result<Option<Bytes>>> {
282 Box::pin(async move {
283 let path = self.resolve(&options.path);
284 with_cancellation(&options.cancellation, async {
285 match tokio::fs::read(&path).await {
286 Ok(bytes) => Ok(Some(Bytes::from(bytes))),
287 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
288 Err(error) => Err(error),
289 }
290 })
291 .await
292 })
293 }
294
295 fn read_text_file(
296 &self,
297 options: ReadTextFileOptions,
298 ) -> BoxFuture<'_, io::Result<Option<String>>> {
299 Box::pin(async move {
300 let bytes = self
301 .read_binary_file(ReadFileOptions {
302 path: options.path,
303 cancellation: options.cancellation,
304 })
305 .await?;
306 let Some(bytes) = bytes else {
307 return Ok(None);
308 };
309 let text = decode_text(&bytes, options.encoding.as_deref())?;
310 Ok(Some(select_lines(
311 &text,
312 options.start_line,
313 options.end_line,
314 )))
315 })
316 }
317
318 fn write_file(&self, options: WriteFileOptions<ByteStream>) -> BoxFuture<'_, io::Result<()>> {
319 Box::pin(async move {
320 let path = self.resolve(&options.path);
321 let mut content = options.content;
322 with_cancellation(&options.cancellation, async {
323 let mut file = create_with_parents(&path).await?;
324 while let Some(chunk) = content.next().await {
325 file.write_all(&chunk?).await?;
326 }
327 file.flush().await
328 })
329 .await
330 })
331 }
332
333 fn write_binary_file(&self, options: WriteFileOptions<Bytes>) -> BoxFuture<'_, io::Result<()>> {
334 Box::pin(async move {
335 let path = self.resolve(&options.path);
336 with_cancellation(&options.cancellation, async {
337 let mut file = create_with_parents(&path).await?;
338 file.write_all(&options.content).await?;
339 file.flush().await
340 })
341 .await
342 })
343 }
344
345 fn write_text_file(&self, options: WriteFileOptions<String>) -> BoxFuture<'_, io::Result<()>> {
346 self.write_binary_file(WriteFileOptions {
347 path: options.path,
348 content: Bytes::from(options.content),
349 cancellation: options.cancellation,
350 })
351 }
352
353 fn spawn(&self, options: ProcessOptions) -> BoxFuture<'_, io::Result<Box<dyn SandboxProcess>>> {
354 Box::pin(async move {
355 if options.cancellation.is_cancelled() {
356 return Err(cancelled());
357 }
358 let mut child = self.command(&options).spawn()?;
359 let pid = child.id();
360 let stdout = child
361 .stdout
362 .take()
363 .map(|reader| reader_stream(reader, options.cancellation.clone()));
364 let stderr = child
365 .stderr
366 .take()
367 .map(|reader| reader_stream(reader, options.cancellation.clone()));
368 let kill = CancellationToken::new();
369 let mut tasks = JoinSet::new();
370 tasks.spawn(supervise_process(child, options.cancellation, kill.clone()));
371 Ok(Box::new(LocalProcess {
372 pid,
373 tasks,
374 completion: None,
375 stdout,
376 stderr,
377 kill,
378 }) as Box<dyn SandboxProcess>)
379 })
380 }
381
382 fn run(&self, options: ProcessOptions) -> BoxFuture<'_, io::Result<ProcessResult>> {
383 Box::pin(async move {
384 let cancellation = options.cancellation.clone();
385 if cancellation.is_cancelled() {
386 return Err(cancelled());
387 }
388 let mut child = self.command(&options).spawn()?;
389 let output = with_cancellation(&cancellation, async {
390 let stdout = child.stdout.take();
391 let stderr = child.stderr.take();
392 let (stdout, stderr) =
393 futures_util::future::join(read_all(stdout), read_all(stderr)).await;
394 let status = child.wait().await?;
395 Ok(ProcessResult {
396 exit_code: status.code().unwrap_or(-1),
397 stdout: String::from_utf8_lossy(&stdout?).into_owned(),
398 stderr: String::from_utf8_lossy(&stderr?).into_owned(),
399 })
400 })
401 .await;
402 if output
403 .as_ref()
404 .is_err_and(|error| error.kind() == io::ErrorKind::Interrupted)
405 {
406 let _ = child.kill().await;
407 }
408 output
409 })
410 }
411}
412
413async fn read_all(reader: Option<impl AsyncRead + Unpin>) -> io::Result<Vec<u8>> {
414 let mut buffer = Vec::new();
415 if let Some(mut reader) = reader {
416 reader.read_to_end(&mut buffer).await?;
417 }
418 Ok(buffer)
419}