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`. **Path containment is enforced**: model-facing paths are
5//! relative, `..` is rejected, and resolved paths must stay under the
6//! canonicalized root.
7//!
8//! See `SECURITY.md`: this is *not* an OS-level sandbox (no chroot/landlock/
9//! UID separation). It prevents accidental path escape; it is not a defense
10//! against a determined adversary until OS isolation lands.
11
12use std::os::fd::{AsFd, OwnedFd};
13use std::path::{Component, Path, PathBuf};
14
15use async_trait::async_trait;
16use rustix::fs::{fstat, open, openat, Mode, OFlags};
17use rustix::io::Errno;
18use tokio::io::AsyncReadExt;
19use tokio::process::Command;
20use tokio_util::sync::CancellationToken;
21
22use crate::env::{Limits, SessionEnv, ShellResult};
23use crate::error::{RuntimeError, RuntimeResult};
24
25/// POSIX `st_mode` masks (stable, platform-independent) for the regular-file
26/// check — avoids pulling `libc` just for `S_ISREG`.
27const ST_MODE_TYPE_MASK: u32 = 0o170_000; // S_IFMT
28const ST_MODE_REGULAR: u32 = 0o100_000; // S_IFREG
29
30/// A `SessionEnv` backed by a real local directory.
31pub struct LocalSessionEnv {
32    /// Canonicalized root all relative paths are joined under.
33    root: PathBuf,
34    /// Held fd over the canonical root (B-Swift Phase C1a / #4): the anchor for
35    /// `openat`-walked reads. Opened once at construction with
36    /// `O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC`, so root-path re-resolution never
37    /// re-enters the read hot path. `OwnedFd` is `Send + Sync` on Unix.
38    root_fd: OwnedFd,
39    #[allow(dead_code)]
40    limits: Limits,
41}
42
43impl LocalSessionEnv {
44    /// Create an env rooted at `root`. The directory is canonicalized; if it
45    /// does not exist it is created.
46    pub async fn new(root: impl Into<PathBuf>, limits: Limits) -> RuntimeResult<Self> {
47        let root = root.into();
48        tokio::fs::create_dir_all(&root)
49            .await
50            .map_err(RuntimeError::Io)?;
51        let canon = tokio::fs::canonicalize(&root)
52            .await
53            .map_err(RuntimeError::Io)?;
54        // Hold an fd over the canonical root (B-Swift Phase C1a / #4). Opened
55        // with O_NOFOLLOW (reject a root swapped to a symlink since construction)
56        // + O_DIRECTORY + O_CLOEXEC.
57        let root_flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
58        let root_fd = match open(&canon, root_flags, Mode::empty()) {
59            Ok(fd) => fd,
60            Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
61        };
62        Ok(Self {
63            root: canon,
64            root_fd,
65            limits,
66        })
67    }
68
69    /// Resolve a model-supplied relative path under the root, rejecting
70    /// escapes. Returns the absolute joined path on success.
71    fn resolve(&self, rel: &Path) -> RuntimeResult<PathBuf> {
72        if rel.is_absolute() {
73            return Err(RuntimeError::Sandbox(format!(
74                "absolute paths are not allowed: `{}`",
75                rel.display()
76            )));
77        }
78        if rel.components().any(|c| matches!(c, Component::ParentDir)) {
79            return Err(RuntimeError::Sandbox(format!(
80                "`..` is not allowed in paths: `{}`",
81                rel.display()
82            )));
83        }
84        let joined = self.root.join(rel);
85        // Find the deepest existing ancestor (handles not-yet-created write
86        // targets and `.`), canonicalize it, and verify containment.
87        let mut anchor = joined.clone();
88        while !anchor.exists() && anchor.parent().is_some() {
89            anchor = match anchor.parent() {
90                Some(p) if p.starts_with(&self.root) => p.to_path_buf(),
91                _ => break,
92            };
93        }
94        let canon = anchor.canonicalize().map_err(RuntimeError::Io)?;
95        if !canon.starts_with(&self.root) {
96            return Err(RuntimeError::Sandbox(format!(
97                "path escapes sandbox root: `{}`",
98                rel.display()
99            )));
100        }
101        // Return the canonical path if the target exists; else the joined path.
102        match joined.canonicalize() {
103            Ok(c) if c.starts_with(&self.root) => Ok(c),
104            Ok(_) => Err(RuntimeError::Sandbox(format!(
105                "path escapes sandbox root: `{}`",
106                rel.display()
107            ))),
108            Err(_) => Ok(joined),
109        }
110    }
111
112    /// Open `rel` for reading via an fd-anchored walk from the held root fd
113    /// (B-Swift Phase C1a / #4). Closes the path-based TOCTOU at the daemon
114    /// read: every component is opened with `O_NOFOLLOW` (symlink → `ELOOP`),
115    /// and the leaf is `fstat`'d on the SAME fd we hand back for reading — so a
116    /// symlink/hardlink swap between confinement and the read cannot exfiltrate.
117    /// Mirrors the Swift `readFdAnchored`.
118    ///
119    /// Returns the opened regular-file `File` and its size in bytes (the size is
120    /// authoritative — taken off the open fd, not the path).
121    fn open_anchored_read(&self, rel: &Path) -> RuntimeResult<(std::fs::File, u64)> {
122        // Input shape checks (the fd walk itself enforces containment — there is
123        // no canonicalize-then-contain step, so no path re-resolution).
124        if rel.is_absolute() {
125            return Err(RuntimeError::Sandbox(format!(
126                "absolute paths are not allowed: `{}`",
127                rel.display()
128            )));
129        }
130        if rel.components().any(|c| matches!(c, Component::ParentDir)) {
131            return Err(RuntimeError::Sandbox(format!(
132                "`..` is not allowed in paths: `{}`",
133                rel.display()
134            )));
135        }
136
137        let oflag = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
138        // Walk: hold every opened fd in `chain` so intermediates stay alive
139        // until the next level is opened; the last element is the leaf.
140        let mut chain: Vec<OwnedFd> = Vec::new();
141        for comp in rel.components() {
142            if let Component::Normal(name) = comp {
143                let dir = match chain.last() {
144                    Some(f) => f.as_fd(),
145                    None => self.root_fd.as_fd(),
146                };
147                let fd = match openat(dir, name, oflag, Mode::empty()) {
148                    Ok(fd) => fd,
149                    Err(Errno::LOOP) => {
150                        return Err(RuntimeError::Sandbox(format!(
151                            "symlinks are not allowed in read paths: `{}`",
152                            rel.display()
153                        )));
154                    }
155                    Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
156                };
157                chain.push(fd);
158            }
159            // `Component::CurDir` (".") is skipped; `ParentDir`/absolute are
160            // pre-rejected above.
161        }
162        let leaf_owned = chain
163            .pop()
164            .ok_or_else(|| RuntimeError::Sandbox("read path has no components".to_string()))?;
165        // Remaining `chain` (intermediates) drops here → their fds close.
166
167        // Authoritative leaf check: fstat the OPENED fd (not the path).
168        let stat = match fstat(leaf_owned.as_fd()) {
169            Ok(s) => s,
170            Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
171        };
172        if (stat.st_mode as u32 & ST_MODE_TYPE_MASK) != ST_MODE_REGULAR {
173            return Err(RuntimeError::Sandbox(format!(
174                "not a regular file: `{}`",
175                rel.display()
176            )));
177        }
178        if stat.st_nlink > 1 {
179            // Hardlink exfil (`ln secret in_root; read in_root/link`) — mirrors
180            // the Swift-side C2/#3 reject. Authoritative here: fstat off the
181            // open fd, not the path.
182            return Err(RuntimeError::Sandbox(format!(
183                "multiple hard links — can't safely confine: `{}`",
184                rel.display()
185            )));
186        }
187        let size = stat.st_size.max(0) as u64;
188        Ok((std::fs::File::from(leaf_owned), size))
189    }
190}
191
192#[async_trait]
193impl SessionEnv for LocalSessionEnv {
194    async fn read_file(
195        &self,
196        path: &Path,
197        max_lines: usize,
198        max_bytes: usize,
199    ) -> RuntimeResult<String> {
200        // B-Swift Phase C1a / #4: fd-anchored open + read from the SAME fd
201        // (closes the check-then-use TOCTOU the path-based read had).
202        let (file, _size) = self.open_anchored_read(path)?;
203        let mut file = tokio::fs::File::from_std(file);
204        let mut raw = String::new();
205        file.read_to_string(&mut raw)
206            .await
207            .map_err(RuntimeError::Io)?;
208        Ok(apply_read_limits(raw, max_lines, max_bytes))
209    }
210
211    async fn read_file_full(&self, path: &Path, max_bytes: usize) -> RuntimeResult<String> {
212        // B-Swift Phase C1a / #4: size + read off the SAME open fd. The old
213        // path-based metadata check raced the read; now the size gate is
214        // authoritative (fstat off the open fd) and the read uses that fd.
215        let (file, size) = self.open_anchored_read(path)?;
216        let size = size as usize;
217        if size > max_bytes {
218            return Err(RuntimeError::FileTooLarge {
219                path: path.display().to_string(),
220                size,
221                max: max_bytes,
222            });
223        }
224        let mut file = tokio::fs::File::from_std(file);
225        let mut raw = String::new();
226        file.read_to_string(&mut raw)
227            .await
228            .map_err(RuntimeError::Io)?;
229        Ok(raw)
230    }
231
232    async fn write_file(&self, path: &Path, content: &str) -> RuntimeResult<()> {
233        let resolved = self.resolve(path)?;
234        if let Some(parent) = resolved.parent() {
235            tokio::fs::create_dir_all(parent)
236                .await
237                .map_err(RuntimeError::Io)?;
238        }
239        tokio::fs::write(&resolved, content)
240            .await
241            .map_err(RuntimeError::Io)?;
242        Ok(())
243    }
244
245    async fn exec(
246        &self,
247        command: &str,
248        cwd: &Path,
249        timeout_ms: Option<u64>,
250        cancel: &CancellationToken,
251    ) -> RuntimeResult<ShellResult> {
252        // The cwd is also relative to the root.
253        let cwd_resolved = self.resolve(cwd)?;
254
255        let mut child = Command::new("sh")
256            .arg("-c")
257            .arg(command)
258            .current_dir(&cwd_resolved)
259            .stdout(std::process::Stdio::piped())
260            .stderr(std::process::Stdio::piped())
261            .spawn()
262            .map_err(RuntimeError::Io)?;
263
264        let timeout_ms_value = timeout_ms;
265        let timeout_fut = match timeout_ms {
266            Some(ms) => Box::pin(tokio::time::sleep(std::time::Duration::from_millis(ms)))
267                as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
268            None => Box::pin(std::future::pending()),
269        };
270        let cancel_fut = cancel.cancelled();
271
272        tokio::select! {
273            _ = timeout_fut => {
274                // Timeout: try to kill, then return the 124-shaped result.
275                let _ = child.kill().await;
276                return Ok(ShellResult {
277                    exit_code: 124,
278                    stdout: String::new(),
279                    stderr: format!("command timed out after {}ms", timeout_ms_value.unwrap_or(0)),
280                });
281            }
282            _ = cancel_fut => {
283                let _ = child.kill().await;
284                return Err(RuntimeError::Sandbox("command cancelled".into()));
285            }
286            status = child.wait() => {
287                let status = status.map_err(RuntimeError::Io)?;
288                let output = child.wait_with_output().await.map_err(RuntimeError::Io)?;
289                Ok(ShellResult {
290                    exit_code: status.code().unwrap_or(-1),
291                    stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
292                    stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
293                })
294            }
295        }
296    }
297
298    async fn glob(&self, pattern: &str, limit: usize) -> RuntimeResult<Vec<String>> {
299        // Containment: reject absolute patterns and `..` so the model can't
300        // list files outside the root (e.g. `../../*` or `/etc/*`).
301        validate_search_pattern(pattern)?;
302        // Glob relative to the root.
303        let full = self.root.join(pattern);
304        let matched: Vec<PathBuf> = glob_match(&full, limit);
305        // Strip the root prefix so results are relative.
306        let stripped: Vec<String> = matched
307            .iter()
308            .filter_map(|p| p.strip_prefix(&self.root).ok())
309            .map(|p| p.display().to_string())
310            .collect();
311        Ok(stripped)
312    }
313
314    async fn grep(
315        &self,
316        pattern: &str,
317        paths: &[&str],
318        max_matches: usize,
319    ) -> RuntimeResult<Vec<String>> {
320        // Containment: validate each search path. Reject absolute/`..` so the
321        // model can't search outside the root (e.g. `../.env` or `/etc/passwd`).
322        let mut validated: Vec<String> = Vec::new();
323        if paths.is_empty() {
324            validated.push(".".to_string());
325        } else {
326            for p in paths {
327                validate_search_pattern(p)?;
328                validated.push(shell_quote(p));
329            }
330        }
331        let search = validated.join(" ");
332        // Shell out to `rg` if present, else `grep -rn`. Search under root.
333        let rg = std::process::Command::new("sh")
334            .arg("-c")
335            .arg(format!(
336                "rg -n -- {pat} {search} 2>/dev/null || grep -rn -- {pat} {search} 2>/dev/null",
337                pat = shell_quote(pattern),
338            ))
339            .current_dir(&self.root)
340            .output()
341            .map_err(RuntimeError::Io)?;
342        let out = String::from_utf8_lossy(&rg.stdout);
343        Ok(out.lines().take(max_matches).map(String::from).collect())
344    }
345}
346
347/// Truncate `raw` to `max_lines` and `max_bytes`, whichever binds first.
348fn apply_read_limits(raw: String, max_lines: usize, max_bytes: usize) -> String {
349    let mut bytes_left = max_bytes;
350    let mut out = String::new();
351    let mut truncated = false;
352    for (i, line) in raw.split_inclusive('\n').enumerate() {
353        if i >= max_lines {
354            out.push_str(&format!("\n[... truncated at {max_lines} lines ...]"));
355            truncated = true;
356            break;
357        }
358        if bytes_left < line.len() {
359            // Take as many whole bytes as fit on a UTF-8 boundary.
360            let take = line
361                .char_indices()
362                .map(|(i, _)| i)
363                .find(|&pos| pos > bytes_left)
364                .unwrap_or(line.len());
365            out.push_str(line.get(..take).unwrap_or(line));
366            out.push_str(&format!("\n[... truncated at {max_bytes} bytes ...]"));
367            truncated = true;
368            break;
369        }
370        out.push_str(line);
371        bytes_left -= line.len();
372    }
373    if truncated {
374        out
375    } else {
376        raw
377    }
378}
379
380/// Minimal recursive glob matcher supporting `*`, `**`, and `?`.
381fn glob_match(pattern: &Path, limit: usize) -> Vec<PathBuf> {
382    let mut results = Vec::new();
383    let base = pattern
384        .parent()
385        .map(Path::to_path_buf)
386        .unwrap_or_else(|| PathBuf::from("."));
387    let pat = pattern.file_name().and_then(|s| s.to_str()).unwrap_or("*");
388    walk_glob(&base, pat, &mut results, limit);
389    results.sort();
390    results
391}
392
393fn walk_glob(dir: &Path, pat: &str, out: &mut Vec<PathBuf>, limit: usize) {
394    if out.len() >= limit {
395        return;
396    }
397    let Ok(entries) = std::fs::read_dir(dir) else {
398        return;
399    };
400    for entry in entries.flatten() {
401        if out.len() >= limit {
402            return;
403        }
404        let path = entry.path();
405        if matches_glob(entry.file_name().to_string_lossy().as_ref(), pat) {
406            out.push(path.clone());
407        }
408        if path.is_dir() {
409            walk_glob(&path, pat, out, limit);
410        }
411    }
412}
413
414/// Single-segment glob (`*`/`?`) matcher. `**` is treated as `*` here.
415fn matches_glob(name: &str, pat: &str) -> bool {
416    let name_b = name.as_bytes();
417    let pat_b = pat.as_bytes();
418    matches_at(name_b, pat_b, 0, 0)
419}
420
421fn matches_at(n: &[u8], p: &[u8], mut ni: usize, mut pi: usize) -> bool {
422    let mut star: Option<(usize, usize)> = None;
423    while ni < n.len() {
424        if pi < p.len() && (p[pi] == b'?' || p[pi] == b'*') {
425            if p[pi] == b'*' {
426                star = Some((pi, ni));
427                pi += 1;
428                continue;
429            }
430            pi += 1;
431            ni += 1;
432        } else if pi < p.len() && p[pi] == n[ni] {
433            pi += 1;
434            ni += 1;
435        } else if let Some((sp, sn)) = star {
436            pi = sp + 1;
437            ni = sn + 1;
438            star = Some((sp, sn + 1));
439        } else {
440            return false;
441        }
442    }
443    while pi < p.len() && p[pi] == b'*' {
444        pi += 1;
445    }
446    pi == p.len()
447}
448
449/// Validate a glob/grep search pattern/path is contained: reject absolute
450/// paths and `..` components so the model can't reach outside the root.
451///
452/// Patterns may legitimately contain `*`/`?` (glob) — only path-structure
453/// escapes are rejected.
454fn validate_search_pattern(input: &str) -> RuntimeResult<()> {
455    // Reject absolute paths.
456    if input.starts_with('/') || input.starts_with('\\') {
457        return Err(RuntimeError::Sandbox(format!(
458            "absolute paths are not allowed: `{input}`"
459        )));
460    }
461    // Reject any `..` path component. Walk segments, ignoring glob wildcards.
462    for seg in input.split('/') {
463        if seg == ".." {
464            return Err(RuntimeError::Sandbox(format!(
465                "`..` is not allowed in search paths: `{input}`"
466            )));
467        }
468    }
469    Ok(())
470}
471
472/// Quote a string for safe inclusion in a `sh -c` command.
473fn shell_quote(s: &str) -> String {
474    format!("'{}'", s.replace('\'', "'\\''"))
475}
476
477#[cfg(test)]
478mod tests {
479    //! Local sandbox path-containment and tool tests against a temp dir.
480
481    use super::*;
482
483    #[tokio::test]
484    async fn read_file_within_root_works() {
485        let dir = tempfile::tempdir().unwrap();
486        let env = LocalSessionEnv::new(dir.path(), Limits::default())
487            .await
488            .unwrap();
489        tokio::fs::write(dir.path().join("hello.txt"), "hi there\n")
490            .await
491            .unwrap();
492        let got = env
493            .read_file(Path::new("hello.txt"), 100, 1024)
494            .await
495            .unwrap();
496        assert_eq!(got, "hi there\n");
497    }
498
499    #[tokio::test]
500    async fn read_file_rejects_absolute_path() {
501        let dir = tempfile::tempdir().unwrap();
502        let env = LocalSessionEnv::new(dir.path(), Limits::default())
503            .await
504            .unwrap();
505        let res = env.read_file(Path::new("/etc/passwd"), 100, 1024).await;
506        assert!(res.is_err(), "absolute paths must be rejected");
507    }
508
509    #[tokio::test]
510    async fn read_file_rejects_parent_dir() {
511        let dir = tempfile::tempdir().unwrap();
512        let env = LocalSessionEnv::new(dir.path(), Limits::default())
513            .await
514            .unwrap();
515        let res = env.read_file(Path::new("../escape.txt"), 100, 1024).await;
516        assert!(res.is_err(), "`..` must be rejected");
517    }
518
519    #[tokio::test]
520    async fn read_file_full_returns_complete_content_without_truncation() {
521        let dir = tempfile::tempdir().unwrap();
522        let env = LocalSessionEnv::new(dir.path(), Limits::default())
523            .await
524            .unwrap();
525        // 10 lines of 60 bytes each = 600 bytes, well under the default cap,
526        // but above the *truncating* read's line/byte interplay. Ensure the
527        // full-read path returns the whole file verbatim, with no marker.
528        let body = (0..10)
529            .map(|i| format!("line number {i:02} with some padding text\n"))
530            .collect::<String>();
531        tokio::fs::write(dir.path().join("big.txt"), &body)
532            .await
533            .unwrap();
534        let got = env
535            .read_file_full(Path::new("big.txt"), 1024)
536            .await
537            .unwrap();
538        assert_eq!(got, body);
539        assert!(!got.contains("[... truncated"));
540    }
541
542    #[tokio::test]
543    async fn read_file_full_rejects_absolute_path() {
544        let dir = tempfile::tempdir().unwrap();
545        let env = LocalSessionEnv::new(dir.path(), Limits::default())
546            .await
547            .unwrap();
548        let res = env.read_file_full(Path::new("/etc/passwd"), 1024).await;
549        assert!(res.is_err(), "absolute paths must be rejected");
550    }
551
552    #[tokio::test]
553    async fn read_file_full_rejects_parent_dir() {
554        let dir = tempfile::tempdir().unwrap();
555        let env = LocalSessionEnv::new(dir.path(), Limits::default())
556            .await
557            .unwrap();
558        let res = env.read_file_full(Path::new("../escape.txt"), 1024).await;
559        assert!(res.is_err(), "`..` must be rejected");
560    }
561
562    #[tokio::test]
563    async fn read_file_full_errors_when_too_large_not_truncated() {
564        let dir = tempfile::tempdir().unwrap();
565        let env = LocalSessionEnv::new(dir.path(), Limits::default())
566            .await
567            .unwrap();
568        // 100 bytes, cap at 50 -> must ERROR (FileTooLarge), never return a
569        // truncated prefix (the whole point vs `read_file`).
570        tokio::fs::write(dir.path().join("over.txt"), &"a".repeat(100))
571            .await
572            .unwrap();
573        let res = env.read_file_full(Path::new("over.txt"), 50).await;
574        assert!(res.is_err(), "oversized file must error, not truncate");
575        match res {
576            Err(RuntimeError::FileTooLarge { size, max, .. }) => {
577                assert_eq!(size, 100);
578                assert_eq!(max, 50);
579            }
580            other => panic!("expected FileTooLarge, got {other:?}"),
581        }
582    }
583
584    #[tokio::test]
585    async fn write_then_read_roundtrips() {
586        let dir = tempfile::tempdir().unwrap();
587        let env = LocalSessionEnv::new(dir.path(), Limits::default())
588            .await
589            .unwrap();
590        env.write_file(Path::new("sub/nested/file.txt"), "deep content")
591            .await
592            .unwrap();
593        let got = env
594            .read_file(Path::new("sub/nested/file.txt"), 100, 1024)
595            .await
596            .unwrap();
597        assert_eq!(got, "deep content");
598    }
599
600    #[tokio::test]
601    async fn exec_runs_shell_command() {
602        let dir = tempfile::tempdir().unwrap();
603        let env = LocalSessionEnv::new(dir.path(), Limits::default())
604            .await
605            .unwrap();
606        let res = env
607            .exec(
608                "echo hello",
609                Path::new("."),
610                None,
611                &CancellationToken::new(),
612            )
613            .await
614            .unwrap();
615        assert_eq!(res.exit_code, 0);
616        assert_eq!(res.stdout.trim(), "hello");
617    }
618
619    #[tokio::test]
620    async fn exec_timeout_returns_124() {
621        let dir = tempfile::tempdir().unwrap();
622        let env = LocalSessionEnv::new(dir.path(), Limits::default())
623            .await
624            .unwrap();
625        let res = env
626            .exec(
627                "sleep 5",
628                Path::new("."),
629                Some(200),
630                &CancellationToken::new(),
631            )
632            .await
633            .unwrap();
634        assert_eq!(res.exit_code, 124, "timeout must yield exit 124");
635    }
636
637    #[test]
638    fn glob_matcher_basics() {
639        assert!(matches_glob("foo.txt", "*.txt"));
640        assert!(matches_glob("foo.txt", "foo.*"));
641        assert!(!matches_glob("foo.txt", "*.md"));
642        assert!(matches_glob("a", "?"));
643    }
644
645    #[test]
646    fn read_limit_truncates() {
647        let got = apply_read_limits("a\nb\nc\nd\n".into(), 2, 1024);
648        assert!(got.contains("a"));
649        assert!(got.contains("b"));
650        assert!(got.contains("truncated"));
651    }
652
653    #[tokio::test]
654    async fn glob_rejects_absolute_pattern() {
655        let dir = tempfile::tempdir().unwrap();
656        let env = LocalSessionEnv::new(dir.path(), Limits::default())
657            .await
658            .unwrap();
659        let res = env.glob("/etc/*", 10).await;
660        assert!(res.is_err(), "absolute glob patterns must be rejected");
661    }
662
663    #[tokio::test]
664    async fn glob_rejects_parent_dir_pattern() {
665        let dir = tempfile::tempdir().unwrap();
666        let env = LocalSessionEnv::new(dir.path(), Limits::default())
667            .await
668            .unwrap();
669        let res = env.glob("../**/*", 10).await;
670        assert!(res.is_err(), "`..` in glob patterns must be rejected");
671    }
672
673    #[tokio::test]
674    async fn grep_rejects_absolute_path() {
675        let dir = tempfile::tempdir().unwrap();
676        let env = LocalSessionEnv::new(dir.path(), Limits::default())
677            .await
678            .unwrap();
679        let res = env.grep("foo", &["/etc/passwd"], 10).await;
680        assert!(res.is_err(), "absolute grep paths must be rejected");
681    }
682
683    #[tokio::test]
684    async fn grep_rejects_parent_dir_path() {
685        let dir = tempfile::tempdir().unwrap();
686        let env = LocalSessionEnv::new(dir.path(), Limits::default())
687            .await
688            .unwrap();
689        let res = env.grep("foo", &["../.env"], 10).await;
690        assert!(res.is_err(), "`..` grep paths must be rejected");
691    }
692
693    // ── B-Swift Phase C1a / #4: fd-anchored read TOCTOU / hardlink coverage ──
694    // These prove the fix: the OLD path-based `read_to_string(resolved)` followed
695    // symlinks (leaking the target) and ignored `st_nlink`, so each of these
696    // would have SUCCEEDED (exfiltrated the secret) before the fix.
697
698    /// Write a secret to a file OUTSIDE the env root (a sibling temp dir) and
699    /// return both the held `TempDir` (keep alive for the test) and its path.
700    #[cfg(unix)]
701    fn outside_secret(body: &str) -> (tempfile::TempDir, PathBuf) {
702        use std::io::Write;
703        let dir = tempfile::tempdir().unwrap();
704        let path = dir.path().join("secret.txt");
705        let mut f = std::fs::File::create(&path).unwrap();
706        f.write_all(body.as_bytes()).unwrap();
707        (dir, path)
708    }
709
710    #[cfg(unix)]
711    #[tokio::test]
712    async fn read_file_rejects_symlink_leaf_even_when_target_inside_root() {
713        use std::os::unix::fs::symlink;
714        let dir = tempfile::tempdir().unwrap();
715        let env = LocalSessionEnv::new(dir.path(), Limits::default())
716            .await
717            .unwrap();
718        tokio::fs::write(dir.path().join("inside.txt"), "ok\n")
719            .await
720            .unwrap();
721        symlink("inside.txt", dir.path().join("link.txt")).unwrap();
722        let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
723        assert!(
724            res.is_err(),
725            "a symlink leaf must be rejected even if its target is inside the root"
726        );
727    }
728
729    #[cfg(unix)]
730    #[tokio::test]
731    async fn read_file_rejects_symlink_leaf_to_outside_root() {
732        // Exfil via symlink: link.txt -> /outside/secret. The OLD read followed
733        // it and leaked "TOPSECRET"; the anchored `openat(O_NOFOLLOW)` rejects
734        // the symlink leaf outright.
735        use std::os::unix::fs::symlink;
736        let dir = tempfile::tempdir().unwrap();
737        let env = LocalSessionEnv::new(dir.path(), Limits::default())
738            .await
739            .unwrap();
740        let (_outside, secret) = outside_secret("TOPSECRET");
741        symlink(&secret, dir.path().join("link.txt")).unwrap();
742        let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
743        assert!(
744            res.is_err(),
745            "a symlink to outside the root must be rejected"
746        );
747        if let Ok(s) = res {
748            assert!(!s.contains("TOPSECRET"), "the secret must not leak");
749        }
750    }
751
752    #[cfg(unix)]
753    #[tokio::test]
754    async fn read_file_rejects_intermediate_symlink_dir() {
755        // Exfil via a symlinked intermediate dir: linkdir -> realdir; reading
756        // `linkdir/file.txt` must reject at the `linkdir` component (per-component
757        // `openat(O_NOFOLLOW)`).
758        use std::os::unix::fs::symlink;
759        let dir = tempfile::tempdir().unwrap();
760        let env = LocalSessionEnv::new(dir.path(), Limits::default())
761            .await
762            .unwrap();
763        tokio::fs::create_dir_all(dir.path().join("realdir"))
764            .await
765            .unwrap();
766        tokio::fs::write(dir.path().join("realdir/file.txt"), "ok\n")
767            .await
768            .unwrap();
769        symlink("realdir", dir.path().join("linkdir")).unwrap();
770        let res = env
771            .read_file(Path::new("linkdir/file.txt"), 100, 1024)
772            .await;
773        assert!(
774            res.is_err(),
775            "a symlinked intermediate dir must be rejected"
776        );
777    }
778
779    #[cfg(unix)]
780    #[tokio::test]
781    async fn read_file_rejects_hardlink_to_outside_secret() {
782        // Hardlink exfil: `ln /outside/secret root/link.txt`. The file is regular
783        // and inside the root, but `st_nlink > 1` → reject (mirrors the Swift
784        // C2/#3 decision; authoritative here via post-open `fstat`).
785        let dir = tempfile::tempdir().unwrap();
786        let env = LocalSessionEnv::new(dir.path(), Limits::default())
787            .await
788            .unwrap();
789        let (_outside, secret) = outside_secret("TOPSECRET");
790        std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
791        let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
792        assert!(res.is_err(), "a hardlink (st_nlink > 1) must be rejected");
793        if let Ok(s) = res {
794            assert!(!s.contains("TOPSECRET"), "the secret must not leak");
795        }
796    }
797
798    #[cfg(unix)]
799    #[tokio::test]
800    async fn read_file_full_rejects_symlink_leaf() {
801        use std::os::unix::fs::symlink;
802        let dir = tempfile::tempdir().unwrap();
803        let env = LocalSessionEnv::new(dir.path(), Limits::default())
804            .await
805            .unwrap();
806        let (_outside, secret) = outside_secret("TOPSECRET");
807        symlink(&secret, dir.path().join("link.txt")).unwrap();
808        let res = env.read_file_full(Path::new("link.txt"), 1024).await;
809        assert!(res.is_err(), "read_file_full must reject a symlink leaf");
810        if let Ok(s) = res {
811            assert!(!s.contains("TOPSECRET"));
812        }
813    }
814
815    #[cfg(unix)]
816    #[tokio::test]
817    async fn read_file_full_rejects_hardlink() {
818        let dir = tempfile::tempdir().unwrap();
819        let env = LocalSessionEnv::new(dir.path(), Limits::default())
820            .await
821            .unwrap();
822        let (_outside, secret) = outside_secret("TOPSECRET");
823        std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
824        let res = env.read_file_full(Path::new("link.txt"), 1024).await;
825        assert!(
826            res.is_err(),
827            "read_file_full must reject a hardlink (st_nlink > 1)"
828        );
829    }
830
831    #[cfg(unix)]
832    #[tokio::test]
833    async fn read_anchored_nested_relative_path_still_works() {
834        // Regression guard: the anchored walk must still read a real nested
835        // file (intermediate dirs are opened `O_NOFOLLOW` + read off the leaf fd).
836        let dir = tempfile::tempdir().unwrap();
837        let env = LocalSessionEnv::new(dir.path(), Limits::default())
838            .await
839            .unwrap();
840        tokio::fs::create_dir_all(dir.path().join("a/b"))
841            .await
842            .unwrap();
843        tokio::fs::write(dir.path().join("a/b/c.txt"), "deep\n")
844            .await
845            .unwrap();
846        let got = env
847            .read_file(Path::new("a/b/c.txt"), 100, 1024)
848            .await
849            .unwrap();
850        assert_eq!(got, "deep\n");
851    }
852}