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)?;
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 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 let leaf_fd = self.open_anchored_write(path)?;
431 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 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 let cwd_fd = self.open_anchored_dir(cwd)?;
464 let cwd_path = Self::fd_real_path(cwd_fd.as_fd())?;
465
466 let child = Command::new("sh")
470 .arg("-c")
471 .arg(command)
472 .current_dir(&cwd_path)
473 .stdout(std::process::Stdio::piped())
474 .stderr(std::process::Stdio::piped())
475 .kill_on_drop(true)
476 .spawn()
477 .map_err(RuntimeError::Io)?;
478 let timeout_fut = match timeout_ms {
481 Some(ms) => Box::pin(tokio::time::sleep(std::time::Duration::from_millis(ms)))
482 as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
483 None => Box::pin(std::future::pending()),
484 };
485 let cancel_fut = cancel.cancelled();
486
487 tokio::select! {
493 _ = timeout_fut => {
494 Ok(ShellResult {
497 exit_code: 124,
498 stdout: String::new(),
499 stderr: format!("command timed out after {}ms", timeout_ms.unwrap_or(0)),
500 })
501 }
502 _ = cancel_fut => {
503 Err(RuntimeError::Sandbox("command cancelled".into()))
504 }
505 output = child.wait_with_output() => {
506 let output = output.map_err(RuntimeError::Io)?;
507 Ok(ShellResult {
508 exit_code: output.status.code().unwrap_or(-1),
509 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
510 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
511 })
512 }
513 }
514 }
515
516 async fn glob(&self, pattern: &str, limit: usize) -> RuntimeResult<Vec<String>> {
517 validate_search_pattern(pattern)?;
520 let pat_path = Path::new(pattern);
525 let base_rel = pat_path.parent().unwrap_or_else(|| Path::new(""));
526 let fname = pat_path.file_name().and_then(|s| s.to_str()).unwrap_or("*");
527 let base_prefix = self
532 .normal_components(base_rel)?
533 .iter()
534 .map(|s| s.to_string_lossy().into_owned())
535 .collect::<Vec<_>>()
536 .join("/");
537 let base_fd = match self.open_anchored_dir(base_rel) {
540 Ok(fd) => fd,
541 Err(_) => return Ok(Vec::new()),
542 };
543 let dir = match Dir::new(base_fd) {
544 Ok(d) => d,
545 Err(_) => return Ok(Vec::new()),
546 };
547 let mut results: Vec<String> = Vec::new();
548 walk_glob_fd(dir, fname, &base_prefix, &mut results, limit)?;
549 results.sort();
550 results.dedup();
552 Ok(results)
553 }
554
555 async fn grep(
556 &self,
557 pattern: &str,
558 paths: &[&str],
559 max_matches: usize,
560 ) -> RuntimeResult<Vec<String>> {
561 let root_path = Self::fd_real_path(self.root_fd.as_fd())?;
569 let mut validated: Vec<String> = Vec::new();
570 if paths.is_empty() {
571 validated.push(shell_quote(&root_path.to_string_lossy()));
572 } else {
573 for p in paths {
574 validate_search_pattern(p)?;
575 let inode = self.search_path_inode(p)?;
576 validated.push(shell_quote(&inode.to_string_lossy()));
577 }
578 }
579 let search = validated.join(" ");
580 let rg = std::process::Command::new("sh")
583 .arg("-c")
584 .arg(format!(
585 "rg -n --no-follow -- {pat} {search} 2>/dev/null \
586 || find -P {search} -type f -exec grep -Hn -- {pat} {{}} + 2>/dev/null",
587 pat = shell_quote(pattern),
588 ))
589 .current_dir(&root_path)
590 .output()
591 .map_err(RuntimeError::Io)?;
592 let out = String::from_utf8_lossy(&rg.stdout);
593 let root_prefix = format!("{}/", root_path.to_string_lossy());
598 Ok(out
599 .lines()
600 .map(|l| {
601 l.strip_prefix(root_prefix.as_str())
602 .unwrap_or(l)
603 .to_string()
604 })
605 .take(max_matches)
606 .collect())
607 }
608}
609
610fn apply_read_limits(raw: String, max_lines: usize, max_bytes: usize) -> String {
612 let mut bytes_left = max_bytes;
613 let mut out = String::new();
614 let mut truncated = false;
615 for (i, line) in raw.split_inclusive('\n').enumerate() {
616 if i >= max_lines {
617 out.push_str(&format!("\n[... truncated at {max_lines} lines ...]"));
618 truncated = true;
619 break;
620 }
621 if bytes_left < line.len() {
622 let take = line
624 .char_indices()
625 .map(|(i, _)| i)
626 .find(|&pos| pos > bytes_left)
627 .unwrap_or(line.len());
628 out.push_str(line.get(..take).unwrap_or(line));
629 out.push_str(&format!("\n[... truncated at {max_bytes} bytes ...]"));
630 truncated = true;
631 break;
632 }
633 out.push_str(line);
634 bytes_left -= line.len();
635 }
636 if truncated {
637 out
638 } else {
639 raw
640 }
641}
642
643fn walk_glob_fd(
652 mut dir: Dir,
653 fname_pat: &str,
654 rel_prefix: &str,
655 out: &mut Vec<String>,
656 limit: usize,
657) -> RuntimeResult<()> {
658 let mut entries: Vec<(String, FileType)> = Vec::new();
662 for res in &mut dir {
663 match res {
664 Ok(e) => {
665 let name = e.file_name().to_string_lossy().into_owned();
666 if name == "." || name == ".." {
667 continue;
668 }
669 entries.push((name, e.file_type()));
670 }
671 Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
672 }
673 }
674 if out.len() >= limit {
675 return Ok(());
676 }
677 let parent_fd = dir
680 .fd()
681 .map_err(|e| RuntimeError::Io(std::io::Error::from(e)))?;
682 for (name, ftype) in entries {
683 if out.len() >= limit {
684 return Ok(());
685 }
686 let rel = if rel_prefix.is_empty() {
687 name.clone()
688 } else {
689 format!("{rel_prefix}/{name}")
690 };
691 if matches_glob(&name, fname_pat) {
692 out.push(rel.clone());
693 }
694 if ftype.is_dir() {
698 if let Ok(child_fd) = openat(
699 parent_fd,
700 name.as_str(),
701 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
702 Mode::empty(),
703 ) {
704 if let Ok(child_dir) = Dir::new(child_fd) {
705 walk_glob_fd(child_dir, fname_pat, &rel, out, limit)?;
706 }
707 }
708 }
710 }
711 Ok(())
712}
713
714fn matches_glob(name: &str, pat: &str) -> bool {
716 let name_b = name.as_bytes();
717 let pat_b = pat.as_bytes();
718 matches_at(name_b, pat_b, 0, 0)
719}
720
721fn matches_at(n: &[u8], p: &[u8], mut ni: usize, mut pi: usize) -> bool {
722 let mut star: Option<(usize, usize)> = None;
723 while ni < n.len() {
724 if pi < p.len() && (p[pi] == b'?' || p[pi] == b'*') {
725 if p[pi] == b'*' {
726 star = Some((pi, ni));
727 pi += 1;
728 continue;
729 }
730 pi += 1;
731 ni += 1;
732 } else if pi < p.len() && p[pi] == n[ni] {
733 pi += 1;
734 ni += 1;
735 } else if let Some((sp, sn)) = star {
736 pi = sp + 1;
737 ni = sn + 1;
738 star = Some((sp, sn + 1));
739 } else {
740 return false;
741 }
742 }
743 while pi < p.len() && p[pi] == b'*' {
744 pi += 1;
745 }
746 pi == p.len()
747}
748
749fn validate_search_pattern(input: &str) -> RuntimeResult<()> {
755 if input.starts_with('/') || input.starts_with('\\') {
757 return Err(RuntimeError::Sandbox(format!(
758 "absolute paths are not allowed: `{input}`"
759 )));
760 }
761 for seg in input.split('/') {
763 if seg == ".." {
764 return Err(RuntimeError::Sandbox(format!(
765 "`..` is not allowed in search paths: `{input}`"
766 )));
767 }
768 }
769 Ok(())
770}
771
772fn shell_quote(s: &str) -> String {
774 format!("'{}'", s.replace('\'', "'\\''"))
775}
776
777#[cfg(test)]
778mod tests {
779 use super::*;
782
783 #[tokio::test]
784 async fn read_file_within_root_works() {
785 let dir = tempfile::tempdir().unwrap();
786 let env = LocalSessionEnv::new(dir.path(), Limits::default())
787 .await
788 .unwrap();
789 tokio::fs::write(dir.path().join("hello.txt"), "hi there\n")
790 .await
791 .unwrap();
792 let got = env
793 .read_file(Path::new("hello.txt"), 100, 1024)
794 .await
795 .unwrap();
796 assert_eq!(got, "hi there\n");
797 }
798
799 #[tokio::test]
800 async fn read_file_rejects_absolute_path() {
801 let dir = tempfile::tempdir().unwrap();
802 let env = LocalSessionEnv::new(dir.path(), Limits::default())
803 .await
804 .unwrap();
805 let res = env.read_file(Path::new("/etc/passwd"), 100, 1024).await;
806 assert!(res.is_err(), "absolute paths must be rejected");
807 }
808
809 #[tokio::test]
810 async fn read_file_rejects_parent_dir() {
811 let dir = tempfile::tempdir().unwrap();
812 let env = LocalSessionEnv::new(dir.path(), Limits::default())
813 .await
814 .unwrap();
815 let res = env.read_file(Path::new("../escape.txt"), 100, 1024).await;
816 assert!(res.is_err(), "`..` must be rejected");
817 }
818
819 #[tokio::test]
820 async fn read_file_full_returns_complete_content_without_truncation() {
821 let dir = tempfile::tempdir().unwrap();
822 let env = LocalSessionEnv::new(dir.path(), Limits::default())
823 .await
824 .unwrap();
825 let body = (0..10)
829 .map(|i| format!("line number {i:02} with some padding text\n"))
830 .collect::<String>();
831 tokio::fs::write(dir.path().join("big.txt"), &body)
832 .await
833 .unwrap();
834 let got = env
835 .read_file_full(Path::new("big.txt"), 1024)
836 .await
837 .unwrap();
838 assert_eq!(got, body);
839 assert!(!got.contains("[... truncated"));
840 }
841
842 #[tokio::test]
843 async fn read_file_full_rejects_absolute_path() {
844 let dir = tempfile::tempdir().unwrap();
845 let env = LocalSessionEnv::new(dir.path(), Limits::default())
846 .await
847 .unwrap();
848 let res = env.read_file_full(Path::new("/etc/passwd"), 1024).await;
849 assert!(res.is_err(), "absolute paths must be rejected");
850 }
851
852 #[tokio::test]
853 async fn read_file_full_rejects_parent_dir() {
854 let dir = tempfile::tempdir().unwrap();
855 let env = LocalSessionEnv::new(dir.path(), Limits::default())
856 .await
857 .unwrap();
858 let res = env.read_file_full(Path::new("../escape.txt"), 1024).await;
859 assert!(res.is_err(), "`..` must be rejected");
860 }
861
862 #[tokio::test]
863 async fn read_file_full_errors_when_too_large_not_truncated() {
864 let dir = tempfile::tempdir().unwrap();
865 let env = LocalSessionEnv::new(dir.path(), Limits::default())
866 .await
867 .unwrap();
868 tokio::fs::write(dir.path().join("over.txt"), &"a".repeat(100))
871 .await
872 .unwrap();
873 let res = env.read_file_full(Path::new("over.txt"), 50).await;
874 assert!(res.is_err(), "oversized file must error, not truncate");
875 match res {
876 Err(RuntimeError::FileTooLarge { size, max, .. }) => {
877 assert_eq!(size, 100);
878 assert_eq!(max, 50);
879 }
880 other => panic!("expected FileTooLarge, got {other:?}"),
881 }
882 }
883
884 #[tokio::test]
885 async fn write_then_read_roundtrips() {
886 let dir = tempfile::tempdir().unwrap();
887 let env = LocalSessionEnv::new(dir.path(), Limits::default())
888 .await
889 .unwrap();
890 env.write_file(Path::new("sub/nested/file.txt"), "deep content")
891 .await
892 .unwrap();
893 let got = env
894 .read_file(Path::new("sub/nested/file.txt"), 100, 1024)
895 .await
896 .unwrap();
897 assert_eq!(got, "deep content");
898 }
899
900 #[tokio::test]
901 async fn exec_runs_shell_command() {
902 let dir = tempfile::tempdir().unwrap();
903 let env = LocalSessionEnv::new(dir.path(), Limits::default())
904 .await
905 .unwrap();
906 let res = env
907 .exec(
908 "echo hello",
909 Path::new("."),
910 None,
911 &CancellationToken::new(),
912 )
913 .await
914 .unwrap();
915 assert_eq!(res.exit_code, 0);
916 assert_eq!(res.stdout.trim(), "hello");
917 }
918
919 #[tokio::test]
920 async fn exec_timeout_returns_124() {
921 let dir = tempfile::tempdir().unwrap();
922 let env = LocalSessionEnv::new(dir.path(), Limits::default())
923 .await
924 .unwrap();
925 let res = env
926 .exec(
927 "sleep 5",
928 Path::new("."),
929 Some(200),
930 &CancellationToken::new(),
931 )
932 .await
933 .unwrap();
934 assert_eq!(res.exit_code, 124, "timeout must yield exit 124");
935 }
936
937 #[test]
938 fn glob_matcher_basics() {
939 assert!(matches_glob("foo.txt", "*.txt"));
940 assert!(matches_glob("foo.txt", "foo.*"));
941 assert!(!matches_glob("foo.txt", "*.md"));
942 assert!(matches_glob("a", "?"));
943 }
944
945 #[test]
946 fn read_limit_truncates() {
947 let got = apply_read_limits("a\nb\nc\nd\n".into(), 2, 1024);
948 assert!(got.contains("a"));
949 assert!(got.contains("b"));
950 assert!(got.contains("truncated"));
951 }
952
953 #[tokio::test]
954 async fn glob_rejects_absolute_pattern() {
955 let dir = tempfile::tempdir().unwrap();
956 let env = LocalSessionEnv::new(dir.path(), Limits::default())
957 .await
958 .unwrap();
959 let res = env.glob("/etc/*", 10).await;
960 assert!(res.is_err(), "absolute glob patterns must be rejected");
961 }
962
963 #[tokio::test]
964 async fn glob_rejects_parent_dir_pattern() {
965 let dir = tempfile::tempdir().unwrap();
966 let env = LocalSessionEnv::new(dir.path(), Limits::default())
967 .await
968 .unwrap();
969 let res = env.glob("../**/*", 10).await;
970 assert!(res.is_err(), "`..` in glob patterns must be rejected");
971 }
972
973 #[tokio::test]
974 async fn grep_rejects_absolute_path() {
975 let dir = tempfile::tempdir().unwrap();
976 let env = LocalSessionEnv::new(dir.path(), Limits::default())
977 .await
978 .unwrap();
979 let res = env.grep("foo", &["/etc/passwd"], 10).await;
980 assert!(res.is_err(), "absolute grep paths must be rejected");
981 }
982
983 #[tokio::test]
984 async fn grep_rejects_parent_dir_path() {
985 let dir = tempfile::tempdir().unwrap();
986 let env = LocalSessionEnv::new(dir.path(), Limits::default())
987 .await
988 .unwrap();
989 let res = env.grep("foo", &["../.env"], 10).await;
990 assert!(res.is_err(), "`..` grep paths must be rejected");
991 }
992
993 #[cfg(unix)]
1001 fn outside_secret(body: &str) -> (tempfile::TempDir, PathBuf) {
1002 use std::io::Write;
1003 let dir = tempfile::tempdir().unwrap();
1004 let path = dir.path().join("secret.txt");
1005 let mut f = std::fs::File::create(&path).unwrap();
1006 f.write_all(body.as_bytes()).unwrap();
1007 (dir, path)
1008 }
1009
1010 #[cfg(unix)]
1011 #[tokio::test]
1012 async fn read_file_rejects_symlink_leaf_even_when_target_inside_root() {
1013 use std::os::unix::fs::symlink;
1014 let dir = tempfile::tempdir().unwrap();
1015 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1016 .await
1017 .unwrap();
1018 tokio::fs::write(dir.path().join("inside.txt"), "ok\n")
1019 .await
1020 .unwrap();
1021 symlink("inside.txt", dir.path().join("link.txt")).unwrap();
1022 let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
1023 assert!(
1024 res.is_err(),
1025 "a symlink leaf must be rejected even if its target is inside the root"
1026 );
1027 }
1028
1029 #[cfg(unix)]
1030 #[tokio::test]
1031 async fn read_file_rejects_symlink_leaf_to_outside_root() {
1032 use std::os::unix::fs::symlink;
1036 let dir = tempfile::tempdir().unwrap();
1037 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1038 .await
1039 .unwrap();
1040 let (_outside, secret) = outside_secret("TOPSECRET");
1041 symlink(&secret, dir.path().join("link.txt")).unwrap();
1042 let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
1043 assert!(
1044 res.is_err(),
1045 "a symlink to outside the root must be rejected"
1046 );
1047 if let Ok(s) = res {
1048 assert!(!s.contains("TOPSECRET"), "the secret must not leak");
1049 }
1050 }
1051
1052 #[cfg(unix)]
1053 #[tokio::test]
1054 async fn read_file_rejects_intermediate_symlink_dir() {
1055 use std::os::unix::fs::symlink;
1059 let dir = tempfile::tempdir().unwrap();
1060 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1061 .await
1062 .unwrap();
1063 tokio::fs::create_dir_all(dir.path().join("realdir"))
1064 .await
1065 .unwrap();
1066 tokio::fs::write(dir.path().join("realdir/file.txt"), "ok\n")
1067 .await
1068 .unwrap();
1069 symlink("realdir", dir.path().join("linkdir")).unwrap();
1070 let res = env
1071 .read_file(Path::new("linkdir/file.txt"), 100, 1024)
1072 .await;
1073 assert!(
1074 res.is_err(),
1075 "a symlinked intermediate dir must be rejected"
1076 );
1077 }
1078
1079 #[cfg(unix)]
1080 #[tokio::test]
1081 async fn read_file_rejects_hardlink_to_outside_secret() {
1082 let dir = tempfile::tempdir().unwrap();
1086 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1087 .await
1088 .unwrap();
1089 let (_outside, secret) = outside_secret("TOPSECRET");
1090 std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
1091 let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
1092 assert!(res.is_err(), "a hardlink (st_nlink > 1) must be rejected");
1093 if let Ok(s) = res {
1094 assert!(!s.contains("TOPSECRET"), "the secret must not leak");
1095 }
1096 }
1097
1098 #[cfg(unix)]
1099 #[tokio::test]
1100 async fn read_file_full_rejects_symlink_leaf() {
1101 use std::os::unix::fs::symlink;
1102 let dir = tempfile::tempdir().unwrap();
1103 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1104 .await
1105 .unwrap();
1106 let (_outside, secret) = outside_secret("TOPSECRET");
1107 symlink(&secret, dir.path().join("link.txt")).unwrap();
1108 let res = env.read_file_full(Path::new("link.txt"), 1024).await;
1109 assert!(res.is_err(), "read_file_full must reject a symlink leaf");
1110 if let Ok(s) = res {
1111 assert!(!s.contains("TOPSECRET"));
1112 }
1113 }
1114
1115 #[cfg(unix)]
1116 #[tokio::test]
1117 async fn read_file_full_rejects_hardlink() {
1118 let dir = tempfile::tempdir().unwrap();
1119 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1120 .await
1121 .unwrap();
1122 let (_outside, secret) = outside_secret("TOPSECRET");
1123 std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
1124 let res = env.read_file_full(Path::new("link.txt"), 1024).await;
1125 assert!(
1126 res.is_err(),
1127 "read_file_full must reject a hardlink (st_nlink > 1)"
1128 );
1129 }
1130
1131 #[cfg(unix)]
1132 #[tokio::test]
1133 async fn read_anchored_nested_relative_path_still_works() {
1134 let dir = tempfile::tempdir().unwrap();
1137 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1138 .await
1139 .unwrap();
1140 tokio::fs::create_dir_all(dir.path().join("a/b"))
1141 .await
1142 .unwrap();
1143 tokio::fs::write(dir.path().join("a/b/c.txt"), "deep\n")
1144 .await
1145 .unwrap();
1146 let got = env
1147 .read_file(Path::new("a/b/c.txt"), 100, 1024)
1148 .await
1149 .unwrap();
1150 assert_eq!(got, "deep\n");
1151 }
1152
1153 #[cfg(unix)]
1161 #[tokio::test]
1162 async fn write_file_rejects_symlink_leaf_pointing_inside() {
1163 use std::os::unix::fs::symlink;
1168 let dir = tempfile::tempdir().unwrap();
1169 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1170 .await
1171 .unwrap();
1172 tokio::fs::write(dir.path().join("target.txt"), "ORIGINAL")
1173 .await
1174 .unwrap();
1175 symlink("target.txt", dir.path().join("link.txt")).unwrap();
1176 let res = env.write_file(Path::new("link.txt"), "OVERWRITE").await;
1177 assert!(
1178 res.is_err(),
1179 "writing through a symlink leaf must be rejected"
1180 );
1181 let got = tokio::fs::read_to_string(dir.path().join("target.txt"))
1182 .await
1183 .unwrap();
1184 assert_eq!(
1185 got, "ORIGINAL",
1186 "the symlink target must not be overwritten"
1187 );
1188 }
1189
1190 #[cfg(unix)]
1191 #[tokio::test]
1192 async fn write_file_rejects_symlinked_intermediate_dir() {
1193 use std::os::unix::fs::symlink;
1197 let dir = tempfile::tempdir().unwrap();
1198 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1199 .await
1200 .unwrap();
1201 tokio::fs::create_dir_all(dir.path().join("realdir"))
1202 .await
1203 .unwrap();
1204 symlink("realdir", dir.path().join("linkdir")).unwrap();
1205 let res = env.write_file(Path::new("linkdir/file.txt"), "data").await;
1206 assert!(
1207 res.is_err(),
1208 "writing through a symlinked intermediate dir must be rejected"
1209 );
1210 }
1211
1212 #[cfg(unix)]
1213 #[tokio::test]
1214 async fn write_file_rejects_hardlink_to_outside_secret() {
1215 let dir = tempfile::tempdir().unwrap();
1220 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1221 .await
1222 .unwrap();
1223 let (_outside, secret) = outside_secret("ORIGINAL-SECRET");
1224 std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
1225 let res = env.write_file(Path::new("link.txt"), "CORRUPTED").await;
1226 assert!(
1227 res.is_err(),
1228 "writing a hardlink (st_nlink > 1) must be rejected"
1229 );
1230 let got = std::fs::read_to_string(&secret).unwrap();
1231 assert_eq!(
1232 got, "ORIGINAL-SECRET",
1233 "the outside secret must not be corrupted"
1234 );
1235 }
1236
1237 #[tokio::test]
1238 async fn write_file_creates_new_nested_path() {
1239 let dir = tempfile::tempdir().unwrap();
1242 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1243 .await
1244 .unwrap();
1245 env.write_file(Path::new("a/b/c/new.txt"), "deep")
1246 .await
1247 .unwrap();
1248 let got = env
1249 .read_file(Path::new("a/b/c/new.txt"), 100, 1024)
1250 .await
1251 .unwrap();
1252 assert_eq!(got, "deep");
1253 }
1254
1255 #[cfg(unix)]
1256 #[tokio::test]
1257 async fn exec_rejects_symlinked_cwd_pointing_inside() {
1258 use std::os::unix::fs::symlink;
1262 let dir = tempfile::tempdir().unwrap();
1263 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1264 .await
1265 .unwrap();
1266 tokio::fs::create_dir_all(dir.path().join("realcwd"))
1267 .await
1268 .unwrap();
1269 symlink("realcwd", dir.path().join("linkcwd")).unwrap();
1270 let res = env
1271 .exec(
1272 "echo hi",
1273 Path::new("linkcwd"),
1274 None,
1275 &CancellationToken::new(),
1276 )
1277 .await;
1278 assert!(res.is_err(), "a symlinked cwd must be rejected");
1279 }
1280
1281 #[tokio::test]
1282 async fn exec_large_stdout_does_not_deadlock() {
1283 let dir = tempfile::tempdir().unwrap();
1289 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1290 .await
1291 .unwrap();
1292 let res = env
1293 .exec(
1294 "yes a | head -c 200000",
1295 Path::new("."),
1296 None,
1297 &CancellationToken::new(),
1298 )
1299 .await
1300 .unwrap();
1301 assert_eq!(res.exit_code, 0);
1302 assert_eq!(
1303 res.stdout.len(),
1304 200_000,
1305 "full >64 KB stdout must survive without deadlock"
1306 );
1307 }
1308
1309 #[tokio::test]
1310 async fn glob_returns_matching_files() {
1311 let dir = tempfile::tempdir().unwrap();
1314 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1315 .await
1316 .unwrap();
1317 tokio::fs::write(dir.path().join("top.txt"), "x")
1318 .await
1319 .unwrap();
1320 tokio::fs::create_dir_all(dir.path().join("sub"))
1321 .await
1322 .unwrap();
1323 tokio::fs::write(dir.path().join("sub/nested.txt"), "x")
1324 .await
1325 .unwrap();
1326 let matched = env.glob("*.txt", 100).await.unwrap();
1327 assert!(
1328 matched.iter().any(|m| m == "top.txt"),
1329 "base file should match: {matched:?}"
1330 );
1331 assert!(
1332 matched.iter().any(|m| m == "sub/nested.txt"),
1333 "nested file should match: {matched:?}"
1334 );
1335 }
1336
1337 #[tokio::test]
1338 async fn glob_subdir_pattern_reports_root_relative_paths() {
1339 let dir = tempfile::tempdir().unwrap();
1342 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1343 .await
1344 .unwrap();
1345 tokio::fs::create_dir_all(dir.path().join("sub"))
1346 .await
1347 .unwrap();
1348 tokio::fs::write(dir.path().join("sub/nested.txt"), "x")
1349 .await
1350 .unwrap();
1351 let matched = env.glob("sub/*.txt", 100).await.unwrap();
1352 assert!(
1353 matched.iter().any(|m| m == "sub/nested.txt"),
1354 "must be root-relative (`sub/nested.txt`), not base-relative: {matched:?}"
1355 );
1356 assert!(
1357 !matched.iter().any(|m| m == "nested.txt"),
1358 "base-relative leak must not happen: {matched:?}"
1359 );
1360 }
1361
1362 #[cfg(unix)]
1363 #[tokio::test]
1364 async fn glob_does_not_traverse_symlinked_dir_to_outside() {
1365 use std::os::unix::fs::symlink;
1369 let dir = tempfile::tempdir().unwrap();
1370 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1371 .await
1372 .unwrap();
1373 tokio::fs::write(dir.path().join("inside.txt"), "ok")
1374 .await
1375 .unwrap();
1376 tokio::fs::create_dir_all(dir.path().join("realdir"))
1377 .await
1378 .unwrap();
1379 tokio::fs::write(dir.path().join("realdir/nested.txt"), "ok")
1380 .await
1381 .unwrap();
1382 let (_outside, secret) = outside_secret("OUTSIDE-SECRET");
1385 let outside_dir = secret.parent().unwrap();
1386 symlink(outside_dir, dir.path().join("linkdir")).unwrap();
1387 let matched = env.glob("*.txt", 100).await.unwrap();
1388 assert!(
1389 matched.iter().any(|m| m == "inside.txt"),
1390 "inside file should match: {matched:?}"
1391 );
1392 assert!(
1393 matched.iter().any(|m| m == "realdir/nested.txt"),
1394 "real nested file should match: {matched:?}"
1395 );
1396 assert!(
1397 !matched.iter().any(|m| m.starts_with("linkdir")),
1398 "symlinked dir must not be traversed: {matched:?}"
1399 );
1400 for m in &matched {
1401 assert!(
1402 !m.contains("secret.txt") && !m.contains("OUTSIDE-SECRET"),
1403 "outside file must not leak: {m}"
1404 );
1405 }
1406 }
1407
1408 #[tokio::test]
1409 async fn grep_returns_matches() {
1410 let dir = tempfile::tempdir().unwrap();
1415 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1416 .await
1417 .unwrap();
1418 tokio::fs::write(dir.path().join("note.md"), "findme here\n")
1419 .await
1420 .unwrap();
1421 let matched = env.grep("findme", &["."], 100).await.unwrap();
1422 assert!(
1423 matched.iter().any(|m| m.contains("findme")),
1424 "expected a match: {matched:?}"
1425 );
1426 assert!(
1428 matched.iter().any(|m| m.starts_with("note.md:")),
1429 "expected a root-relative `note.md:` line: {matched:?}"
1430 );
1431 let root_str = dir.path().to_string_lossy().into_owned();
1433 for m in &matched {
1434 assert!(
1435 !m.contains(&root_str),
1436 "grep output must not leak the absolute root path: {m}"
1437 );
1438 }
1439 }
1440
1441 #[cfg(unix)]
1442 #[tokio::test]
1443 async fn grep_rejects_symlinked_search_path() {
1444 use std::os::unix::fs::symlink;
1448 let dir = tempfile::tempdir().unwrap();
1449 let env = LocalSessionEnv::new(dir.path(), Limits::default())
1450 .await
1451 .unwrap();
1452 let (_outside, secret) = outside_secret("GREP-LEAK");
1453 let outside_dir = secret.parent().unwrap();
1454 symlink(outside_dir, dir.path().join("linkdir")).unwrap();
1455 let res = env.grep("GREP-LEAK", &["linkdir"], 100).await;
1457 assert!(
1458 res.is_err(),
1459 "an explicit symlinked search path must be rejected"
1460 );
1461 let matched = env.grep("GREP-LEAK", &["."], 100).await.unwrap();
1463 assert!(
1464 matched.is_empty(),
1465 "the symlinked dir must not be traversed: {matched:?}"
1466 );
1467 }
1468
1469 #[cfg(unix)]
1470 #[tokio::test]
1471 async fn grep_anchors_to_root_fd_not_root_path() {
1472 use std::os::unix::fs::symlink;
1478 let nonce = std::time::SystemTime::now()
1481 .duration_since(std::time::UNIX_EPOCH)
1482 .map(|d| d.as_nanos())
1483 .unwrap_or(0);
1484 let parent = std::env::temp_dir().join(format!("fluers-grep-swap-{nonce}"));
1485 std::fs::create_dir_all(&parent).unwrap();
1486 let root_path = parent.join("root");
1487 std::fs::create_dir_all(&root_path).unwrap();
1488 let env = LocalSessionEnv::new(&root_path, Limits::default())
1489 .await
1490 .unwrap();
1491
1492 let outside = parent.join("outside");
1493 std::fs::create_dir_all(&outside).unwrap();
1494 std::fs::write(outside.join("leak.txt"), "PATHSWAP-SECRET\n").unwrap();
1495
1496 let moved = parent.join("moved-real-root");
1499 std::fs::rename(&root_path, &moved).unwrap();
1500 symlink(&outside, &root_path).unwrap();
1501
1502 let matched = env.grep("PATHSWAP-SECRET", &["."], 100).await.unwrap();
1503 assert!(
1504 matched.is_empty(),
1505 "root-fd anchoring must not follow the swapped root path: {matched:?}"
1506 );
1507
1508 let _ = std::fs::remove_dir_all(&parent);
1510 }
1511}