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        let mut child = Command::new("sh")
467            .arg("-c")
468            .arg(command)
469            .current_dir(&cwd_path)
470            .stdout(std::process::Stdio::piped())
471            .stderr(std::process::Stdio::piped())
472            .spawn()
473            .map_err(RuntimeError::Io)?;
474        // `cwd_fd` stays live until end of scope (spawn has run by now).
475
476        let timeout_ms_value = timeout_ms;
477        let timeout_fut = match timeout_ms {
478            Some(ms) => Box::pin(tokio::time::sleep(std::time::Duration::from_millis(ms)))
479                as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
480            None => Box::pin(std::future::pending()),
481        };
482        let cancel_fut = cancel.cancelled();
483
484        tokio::select! {
485            _ = timeout_fut => {
486                // Timeout: try to kill, then return the 124-shaped result.
487                let _ = child.kill().await;
488                return Ok(ShellResult {
489                    exit_code: 124,
490                    stdout: String::new(),
491                    stderr: format!("command timed out after {}ms", timeout_ms_value.unwrap_or(0)),
492                });
493            }
494            _ = cancel_fut => {
495                let _ = child.kill().await;
496                return Err(RuntimeError::Sandbox("command cancelled".into()));
497            }
498            status = child.wait() => {
499                let status = status.map_err(RuntimeError::Io)?;
500                let output = child.wait_with_output().await.map_err(RuntimeError::Io)?;
501                Ok(ShellResult {
502                    exit_code: status.code().unwrap_or(-1),
503                    stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
504                    stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
505                })
506            }
507        }
508    }
509
510    async fn glob(&self, pattern: &str, limit: usize) -> RuntimeResult<Vec<String>> {
511        // Containment: reject absolute patterns and `..` so the model can't
512        // list files outside the root (e.g. `../../*` or `/etc/*`).
513        validate_search_pattern(pattern)?;
514        // Split into a base dir (must exist) + a single-segment filename pattern.
515        // As in the original matcher, the filename pattern is applied at every
516        // depth under the base (the descent is what changed: it is now
517        // fd-anchored and never enters a symlinked directory).
518        let pat_path = Path::new(pattern);
519        let base_rel = pat_path.parent().unwrap_or_else(|| Path::new(""));
520        let fname = pat_path.file_name().and_then(|s| s.to_str()).unwrap_or("*");
521        // Results are reported relative to the ROOT, but the walk starts at the
522        // base dir — so seed the descent with the base's own path relative to
523        // root (e.g. `sub/*.txt` → base prefix `sub`, so `sub/nested.txt` is
524        // reported, not `nested.txt`).
525        let base_prefix = self
526            .normal_components(base_rel)?
527            .iter()
528            .map(|s| s.to_string_lossy().into_owned())
529            .collect::<Vec<_>>()
530            .join("/");
531        // A missing/symlinked base yields no matches (preserves the original
532        // "no results" behavior for non-existent bases after validation).
533        let base_fd = match self.open_anchored_dir(base_rel) {
534            Ok(fd) => fd,
535            Err(_) => return Ok(Vec::new()),
536        };
537        let dir = match Dir::new(base_fd) {
538            Ok(d) => d,
539            Err(_) => return Ok(Vec::new()),
540        };
541        let mut results: Vec<String> = Vec::new();
542        walk_glob_fd(dir, fname, &base_prefix, &mut results, limit)?;
543        results.sort();
544        // De-dup (a `**`/depth-recursion can surface the same relative path).
545        results.dedup();
546        Ok(results)
547    }
548
549    async fn grep(
550        &self,
551        pattern: &str,
552        paths: &[&str],
553        max_matches: usize,
554    ) -> RuntimeResult<Vec<String>> {
555        // Containment: validate each search path's SHAPE (reject absolute/`..`
556        // so the model can't reach outside the root), then resolve it fd-anchored
557        // to its real INODE path. This is essential: `rg --no-follow` still
558        // follows a symlinked dir passed EXPLICITLY as a search path, so passing
559        // the input string would leak through `linkdir -> outside`. Resolving to
560        // the inode path (and rejecting symlinks outright at `openat(NO_FOLLOW)`)
561        // closes that — the search runs against the real confined dir/file.
562        let root_path = Self::fd_real_path(self.root_fd.as_fd())?;
563        let mut validated: Vec<String> = Vec::new();
564        if paths.is_empty() {
565            validated.push(shell_quote(&root_path.to_string_lossy()));
566        } else {
567            for p in paths {
568                validate_search_pattern(p)?;
569                let inode = self.search_path_inode(p)?;
570                validated.push(shell_quote(&inode.to_string_lossy()));
571            }
572        }
573        let search = validated.join(" ");
574        // The process cwd is the root's inode path too (belt-and-suspenders);
575        // `rg --no-follow` / the `find -P` fallback never follow symlinks.
576        let rg = std::process::Command::new("sh")
577            .arg("-c")
578            .arg(format!(
579                "rg -n --no-follow -- {pat} {search} 2>/dev/null \
580                 || find -P {search} -type f -exec grep -Hn -- {pat} {{}} + 2>/dev/null",
581                pat = shell_quote(pattern),
582            ))
583            .current_dir(&root_path)
584            .output()
585            .map_err(RuntimeError::Io)?;
586        let out = String::from_utf8_lossy(&rg.stdout);
587        // Search paths are absolute inode paths (see above), so `rg`/`grep` emit
588        // absolute paths — strip the root's inode prefix so results stay
589        // root-relative (as they did pre-fd-anchoring) and don't leak the host
590        // temp/root path to the model.
591        let root_prefix = format!("{}/", root_path.to_string_lossy());
592        Ok(out
593            .lines()
594            .map(|l| {
595                l.strip_prefix(root_prefix.as_str())
596                    .unwrap_or(l)
597                    .to_string()
598            })
599            .take(max_matches)
600            .collect())
601    }
602}
603
604/// Truncate `raw` to `max_lines` and `max_bytes`, whichever binds first.
605fn apply_read_limits(raw: String, max_lines: usize, max_bytes: usize) -> String {
606    let mut bytes_left = max_bytes;
607    let mut out = String::new();
608    let mut truncated = false;
609    for (i, line) in raw.split_inclusive('\n').enumerate() {
610        if i >= max_lines {
611            out.push_str(&format!("\n[... truncated at {max_lines} lines ...]"));
612            truncated = true;
613            break;
614        }
615        if bytes_left < line.len() {
616            // Take as many whole bytes as fit on a UTF-8 boundary.
617            let take = line
618                .char_indices()
619                .map(|(i, _)| i)
620                .find(|&pos| pos > bytes_left)
621                .unwrap_or(line.len());
622            out.push_str(line.get(..take).unwrap_or(line));
623            out.push_str(&format!("\n[... truncated at {max_bytes} bytes ...]"));
624            truncated = true;
625            break;
626        }
627        out.push_str(line);
628        bytes_left -= line.len();
629    }
630    if truncated {
631        out
632    } else {
633        raw
634    }
635}
636
637/// fd-anchored recursive glob descent. `dir` is an already-opened directory
638/// (opened `O_NOFOLLOW` by the caller). The single-segment filename pattern
639/// `fname_pat` (supporting `*`/`?`) is matched against every entry at every
640/// depth under `dir`. Recursion into a subdirectory happens ONLY via
641/// `openat(O_DIRECTORY | O_NOFOLLOW)` — that gate authoritatively refuses a
642/// symlinked directory, so a symlink can never lead the walk out of the root.
643/// `rel_prefix` is the path of `dir` relative to the session root ("" at the
644/// base); results are accumulated as root-relative strings.
645fn walk_glob_fd(
646    mut dir: Dir,
647    fname_pat: &str,
648    rel_prefix: &str,
649    out: &mut Vec<String>,
650    limit: usize,
651) -> RuntimeResult<()> {
652    // Phase 1: drain entries into an owned vec. This ends the mutable borrow of
653    // `dir` so phase 2 can take an immutable borrow for `dir.fd()` (needed to
654    // openat children). `.`/`..` are skipped.
655    let mut entries: Vec<(String, FileType)> = Vec::new();
656    for res in &mut dir {
657        match res {
658            Ok(e) => {
659                let name = e.file_name().to_string_lossy().into_owned();
660                if name == "." || name == ".." {
661                    continue;
662                }
663                entries.push((name, e.file_type()));
664            }
665            Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
666        }
667    }
668    if out.len() >= limit {
669        return Ok(());
670    }
671    // The parent fd for recursion (immutable borrow — no conflict with the
672    // finished iterator).
673    let parent_fd = dir
674        .fd()
675        .map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
676    for (name, ftype) in entries {
677        if out.len() >= limit {
678            return Ok(());
679        }
680        let rel = if rel_prefix.is_empty() {
681            name.clone()
682        } else {
683            format!("{rel_prefix}/{name}")
684        };
685        if matches_glob(&name, fname_pat) {
686            out.push(rel.clone());
687        }
688        // `is_dir()` is only a *hint* to attempt recursion; the authoritative
689        // gate is the `openat(O_DIRECTORY | O_NOFOLLOW)` below — even if d_type
690        // lies, a symlinked dir cannot be entered.
691        if ftype.is_dir() {
692            if let Ok(child_fd) = openat(
693                parent_fd,
694                name.as_str(),
695                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
696                Mode::empty(),
697            ) {
698                if let Ok(child_dir) = Dir::new(child_fd) {
699                    walk_glob_fd(child_dir, fname_pat, &rel, out, limit)?;
700                }
701            }
702            // openat/Dir failure (symlink, ENOTDIR, race, …) → skip, don't error.
703        }
704    }
705    Ok(())
706}
707
708/// Single-segment glob (`*`/`?`) matcher. `**` is treated as `*` here.
709fn matches_glob(name: &str, pat: &str) -> bool {
710    let name_b = name.as_bytes();
711    let pat_b = pat.as_bytes();
712    matches_at(name_b, pat_b, 0, 0)
713}
714
715fn matches_at(n: &[u8], p: &[u8], mut ni: usize, mut pi: usize) -> bool {
716    let mut star: Option<(usize, usize)> = None;
717    while ni < n.len() {
718        if pi < p.len() && (p[pi] == b'?' || p[pi] == b'*') {
719            if p[pi] == b'*' {
720                star = Some((pi, ni));
721                pi += 1;
722                continue;
723            }
724            pi += 1;
725            ni += 1;
726        } else if pi < p.len() && p[pi] == n[ni] {
727            pi += 1;
728            ni += 1;
729        } else if let Some((sp, sn)) = star {
730            pi = sp + 1;
731            ni = sn + 1;
732            star = Some((sp, sn + 1));
733        } else {
734            return false;
735        }
736    }
737    while pi < p.len() && p[pi] == b'*' {
738        pi += 1;
739    }
740    pi == p.len()
741}
742
743/// Validate a glob/grep search pattern/path is contained: reject absolute
744/// paths and `..` components so the model can't reach outside the root.
745///
746/// Patterns may legitimately contain `*`/`?` (glob) — only path-structure
747/// escapes are rejected.
748fn validate_search_pattern(input: &str) -> RuntimeResult<()> {
749    // Reject absolute paths.
750    if input.starts_with('/') || input.starts_with('\\') {
751        return Err(RuntimeError::Sandbox(format!(
752            "absolute paths are not allowed: `{input}`"
753        )));
754    }
755    // Reject any `..` path component. Walk segments, ignoring glob wildcards.
756    for seg in input.split('/') {
757        if seg == ".." {
758            return Err(RuntimeError::Sandbox(format!(
759                "`..` is not allowed in search paths: `{input}`"
760            )));
761        }
762    }
763    Ok(())
764}
765
766/// Quote a string for safe inclusion in a `sh -c` command.
767fn shell_quote(s: &str) -> String {
768    format!("'{}'", s.replace('\'', "'\\''"))
769}
770
771#[cfg(test)]
772mod tests {
773    //! Local sandbox path-containment and tool tests against a temp dir.
774
775    use super::*;
776
777    #[tokio::test]
778    async fn read_file_within_root_works() {
779        let dir = tempfile::tempdir().unwrap();
780        let env = LocalSessionEnv::new(dir.path(), Limits::default())
781            .await
782            .unwrap();
783        tokio::fs::write(dir.path().join("hello.txt"), "hi there\n")
784            .await
785            .unwrap();
786        let got = env
787            .read_file(Path::new("hello.txt"), 100, 1024)
788            .await
789            .unwrap();
790        assert_eq!(got, "hi there\n");
791    }
792
793    #[tokio::test]
794    async fn read_file_rejects_absolute_path() {
795        let dir = tempfile::tempdir().unwrap();
796        let env = LocalSessionEnv::new(dir.path(), Limits::default())
797            .await
798            .unwrap();
799        let res = env.read_file(Path::new("/etc/passwd"), 100, 1024).await;
800        assert!(res.is_err(), "absolute paths must be rejected");
801    }
802
803    #[tokio::test]
804    async fn read_file_rejects_parent_dir() {
805        let dir = tempfile::tempdir().unwrap();
806        let env = LocalSessionEnv::new(dir.path(), Limits::default())
807            .await
808            .unwrap();
809        let res = env.read_file(Path::new("../escape.txt"), 100, 1024).await;
810        assert!(res.is_err(), "`..` must be rejected");
811    }
812
813    #[tokio::test]
814    async fn read_file_full_returns_complete_content_without_truncation() {
815        let dir = tempfile::tempdir().unwrap();
816        let env = LocalSessionEnv::new(dir.path(), Limits::default())
817            .await
818            .unwrap();
819        // 10 lines of 60 bytes each = 600 bytes, well under the default cap,
820        // but above the *truncating* read's line/byte interplay. Ensure the
821        // full-read path returns the whole file verbatim, with no marker.
822        let body = (0..10)
823            .map(|i| format!("line number {i:02} with some padding text\n"))
824            .collect::<String>();
825        tokio::fs::write(dir.path().join("big.txt"), &body)
826            .await
827            .unwrap();
828        let got = env
829            .read_file_full(Path::new("big.txt"), 1024)
830            .await
831            .unwrap();
832        assert_eq!(got, body);
833        assert!(!got.contains("[... truncated"));
834    }
835
836    #[tokio::test]
837    async fn read_file_full_rejects_absolute_path() {
838        let dir = tempfile::tempdir().unwrap();
839        let env = LocalSessionEnv::new(dir.path(), Limits::default())
840            .await
841            .unwrap();
842        let res = env.read_file_full(Path::new("/etc/passwd"), 1024).await;
843        assert!(res.is_err(), "absolute paths must be rejected");
844    }
845
846    #[tokio::test]
847    async fn read_file_full_rejects_parent_dir() {
848        let dir = tempfile::tempdir().unwrap();
849        let env = LocalSessionEnv::new(dir.path(), Limits::default())
850            .await
851            .unwrap();
852        let res = env.read_file_full(Path::new("../escape.txt"), 1024).await;
853        assert!(res.is_err(), "`..` must be rejected");
854    }
855
856    #[tokio::test]
857    async fn read_file_full_errors_when_too_large_not_truncated() {
858        let dir = tempfile::tempdir().unwrap();
859        let env = LocalSessionEnv::new(dir.path(), Limits::default())
860            .await
861            .unwrap();
862        // 100 bytes, cap at 50 -> must ERROR (FileTooLarge), never return a
863        // truncated prefix (the whole point vs `read_file`).
864        tokio::fs::write(dir.path().join("over.txt"), &"a".repeat(100))
865            .await
866            .unwrap();
867        let res = env.read_file_full(Path::new("over.txt"), 50).await;
868        assert!(res.is_err(), "oversized file must error, not truncate");
869        match res {
870            Err(RuntimeError::FileTooLarge { size, max, .. }) => {
871                assert_eq!(size, 100);
872                assert_eq!(max, 50);
873            }
874            other => panic!("expected FileTooLarge, got {other:?}"),
875        }
876    }
877
878    #[tokio::test]
879    async fn write_then_read_roundtrips() {
880        let dir = tempfile::tempdir().unwrap();
881        let env = LocalSessionEnv::new(dir.path(), Limits::default())
882            .await
883            .unwrap();
884        env.write_file(Path::new("sub/nested/file.txt"), "deep content")
885            .await
886            .unwrap();
887        let got = env
888            .read_file(Path::new("sub/nested/file.txt"), 100, 1024)
889            .await
890            .unwrap();
891        assert_eq!(got, "deep content");
892    }
893
894    #[tokio::test]
895    async fn exec_runs_shell_command() {
896        let dir = tempfile::tempdir().unwrap();
897        let env = LocalSessionEnv::new(dir.path(), Limits::default())
898            .await
899            .unwrap();
900        let res = env
901            .exec(
902                "echo hello",
903                Path::new("."),
904                None,
905                &CancellationToken::new(),
906            )
907            .await
908            .unwrap();
909        assert_eq!(res.exit_code, 0);
910        assert_eq!(res.stdout.trim(), "hello");
911    }
912
913    #[tokio::test]
914    async fn exec_timeout_returns_124() {
915        let dir = tempfile::tempdir().unwrap();
916        let env = LocalSessionEnv::new(dir.path(), Limits::default())
917            .await
918            .unwrap();
919        let res = env
920            .exec(
921                "sleep 5",
922                Path::new("."),
923                Some(200),
924                &CancellationToken::new(),
925            )
926            .await
927            .unwrap();
928        assert_eq!(res.exit_code, 124, "timeout must yield exit 124");
929    }
930
931    #[test]
932    fn glob_matcher_basics() {
933        assert!(matches_glob("foo.txt", "*.txt"));
934        assert!(matches_glob("foo.txt", "foo.*"));
935        assert!(!matches_glob("foo.txt", "*.md"));
936        assert!(matches_glob("a", "?"));
937    }
938
939    #[test]
940    fn read_limit_truncates() {
941        let got = apply_read_limits("a\nb\nc\nd\n".into(), 2, 1024);
942        assert!(got.contains("a"));
943        assert!(got.contains("b"));
944        assert!(got.contains("truncated"));
945    }
946
947    #[tokio::test]
948    async fn glob_rejects_absolute_pattern() {
949        let dir = tempfile::tempdir().unwrap();
950        let env = LocalSessionEnv::new(dir.path(), Limits::default())
951            .await
952            .unwrap();
953        let res = env.glob("/etc/*", 10).await;
954        assert!(res.is_err(), "absolute glob patterns must be rejected");
955    }
956
957    #[tokio::test]
958    async fn glob_rejects_parent_dir_pattern() {
959        let dir = tempfile::tempdir().unwrap();
960        let env = LocalSessionEnv::new(dir.path(), Limits::default())
961            .await
962            .unwrap();
963        let res = env.glob("../**/*", 10).await;
964        assert!(res.is_err(), "`..` in glob patterns must be rejected");
965    }
966
967    #[tokio::test]
968    async fn grep_rejects_absolute_path() {
969        let dir = tempfile::tempdir().unwrap();
970        let env = LocalSessionEnv::new(dir.path(), Limits::default())
971            .await
972            .unwrap();
973        let res = env.grep("foo", &["/etc/passwd"], 10).await;
974        assert!(res.is_err(), "absolute grep paths must be rejected");
975    }
976
977    #[tokio::test]
978    async fn grep_rejects_parent_dir_path() {
979        let dir = tempfile::tempdir().unwrap();
980        let env = LocalSessionEnv::new(dir.path(), Limits::default())
981            .await
982            .unwrap();
983        let res = env.grep("foo", &["../.env"], 10).await;
984        assert!(res.is_err(), "`..` grep paths must be rejected");
985    }
986
987    // ── B-Swift Phase C1a / #4: fd-anchored read TOCTOU / hardlink coverage ──
988    // These prove the fix: the OLD path-based `read_to_string(resolved)` followed
989    // symlinks (leaking the target) and ignored `st_nlink`, so each of these
990    // would have SUCCEEDED (exfiltrated the secret) before the fix.
991
992    /// Write a secret to a file OUTSIDE the env root (a sibling temp dir) and
993    /// return both the held `TempDir` (keep alive for the test) and its path.
994    #[cfg(unix)]
995    fn outside_secret(body: &str) -> (tempfile::TempDir, PathBuf) {
996        use std::io::Write;
997        let dir = tempfile::tempdir().unwrap();
998        let path = dir.path().join("secret.txt");
999        let mut f = std::fs::File::create(&path).unwrap();
1000        f.write_all(body.as_bytes()).unwrap();
1001        (dir, path)
1002    }
1003
1004    #[cfg(unix)]
1005    #[tokio::test]
1006    async fn read_file_rejects_symlink_leaf_even_when_target_inside_root() {
1007        use std::os::unix::fs::symlink;
1008        let dir = tempfile::tempdir().unwrap();
1009        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1010            .await
1011            .unwrap();
1012        tokio::fs::write(dir.path().join("inside.txt"), "ok\n")
1013            .await
1014            .unwrap();
1015        symlink("inside.txt", dir.path().join("link.txt")).unwrap();
1016        let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
1017        assert!(
1018            res.is_err(),
1019            "a symlink leaf must be rejected even if its target is inside the root"
1020        );
1021    }
1022
1023    #[cfg(unix)]
1024    #[tokio::test]
1025    async fn read_file_rejects_symlink_leaf_to_outside_root() {
1026        // Exfil via symlink: link.txt -> /outside/secret. The OLD read followed
1027        // it and leaked "TOPSECRET"; the anchored `openat(O_NOFOLLOW)` rejects
1028        // the symlink leaf outright.
1029        use std::os::unix::fs::symlink;
1030        let dir = tempfile::tempdir().unwrap();
1031        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1032            .await
1033            .unwrap();
1034        let (_outside, secret) = outside_secret("TOPSECRET");
1035        symlink(&secret, dir.path().join("link.txt")).unwrap();
1036        let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
1037        assert!(
1038            res.is_err(),
1039            "a symlink to outside the root must be rejected"
1040        );
1041        if let Ok(s) = res {
1042            assert!(!s.contains("TOPSECRET"), "the secret must not leak");
1043        }
1044    }
1045
1046    #[cfg(unix)]
1047    #[tokio::test]
1048    async fn read_file_rejects_intermediate_symlink_dir() {
1049        // Exfil via a symlinked intermediate dir: linkdir -> realdir; reading
1050        // `linkdir/file.txt` must reject at the `linkdir` component (per-component
1051        // `openat(O_NOFOLLOW)`).
1052        use std::os::unix::fs::symlink;
1053        let dir = tempfile::tempdir().unwrap();
1054        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1055            .await
1056            .unwrap();
1057        tokio::fs::create_dir_all(dir.path().join("realdir"))
1058            .await
1059            .unwrap();
1060        tokio::fs::write(dir.path().join("realdir/file.txt"), "ok\n")
1061            .await
1062            .unwrap();
1063        symlink("realdir", dir.path().join("linkdir")).unwrap();
1064        let res = env
1065            .read_file(Path::new("linkdir/file.txt"), 100, 1024)
1066            .await;
1067        assert!(
1068            res.is_err(),
1069            "a symlinked intermediate dir must be rejected"
1070        );
1071    }
1072
1073    #[cfg(unix)]
1074    #[tokio::test]
1075    async fn read_file_rejects_hardlink_to_outside_secret() {
1076        // Hardlink exfil: `ln /outside/secret root/link.txt`. The file is regular
1077        // and inside the root, but `st_nlink > 1` → reject (mirrors the Swift
1078        // C2/#3 decision; authoritative here via post-open `fstat`).
1079        let dir = tempfile::tempdir().unwrap();
1080        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1081            .await
1082            .unwrap();
1083        let (_outside, secret) = outside_secret("TOPSECRET");
1084        std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
1085        let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
1086        assert!(res.is_err(), "a hardlink (st_nlink > 1) must be rejected");
1087        if let Ok(s) = res {
1088            assert!(!s.contains("TOPSECRET"), "the secret must not leak");
1089        }
1090    }
1091
1092    #[cfg(unix)]
1093    #[tokio::test]
1094    async fn read_file_full_rejects_symlink_leaf() {
1095        use std::os::unix::fs::symlink;
1096        let dir = tempfile::tempdir().unwrap();
1097        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1098            .await
1099            .unwrap();
1100        let (_outside, secret) = outside_secret("TOPSECRET");
1101        symlink(&secret, dir.path().join("link.txt")).unwrap();
1102        let res = env.read_file_full(Path::new("link.txt"), 1024).await;
1103        assert!(res.is_err(), "read_file_full must reject a symlink leaf");
1104        if let Ok(s) = res {
1105            assert!(!s.contains("TOPSECRET"));
1106        }
1107    }
1108
1109    #[cfg(unix)]
1110    #[tokio::test]
1111    async fn read_file_full_rejects_hardlink() {
1112        let dir = tempfile::tempdir().unwrap();
1113        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1114            .await
1115            .unwrap();
1116        let (_outside, secret) = outside_secret("TOPSECRET");
1117        std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
1118        let res = env.read_file_full(Path::new("link.txt"), 1024).await;
1119        assert!(
1120            res.is_err(),
1121            "read_file_full must reject a hardlink (st_nlink > 1)"
1122        );
1123    }
1124
1125    #[cfg(unix)]
1126    #[tokio::test]
1127    async fn read_anchored_nested_relative_path_still_works() {
1128        // Regression guard: the anchored walk must still read a real nested
1129        // file (intermediate dirs are opened `O_NOFOLLOW` + read off the leaf fd).
1130        let dir = tempfile::tempdir().unwrap();
1131        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1132            .await
1133            .unwrap();
1134        tokio::fs::create_dir_all(dir.path().join("a/b"))
1135            .await
1136            .unwrap();
1137        tokio::fs::write(dir.path().join("a/b/c.txt"), "deep\n")
1138            .await
1139            .unwrap();
1140        let got = env
1141            .read_file(Path::new("a/b/c.txt"), 100, 1024)
1142            .await
1143            .unwrap();
1144        assert_eq!(got, "deep\n");
1145    }
1146
1147    // ── B-Swift Phase C1b: fd-anchored write / exec / glob / grep TOCTOU ──
1148    // Each of these FAILED (or leaked) on the old path-based `resolve()` and
1149    // passes on the fd-anchored walk. The inside-target symlink cases are the
1150    // real TOCTOU proof: the OLD `resolve()` canonicalized a symlink whose
1151    // target was inside the root → passed containment → the subsequent path-
1152    // based op followed it. The fd-anchored walk rejects at `openat(NO_FOLLOW)`.
1153
1154    #[cfg(unix)]
1155    #[tokio::test]
1156    async fn write_file_rejects_symlink_leaf_pointing_inside() {
1157        // OLD: resolve() canonicalized `link.txt` → inside `target.txt`
1158        // (contained) → `tokio::fs::write` followed the symlink and overwrote
1159        // the target. NEW: `openat(O_NOFOLLOW)` rejects the symlink leaf; the
1160        // inside target is untouched.
1161        use std::os::unix::fs::symlink;
1162        let dir = tempfile::tempdir().unwrap();
1163        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1164            .await
1165            .unwrap();
1166        tokio::fs::write(dir.path().join("target.txt"), "ORIGINAL")
1167            .await
1168            .unwrap();
1169        symlink("target.txt", dir.path().join("link.txt")).unwrap();
1170        let res = env.write_file(Path::new("link.txt"), "OVERWRITE").await;
1171        assert!(
1172            res.is_err(),
1173            "writing through a symlink leaf must be rejected"
1174        );
1175        let got = tokio::fs::read_to_string(dir.path().join("target.txt"))
1176            .await
1177            .unwrap();
1178        assert_eq!(
1179            got, "ORIGINAL",
1180            "the symlink target must not be overwritten"
1181        );
1182    }
1183
1184    #[cfg(unix)]
1185    #[tokio::test]
1186    async fn write_file_rejects_symlinked_intermediate_dir() {
1187        // OLD: resolve() canonicalized `linkdir/file.txt` through the symlink
1188        // (contained) → wrote through it. NEW: the mkdirat/openat walk rejects
1189        // the symlinked `linkdir` component.
1190        use std::os::unix::fs::symlink;
1191        let dir = tempfile::tempdir().unwrap();
1192        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1193            .await
1194            .unwrap();
1195        tokio::fs::create_dir_all(dir.path().join("realdir"))
1196            .await
1197            .unwrap();
1198        symlink("realdir", dir.path().join("linkdir")).unwrap();
1199        let res = env.write_file(Path::new("linkdir/file.txt"), "data").await;
1200        assert!(
1201            res.is_err(),
1202            "writing through a symlinked intermediate dir must be rejected"
1203        );
1204    }
1205
1206    #[cfg(unix)]
1207    #[tokio::test]
1208    async fn write_file_rejects_hardlink_to_outside_secret() {
1209        // OLD: resolve() canonicalized the inside link (contained) →
1210        // `tokio::fs::write` wrote through the shared inode → corrupted
1211        // /outside/secret. NEW: fstat off the open fd sees `st_nlink > 1` →
1212        // reject; the outside file is unchanged.
1213        let dir = tempfile::tempdir().unwrap();
1214        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1215            .await
1216            .unwrap();
1217        let (_outside, secret) = outside_secret("ORIGINAL-SECRET");
1218        std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
1219        let res = env.write_file(Path::new("link.txt"), "CORRUPTED").await;
1220        assert!(
1221            res.is_err(),
1222            "writing a hardlink (st_nlink > 1) must be rejected"
1223        );
1224        let got = std::fs::read_to_string(&secret).unwrap();
1225        assert_eq!(
1226            got, "ORIGINAL-SECRET",
1227            "the outside secret must not be corrupted"
1228        );
1229    }
1230
1231    #[tokio::test]
1232    async fn write_file_creates_new_nested_path() {
1233        // Regression: the mkdirat walk + leaf open must still create brand-new
1234        // nested files (the happy path must not regress).
1235        let dir = tempfile::tempdir().unwrap();
1236        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1237            .await
1238            .unwrap();
1239        env.write_file(Path::new("a/b/c/new.txt"), "deep")
1240            .await
1241            .unwrap();
1242        let got = env
1243            .read_file(Path::new("a/b/c/new.txt"), 100, 1024)
1244            .await
1245            .unwrap();
1246        assert_eq!(got, "deep");
1247    }
1248
1249    #[cfg(unix)]
1250    #[tokio::test]
1251    async fn exec_rejects_symlinked_cwd_pointing_inside() {
1252        // OLD: resolve() canonicalized the symlinked cwd → inside dir
1253        // (contained) → the child ran there. NEW: open_anchored_dir rejects the
1254        // symlink at the openat component.
1255        use std::os::unix::fs::symlink;
1256        let dir = tempfile::tempdir().unwrap();
1257        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1258            .await
1259            .unwrap();
1260        tokio::fs::create_dir_all(dir.path().join("realcwd"))
1261            .await
1262            .unwrap();
1263        symlink("realcwd", dir.path().join("linkcwd")).unwrap();
1264        let res = env
1265            .exec(
1266                "echo hi",
1267                Path::new("linkcwd"),
1268                None,
1269                &CancellationToken::new(),
1270            )
1271            .await;
1272        assert!(res.is_err(), "a symlinked cwd must be rejected");
1273    }
1274
1275    #[tokio::test]
1276    async fn glob_returns_matching_files() {
1277        // Regression for the fd-anchored rewrite: it must still surface real
1278        // files at the base and nested under real subdirectories.
1279        let dir = tempfile::tempdir().unwrap();
1280        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1281            .await
1282            .unwrap();
1283        tokio::fs::write(dir.path().join("top.txt"), "x")
1284            .await
1285            .unwrap();
1286        tokio::fs::create_dir_all(dir.path().join("sub"))
1287            .await
1288            .unwrap();
1289        tokio::fs::write(dir.path().join("sub/nested.txt"), "x")
1290            .await
1291            .unwrap();
1292        let matched = env.glob("*.txt", 100).await.unwrap();
1293        assert!(
1294            matched.iter().any(|m| m == "top.txt"),
1295            "base file should match: {matched:?}"
1296        );
1297        assert!(
1298            matched.iter().any(|m| m == "sub/nested.txt"),
1299            "nested file should match: {matched:?}"
1300        );
1301    }
1302
1303    #[tokio::test]
1304    async fn glob_subdir_pattern_reports_root_relative_paths() {
1305        // Regression for the base-prefix bug: a pattern with a subdir base must
1306        // report paths relative to the ROOT, not relative to the base.
1307        let dir = tempfile::tempdir().unwrap();
1308        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1309            .await
1310            .unwrap();
1311        tokio::fs::create_dir_all(dir.path().join("sub"))
1312            .await
1313            .unwrap();
1314        tokio::fs::write(dir.path().join("sub/nested.txt"), "x")
1315            .await
1316            .unwrap();
1317        let matched = env.glob("sub/*.txt", 100).await.unwrap();
1318        assert!(
1319            matched.iter().any(|m| m == "sub/nested.txt"),
1320            "must be root-relative (`sub/nested.txt`), not base-relative: {matched:?}"
1321        );
1322        assert!(
1323            !matched.iter().any(|m| m == "nested.txt"),
1324            "base-relative leak must not happen: {matched:?}"
1325        );
1326    }
1327
1328    #[cfg(unix)]
1329    #[tokio::test]
1330    async fn glob_does_not_traverse_symlinked_dir_to_outside() {
1331        // OLD glob's `path.is_dir()` FOLLOWED the symlink → recursed into the
1332        // outside dir → leaked its `.txt`. NEW: descent is via
1333        // `openat(O_DIRECTORY|O_NOFOLLOW)` → the symlinked dir is never entered.
1334        use std::os::unix::fs::symlink;
1335        let dir = tempfile::tempdir().unwrap();
1336        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1337            .await
1338            .unwrap();
1339        tokio::fs::write(dir.path().join("inside.txt"), "ok")
1340            .await
1341            .unwrap();
1342        tokio::fs::create_dir_all(dir.path().join("realdir"))
1343            .await
1344            .unwrap();
1345        tokio::fs::write(dir.path().join("realdir/nested.txt"), "ok")
1346            .await
1347            .unwrap();
1348        // A symlinked dir pointing at the outside temp dir (which holds
1349        // `secret.txt`).
1350        let (_outside, secret) = outside_secret("OUTSIDE-SECRET");
1351        let outside_dir = secret.parent().unwrap();
1352        symlink(outside_dir, dir.path().join("linkdir")).unwrap();
1353        let matched = env.glob("*.txt", 100).await.unwrap();
1354        assert!(
1355            matched.iter().any(|m| m == "inside.txt"),
1356            "inside file should match: {matched:?}"
1357        );
1358        assert!(
1359            matched.iter().any(|m| m == "realdir/nested.txt"),
1360            "real nested file should match: {matched:?}"
1361        );
1362        assert!(
1363            !matched.iter().any(|m| m.starts_with("linkdir")),
1364            "symlinked dir must not be traversed: {matched:?}"
1365        );
1366        for m in &matched {
1367            assert!(
1368                !m.contains("secret.txt") && !m.contains("OUTSIDE-SECRET"),
1369                "outside file must not leak: {m}"
1370            );
1371        }
1372    }
1373
1374    #[tokio::test]
1375    async fn grep_returns_matches() {
1376        // Regression for the inode-anchored search: it must still surface real
1377        // matches inside the root, AND the output must be root-relative (not the
1378        // absolute host temp/root path, which the inode-path search would
1379        // otherwise leak).
1380        let dir = tempfile::tempdir().unwrap();
1381        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1382            .await
1383            .unwrap();
1384        tokio::fs::write(dir.path().join("note.md"), "findme here\n")
1385            .await
1386            .unwrap();
1387        let matched = env.grep("findme", &["."], 100).await.unwrap();
1388        assert!(
1389            matched.iter().any(|m| m.contains("findme")),
1390            "expected a match: {matched:?}"
1391        );
1392        // Output paths are root-relative...
1393        assert!(
1394            matched.iter().any(|m| m.starts_with("note.md:")),
1395            "expected a root-relative `note.md:` line: {matched:?}"
1396        );
1397        // ...and must NOT leak the host temp/root path.
1398        let root_str = dir.path().to_string_lossy().into_owned();
1399        for m in &matched {
1400            assert!(
1401                !m.contains(&root_str),
1402                "grep output must not leak the absolute root path: {m}"
1403            );
1404        }
1405    }
1406
1407    #[cfg(unix)]
1408    #[tokio::test]
1409    async fn grep_rejects_symlinked_search_path() {
1410        // `rg --no-follow` still follows a symlinked dir passed EXPLICITLY as a
1411        // search path, so the path is resolved fd-anchored to its inode and a
1412        // symlink is rejected outright (no leak via `linkdir -> outside`).
1413        use std::os::unix::fs::symlink;
1414        let dir = tempfile::tempdir().unwrap();
1415        let env = LocalSessionEnv::new(dir.path(), Limits::default())
1416            .await
1417            .unwrap();
1418        let (_outside, secret) = outside_secret("GREP-LEAK");
1419        let outside_dir = secret.parent().unwrap();
1420        symlink(outside_dir, dir.path().join("linkdir")).unwrap();
1421        // Explicit symlinked path → rejected (Err), never searched.
1422        let res = env.grep("GREP-LEAK", &["linkdir"], 100).await;
1423        assert!(
1424            res.is_err(),
1425            "an explicit symlinked search path must be rejected"
1426        );
1427        // And a `.` search must not traverse the symlinked dir either.
1428        let matched = env.grep("GREP-LEAK", &["."], 100).await.unwrap();
1429        assert!(
1430            matched.is_empty(),
1431            "the symlinked dir must not be traversed: {matched:?}"
1432        );
1433    }
1434
1435    #[cfg(unix)]
1436    #[tokio::test]
1437    async fn grep_anchors_to_root_fd_not_root_path() {
1438        // TOCTOU for grep: after the env is built, move the real root aside and
1439        // replace the root *path* with a symlink to an outside dir holding a
1440        // secret. OLD grep used `current_dir(self.root)` (the path) → would
1441        // chdir through the symlink and surface the secret. NEW grep anchors to
1442        // `/dev/fd/{root_fd}` → chdir to the real (moved) root → no leak.
1443        use std::os::unix::fs::symlink;
1444        // A parent dir we fully control (manual, not TempDir, so the swap + the
1445        // symlink-over-root don't confuse Drop cleanup).
1446        let nonce = std::time::SystemTime::now()
1447            .duration_since(std::time::UNIX_EPOCH)
1448            .map(|d| d.as_nanos())
1449            .unwrap_or(0);
1450        let parent = std::env::temp_dir().join(format!("fluers-grep-swap-{nonce}"));
1451        std::fs::create_dir_all(&parent).unwrap();
1452        let root_path = parent.join("root");
1453        std::fs::create_dir_all(&root_path).unwrap();
1454        let env = LocalSessionEnv::new(&root_path, Limits::default())
1455            .await
1456            .unwrap();
1457
1458        let outside = parent.join("outside");
1459        std::fs::create_dir_all(&outside).unwrap();
1460        std::fs::write(outside.join("leak.txt"), "PATHSWAP-SECRET\n").unwrap();
1461
1462        // Swap: move the real root aside (sibling), then symlink the root path
1463        // → outside.
1464        let moved = parent.join("moved-real-root");
1465        std::fs::rename(&root_path, &moved).unwrap();
1466        symlink(&outside, &root_path).unwrap();
1467
1468        let matched = env.grep("PATHSWAP-SECRET", &["."], 100).await.unwrap();
1469        assert!(
1470            matched.is_empty(),
1471            "root-fd anchoring must not follow the swapped root path: {matched:?}"
1472        );
1473
1474        // We own `parent` fully — clean up everything under it.
1475        let _ = std::fs::remove_dir_all(&parent);
1476    }
1477}