1use 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#[cfg(target_os = "macos")]
29use rustix::fs::getpath;
30#[cfg(target_os = "linux")]
32use std::os::fd::AsRawFd;
33
34use crate::env::{Limits, SessionEnv, ShellResult};
35use crate::error::{RuntimeError, RuntimeResult};
36
37const ST_MODE_TYPE_MASK: u32 = 0o170_000; const ST_MODE_REGULAR: u32 = 0o100_000; pub struct LocalSessionEnv {
44 root_fd: OwnedFd,
51 #[allow(dead_code)]
52 limits: Limits,
53}
54
55impl LocalSessionEnv {
56 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 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 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 _ => None,
101 })
102 .collect())
103 }
104
105 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 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 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 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 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 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 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 fn search_path_inode(&self, p: &str) -> RuntimeResult<PathBuf> {
237 let names = self.normal_components(Path::new(p))?;
238 if names.is_empty() {
239 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 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 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 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 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 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 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 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 let (file, _size) = self.open_anchored_read(path)?;
403 let (raw, truncated_at_cap) = read_bounded_string(file, max_bytes).await?;
404 let mut out = apply_read_limits(raw, max_lines, max_bytes);
405 if truncated_at_cap && !out.contains("[... truncated") {
410 out.push_str(&format!("\n[... truncated at {max_bytes} bytes ...]"));
411 }
412 Ok(out)
413 }
414
415 async fn read_file_full(&self, path: &Path, max_bytes: usize) -> RuntimeResult<String> {
416 let (file, size) = self.open_anchored_read(path)?;
420 let size = size as usize;
421 if size > max_bytes {
422 return Err(RuntimeError::FileTooLarge {
423 path: path.display().to_string(),
424 size,
425 max: max_bytes,
426 });
427 }
428 let mut file = tokio::fs::File::from_std(file);
429 let mut raw = String::new();
430 file.read_to_string(&mut raw)
431 .await
432 .map_err(RuntimeError::Io)?;
433 Ok(raw)
434 }
435
436 async fn write_file(&self, path: &Path, content: &str) -> RuntimeResult<()> {
437 let leaf_fd = self.open_anchored_write(path)?;
441 ftruncate(&leaf_fd, 0).map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
443 let mut file = tokio::fs::File::from_std(std::fs::File::from(leaf_fd));
444 file.write_all(content.as_bytes())
445 .await
446 .map_err(RuntimeError::Io)?;
447 file.flush().await.map_err(RuntimeError::Io)?;
455 Ok(())
456 }
457
458 async fn exec(
459 &self,
460 command: &str,
461 cwd: &Path,
462 timeout_ms: Option<u64>,
463 cancel: &CancellationToken,
464 ) -> RuntimeResult<ShellResult> {
465 let cwd_fd = self.open_anchored_dir(cwd)?;
474 let cwd_path = Self::fd_real_path(cwd_fd.as_fd())?;
475
476 let child = Command::new("sh")
487 .arg("-c")
488 .arg(command)
489 .current_dir(&cwd_path)
490 .env_clear()
491 .envs(safe_exec_env())
492 .stdout(std::process::Stdio::piped())
493 .stderr(std::process::Stdio::piped())
494 .kill_on_drop(true)
495 .spawn()
496 .map_err(RuntimeError::Io)?;
497 let timeout_fut = match timeout_ms {
500 Some(ms) => Box::pin(tokio::time::sleep(std::time::Duration::from_millis(ms)))
501 as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
502 None => Box::pin(std::future::pending()),
503 };
504 let cancel_fut = cancel.cancelled();
505
506 tokio::select! {
512 _ = timeout_fut => {
513 Ok(ShellResult {
516 exit_code: 124,
517 stdout: String::new(),
518 stderr: format!("command timed out after {}ms", timeout_ms.unwrap_or(0)),
519 })
520 }
521 _ = cancel_fut => {
522 Err(RuntimeError::Sandbox("command cancelled".into()))
523 }
524 output = child.wait_with_output() => {
525 let output = output.map_err(RuntimeError::Io)?;
526 Ok(ShellResult {
527 exit_code: output.status.code().unwrap_or(-1),
528 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
529 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
530 })
531 }
532 }
533 }
534
535 async fn glob(&self, pattern: &str, limit: usize) -> RuntimeResult<Vec<String>> {
536 validate_search_pattern(pattern)?;
539 let pat_path = Path::new(pattern);
544 let base_rel = pat_path.parent().unwrap_or_else(|| Path::new(""));
545 let fname = pat_path.file_name().and_then(|s| s.to_str()).unwrap_or("*");
546 let base_prefix = self
551 .normal_components(base_rel)?
552 .iter()
553 .map(|s| s.to_string_lossy().into_owned())
554 .collect::<Vec<_>>()
555 .join("/");
556 let base_fd = match self.open_anchored_dir(base_rel) {
559 Ok(fd) => fd,
560 Err(_) => return Ok(Vec::new()),
561 };
562 let dir = match Dir::new(base_fd) {
563 Ok(d) => d,
564 Err(_) => return Ok(Vec::new()),
565 };
566 let mut results: Vec<String> = Vec::new();
567 walk_glob_fd(dir, fname, &base_prefix, &mut results, limit)?;
568 results.sort();
569 results.dedup();
571 Ok(results)
572 }
573
574 async fn grep(
575 &self,
576 pattern: &str,
577 paths: &[&str],
578 max_matches: usize,
579 ) -> RuntimeResult<Vec<String>> {
580 let root_path = Self::fd_real_path(self.root_fd.as_fd())?;
588 let mut validated: Vec<String> = Vec::new();
589 if paths.is_empty() {
590 validated.push(shell_quote(&root_path.to_string_lossy()));
591 } else {
592 for p in paths {
593 validate_search_pattern(p)?;
594 let inode = self.search_path_inode(p)?;
595 validated.push(shell_quote(&inode.to_string_lossy()));
596 }
597 }
598 let search = validated.join(" ");
599 const GREP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
606 let child = Command::new("sh")
607 .arg("-c")
608 .arg(format!(
609 "rg -n --no-follow -- {pat} {search} 2>/dev/null \
610 || find -P {search} -type f -exec grep -Hn -- {pat} {{}} + 2>/dev/null",
611 pat = shell_quote(pattern),
612 ))
613 .current_dir(&root_path)
614 .stdout(std::process::Stdio::piped())
615 .stderr(std::process::Stdio::piped())
616 .kill_on_drop(true)
617 .spawn()
618 .map_err(RuntimeError::Io)?;
619 let rg = match tokio::time::timeout(GREP_TIMEOUT, child.wait_with_output()).await {
620 Ok(res) => res.map_err(RuntimeError::Io)?,
621 Err(_) => return Ok(Vec::new()),
624 };
625 let out = String::from_utf8_lossy(&rg.stdout);
626 let root_prefix = format!("{}/", root_path.to_string_lossy());
631 Ok(out
632 .lines()
633 .map(|l| {
634 l.strip_prefix(root_prefix.as_str())
635 .unwrap_or(l)
636 .to_string()
637 })
638 .take(max_matches)
639 .collect())
640 }
641}
642
643fn apply_read_limits(raw: String, max_lines: usize, max_bytes: usize) -> String {
645 let mut bytes_left = max_bytes;
646 let mut out = String::new();
647 let mut truncated = false;
648 for (i, line) in raw.split_inclusive('\n').enumerate() {
649 if i >= max_lines {
650 out.push_str(&format!("\n[... truncated at {max_lines} lines ...]"));
651 truncated = true;
652 break;
653 }
654 if bytes_left < line.len() {
655 let take = line
657 .char_indices()
658 .map(|(i, _)| i)
659 .find(|&pos| pos > bytes_left)
660 .unwrap_or(line.len());
661 out.push_str(line.get(..take).unwrap_or(line));
662 out.push_str(&format!("\n[... truncated at {max_bytes} bytes ...]"));
663 truncated = true;
664 break;
665 }
666 out.push_str(line);
667 bytes_left -= line.len();
668 }
669 if truncated {
670 out
671 } else {
672 raw
673 }
674}
675
676async fn read_bounded_string(
690 file: std::fs::File,
691 max_bytes: usize,
692) -> RuntimeResult<(String, bool)> {
693 let file = tokio::fs::File::from_std(file);
694 let mut buf: Vec<u8> = Vec::with_capacity(max_bytes.min(8 * 1024));
695 file.take(max_bytes as u64)
696 .read_to_end(&mut buf)
697 .await
698 .map_err(RuntimeError::Io)?;
699 let read_full = buf.len() < max_bytes;
702 let truncated_at_cap = !read_full;
703 match std::str::from_utf8(&buf) {
704 Ok(s) => Ok((s.to_string(), truncated_at_cap)),
705 Err(e) => {
706 let vu = e.valid_up_to();
707 if read_full {
708 Err(RuntimeError::Io(std::io::Error::new(
709 std::io::ErrorKind::InvalidData,
710 "stream did not contain valid UTF-8",
711 )))
712 } else {
713 Ok((
716 std::str::from_utf8(&buf[..vu])
717 .map(str::to_string)
718 .unwrap_or_default(),
719 truncated_at_cap,
720 ))
721 }
722 }
723 }
724}
725
726fn walk_glob_fd(
735 mut dir: Dir,
736 fname_pat: &str,
737 rel_prefix: &str,
738 out: &mut Vec<String>,
739 limit: usize,
740) -> RuntimeResult<()> {
741 let mut entries: Vec<(String, FileType)> = Vec::new();
745 for res in &mut dir {
746 match res {
747 Ok(e) => {
748 let name = e.file_name().to_string_lossy().into_owned();
749 if name == "." || name == ".." {
750 continue;
751 }
752 entries.push((name, e.file_type()));
753 }
754 Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
755 }
756 }
757 if out.len() >= limit {
758 return Ok(());
759 }
760 let parent_fd = dir
763 .fd()
764 .map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
765 for (name, ftype) in entries {
766 if out.len() >= limit {
767 return Ok(());
768 }
769 let rel = if rel_prefix.is_empty() {
770 name.clone()
771 } else {
772 format!("{rel_prefix}/{name}")
773 };
774 if matches_glob(&name, fname_pat) {
775 out.push(rel.clone());
776 }
777 if ftype.is_dir() {
781 if let Ok(child_fd) = openat(
782 parent_fd,
783 name.as_str(),
784 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
785 Mode::empty(),
786 ) {
787 if let Ok(child_dir) = Dir::new(child_fd) {
788 walk_glob_fd(child_dir, fname_pat, &rel, out, limit)?;
789 }
790 }
791 }
793 }
794 Ok(())
795}
796
797fn matches_glob(name: &str, pat: &str) -> bool {
799 let name_b = name.as_bytes();
800 let pat_b = pat.as_bytes();
801 matches_at(name_b, pat_b, 0, 0)
802}
803
804fn matches_at(n: &[u8], p: &[u8], mut ni: usize, mut pi: usize) -> bool {
805 let mut star: Option<(usize, usize)> = None;
806 while ni < n.len() {
807 if pi < p.len() && (p[pi] == b'?' || p[pi] == b'*') {
808 if p[pi] == b'*' {
809 star = Some((pi, ni));
810 pi += 1;
811 continue;
812 }
813 pi += 1;
814 ni += 1;
815 } else if pi < p.len() && p[pi] == n[ni] {
816 pi += 1;
817 ni += 1;
818 } else if let Some((sp, sn)) = star {
819 pi = sp + 1;
820 ni = sn + 1;
821 star = Some((sp, sn + 1));
822 } else {
823 return false;
824 }
825 }
826 while pi < p.len() && p[pi] == b'*' {
827 pi += 1;
828 }
829 pi == p.len()
830}
831
832fn validate_search_pattern(input: &str) -> RuntimeResult<()> {
838 if input.starts_with('/') || input.starts_with('\\') {
840 return Err(RuntimeError::Sandbox(format!(
841 "absolute paths are not allowed: `{input}`"
842 )));
843 }
844 for seg in input.split('/') {
846 if seg == ".." {
847 return Err(RuntimeError::Sandbox(format!(
848 "`..` is not allowed in search paths: `{input}`"
849 )));
850 }
851 }
852 Ok(())
853}
854
855fn shell_quote(s: &str) -> String {
857 format!("'{}'", s.replace('\'', "'\\''"))
858}
859
860fn safe_exec_env() -> Vec<(String, std::ffi::OsString)> {
867 let mut out: Vec<(String, std::ffi::OsString)> = Vec::new();
868 for name in ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR"] {
870 if let Some(v) = std::env::var_os(name) {
871 out.push((name.to_string(), v));
872 }
873 }
874 for (k, v) in std::env::vars_os() {
878 let key = k.to_string_lossy().into_owned();
879 if matches!(key.as_str(), "TZ" | "LANG" | "LANGUAGE") || key.starts_with("LC_") {
880 out.push((key, v));
881 }
882 }
883 out
884}
885
886#[cfg(test)]
887mod tests {
888 use super::*;
891
892 #[tokio::test]
893 async fn read_file_within_root_works() {
894 let dir = tempfile::tempdir().unwrap();
895 let env = LocalSessionEnv::new(dir.path(), Limits::default())
896 .await
897 .unwrap();
898 tokio::fs::write(dir.path().join("hello.txt"), "hi there\n")
899 .await
900 .unwrap();
901 let got = env
902 .read_file(Path::new("hello.txt"), 100, 1024)
903 .await
904 .unwrap();
905 assert_eq!(got, "hi there\n");
906 }
907
908 #[tokio::test]
909 async fn read_file_rejects_absolute_path() {
910 let dir = tempfile::tempdir().unwrap();
911 let env = LocalSessionEnv::new(dir.path(), Limits::default())
912 .await
913 .unwrap();
914 let res = env.read_file(Path::new("/etc/passwd"), 100, 1024).await;
915 assert!(res.is_err(), "absolute paths must be rejected");
916 }
917
918 #[tokio::test]
919 async fn read_file_rejects_parent_dir() {
920 let dir = tempfile::tempdir().unwrap();
921 let env = LocalSessionEnv::new(dir.path(), Limits::default())
922 .await
923 .unwrap();
924 let res = env.read_file(Path::new("../escape.txt"), 100, 1024).await;
925 assert!(res.is_err(), "`..` must be rejected");
926 }
927
928 #[tokio::test]
929 async fn read_file_full_returns_complete_content_without_truncation() {
930 let dir = tempfile::tempdir().unwrap();
931 let env = LocalSessionEnv::new(dir.path(), Limits::default())
932 .await
933 .unwrap();
934 let body = (0..10)
938 .map(|i| format!("line number {i:02} with some padding text\n"))
939 .collect::<String>();
940 tokio::fs::write(dir.path().join("big.txt"), &body)
941 .await
942 .unwrap();
943 let got = env
944 .read_file_full(Path::new("big.txt"), 1024)
945 .await
946 .unwrap();
947 assert_eq!(got, body);
948 assert!(!got.contains("[... truncated"));
949 }
950
951 #[tokio::test]
952 async fn read_file_full_rejects_absolute_path() {
953 let dir = tempfile::tempdir().unwrap();
954 let env = LocalSessionEnv::new(dir.path(), Limits::default())
955 .await
956 .unwrap();
957 let res = env.read_file_full(Path::new("/etc/passwd"), 1024).await;
958 assert!(res.is_err(), "absolute paths must be rejected");
959 }
960
961 #[tokio::test]
962 async fn read_file_full_rejects_parent_dir() {
963 let dir = tempfile::tempdir().unwrap();
964 let env = LocalSessionEnv::new(dir.path(), Limits::default())
965 .await
966 .unwrap();
967 let res = env.read_file_full(Path::new("../escape.txt"), 1024).await;
968 assert!(res.is_err(), "`..` must be rejected");
969 }
970
971 #[tokio::test]
972 async fn read_file_full_errors_when_too_large_not_truncated() {
973 let dir = tempfile::tempdir().unwrap();
974 let env = LocalSessionEnv::new(dir.path(), Limits::default())
975 .await
976 .unwrap();
977 tokio::fs::write(dir.path().join("over.txt"), &"a".repeat(100))
980 .await
981 .unwrap();
982 let res = env.read_file_full(Path::new("over.txt"), 50).await;
983 assert!(res.is_err(), "oversized file must error, not truncate");
984 match res {
985 Err(RuntimeError::FileTooLarge { size, max, .. }) => {
986 assert_eq!(size, 100);
987 assert_eq!(max, 50);
988 }
989 other => panic!("expected FileTooLarge, got {other:?}"),
990 }
991 }
992
993 #[tokio::test]
994 async fn write_then_read_roundtrips() {
995 let dir = tempfile::tempdir().unwrap();
996 let env = LocalSessionEnv::new(dir.path(), Limits::default())
997 .await
998 .unwrap();
999 env.write_file(Path::new("sub/nested/file.txt"), "deep content")
1000 .await
1001 .unwrap();
1002 let got = env
1003 .read_file(Path::new("sub/nested/file.txt"), 100, 1024)
1004 .await
1005 .unwrap();
1006 assert_eq!(got, "deep content");
1007 }
1008
1009 #[tokio::test]
1010 async fn read_file_bounded_read_does_not_oom_on_large_file() {
1011 let dir = tempfile::tempdir().unwrap();
1016 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1017 .await
1018 .unwrap();
1019 let body = "a".repeat(100 * 1024);
1022 tokio::fs::write(dir.path().join("big.txt"), &body)
1023 .await
1024 .unwrap();
1025 let got = env
1026 .read_file(Path::new("big.txt"), 10_000, 64)
1027 .await
1028 .unwrap();
1029 assert!(
1030 got.contains("[... truncated at 64 bytes"),
1031 "expected a byte-cap truncation marker: {got:?}"
1032 );
1033 assert!(got.len() < 128, "output must be bounded near max_bytes");
1034 }
1035
1036 #[tokio::test]
1037 async fn read_file_bounded_read_trims_multibyte_boundary() {
1038 let dir = tempfile::tempdir().unwrap();
1041 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1042 .await
1043 .unwrap();
1044 let body = "é".repeat(10);
1048 tokio::fs::write(dir.path().join("accent.txt"), body.as_bytes())
1049 .await
1050 .unwrap();
1051 let got = env
1052 .read_file(Path::new("accent.txt"), 10_000, 11)
1053 .await
1054 .unwrap();
1055 assert!(
1057 got.starts_with("ééééé"),
1058 "trimmed prefix should be whole chars"
1059 );
1060 }
1061
1062 #[tokio::test]
1063 async fn exec_runs_shell_command() {
1064 let dir = tempfile::tempdir().unwrap();
1065 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1066 .await
1067 .unwrap();
1068 let res = env
1069 .exec(
1070 "echo hello",
1071 Path::new("."),
1072 None,
1073 &CancellationToken::new(),
1074 )
1075 .await
1076 .unwrap();
1077 assert_eq!(res.exit_code, 0);
1078 assert_eq!(res.stdout.trim(), "hello");
1079 }
1080
1081 #[tokio::test]
1082 async fn exec_does_not_leak_parent_env_secrets() {
1083 let dir = tempfile::tempdir().unwrap();
1087 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1088 .await
1089 .unwrap();
1090 std::env::set_var("FLUERS_TEST_SECRET", "leak-me-if-you-can");
1091 let res = env
1092 .exec("env", Path::new("."), None, &CancellationToken::new())
1093 .await
1094 .unwrap();
1095 assert_eq!(res.exit_code, 0, "env should run");
1096 assert!(
1097 !res.stdout.contains("FLUERS_TEST_SECRET"),
1098 "parent env secret must not leak into the model-run shell"
1099 );
1100 assert!(
1101 !res.stdout.contains("leak-me-if-you-can"),
1102 "the secret value must not appear in the child env"
1103 );
1104 std::env::remove_var("FLUERS_TEST_SECRET");
1105 }
1106
1107 #[tokio::test]
1108 async fn exec_does_not_leak_lang_prefixed_secrets() {
1109 let dir = tempfile::tempdir().unwrap();
1113 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1114 .await
1115 .unwrap();
1116 std::env::set_var("LANGCHAIN_API_KEY", "lang-prefixed-secret");
1117 let res = env
1118 .exec("env", Path::new("."), None, &CancellationToken::new())
1119 .await
1120 .unwrap();
1121 std::env::remove_var("LANGCHAIN_API_KEY");
1122 assert!(
1123 !res.stdout.contains("LANGCHAIN_API_KEY"),
1124 "a LANG-prefixed secret must not leak into the model-run shell"
1125 );
1126 assert!(
1127 !res.stdout.contains("lang-prefixed-secret"),
1128 "the LANG-prefixed secret value must not appear in the child env"
1129 );
1130 }
1131
1132 #[tokio::test]
1133 async fn exec_timeout_returns_124() {
1134 let dir = tempfile::tempdir().unwrap();
1135 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1136 .await
1137 .unwrap();
1138 let res = env
1139 .exec(
1140 "sleep 5",
1141 Path::new("."),
1142 Some(200),
1143 &CancellationToken::new(),
1144 )
1145 .await
1146 .unwrap();
1147 assert_eq!(res.exit_code, 124, "timeout must yield exit 124");
1148 }
1149
1150 #[test]
1151 fn glob_matcher_basics() {
1152 assert!(matches_glob("foo.txt", "*.txt"));
1153 assert!(matches_glob("foo.txt", "foo.*"));
1154 assert!(!matches_glob("foo.txt", "*.md"));
1155 assert!(matches_glob("a", "?"));
1156 }
1157
1158 #[test]
1159 fn read_limit_truncates() {
1160 let got = apply_read_limits("a\nb\nc\nd\n".into(), 2, 1024);
1161 assert!(got.contains("a"));
1162 assert!(got.contains("b"));
1163 assert!(got.contains("truncated"));
1164 }
1165
1166 #[tokio::test]
1167 async fn glob_rejects_absolute_pattern() {
1168 let dir = tempfile::tempdir().unwrap();
1169 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1170 .await
1171 .unwrap();
1172 let res = env.glob("/etc/*", 10).await;
1173 assert!(res.is_err(), "absolute glob patterns must be rejected");
1174 }
1175
1176 #[tokio::test]
1177 async fn glob_rejects_parent_dir_pattern() {
1178 let dir = tempfile::tempdir().unwrap();
1179 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1180 .await
1181 .unwrap();
1182 let res = env.glob("../**/*", 10).await;
1183 assert!(res.is_err(), "`..` in glob patterns must be rejected");
1184 }
1185
1186 #[tokio::test]
1187 async fn grep_rejects_absolute_path() {
1188 let dir = tempfile::tempdir().unwrap();
1189 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1190 .await
1191 .unwrap();
1192 let res = env.grep("foo", &["/etc/passwd"], 10).await;
1193 assert!(res.is_err(), "absolute grep paths must be rejected");
1194 }
1195
1196 #[tokio::test]
1197 async fn grep_rejects_parent_dir_path() {
1198 let dir = tempfile::tempdir().unwrap();
1199 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1200 .await
1201 .unwrap();
1202 let res = env.grep("foo", &["../.env"], 10).await;
1203 assert!(res.is_err(), "`..` grep paths must be rejected");
1204 }
1205
1206 #[cfg(unix)]
1214 fn outside_secret(body: &str) -> (tempfile::TempDir, PathBuf) {
1215 use std::io::Write;
1216 let dir = tempfile::tempdir().unwrap();
1217 let path = dir.path().join("secret.txt");
1218 let mut f = std::fs::File::create(&path).unwrap();
1219 f.write_all(body.as_bytes()).unwrap();
1220 (dir, path)
1221 }
1222
1223 #[cfg(unix)]
1224 #[tokio::test]
1225 async fn read_file_rejects_symlink_leaf_even_when_target_inside_root() {
1226 use std::os::unix::fs::symlink;
1227 let dir = tempfile::tempdir().unwrap();
1228 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1229 .await
1230 .unwrap();
1231 tokio::fs::write(dir.path().join("inside.txt"), "ok\n")
1232 .await
1233 .unwrap();
1234 symlink("inside.txt", dir.path().join("link.txt")).unwrap();
1235 let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
1236 assert!(
1237 res.is_err(),
1238 "a symlink leaf must be rejected even if its target is inside the root"
1239 );
1240 }
1241
1242 #[cfg(unix)]
1243 #[tokio::test]
1244 async fn read_file_rejects_symlink_leaf_to_outside_root() {
1245 use std::os::unix::fs::symlink;
1249 let dir = tempfile::tempdir().unwrap();
1250 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1251 .await
1252 .unwrap();
1253 let (_outside, secret) = outside_secret("TOPSECRET");
1254 symlink(&secret, dir.path().join("link.txt")).unwrap();
1255 let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
1256 assert!(
1257 res.is_err(),
1258 "a symlink to outside the root must be rejected"
1259 );
1260 if let Ok(s) = res {
1261 assert!(!s.contains("TOPSECRET"), "the secret must not leak");
1262 }
1263 }
1264
1265 #[cfg(unix)]
1266 #[tokio::test]
1267 async fn read_file_rejects_intermediate_symlink_dir() {
1268 use std::os::unix::fs::symlink;
1272 let dir = tempfile::tempdir().unwrap();
1273 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1274 .await
1275 .unwrap();
1276 tokio::fs::create_dir_all(dir.path().join("realdir"))
1277 .await
1278 .unwrap();
1279 tokio::fs::write(dir.path().join("realdir/file.txt"), "ok\n")
1280 .await
1281 .unwrap();
1282 symlink("realdir", dir.path().join("linkdir")).unwrap();
1283 let res = env
1284 .read_file(Path::new("linkdir/file.txt"), 100, 1024)
1285 .await;
1286 assert!(
1287 res.is_err(),
1288 "a symlinked intermediate dir must be rejected"
1289 );
1290 }
1291
1292 #[cfg(unix)]
1293 #[tokio::test]
1294 async fn read_file_rejects_hardlink_to_outside_secret() {
1295 let dir = tempfile::tempdir().unwrap();
1299 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1300 .await
1301 .unwrap();
1302 let (_outside, secret) = outside_secret("TOPSECRET");
1303 std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
1304 let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
1305 assert!(res.is_err(), "a hardlink (st_nlink > 1) must be rejected");
1306 if let Ok(s) = res {
1307 assert!(!s.contains("TOPSECRET"), "the secret must not leak");
1308 }
1309 }
1310
1311 #[cfg(unix)]
1312 #[tokio::test]
1313 async fn read_file_full_rejects_symlink_leaf() {
1314 use std::os::unix::fs::symlink;
1315 let dir = tempfile::tempdir().unwrap();
1316 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1317 .await
1318 .unwrap();
1319 let (_outside, secret) = outside_secret("TOPSECRET");
1320 symlink(&secret, dir.path().join("link.txt")).unwrap();
1321 let res = env.read_file_full(Path::new("link.txt"), 1024).await;
1322 assert!(res.is_err(), "read_file_full must reject a symlink leaf");
1323 if let Ok(s) = res {
1324 assert!(!s.contains("TOPSECRET"));
1325 }
1326 }
1327
1328 #[cfg(unix)]
1329 #[tokio::test]
1330 async fn read_file_full_rejects_hardlink() {
1331 let dir = tempfile::tempdir().unwrap();
1332 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1333 .await
1334 .unwrap();
1335 let (_outside, secret) = outside_secret("TOPSECRET");
1336 std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
1337 let res = env.read_file_full(Path::new("link.txt"), 1024).await;
1338 assert!(
1339 res.is_err(),
1340 "read_file_full must reject a hardlink (st_nlink > 1)"
1341 );
1342 }
1343
1344 #[cfg(unix)]
1345 #[tokio::test]
1346 async fn read_anchored_nested_relative_path_still_works() {
1347 let dir = tempfile::tempdir().unwrap();
1350 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1351 .await
1352 .unwrap();
1353 tokio::fs::create_dir_all(dir.path().join("a/b"))
1354 .await
1355 .unwrap();
1356 tokio::fs::write(dir.path().join("a/b/c.txt"), "deep\n")
1357 .await
1358 .unwrap();
1359 let got = env
1360 .read_file(Path::new("a/b/c.txt"), 100, 1024)
1361 .await
1362 .unwrap();
1363 assert_eq!(got, "deep\n");
1364 }
1365
1366 #[cfg(unix)]
1374 #[tokio::test]
1375 async fn write_file_rejects_symlink_leaf_pointing_inside() {
1376 use std::os::unix::fs::symlink;
1381 let dir = tempfile::tempdir().unwrap();
1382 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1383 .await
1384 .unwrap();
1385 tokio::fs::write(dir.path().join("target.txt"), "ORIGINAL")
1386 .await
1387 .unwrap();
1388 symlink("target.txt", dir.path().join("link.txt")).unwrap();
1389 let res = env.write_file(Path::new("link.txt"), "OVERWRITE").await;
1390 assert!(
1391 res.is_err(),
1392 "writing through a symlink leaf must be rejected"
1393 );
1394 let got = tokio::fs::read_to_string(dir.path().join("target.txt"))
1395 .await
1396 .unwrap();
1397 assert_eq!(
1398 got, "ORIGINAL",
1399 "the symlink target must not be overwritten"
1400 );
1401 }
1402
1403 #[cfg(unix)]
1404 #[tokio::test]
1405 async fn write_file_rejects_symlinked_intermediate_dir() {
1406 use std::os::unix::fs::symlink;
1410 let dir = tempfile::tempdir().unwrap();
1411 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1412 .await
1413 .unwrap();
1414 tokio::fs::create_dir_all(dir.path().join("realdir"))
1415 .await
1416 .unwrap();
1417 symlink("realdir", dir.path().join("linkdir")).unwrap();
1418 let res = env.write_file(Path::new("linkdir/file.txt"), "data").await;
1419 assert!(
1420 res.is_err(),
1421 "writing through a symlinked intermediate dir must be rejected"
1422 );
1423 }
1424
1425 #[cfg(unix)]
1426 #[tokio::test]
1427 async fn write_file_rejects_hardlink_to_outside_secret() {
1428 let dir = tempfile::tempdir().unwrap();
1433 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1434 .await
1435 .unwrap();
1436 let (_outside, secret) = outside_secret("ORIGINAL-SECRET");
1437 std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
1438 let res = env.write_file(Path::new("link.txt"), "CORRUPTED").await;
1439 assert!(
1440 res.is_err(),
1441 "writing a hardlink (st_nlink > 1) must be rejected"
1442 );
1443 let got = std::fs::read_to_string(&secret).unwrap();
1444 assert_eq!(
1445 got, "ORIGINAL-SECRET",
1446 "the outside secret must not be corrupted"
1447 );
1448 }
1449
1450 #[tokio::test]
1451 async fn write_file_creates_new_nested_path() {
1452 let dir = tempfile::tempdir().unwrap();
1455 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1456 .await
1457 .unwrap();
1458 env.write_file(Path::new("a/b/c/new.txt"), "deep")
1459 .await
1460 .unwrap();
1461 let got = env
1462 .read_file(Path::new("a/b/c/new.txt"), 100, 1024)
1463 .await
1464 .unwrap();
1465 assert_eq!(got, "deep");
1466 }
1467
1468 #[cfg(unix)]
1469 #[tokio::test]
1470 async fn exec_rejects_symlinked_cwd_pointing_inside() {
1471 use std::os::unix::fs::symlink;
1475 let dir = tempfile::tempdir().unwrap();
1476 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1477 .await
1478 .unwrap();
1479 tokio::fs::create_dir_all(dir.path().join("realcwd"))
1480 .await
1481 .unwrap();
1482 symlink("realcwd", dir.path().join("linkcwd")).unwrap();
1483 let res = env
1484 .exec(
1485 "echo hi",
1486 Path::new("linkcwd"),
1487 None,
1488 &CancellationToken::new(),
1489 )
1490 .await;
1491 assert!(res.is_err(), "a symlinked cwd must be rejected");
1492 }
1493
1494 #[tokio::test]
1495 async fn exec_large_stdout_does_not_deadlock() {
1496 let dir = tempfile::tempdir().unwrap();
1502 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1503 .await
1504 .unwrap();
1505 let res = env
1506 .exec(
1507 "yes a | head -c 200000",
1508 Path::new("."),
1509 None,
1510 &CancellationToken::new(),
1511 )
1512 .await
1513 .unwrap();
1514 assert_eq!(res.exit_code, 0);
1515 assert_eq!(
1516 res.stdout.len(),
1517 200_000,
1518 "full >64 KB stdout must survive without deadlock"
1519 );
1520 }
1521
1522 #[tokio::test]
1523 async fn glob_returns_matching_files() {
1524 let dir = tempfile::tempdir().unwrap();
1527 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1528 .await
1529 .unwrap();
1530 tokio::fs::write(dir.path().join("top.txt"), "x")
1531 .await
1532 .unwrap();
1533 tokio::fs::create_dir_all(dir.path().join("sub"))
1534 .await
1535 .unwrap();
1536 tokio::fs::write(dir.path().join("sub/nested.txt"), "x")
1537 .await
1538 .unwrap();
1539 let matched = env.glob("*.txt", 100).await.unwrap();
1540 assert!(
1541 matched.iter().any(|m| m == "top.txt"),
1542 "base file should match: {matched:?}"
1543 );
1544 assert!(
1545 matched.iter().any(|m| m == "sub/nested.txt"),
1546 "nested file should match: {matched:?}"
1547 );
1548 }
1549
1550 #[tokio::test]
1551 async fn glob_subdir_pattern_reports_root_relative_paths() {
1552 let dir = tempfile::tempdir().unwrap();
1555 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1556 .await
1557 .unwrap();
1558 tokio::fs::create_dir_all(dir.path().join("sub"))
1559 .await
1560 .unwrap();
1561 tokio::fs::write(dir.path().join("sub/nested.txt"), "x")
1562 .await
1563 .unwrap();
1564 let matched = env.glob("sub/*.txt", 100).await.unwrap();
1565 assert!(
1566 matched.iter().any(|m| m == "sub/nested.txt"),
1567 "must be root-relative (`sub/nested.txt`), not base-relative: {matched:?}"
1568 );
1569 assert!(
1570 !matched.iter().any(|m| m == "nested.txt"),
1571 "base-relative leak must not happen: {matched:?}"
1572 );
1573 }
1574
1575 #[cfg(unix)]
1576 #[tokio::test]
1577 async fn glob_does_not_traverse_symlinked_dir_to_outside() {
1578 use std::os::unix::fs::symlink;
1582 let dir = tempfile::tempdir().unwrap();
1583 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1584 .await
1585 .unwrap();
1586 tokio::fs::write(dir.path().join("inside.txt"), "ok")
1587 .await
1588 .unwrap();
1589 tokio::fs::create_dir_all(dir.path().join("realdir"))
1590 .await
1591 .unwrap();
1592 tokio::fs::write(dir.path().join("realdir/nested.txt"), "ok")
1593 .await
1594 .unwrap();
1595 let (_outside, secret) = outside_secret("OUTSIDE-SECRET");
1598 let outside_dir = secret.parent().unwrap();
1599 symlink(outside_dir, dir.path().join("linkdir")).unwrap();
1600 let matched = env.glob("*.txt", 100).await.unwrap();
1601 assert!(
1602 matched.iter().any(|m| m == "inside.txt"),
1603 "inside file should match: {matched:?}"
1604 );
1605 assert!(
1606 matched.iter().any(|m| m == "realdir/nested.txt"),
1607 "real nested file should match: {matched:?}"
1608 );
1609 assert!(
1610 !matched.iter().any(|m| m.starts_with("linkdir")),
1611 "symlinked dir must not be traversed: {matched:?}"
1612 );
1613 for m in &matched {
1614 assert!(
1615 !m.contains("secret.txt") && !m.contains("OUTSIDE-SECRET"),
1616 "outside file must not leak: {m}"
1617 );
1618 }
1619 }
1620
1621 #[tokio::test]
1622 async fn grep_returns_matches() {
1623 let dir = tempfile::tempdir().unwrap();
1628 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1629 .await
1630 .unwrap();
1631 tokio::fs::write(dir.path().join("note.md"), "findme here\n")
1632 .await
1633 .unwrap();
1634 let matched = env.grep("findme", &["."], 100).await.unwrap();
1635 assert!(
1636 matched.iter().any(|m| m.contains("findme")),
1637 "expected a match: {matched:?}"
1638 );
1639 assert!(
1641 matched.iter().any(|m| m.starts_with("note.md:")),
1642 "expected a root-relative `note.md:` line: {matched:?}"
1643 );
1644 let root_str = dir.path().to_string_lossy().into_owned();
1646 for m in &matched {
1647 assert!(
1648 !m.contains(&root_str),
1649 "grep output must not leak the absolute root path: {m}"
1650 );
1651 }
1652 }
1653
1654 #[cfg(unix)]
1655 #[tokio::test]
1656 async fn grep_rejects_symlinked_search_path() {
1657 use std::os::unix::fs::symlink;
1661 let dir = tempfile::tempdir().unwrap();
1662 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1663 .await
1664 .unwrap();
1665 let (_outside, secret) = outside_secret("GREP-LEAK");
1666 let outside_dir = secret.parent().unwrap();
1667 symlink(outside_dir, dir.path().join("linkdir")).unwrap();
1668 let res = env.grep("GREP-LEAK", &["linkdir"], 100).await;
1670 assert!(
1671 res.is_err(),
1672 "an explicit symlinked search path must be rejected"
1673 );
1674 let matched = env.grep("GREP-LEAK", &["."], 100).await.unwrap();
1676 assert!(
1677 matched.is_empty(),
1678 "the symlinked dir must not be traversed: {matched:?}"
1679 );
1680 }
1681
1682 #[cfg(unix)]
1683 #[tokio::test]
1684 async fn grep_anchors_to_root_fd_not_root_path() {
1685 use std::os::unix::fs::symlink;
1691 let nonce = std::time::SystemTime::now()
1694 .duration_since(std::time::UNIX_EPOCH)
1695 .map(|d| d.as_nanos())
1696 .unwrap_or(0);
1697 let parent = std::env::temp_dir().join(format!("fluers-grep-swap-{nonce}"));
1698 std::fs::create_dir_all(&parent).unwrap();
1699 let root_path = parent.join("root");
1700 std::fs::create_dir_all(&root_path).unwrap();
1701 let env = LocalSessionEnv::new(&root_path, Limits::default())
1702 .await
1703 .unwrap();
1704
1705 let outside = parent.join("outside");
1706 std::fs::create_dir_all(&outside).unwrap();
1707 std::fs::write(outside.join("leak.txt"), "PATHSWAP-SECRET\n").unwrap();
1708
1709 let moved = parent.join("moved-real-root");
1712 std::fs::rename(&root_path, &moved).unwrap();
1713 symlink(&outside, &root_path).unwrap();
1714
1715 let matched = env.grep("PATHSWAP-SECRET", &["."], 100).await.unwrap();
1716 assert!(
1717 matched.is_empty(),
1718 "root-fd anchoring must not follow the swapped root path: {matched:?}"
1719 );
1720
1721 let _ = std::fs::remove_dir_all(&parent);
1723 }
1724}