Skip to main content

ferrin_tool/sandbox/
local.rs

1//! [`LocalProcessSandbox`]: runs on the host without isolation.
2
3use 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/// A [`Sandbox`] backed by the local file system and shell.
28///
29/// **Provides no isolation.** Paths resolve relative to `root`, but absolute
30/// paths and `..` are not blocked. Use only in tests and examples.
31#[derive(Debug, Clone)]
32pub struct LocalProcessSandbox {
33    root: PathBuf,
34    shell: Vec<String>,
35    description: String,
36}
37
38impl LocalProcessSandbox {
39    /// Uses `root` as the working directory and the platform shell
40    /// (`/bin/sh -c` or `cmd /C`).
41    #[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    /// Overrides the shell used to run commands (program followed by the
60    /// arguments that precede the command line).
61    #[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    /// Overrides the description.
72    #[must_use]
73    pub fn with_description(mut self, description: impl Into<String>) -> Self {
74        self.description = description.into();
75        self
76    }
77
78    /// The root directory.
79    #[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}