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