Skip to main content

fluers_runtime/
local_env.rs

1//! The real local-filesystem `SessionEnv`.
2//!
3//! Tools run against a real directory on disk via `tokio::fs` +
4//! `tokio::process`. **Confinement is fd-anchored**: every read, write, search,
5//! and exec cwd is resolved off a single held root fd via `openat`
6//! per-component walks with `O_NOFOLLOW` + an authoritative `fstat` on the
7//! opened leaf fd. There is no canonicalize-then-contain step in any data path,
8//! so a symlink/hardlink swapped between the containment check and the operation
9//! cannot redirect a read (exfil) or a write/exec (data loss).
10//!
11//! See `SECURITY.md`: this is *not* an OS-level sandbox (no chroot/landlock/
12//! UID separation). The fd-anchoring closes the TOCTOU class the path-based
13//! `resolve()` had; it does not turn this into a security boundary against a
14//! determined adversary until OS isolation lands.
15
16use std::ffi::OsStr;
17use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
18use std::path::{Component, Path, PathBuf};
19
20use async_trait::async_trait;
21use rustix::fs::{fstat, ftruncate, mkdirat, open, openat, Dir, FileType, Mode, OFlags};
22use rustix::io::Errno;
23use tokio::io::{AsyncReadExt, AsyncWriteExt};
24use tokio::process::Command;
25use tokio_util::sync::CancellationToken;
26
27// `fcntl(F_GETPATH)` is apple-only; it backs `fd_real_path` on macOS.
28#[cfg(target_os = "macos")]
29use rustix::fs::getpath;
30// `/proc/self/fd/N` readlink needs the raw fd int on Linux.
31#[cfg(target_os = "linux")]
32use std::os::fd::AsRawFd;
33
34use crate::env::{Limits, SessionEnv, ShellResult};
35use crate::error::{RuntimeError, RuntimeResult};
36
37/// POSIX `st_mode` masks (stable, platform-independent) for the regular-file
38/// check — avoids pulling `libc` just for `S_ISREG`.
39const ST_MODE_TYPE_MASK: u32 = 0o170_000; // S_IFMT
40const ST_MODE_REGULAR: u32 = 0o100_000; // S_IFREG
41
42/// A `SessionEnv` backed by a real local directory.
43pub struct LocalSessionEnv {
44    /// Held fd over the canonical root: the anchor for every fd-anchored walk.
45    /// Opened once at construction with `O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC`,
46    /// so root-path re-resolution never re-enters any data hot path. Because
47    /// the root is pinned by fd (not path), renaming/symlinking the root *path*
48    /// after construction cannot redirect a subsequent operation. `OwnedFd` is
49    /// `Send + Sync` on Unix.
50    root_fd: OwnedFd,
51    #[allow(dead_code)]
52    limits: Limits,
53}
54
55impl LocalSessionEnv {
56    /// Create an env rooted at `root`. The directory is canonicalized; if it
57    /// does not exist it is created. An fd is held over the canonical root for
58    /// the lifetime of the env.
59    pub async fn new(root: impl Into<PathBuf>, limits: Limits) -> RuntimeResult<Self> {
60        let root = root.into();
61        tokio::fs::create_dir_all(&root)
62            .await
63            .map_err(RuntimeError::Io)?;
64        let canon = tokio::fs::canonicalize(&root)
65            .await
66            .map_err(RuntimeError::Io)?;
67        // Hold an fd over the canonical root. Opened with O_NOFOLLOW (reject a
68        // root swapped to a symlink since construction) + O_DIRECTORY +
69        // O_CLOEXEC. From here on, no operation re-resolves the root *path* —
70        // they all anchor off this fd.
71        let root_flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
72        let root_fd = open(&canon, root_flags, Mode::empty())
73            .map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
74        Ok(Self { root_fd, limits })
75    }
76
77    /// Validate a model-supplied relative path and return its `Normal`
78    /// components (skipping `.`). Rejects absolute paths and any `..`
79    /// component up front — the fd walk itself then enforces containment, so
80    /// there is no canonicalize-then-contain step anywhere in the data path.
81    fn normal_components<'a>(&self, rel: &'a Path) -> RuntimeResult<Vec<&'a OsStr>> {
82        if rel.is_absolute() {
83            return Err(RuntimeError::Sandbox(format!(
84                "absolute paths are not allowed: `{}`",
85                rel.display()
86            )));
87        }
88        if rel.components().any(|c| matches!(c, Component::ParentDir)) {
89            return Err(RuntimeError::Sandbox(format!(
90                "`..` is not allowed in paths: `{}`",
91                rel.display()
92            )));
93        }
94        Ok(rel
95            .components()
96            .filter_map(|c| match c {
97                Component::Normal(name) => Some(name),
98                // `CurDir` (".") is skipped; `ParentDir`/absolute are
99                // pre-rejected above.
100                _ => None,
101            })
102            .collect())
103    }
104
105    /// Open `rel` for reading via an fd-anchored walk from the held root fd
106    /// (B-Swift Phase C1a / #4). Closes the path-based TOCTOU at the daemon
107    /// read: every component is opened with `O_NOFOLLOW` (symlink → `ELOOP`),
108    /// and the leaf is `fstat`'d on the SAME fd we hand back for reading — so a
109    /// symlink/hardlink swap between confinement and the read cannot exfiltrate.
110    /// Mirrors the Swift `readFdAnchored`.
111    ///
112    /// Returns the opened regular-file `File` and its size in bytes (the size is
113    /// authoritative — taken off the open fd, not the path).
114    fn open_anchored_read(&self, rel: &Path) -> RuntimeResult<(std::fs::File, u64)> {
115        let names = self.normal_components(rel)?;
116        if names.is_empty() {
117            return Err(RuntimeError::Sandbox(format!(
118                "read path has no components: `{}`",
119                rel.display()
120            )));
121        }
122
123        let oflag = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
124        // Walk: hold every opened fd in `chain` so intermediates stay alive
125        // until the next level is opened; the last element is the leaf.
126        let mut chain: Vec<OwnedFd> = Vec::new();
127        for name in names {
128            let dir = match chain.last() {
129                Some(f) => f.as_fd(),
130                None => self.root_fd.as_fd(),
131            };
132            let fd = match openat(dir, name, oflag, Mode::empty()) {
133                Ok(fd) => fd,
134                Err(Errno::LOOP) => {
135                    return Err(RuntimeError::Sandbox(format!(
136                        "symlinks are not allowed in read paths: `{}`",
137                        rel.display()
138                    )));
139                }
140                Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
141            };
142            chain.push(fd);
143        }
144        let leaf_owned = chain
145            .pop()
146            .ok_or_else(|| RuntimeError::Sandbox("read path has no components".to_string()))?;
147        // Remaining `chain` (intermediates) drops here → their fds close.
148
149        // Authoritative leaf check: fstat the OPENED fd (not the path).
150        let stat =
151            fstat(leaf_owned.as_fd()).map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
152        if (stat.st_mode as u32 & ST_MODE_TYPE_MASK) != ST_MODE_REGULAR {
153            return Err(RuntimeError::Sandbox(format!(
154                "not a regular file: `{}`",
155                rel.display()
156            )));
157        }
158        if stat.st_nlink > 1 {
159            // Hardlink exfil (`ln secret in_root; read in_root/link`) — mirrors
160            // the Swift-side C2/#3 reject. Authoritative here: fstat off the
161            // open fd, not the path.
162            return Err(RuntimeError::Sandbox(format!(
163                "multiple hard links — can't safely confine: `{}`",
164                rel.display()
165            )));
166        }
167        let size = stat.st_size.max(0) as u64;
168        Ok((std::fs::File::from(leaf_owned), size))
169    }
170
171    /// Open an existing directory `rel` via an fd-anchored walk from the held
172    /// root fd (B-Swift Phase C1b). Used to pin an exec `cwd` by fd (passed to
173    /// the child as `/dev/fd/N`). Every component is opened with
174    /// `O_DIRECTORY | O_NOFOLLOW`, so a symlinked intermediate dir → `ELOOP`
175    /// → reject (never followed).
176    fn open_anchored_dir(&self, rel: &Path) -> RuntimeResult<OwnedFd> {
177        let names = self.normal_components(rel)?;
178        let oflag = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
179        // Open "." relative to the held root → an independent owned starting fd,
180        // so we never borrow `root_fd` across the walk.
181        let mut cur = openat(self.root_fd.as_fd(), ".", oflag, Mode::empty())
182            .map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
183        for name in names {
184            let next = match openat(cur.as_fd(), name, oflag, Mode::empty()) {
185                Ok(fd) => fd,
186                Err(Errno::LOOP) => {
187                    return Err(RuntimeError::Sandbox(format!(
188                        "symlinked directories are not allowed: `{}`",
189                        rel.display()
190                    )));
191                }
192                Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
193            };
194            cur = next;
195        }
196        Ok(cur)
197    }
198
199    /// Derive the real on-disk path of an already-open directory fd — macOS
200    /// `fcntl(F_GETPATH)`, Linux `/proc/self/fd/N`. The path comes from the
201    /// *inode* the fd names, NOT from any model-supplied input string, so a
202    /// symlink swap on the input path between the fd-anchored open and the
203    /// spawn/search can't redirect the operation. (`/dev/fd/N` as a `cwd` is
204    /// Linux-only — macOS fdescfs rejects `chdir` to it with `ENOTDIR`, so the
205    /// inode path is the portable fd-anchored handle.) A post-open *move* of the
206    /// directory is a residual race outside the threat model: this is not an OS
207    /// sandbox, and moving the dir requires write access under the confined root.
208    fn fd_real_path(fd: BorrowedFd<'_>) -> RuntimeResult<PathBuf> {
209        #[cfg(target_os = "macos")]
210        {
211            use std::os::unix::ffi::OsStrExt;
212            let c = getpath(fd).map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
213            Ok(PathBuf::from(OsStr::from_bytes(c.to_bytes())))
214        }
215        #[cfg(target_os = "linux")]
216        {
217            let raw = fd.as_raw_fd();
218            std::fs::read_link(format!("/proc/self/fd/{raw}")).map_err(RuntimeError::Io)
219        }
220        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
221        {
222            let _ = fd;
223            Err(RuntimeError::Sandbox(
224                "fd-derived directory path is unsupported on this platform".into(),
225            ))
226        }
227    }
228
229    /// Resolve a grep search path to its real INODE path, fd-anchored from the
230    /// held root fd. Every component is opened `O_NOFOLLOW`; a symlink anywhere
231    /// in the path (including a symlinked dir passed explicitly) is rejected
232    /// outright — `rg --no-follow` would otherwise follow an explicit
233    /// symlinked-dir argument and leak its contents. The returned path is the
234    /// inode's path (from `fd_real_path`), so a swap on the input can't redirect
235    /// the search. Handles directory and file leaf targets; `.`/empty → root.
236    fn search_path_inode(&self, p: &str) -> RuntimeResult<PathBuf> {
237        let names = self.normal_components(Path::new(p))?;
238        if names.is_empty() {
239            // `.` or empty path → the root.
240            return Self::fd_real_path(self.root_fd.as_fd());
241        }
242        let dir_oflag = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
243        let file_oflag = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
244        let (parents, last) = names.split_at(names.len() - 1);
245        let mut parent = openat(self.root_fd.as_fd(), ".", dir_oflag, Mode::empty())
246            .map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
247        for name in parents.iter().copied() {
248            parent = match openat(parent.as_fd(), name, dir_oflag, Mode::empty()) {
249                Ok(fd) => fd,
250                Err(Errno::LOOP) => {
251                    return Err(RuntimeError::Sandbox(format!(
252                        "symlinked search path is not allowed: `{p}`"
253                    )))
254                }
255                Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
256            };
257        }
258        let last_name = last[0];
259        // Leaf: try dir, fall back to file (a file grep target). `O_NOFOLLOW`
260        // in both means a symlink leaf → `ELOOP` → reject.
261        let leaf_fd = match openat(parent.as_fd(), last_name, dir_oflag, Mode::empty()) {
262            Ok(fd) => fd,
263            Err(Errno::NOTDIR) => {
264                match openat(parent.as_fd(), last_name, file_oflag, Mode::empty()) {
265                    Ok(fd) => fd,
266                    Err(Errno::LOOP) => {
267                        return Err(RuntimeError::Sandbox(format!(
268                            "symlinked search path is not allowed: `{p}`"
269                        )))
270                    }
271                    Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
272                }
273            }
274            Err(Errno::LOOP) => {
275                return Err(RuntimeError::Sandbox(format!(
276                    "symlinked search path is not allowed: `{p}`"
277                )))
278            }
279            Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
280        };
281        Self::fd_real_path(leaf_fd.as_fd())
282    }
283
284    /// Open `rel` for writing via an fd-anchored walk from the held root fd
285    /// (B-Swift Phase C1b — the critical counterpart of `open_anchored_read`).
286    ///
287    /// Invariants:
288    /// - Parent dirs are created with a `mkdirat` walk from the root fd (each
289    ///   level opened `O_NOFOLLOW`); `mkdirat` does not follow a symlink at the
290    ///   target name, and the follow-up `openat(O_DIRECTORY|O_NOFOLLOW)` rejects
291    ///   a symlinked intermediate outright.
292    /// - The leaf is opened `WRONLY | CREATE | NOFOLLOW` — `O_NOFOLLOW` rejects
293    ///   a symlink leaf outright (`ELOOP`). Critically, `O_TRUNC` is **not**
294    ///   passed: truncation is deferred to `ftruncate` *after* the hardlink
295    ///   check, so a write through a hardlink can never mutate before the
296    ///   confinement decision.
297    /// - The opened leaf fd is `fstat`'d (authoritative): non-regular files are
298    ///   rejected, and `st_nlink > 1` is rejected — a write through a hardlink
299    ///   mutates every name in the set (silent cross-target data loss).
300    /// - The caller truncates + writes off the SAME fd.
301    fn open_anchored_write(&self, rel: &Path) -> RuntimeResult<OwnedFd> {
302        let names = self.normal_components(rel)?;
303        let (parents, leaf) = names.split_at(names.len().saturating_sub(1));
304        let leaf_name = leaf.first().copied().ok_or_else(|| {
305            RuntimeError::Sandbox(format!("write path has no file name: `{}`", rel.display()))
306        })?;
307
308        let dir_oflag = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
309        // mkdirat default mode mirrors std's `create_dir` (0o777 & !umask);
310        // files below use 0o666 & !umask (std's `fs::write` default).
311        let dir_mode = Mode::RWXU | Mode::RWXG | Mode::RWXO;
312        let file_mode = Mode::RUSR | Mode::WUSR | Mode::RGRP | Mode::WGRP | Mode::ROTH | Mode::WOTH;
313
314        let mut parent = openat(self.root_fd.as_fd(), ".", dir_oflag, Mode::empty())
315            .map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
316        for name in parents.iter().copied() {
317            let next = match openat(parent.as_fd(), name, dir_oflag, Mode::empty()) {
318                Ok(fd) => fd,
319                Err(Errno::NOENT) => {
320                    // Create the missing intermediate dir. `mkdirat` does NOT
321                    // follow a symlink at `name` (it would fail EEXIST); the
322                    // reopen below re-establishes the fd-anchored position.
323                    // EEXIST from mkdirat means another writer created it
324                    // concurrently — that's safe; just reopen it.
325                    if let Err(e) = mkdirat(parent.as_fd(), name, dir_mode) {
326                        if e != Errno::EXIST {
327                            return Err(RuntimeError::Io(std::io::Error::from(e)));
328                        }
329                    }
330                    match openat(parent.as_fd(), name, dir_oflag, Mode::empty()) {
331                        Ok(fd) => fd,
332                        Err(Errno::LOOP) => {
333                            return Err(RuntimeError::Sandbox(format!(
334                                "symlinked directories are not allowed: `{}`",
335                                rel.display()
336                            )));
337                        }
338                        Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
339                    }
340                }
341                Err(Errno::LOOP) => {
342                    return Err(RuntimeError::Sandbox(format!(
343                        "symlinked directories are not allowed: `{}`",
344                        rel.display()
345                    )));
346                }
347                Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
348            };
349            parent = next;
350        }
351
352        // Leaf: CREATE + NOFOLLOW, but deliberately NO TRUNC — truncate after
353        // the nlink check so a hardlink can't be mutated pre-decision.
354        let leaf_oflag = OFlags::WRONLY | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::CLOEXEC;
355        let leaf_fd = match openat(parent.as_fd(), leaf_name, leaf_oflag, file_mode) {
356            Ok(fd) => fd,
357            Err(Errno::LOOP) => {
358                return Err(RuntimeError::Sandbox(format!(
359                    "symlink leaf is not allowed: `{}`",
360                    rel.display()
361                )));
362            }
363            Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
364        };
365
366        // Authoritative confinement checks off the OPEN fd (not the path).
367        let stat = fstat(leaf_fd.as_fd()).map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
368        if (stat.st_mode as u32 & ST_MODE_TYPE_MASK) != ST_MODE_REGULAR {
369            return Err(RuntimeError::Sandbox(format!(
370                "not a regular file: `{}`",
371                rel.display()
372            )));
373        }
374        if stat.st_nlink > 1 {
375            // A write through a hardlink mutates every name in the set — reject,
376            // mirroring the read-side decision.
377            return Err(RuntimeError::Sandbox(format!(
378                "multiple hard links — can't safely confine: `{}`",
379                rel.display()
380            )));
381        }
382        Ok(leaf_fd)
383    }
384}
385
386#[async_trait]
387impl SessionEnv for LocalSessionEnv {
388    async fn read_file(
389        &self,
390        path: &Path,
391        max_lines: usize,
392        max_bytes: usize,
393    ) -> RuntimeResult<String> {
394        // B-Swift Phase C1a / #4: fd-anchored open + read from the SAME fd
395        // (closes the check-then-use TOCTOU the path-based read had).
396        let (file, _size) = self.open_anchored_read(path)?;
397        let mut file = tokio::fs::File::from_std(file);
398        let mut raw = String::new();
399        file.read_to_string(&mut raw)
400            .await
401            .map_err(RuntimeError::Io)?;
402        Ok(apply_read_limits(raw, max_lines, max_bytes))
403    }
404
405    async fn read_file_full(&self, path: &Path, max_bytes: usize) -> RuntimeResult<String> {
406        // B-Swift Phase C1a / #4: size + read off the SAME open fd. The old
407        // path-based metadata check raced the read; now the size gate is
408        // authoritative (fstat off the open fd) and the read uses that fd.
409        let (file, size) = self.open_anchored_read(path)?;
410        let size = size as usize;
411        if size > max_bytes {
412            return Err(RuntimeError::FileTooLarge {
413                path: path.display().to_string(),
414                size,
415                max: max_bytes,
416            });
417        }
418        let mut file = tokio::fs::File::from_std(file);
419        let mut raw = String::new();
420        file.read_to_string(&mut raw)
421            .await
422            .map_err(RuntimeError::Io)?;
423        Ok(raw)
424    }
425
426    async fn write_file(&self, path: &Path, content: &str) -> RuntimeResult<()> {
427        // B-Swift Phase C1b: fd-anchored write. Open the leaf off the held root
428        // fd (mkdirat-walking parents), fstat for hardlink confinement, THEN
429        // truncate + write off the SAME fd. No path re-resolution in any step.
430        let leaf_fd = self.open_anchored_write(path)?;
431        // Truncate AFTER the nlink check (the open deliberately omitted O_TRUNC).
432        ftruncate(&leaf_fd, 0).map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
433        let mut file = tokio::fs::File::from_std(std::fs::File::from(leaf_fd));
434        file.write_all(content.as_bytes())
435            .await
436            .map_err(RuntimeError::Io)?;
437        // Flush before returning: a subsequent `fstat` (e.g. a size-gated
438        // `read_file_full`) must observe the full new size. `write_all`'s await
439        // dispatches the pwrite on the blocking pool, but tokio `File`'s close is
440        // deferred on drop — without this barrier the size was intermittently
441        // not yet visible to a following `fstat` under parallel load (a rare
442        // flake that returned a stale/short size). `flush` completes the pending
443        // async write without an `fsync` (no durability/perf cost vs `sync_all`).
444        file.flush().await.map_err(RuntimeError::Io)?;
445        Ok(())
446    }
447
448    async fn exec(
449        &self,
450        command: &str,
451        cwd: &Path,
452        timeout_ms: Option<u64>,
453        cancel: &CancellationToken,
454    ) -> RuntimeResult<ShellResult> {
455        // The cwd is opened fd-anchored (`openat(O_DIRECTORY|O_NOFOLLOW)` per
456        // component from the held root fd), so a symlinked cwd dir is rejected
457        // outright. The child then chdirs to the *inode's* real path — derived
458        // from the open fd via `fd_real_path`, not from the input string — so a
459        // symlink swap on the cwd path between open and spawn can't redirect it.
460        // (`/dev/fd/N` would be the pure-inode handle, but macOS fdescfs rejects
461        // `chdir` to it; the inode path is the portable form.) `cwd_fd` is held
462        // in scope through `spawn()` so the inode it names stays valid.
463        let cwd_fd = self.open_anchored_dir(cwd)?;
464        let cwd_path = Self::fd_real_path(cwd_fd.as_fd())?;
465
466        // `kill_on_drop(true)`: on timeout/cancel the in-flight `wait_with_output`
467        // future (which owns the child) is dropped, and its `Drop` sends SIGKILL —
468        // so a still-running child is never orphaned.
469        let child = Command::new("sh")
470            .arg("-c")
471            .arg(command)
472            .current_dir(&cwd_path)
473            .stdout(std::process::Stdio::piped())
474            .stderr(std::process::Stdio::piped())
475            .kill_on_drop(true)
476            .spawn()
477            .map_err(RuntimeError::Io)?;
478        // `cwd_fd` stays live until end of scope (spawn has run by now).
479
480        let timeout_fut = match timeout_ms {
481            Some(ms) => Box::pin(tokio::time::sleep(std::time::Duration::from_millis(ms)))
482                as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
483            None => Box::pin(std::future::pending()),
484        };
485        let cancel_fut = cancel.cancelled();
486
487        // `wait_with_output` drains stdout AND stderr concurrently while it waits.
488        // The old `child.wait()` did not read the pipes, so a child emitting more
489        // than the OS pipe buffer (~64 KB) blocked on a full pipe while `wait()`
490        // blocked on the child — a deadlock that only broke on timeout (output
491        // lost, misreported as a 124), or hung forever with no timeout set.
492        tokio::select! {
493            _ = timeout_fut => {
494                // `child` (moved into the dropped `wait_with_output` future) is
495                // SIGKILLed via `kill_on_drop`. Return the 124-shaped result.
496                Ok(ShellResult {
497                    exit_code: 124,
498                    stdout: String::new(),
499                    stderr: format!("command timed out after {}ms", timeout_ms.unwrap_or(0)),
500                })
501            }
502            _ = cancel_fut => {
503                Err(RuntimeError::Sandbox("command cancelled".into()))
504            }
505            output = child.wait_with_output() => {
506                let output = output.map_err(RuntimeError::Io)?;
507                Ok(ShellResult {
508                    exit_code: output.status.code().unwrap_or(-1),
509                    stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
510                    stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
511                })
512            }
513        }
514    }
515
516    async fn glob(&self, pattern: &str, limit: usize) -> RuntimeResult<Vec<String>> {
517        // Containment: reject absolute patterns and `..` so the model can't
518        // list files outside the root (e.g. `../../*` or `/etc/*`).
519        validate_search_pattern(pattern)?;
520        // Split into a base dir (must exist) + a single-segment filename pattern.
521        // As in the original matcher, the filename pattern is applied at every
522        // depth under the base (the descent is what changed: it is now
523        // fd-anchored and never enters a symlinked directory).
524        let pat_path = Path::new(pattern);
525        let base_rel = pat_path.parent().unwrap_or_else(|| Path::new(""));
526        let fname = pat_path.file_name().and_then(|s| s.to_str()).unwrap_or("*");
527        // Results are reported relative to the ROOT, but the walk starts at the
528        // base dir — so seed the descent with the base's own path relative to
529        // root (e.g. `sub/*.txt` → base prefix `sub`, so `sub/nested.txt` is
530        // reported, not `nested.txt`).
531        let base_prefix = self
532            .normal_components(base_rel)?
533            .iter()
534            .map(|s| s.to_string_lossy().into_owned())
535            .collect::<Vec<_>>()
536            .join("/");
537        // A missing/symlinked base yields no matches (preserves the original
538        // "no results" behavior for non-existent bases after validation).
539        let base_fd = match self.open_anchored_dir(base_rel) {
540            Ok(fd) => fd,
541            Err(_) => return Ok(Vec::new()),
542        };
543        let dir = match Dir::new(base_fd) {
544            Ok(d) => d,
545            Err(_) => return Ok(Vec::new()),
546        };
547        let mut results: Vec<String> = Vec::new();
548        walk_glob_fd(dir, fname, &base_prefix, &mut results, limit)?;
549        results.sort();
550        // De-dup (a `**`/depth-recursion can surface the same relative path).
551        results.dedup();
552        Ok(results)
553    }
554
555    async fn grep(
556        &self,
557        pattern: &str,
558        paths: &[&str],
559        max_matches: usize,
560    ) -> RuntimeResult<Vec<String>> {
561        // Containment: validate each search path's SHAPE (reject absolute/`..`
562        // so the model can't reach outside the root), then resolve it fd-anchored
563        // to its real INODE path. This is essential: `rg --no-follow` still
564        // follows a symlinked dir passed EXPLICITLY as a search path, so passing
565        // the input string would leak through `linkdir -> outside`. Resolving to
566        // the inode path (and rejecting symlinks outright at `openat(NO_FOLLOW)`)
567        // closes that — the search runs against the real confined dir/file.
568        let root_path = Self::fd_real_path(self.root_fd.as_fd())?;
569        let mut validated: Vec<String> = Vec::new();
570        if paths.is_empty() {
571            validated.push(shell_quote(&root_path.to_string_lossy()));
572        } else {
573            for p in paths {
574                validate_search_pattern(p)?;
575                let inode = self.search_path_inode(p)?;
576                validated.push(shell_quote(&inode.to_string_lossy()));
577            }
578        }
579        let search = validated.join(" ");
580        // The process cwd is the root's inode path too (belt-and-suspenders);
581        // `rg --no-follow` / the `find -P` fallback never follow symlinks.
582        let rg = std::process::Command::new("sh")
583            .arg("-c")
584            .arg(format!(
585                "rg -n --no-follow -- {pat} {search} 2>/dev/null \
586                 || find -P {search} -type f -exec grep -Hn -- {pat} {{}} + 2>/dev/null",
587                pat = shell_quote(pattern),
588            ))
589            .current_dir(&root_path)
590            .output()
591            .map_err(RuntimeError::Io)?;
592        let out = String::from_utf8_lossy(&rg.stdout);
593        // Search paths are absolute inode paths (see above), so `rg`/`grep` emit
594        // absolute paths — strip the root's inode prefix so results stay
595        // root-relative (as they did pre-fd-anchoring) and don't leak the host
596        // temp/root path to the model.
597        let root_prefix = format!("{}/", root_path.to_string_lossy());
598        Ok(out
599            .lines()
600            .map(|l| {
601                l.strip_prefix(root_prefix.as_str())
602                    .unwrap_or(l)
603                    .to_string()
604            })
605            .take(max_matches)
606            .collect())
607    }
608}
609
610/// Truncate `raw` to `max_lines` and `max_bytes`, whichever binds first.
611fn apply_read_limits(raw: String, max_lines: usize, max_bytes: usize) -> String {
612    let mut bytes_left = max_bytes;
613    let mut out = String::new();
614    let mut truncated = false;
615    for (i, line) in raw.split_inclusive('\n').enumerate() {
616        if i >= max_lines {
617            out.push_str(&format!("\n[... truncated at {max_lines} lines ...]"));
618            truncated = true;
619            break;
620        }
621        if bytes_left < line.len() {
622            // Take as many whole bytes as fit on a UTF-8 boundary.
623            let take = line
624                .char_indices()
625                .map(|(i, _)| i)
626                .find(|&pos| pos > bytes_left)
627                .unwrap_or(line.len());
628            out.push_str(line.get(..take).unwrap_or(line));
629            out.push_str(&format!("\n[... truncated at {max_bytes} bytes ...]"));
630            truncated = true;
631            break;
632        }
633        out.push_str(line);
634        bytes_left -= line.len();
635    }
636    if truncated {
637        out
638    } else {
639        raw
640    }
641}
642
643/// fd-anchored recursive glob descent. `dir` is an already-opened directory
644/// (opened `O_NOFOLLOW` by the caller). The single-segment filename pattern
645/// `fname_pat` (supporting `*`/`?`) is matched against every entry at every
646/// depth under `dir`. Recursion into a subdirectory happens ONLY via
647/// `openat(O_DIRECTORY | O_NOFOLLOW)` — that gate authoritatively refuses a
648/// symlinked directory, so a symlink can never lead the walk out of the root.
649/// `rel_prefix` is the path of `dir` relative to the session root ("" at the
650/// base); results are accumulated as root-relative strings.
651fn walk_glob_fd(
652    mut dir: Dir,
653    fname_pat: &str,
654    rel_prefix: &str,
655    out: &mut Vec<String>,
656    limit: usize,
657) -> RuntimeResult<()> {
658    // Phase 1: drain entries into an owned vec. This ends the mutable borrow of
659    // `dir` so phase 2 can take an immutable borrow for `dir.fd()` (needed to
660    // openat children). `.`/`..` are skipped.
661    let mut entries: Vec<(String, FileType)> = Vec::new();
662    for res in &mut dir {
663        match res {
664            Ok(e) => {
665                let name = e.file_name().to_string_lossy().into_owned();
666                if name == "." || name == ".." {
667                    continue;
668                }
669                entries.push((name, e.file_type()));
670            }
671            Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
672        }
673    }
674    if out.len() >= limit {
675        return Ok(());
676    }
677    // The parent fd for recursion (immutable borrow — no conflict with the
678    // finished iterator).
679    let parent_fd = dir
680        .fd()
681        .map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
682    for (name, ftype) in entries {
683        if out.len() >= limit {
684            return Ok(());
685        }
686        let rel = if rel_prefix.is_empty() {
687            name.clone()
688        } else {
689            format!("{rel_prefix}/{name}")
690        };
691        if matches_glob(&name, fname_pat) {
692            out.push(rel.clone());
693        }
694        // `is_dir()` is only a *hint* to attempt recursion; the authoritative
695        // gate is the `openat(O_DIRECTORY | O_NOFOLLOW)` below — even if d_type
696        // lies, a symlinked dir cannot be entered.
697        if ftype.is_dir() {
698            if let Ok(child_fd) = openat(
699                parent_fd,
700                name.as_str(),
701                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
702                Mode::empty(),
703            ) {
704                if let Ok(child_dir) = Dir::new(child_fd) {
705                    walk_glob_fd(child_dir, fname_pat, &rel, out, limit)?;
706                }
707            }
708            // openat/Dir failure (symlink, ENOTDIR, race, …) → skip, don't error.
709        }
710    }
711    Ok(())
712}
713
714/// Single-segment glob (`*`/`?`) matcher. `**` is treated as `*` here.
715fn matches_glob(name: &str, pat: &str) -> bool {
716    let name_b = name.as_bytes();
717    let pat_b = pat.as_bytes();
718    matches_at(name_b, pat_b, 0, 0)
719}
720
721fn matches_at(n: &[u8], p: &[u8], mut ni: usize, mut pi: usize) -> bool {
722    let mut star: Option<(usize, usize)> = None;
723    while ni < n.len() {
724        if pi < p.len() && (p[pi] == b'?' || p[pi] == b'*') {
725            if p[pi] == b'*' {
726                star = Some((pi, ni));
727                pi += 1;
728                continue;
729            }
730            pi += 1;
731            ni += 1;
732        } else if pi < p.len() && p[pi] == n[ni] {
733            pi += 1;
734            ni += 1;
735        } else if let Some((sp, sn)) = star {
736            pi = sp + 1;
737            ni = sn + 1;
738            star = Some((sp, sn + 1));
739        } else {
740            return false;
741        }
742    }
743    while pi < p.len() && p[pi] == b'*' {
744        pi += 1;
745    }
746    pi == p.len()
747}
748
749/// Validate a glob/grep search pattern/path is contained: reject absolute
750/// paths and `..` components so the model can't reach outside the root.
751///
752/// Patterns may legitimately contain `*`/`?` (glob) — only path-structure
753/// escapes are rejected.
754fn validate_search_pattern(input: &str) -> RuntimeResult<()> {
755    // Reject absolute paths.
756    if input.starts_with('/') || input.starts_with('\\') {
757        return Err(RuntimeError::Sandbox(format!(
758            "absolute paths are not allowed: `{input}`"
759        )));
760    }
761    // Reject any `..` path component. Walk segments, ignoring glob wildcards.
762    for seg in input.split('/') {
763        if seg == ".." {
764            return Err(RuntimeError::Sandbox(format!(
765                "`..` is not allowed in search paths: `{input}`"
766            )));
767        }
768    }
769    Ok(())
770}
771
772/// Quote a string for safe inclusion in a `sh -c` command.
773fn shell_quote(s: &str) -> String {
774    format!("'{}'", s.replace('\'', "'\\''"))
775}
776
777#[cfg(test)]
778mod tests {
779    //! Local sandbox path-containment and tool tests against a temp dir.
780
781    use super::*;
782
783    #[tokio::test]
784    async fn read_file_within_root_works() {
785        let dir = tempfile::tempdir().unwrap();
786        let env = LocalSessionEnv::new(dir.path(), Limits::default())
787            .await
788            .unwrap();
789        tokio::fs::write(dir.path().join("hello.txt"), "hi there\n")
790            .await
791            .unwrap();
792        let got = env
793            .read_file(Path::new("hello.txt"), 100, 1024)
794            .await
795            .unwrap();
796        assert_eq!(got, "hi there\n");
797    }
798
799    #[tokio::test]
800    async fn read_file_rejects_absolute_path() {
801        let dir = tempfile::tempdir().unwrap();
802        let env = LocalSessionEnv::new(dir.path(), Limits::default())
803            .await
804            .unwrap();
805        let res = env.read_file(Path::new("/etc/passwd"), 100, 1024).await;
806        assert!(res.is_err(), "absolute paths must be rejected");
807    }
808
809    #[tokio::test]
810    async fn read_file_rejects_parent_dir() {
811        let dir = tempfile::tempdir().unwrap();
812        let env = LocalSessionEnv::new(dir.path(), Limits::default())
813            .await
814            .unwrap();
815        let res = env.read_file(Path::new("../escape.txt"), 100, 1024).await;
816        assert!(res.is_err(), "`..` must be rejected");
817    }
818
819    #[tokio::test]
820    async fn read_file_full_returns_complete_content_without_truncation() {
821        let dir = tempfile::tempdir().unwrap();
822        let env = LocalSessionEnv::new(dir.path(), Limits::default())
823            .await
824            .unwrap();
825        // 10 lines of 60 bytes each = 600 bytes, well under the default cap,
826        // but above the *truncating* read's line/byte interplay. Ensure the
827        // full-read path returns the whole file verbatim, with no marker.
828        let body = (0..10)
829            .map(|i| format!("line number {i:02} with some padding text\n"))
830            .collect::<String>();
831        tokio::fs::write(dir.path().join("big.txt"), &body)
832            .await
833            .unwrap();
834        let got = env
835            .read_file_full(Path::new("big.txt"), 1024)
836            .await
837            .unwrap();
838        assert_eq!(got, body);
839        assert!(!got.contains("[... truncated"));
840    }
841
842    #[tokio::test]
843    async fn read_file_full_rejects_absolute_path() {
844        let dir = tempfile::tempdir().unwrap();
845        let env = LocalSessionEnv::new(dir.path(), Limits::default())
846            .await
847            .unwrap();
848        let res = env.read_file_full(Path::new("/etc/passwd"), 1024).await;
849        assert!(res.is_err(), "absolute paths must be rejected");
850    }
851
852    #[tokio::test]
853    async fn read_file_full_rejects_parent_dir() {
854        let dir = tempfile::tempdir().unwrap();
855        let env = LocalSessionEnv::new(dir.path(), Limits::default())
856            .await
857            .unwrap();
858        let res = env.read_file_full(Path::new("../escape.txt"), 1024).await;
859        assert!(res.is_err(), "`..` must be rejected");
860    }
861
862    #[tokio::test]
863    async fn read_file_full_errors_when_too_large_not_truncated() {
864        let dir = tempfile::tempdir().unwrap();
865        let env = LocalSessionEnv::new(dir.path(), Limits::default())
866            .await
867            .unwrap();
868        // 100 bytes, cap at 50 -> must ERROR (FileTooLarge), never return a
869        // truncated prefix (the whole point vs `read_file`).
870        tokio::fs::write(dir.path().join("over.txt"), &"a".repeat(100))
871            .await
872            .unwrap();
873        let res = env.read_file_full(Path::new("over.txt"), 50).await;
874        assert!(res.is_err(), "oversized file must error, not truncate");
875        match res {
876            Err(RuntimeError::FileTooLarge { size, max, .. }) => {
877                assert_eq!(size, 100);
878                assert_eq!(max, 50);
879            }
880            other => panic!("expected FileTooLarge, got {other:?}"),
881        }
882    }
883
884    #[tokio::test]
885    async fn write_then_read_roundtrips() {
886        let dir = tempfile::tempdir().unwrap();
887        let env = LocalSessionEnv::new(dir.path(), Limits::default())
888            .await
889            .unwrap();
890        env.write_file(Path::new("sub/nested/file.txt"), "deep content")
891            .await
892            .unwrap();
893        let got = env
894            .read_file(Path::new("sub/nested/file.txt"), 100, 1024)
895            .await
896            .unwrap();
897        assert_eq!(got, "deep content");
898    }
899
900    #[tokio::test]
901    async fn exec_runs_shell_command() {
902        let dir = tempfile::tempdir().unwrap();
903        let env = LocalSessionEnv::new(dir.path(), Limits::default())
904            .await
905            .unwrap();
906        let res = env
907            .exec(
908                "echo hello",
909                Path::new("."),
910                None,
911                &CancellationToken::new(),
912            )
913            .await
914            .unwrap();
915        assert_eq!(res.exit_code, 0);
916        assert_eq!(res.stdout.trim(), "hello");
917    }
918
919    #[tokio::test]
920    async fn exec_timeout_returns_124() {
921        let dir = tempfile::tempdir().unwrap();
922        let env = LocalSessionEnv::new(dir.path(), Limits::default())
923            .await
924            .unwrap();
925        let res = env
926            .exec(
927                "sleep 5",
928                Path::new("."),
929                Some(200),
930                &CancellationToken::new(),
931            )
932            .await
933            .unwrap();
934        assert_eq!(res.exit_code, 124, "timeout must yield exit 124");
935    }
936
937    #[test]
938    fn glob_matcher_basics() {
939        assert!(matches_glob("foo.txt", "*.txt"));
940        assert!(matches_glob("foo.txt", "foo.*"));
941        assert!(!matches_glob("foo.txt", "*.md"));
942        assert!(matches_glob("a", "?"));
943    }
944
945    #[test]
946    fn read_limit_truncates() {
947        let got = apply_read_limits("a\nb\nc\nd\n".into(), 2, 1024);
948        assert!(got.contains("a"));
949        assert!(got.contains("b"));
950        assert!(got.contains("truncated"));
951    }
952
953    #[tokio::test]
954    async fn glob_rejects_absolute_pattern() {
955        let dir = tempfile::tempdir().unwrap();
956        let env = LocalSessionEnv::new(dir.path(), Limits::default())
957            .await
958            .unwrap();
959        let res = env.glob("/etc/*", 10).await;
960        assert!(res.is_err(), "absolute glob patterns must be rejected");
961    }
962
963    #[tokio::test]
964    async fn glob_rejects_parent_dir_pattern() {
965        let dir = tempfile::tempdir().unwrap();
966        let env = LocalSessionEnv::new(dir.path(), Limits::default())
967            .await
968            .unwrap();
969        let res = env.glob("../**/*", 10).await;
970        assert!(res.is_err(), "`..` in glob patterns must be rejected");
971    }
972
973    #[tokio::test]
974    async fn grep_rejects_absolute_path() {
975        let dir = tempfile::tempdir().unwrap();
976        let env = LocalSessionEnv::new(dir.path(), Limits::default())
977            .await
978            .unwrap();
979        let res = env.grep("foo", &["/etc/passwd"], 10).await;
980        assert!(res.is_err(), "absolute grep paths must be rejected");
981    }
982
983    #[tokio::test]
984    async fn grep_rejects_parent_dir_path() {
985        let dir = tempfile::tempdir().unwrap();
986        let env = LocalSessionEnv::new(dir.path(), Limits::default())
987            .await
988            .unwrap();
989        let res = env.grep("foo", &["../.env"], 10).await;
990        assert!(res.is_err(), "`..` grep paths must be rejected");
991    }
992
993    // ── B-Swift Phase C1a / #4: fd-anchored read TOCTOU / hardlink coverage ──
994    // These prove the fix: the OLD path-based `read_to_string(resolved)` followed
995    // symlinks (leaking the target) and ignored `st_nlink`, so each of these
996    // would have SUCCEEDED (exfiltrated the secret) before the fix.
997
998    /// Write a secret to a file OUTSIDE the env root (a sibling temp dir) and
999    /// return both the held `TempDir` (keep alive for the test) and its path.
1000    #[cfg(unix)]
1001    fn outside_secret(body: &str) -> (tempfile::TempDir, PathBuf) {
1002        use std::io::Write;
1003        let dir = tempfile::tempdir().unwrap();
1004        let path = dir.path().join("secret.txt");
1005        let mut f = std::fs::File::create(&path).unwrap();
1006        f.write_all(body.as_bytes()).unwrap();
1007        (dir, path)
1008    }
1009
1010    #[cfg(unix)]
1011    #[tokio::test]
1012    async fn read_file_rejects_symlink_leaf_even_when_target_inside_root() {
1013        use std::os::unix::fs::symlink;
1014        let dir = tempfile::tempdir().unwrap();
1015        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1016            .await
1017            .unwrap();
1018        tokio::fs::write(dir.path().join("inside.txt"), "ok\n")
1019            .await
1020            .unwrap();
1021        symlink("inside.txt", dir.path().join("link.txt")).unwrap();
1022        let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
1023        assert!(
1024            res.is_err(),
1025            "a symlink leaf must be rejected even if its target is inside the root"
1026        );
1027    }
1028
1029    #[cfg(unix)]
1030    #[tokio::test]
1031    async fn read_file_rejects_symlink_leaf_to_outside_root() {
1032        // Exfil via symlink: link.txt -> /outside/secret. The OLD read followed
1033        // it and leaked "TOPSECRET"; the anchored `openat(O_NOFOLLOW)` rejects
1034        // the symlink leaf outright.
1035        use std::os::unix::fs::symlink;
1036        let dir = tempfile::tempdir().unwrap();
1037        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1038            .await
1039            .unwrap();
1040        let (_outside, secret) = outside_secret("TOPSECRET");
1041        symlink(&secret, dir.path().join("link.txt")).unwrap();
1042        let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
1043        assert!(
1044            res.is_err(),
1045            "a symlink to outside the root must be rejected"
1046        );
1047        if let Ok(s) = res {
1048            assert!(!s.contains("TOPSECRET"), "the secret must not leak");
1049        }
1050    }
1051
1052    #[cfg(unix)]
1053    #[tokio::test]
1054    async fn read_file_rejects_intermediate_symlink_dir() {
1055        // Exfil via a symlinked intermediate dir: linkdir -> realdir; reading
1056        // `linkdir/file.txt` must reject at the `linkdir` component (per-component
1057        // `openat(O_NOFOLLOW)`).
1058        use std::os::unix::fs::symlink;
1059        let dir = tempfile::tempdir().unwrap();
1060        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1061            .await
1062            .unwrap();
1063        tokio::fs::create_dir_all(dir.path().join("realdir"))
1064            .await
1065            .unwrap();
1066        tokio::fs::write(dir.path().join("realdir/file.txt"), "ok\n")
1067            .await
1068            .unwrap();
1069        symlink("realdir", dir.path().join("linkdir")).unwrap();
1070        let res = env
1071            .read_file(Path::new("linkdir/file.txt"), 100, 1024)
1072            .await;
1073        assert!(
1074            res.is_err(),
1075            "a symlinked intermediate dir must be rejected"
1076        );
1077    }
1078
1079    #[cfg(unix)]
1080    #[tokio::test]
1081    async fn read_file_rejects_hardlink_to_outside_secret() {
1082        // Hardlink exfil: `ln /outside/secret root/link.txt`. The file is regular
1083        // and inside the root, but `st_nlink > 1` → reject (mirrors the Swift
1084        // C2/#3 decision; authoritative here via post-open `fstat`).
1085        let dir = tempfile::tempdir().unwrap();
1086        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1087            .await
1088            .unwrap();
1089        let (_outside, secret) = outside_secret("TOPSECRET");
1090        std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
1091        let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
1092        assert!(res.is_err(), "a hardlink (st_nlink > 1) must be rejected");
1093        if let Ok(s) = res {
1094            assert!(!s.contains("TOPSECRET"), "the secret must not leak");
1095        }
1096    }
1097
1098    #[cfg(unix)]
1099    #[tokio::test]
1100    async fn read_file_full_rejects_symlink_leaf() {
1101        use std::os::unix::fs::symlink;
1102        let dir = tempfile::tempdir().unwrap();
1103        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1104            .await
1105            .unwrap();
1106        let (_outside, secret) = outside_secret("TOPSECRET");
1107        symlink(&secret, dir.path().join("link.txt")).unwrap();
1108        let res = env.read_file_full(Path::new("link.txt"), 1024).await;
1109        assert!(res.is_err(), "read_file_full must reject a symlink leaf");
1110        if let Ok(s) = res {
1111            assert!(!s.contains("TOPSECRET"));
1112        }
1113    }
1114
1115    #[cfg(unix)]
1116    #[tokio::test]
1117    async fn read_file_full_rejects_hardlink() {
1118        let dir = tempfile::tempdir().unwrap();
1119        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1120            .await
1121            .unwrap();
1122        let (_outside, secret) = outside_secret("TOPSECRET");
1123        std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
1124        let res = env.read_file_full(Path::new("link.txt"), 1024).await;
1125        assert!(
1126            res.is_err(),
1127            "read_file_full must reject a hardlink (st_nlink > 1)"
1128        );
1129    }
1130
1131    #[cfg(unix)]
1132    #[tokio::test]
1133    async fn read_anchored_nested_relative_path_still_works() {
1134        // Regression guard: the anchored walk must still read a real nested
1135        // file (intermediate dirs are opened `O_NOFOLLOW` + read off the leaf fd).
1136        let dir = tempfile::tempdir().unwrap();
1137        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1138            .await
1139            .unwrap();
1140        tokio::fs::create_dir_all(dir.path().join("a/b"))
1141            .await
1142            .unwrap();
1143        tokio::fs::write(dir.path().join("a/b/c.txt"), "deep\n")
1144            .await
1145            .unwrap();
1146        let got = env
1147            .read_file(Path::new("a/b/c.txt"), 100, 1024)
1148            .await
1149            .unwrap();
1150        assert_eq!(got, "deep\n");
1151    }
1152
1153    // ── B-Swift Phase C1b: fd-anchored write / exec / glob / grep TOCTOU ──
1154    // Each of these FAILED (or leaked) on the old path-based `resolve()` and
1155    // passes on the fd-anchored walk. The inside-target symlink cases are the
1156    // real TOCTOU proof: the OLD `resolve()` canonicalized a symlink whose
1157    // target was inside the root → passed containment → the subsequent path-
1158    // based op followed it. The fd-anchored walk rejects at `openat(NO_FOLLOW)`.
1159
1160    #[cfg(unix)]
1161    #[tokio::test]
1162    async fn write_file_rejects_symlink_leaf_pointing_inside() {
1163        // OLD: resolve() canonicalized `link.txt` → inside `target.txt`
1164        // (contained) → `tokio::fs::write` followed the symlink and overwrote
1165        // the target. NEW: `openat(O_NOFOLLOW)` rejects the symlink leaf; the
1166        // inside target is untouched.
1167        use std::os::unix::fs::symlink;
1168        let dir = tempfile::tempdir().unwrap();
1169        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1170            .await
1171            .unwrap();
1172        tokio::fs::write(dir.path().join("target.txt"), "ORIGINAL")
1173            .await
1174            .unwrap();
1175        symlink("target.txt", dir.path().join("link.txt")).unwrap();
1176        let res = env.write_file(Path::new("link.txt"), "OVERWRITE").await;
1177        assert!(
1178            res.is_err(),
1179            "writing through a symlink leaf must be rejected"
1180        );
1181        let got = tokio::fs::read_to_string(dir.path().join("target.txt"))
1182            .await
1183            .unwrap();
1184        assert_eq!(
1185            got, "ORIGINAL",
1186            "the symlink target must not be overwritten"
1187        );
1188    }
1189
1190    #[cfg(unix)]
1191    #[tokio::test]
1192    async fn write_file_rejects_symlinked_intermediate_dir() {
1193        // OLD: resolve() canonicalized `linkdir/file.txt` through the symlink
1194        // (contained) → wrote through it. NEW: the mkdirat/openat walk rejects
1195        // the symlinked `linkdir` component.
1196        use std::os::unix::fs::symlink;
1197        let dir = tempfile::tempdir().unwrap();
1198        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1199            .await
1200            .unwrap();
1201        tokio::fs::create_dir_all(dir.path().join("realdir"))
1202            .await
1203            .unwrap();
1204        symlink("realdir", dir.path().join("linkdir")).unwrap();
1205        let res = env.write_file(Path::new("linkdir/file.txt"), "data").await;
1206        assert!(
1207            res.is_err(),
1208            "writing through a symlinked intermediate dir must be rejected"
1209        );
1210    }
1211
1212    #[cfg(unix)]
1213    #[tokio::test]
1214    async fn write_file_rejects_hardlink_to_outside_secret() {
1215        // OLD: resolve() canonicalized the inside link (contained) →
1216        // `tokio::fs::write` wrote through the shared inode → corrupted
1217        // /outside/secret. NEW: fstat off the open fd sees `st_nlink > 1` →
1218        // reject; the outside file is unchanged.
1219        let dir = tempfile::tempdir().unwrap();
1220        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1221            .await
1222            .unwrap();
1223        let (_outside, secret) = outside_secret("ORIGINAL-SECRET");
1224        std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
1225        let res = env.write_file(Path::new("link.txt"), "CORRUPTED").await;
1226        assert!(
1227            res.is_err(),
1228            "writing a hardlink (st_nlink > 1) must be rejected"
1229        );
1230        let got = std::fs::read_to_string(&secret).unwrap();
1231        assert_eq!(
1232            got, "ORIGINAL-SECRET",
1233            "the outside secret must not be corrupted"
1234        );
1235    }
1236
1237    #[tokio::test]
1238    async fn write_file_creates_new_nested_path() {
1239        // Regression: the mkdirat walk + leaf open must still create brand-new
1240        // nested files (the happy path must not regress).
1241        let dir = tempfile::tempdir().unwrap();
1242        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1243            .await
1244            .unwrap();
1245        env.write_file(Path::new("a/b/c/new.txt"), "deep")
1246            .await
1247            .unwrap();
1248        let got = env
1249            .read_file(Path::new("a/b/c/new.txt"), 100, 1024)
1250            .await
1251            .unwrap();
1252        assert_eq!(got, "deep");
1253    }
1254
1255    #[cfg(unix)]
1256    #[tokio::test]
1257    async fn exec_rejects_symlinked_cwd_pointing_inside() {
1258        // OLD: resolve() canonicalized the symlinked cwd → inside dir
1259        // (contained) → the child ran there. NEW: open_anchored_dir rejects the
1260        // symlink at the openat component.
1261        use std::os::unix::fs::symlink;
1262        let dir = tempfile::tempdir().unwrap();
1263        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1264            .await
1265            .unwrap();
1266        tokio::fs::create_dir_all(dir.path().join("realcwd"))
1267            .await
1268            .unwrap();
1269        symlink("realcwd", dir.path().join("linkcwd")).unwrap();
1270        let res = env
1271            .exec(
1272                "echo hi",
1273                Path::new("linkcwd"),
1274                None,
1275                &CancellationToken::new(),
1276            )
1277            .await;
1278        assert!(res.is_err(), "a symlinked cwd must be rejected");
1279    }
1280
1281    #[tokio::test]
1282    async fn exec_large_stdout_does_not_deadlock() {
1283        // Regression: `exec` used to `child.wait()` WITHOUT draining the stdout
1284        // pipe, so a child emitting more than the OS pipe buffer (~64 KB) blocked
1285        // on a full pipe while `wait()` blocked on the child — a deadlock. With no
1286        // timeout set (as here) the old code hung forever; `wait_with_output` now
1287        // drains both pipes concurrently, so the full output returns intact.
1288        let dir = tempfile::tempdir().unwrap();
1289        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1290            .await
1291            .unwrap();
1292        let res = env
1293            .exec(
1294                "yes a | head -c 200000",
1295                Path::new("."),
1296                None,
1297                &CancellationToken::new(),
1298            )
1299            .await
1300            .unwrap();
1301        assert_eq!(res.exit_code, 0);
1302        assert_eq!(
1303            res.stdout.len(),
1304            200_000,
1305            "full >64 KB stdout must survive without deadlock"
1306        );
1307    }
1308
1309    #[tokio::test]
1310    async fn glob_returns_matching_files() {
1311        // Regression for the fd-anchored rewrite: it must still surface real
1312        // files at the base and nested under real subdirectories.
1313        let dir = tempfile::tempdir().unwrap();
1314        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1315            .await
1316            .unwrap();
1317        tokio::fs::write(dir.path().join("top.txt"), "x")
1318            .await
1319            .unwrap();
1320        tokio::fs::create_dir_all(dir.path().join("sub"))
1321            .await
1322            .unwrap();
1323        tokio::fs::write(dir.path().join("sub/nested.txt"), "x")
1324            .await
1325            .unwrap();
1326        let matched = env.glob("*.txt", 100).await.unwrap();
1327        assert!(
1328            matched.iter().any(|m| m == "top.txt"),
1329            "base file should match: {matched:?}"
1330        );
1331        assert!(
1332            matched.iter().any(|m| m == "sub/nested.txt"),
1333            "nested file should match: {matched:?}"
1334        );
1335    }
1336
1337    #[tokio::test]
1338    async fn glob_subdir_pattern_reports_root_relative_paths() {
1339        // Regression for the base-prefix bug: a pattern with a subdir base must
1340        // report paths relative to the ROOT, not relative to the base.
1341        let dir = tempfile::tempdir().unwrap();
1342        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1343            .await
1344            .unwrap();
1345        tokio::fs::create_dir_all(dir.path().join("sub"))
1346            .await
1347            .unwrap();
1348        tokio::fs::write(dir.path().join("sub/nested.txt"), "x")
1349            .await
1350            .unwrap();
1351        let matched = env.glob("sub/*.txt", 100).await.unwrap();
1352        assert!(
1353            matched.iter().any(|m| m == "sub/nested.txt"),
1354            "must be root-relative (`sub/nested.txt`), not base-relative: {matched:?}"
1355        );
1356        assert!(
1357            !matched.iter().any(|m| m == "nested.txt"),
1358            "base-relative leak must not happen: {matched:?}"
1359        );
1360    }
1361
1362    #[cfg(unix)]
1363    #[tokio::test]
1364    async fn glob_does_not_traverse_symlinked_dir_to_outside() {
1365        // OLD glob's `path.is_dir()` FOLLOWED the symlink → recursed into the
1366        // outside dir → leaked its `.txt`. NEW: descent is via
1367        // `openat(O_DIRECTORY|O_NOFOLLOW)` → the symlinked dir is never entered.
1368        use std::os::unix::fs::symlink;
1369        let dir = tempfile::tempdir().unwrap();
1370        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1371            .await
1372            .unwrap();
1373        tokio::fs::write(dir.path().join("inside.txt"), "ok")
1374            .await
1375            .unwrap();
1376        tokio::fs::create_dir_all(dir.path().join("realdir"))
1377            .await
1378            .unwrap();
1379        tokio::fs::write(dir.path().join("realdir/nested.txt"), "ok")
1380            .await
1381            .unwrap();
1382        // A symlinked dir pointing at the outside temp dir (which holds
1383        // `secret.txt`).
1384        let (_outside, secret) = outside_secret("OUTSIDE-SECRET");
1385        let outside_dir = secret.parent().unwrap();
1386        symlink(outside_dir, dir.path().join("linkdir")).unwrap();
1387        let matched = env.glob("*.txt", 100).await.unwrap();
1388        assert!(
1389            matched.iter().any(|m| m == "inside.txt"),
1390            "inside file should match: {matched:?}"
1391        );
1392        assert!(
1393            matched.iter().any(|m| m == "realdir/nested.txt"),
1394            "real nested file should match: {matched:?}"
1395        );
1396        assert!(
1397            !matched.iter().any(|m| m.starts_with("linkdir")),
1398            "symlinked dir must not be traversed: {matched:?}"
1399        );
1400        for m in &matched {
1401            assert!(
1402                !m.contains("secret.txt") && !m.contains("OUTSIDE-SECRET"),
1403                "outside file must not leak: {m}"
1404            );
1405        }
1406    }
1407
1408    #[tokio::test]
1409    async fn grep_returns_matches() {
1410        // Regression for the inode-anchored search: it must still surface real
1411        // matches inside the root, AND the output must be root-relative (not the
1412        // absolute host temp/root path, which the inode-path search would
1413        // otherwise leak).
1414        let dir = tempfile::tempdir().unwrap();
1415        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1416            .await
1417            .unwrap();
1418        tokio::fs::write(dir.path().join("note.md"), "findme here\n")
1419            .await
1420            .unwrap();
1421        let matched = env.grep("findme", &["."], 100).await.unwrap();
1422        assert!(
1423            matched.iter().any(|m| m.contains("findme")),
1424            "expected a match: {matched:?}"
1425        );
1426        // Output paths are root-relative...
1427        assert!(
1428            matched.iter().any(|m| m.starts_with("note.md:")),
1429            "expected a root-relative `note.md:` line: {matched:?}"
1430        );
1431        // ...and must NOT leak the host temp/root path.
1432        let root_str = dir.path().to_string_lossy().into_owned();
1433        for m in &matched {
1434            assert!(
1435                !m.contains(&root_str),
1436                "grep output must not leak the absolute root path: {m}"
1437            );
1438        }
1439    }
1440
1441    #[cfg(unix)]
1442    #[tokio::test]
1443    async fn grep_rejects_symlinked_search_path() {
1444        // `rg --no-follow` still follows a symlinked dir passed EXPLICITLY as a
1445        // search path, so the path is resolved fd-anchored to its inode and a
1446        // symlink is rejected outright (no leak via `linkdir -> outside`).
1447        use std::os::unix::fs::symlink;
1448        let dir = tempfile::tempdir().unwrap();
1449        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1450            .await
1451            .unwrap();
1452        let (_outside, secret) = outside_secret("GREP-LEAK");
1453        let outside_dir = secret.parent().unwrap();
1454        symlink(outside_dir, dir.path().join("linkdir")).unwrap();
1455        // Explicit symlinked path → rejected (Err), never searched.
1456        let res = env.grep("GREP-LEAK", &["linkdir"], 100).await;
1457        assert!(
1458            res.is_err(),
1459            "an explicit symlinked search path must be rejected"
1460        );
1461        // And a `.` search must not traverse the symlinked dir either.
1462        let matched = env.grep("GREP-LEAK", &["."], 100).await.unwrap();
1463        assert!(
1464            matched.is_empty(),
1465            "the symlinked dir must not be traversed: {matched:?}"
1466        );
1467    }
1468
1469    #[cfg(unix)]
1470    #[tokio::test]
1471    async fn grep_anchors_to_root_fd_not_root_path() {
1472        // TOCTOU for grep: after the env is built, move the real root aside and
1473        // replace the root *path* with a symlink to an outside dir holding a
1474        // secret. OLD grep used `current_dir(self.root)` (the path) → would
1475        // chdir through the symlink and surface the secret. NEW grep anchors to
1476        // `/dev/fd/{root_fd}` → chdir to the real (moved) root → no leak.
1477        use std::os::unix::fs::symlink;
1478        // A parent dir we fully control (manual, not TempDir, so the swap + the
1479        // symlink-over-root don't confuse Drop cleanup).
1480        let nonce = std::time::SystemTime::now()
1481            .duration_since(std::time::UNIX_EPOCH)
1482            .map(|d| d.as_nanos())
1483            .unwrap_or(0);
1484        let parent = std::env::temp_dir().join(format!("fluers-grep-swap-{nonce}"));
1485        std::fs::create_dir_all(&parent).unwrap();
1486        let root_path = parent.join("root");
1487        std::fs::create_dir_all(&root_path).unwrap();
1488        let env = LocalSessionEnv::new(&root_path, Limits::default())
1489            .await
1490            .unwrap();
1491
1492        let outside = parent.join("outside");
1493        std::fs::create_dir_all(&outside).unwrap();
1494        std::fs::write(outside.join("leak.txt"), "PATHSWAP-SECRET\n").unwrap();
1495
1496        // Swap: move the real root aside (sibling), then symlink the root path
1497        // → outside.
1498        let moved = parent.join("moved-real-root");
1499        std::fs::rename(&root_path, &moved).unwrap();
1500        symlink(&outside, &root_path).unwrap();
1501
1502        let matched = env.grep("PATHSWAP-SECRET", &["."], 100).await.unwrap();
1503        assert!(
1504            matched.is_empty(),
1505            "root-fd anchoring must not follow the swapped root path: {matched:?}"
1506        );
1507
1508        // We own `parent` fully — clean up everything under it.
1509        let _ = std::fs::remove_dir_all(&parent);
1510    }
1511}