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