1use std::os::fd::{AsFd, OwnedFd};
13use std::path::{Component, Path, PathBuf};
14
15use async_trait::async_trait;
16use rustix::fs::{fstat, open, openat, Mode, OFlags};
17use rustix::io::Errno;
18use tokio::io::AsyncReadExt;
19use tokio::process::Command;
20use tokio_util::sync::CancellationToken;
21
22use crate::env::{Limits, SessionEnv, ShellResult};
23use crate::error::{RuntimeError, RuntimeResult};
24
25const ST_MODE_TYPE_MASK: u32 = 0o170_000; const ST_MODE_REGULAR: u32 = 0o100_000; pub struct LocalSessionEnv {
32 root: PathBuf,
34 root_fd: OwnedFd,
39 #[allow(dead_code)]
40 limits: Limits,
41}
42
43impl LocalSessionEnv {
44 pub async fn new(root: impl Into<PathBuf>, limits: Limits) -> RuntimeResult<Self> {
47 let root = root.into();
48 tokio::fs::create_dir_all(&root)
49 .await
50 .map_err(RuntimeError::Io)?;
51 let canon = tokio::fs::canonicalize(&root)
52 .await
53 .map_err(RuntimeError::Io)?;
54 let root_flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
58 let root_fd = match open(&canon, root_flags, Mode::empty()) {
59 Ok(fd) => fd,
60 Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
61 };
62 Ok(Self {
63 root: canon,
64 root_fd,
65 limits,
66 })
67 }
68
69 fn resolve(&self, rel: &Path) -> RuntimeResult<PathBuf> {
72 if rel.is_absolute() {
73 return Err(RuntimeError::Sandbox(format!(
74 "absolute paths are not allowed: `{}`",
75 rel.display()
76 )));
77 }
78 if rel.components().any(|c| matches!(c, Component::ParentDir)) {
79 return Err(RuntimeError::Sandbox(format!(
80 "`..` is not allowed in paths: `{}`",
81 rel.display()
82 )));
83 }
84 let joined = self.root.join(rel);
85 let mut anchor = joined.clone();
88 while !anchor.exists() && anchor.parent().is_some() {
89 anchor = match anchor.parent() {
90 Some(p) if p.starts_with(&self.root) => p.to_path_buf(),
91 _ => break,
92 };
93 }
94 let canon = anchor.canonicalize().map_err(RuntimeError::Io)?;
95 if !canon.starts_with(&self.root) {
96 return Err(RuntimeError::Sandbox(format!(
97 "path escapes sandbox root: `{}`",
98 rel.display()
99 )));
100 }
101 match joined.canonicalize() {
103 Ok(c) if c.starts_with(&self.root) => Ok(c),
104 Ok(_) => Err(RuntimeError::Sandbox(format!(
105 "path escapes sandbox root: `{}`",
106 rel.display()
107 ))),
108 Err(_) => Ok(joined),
109 }
110 }
111
112 fn open_anchored_read(&self, rel: &Path) -> RuntimeResult<(std::fs::File, u64)> {
122 if rel.is_absolute() {
125 return Err(RuntimeError::Sandbox(format!(
126 "absolute paths are not allowed: `{}`",
127 rel.display()
128 )));
129 }
130 if rel.components().any(|c| matches!(c, Component::ParentDir)) {
131 return Err(RuntimeError::Sandbox(format!(
132 "`..` is not allowed in paths: `{}`",
133 rel.display()
134 )));
135 }
136
137 let oflag = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
138 let mut chain: Vec<OwnedFd> = Vec::new();
141 for comp in rel.components() {
142 if let Component::Normal(name) = comp {
143 let dir = match chain.last() {
144 Some(f) => f.as_fd(),
145 None => self.root_fd.as_fd(),
146 };
147 let fd = match openat(dir, name, oflag, Mode::empty()) {
148 Ok(fd) => fd,
149 Err(Errno::LOOP) => {
150 return Err(RuntimeError::Sandbox(format!(
151 "symlinks are not allowed in read paths: `{}`",
152 rel.display()
153 )));
154 }
155 Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
156 };
157 chain.push(fd);
158 }
159 }
162 let leaf_owned = chain
163 .pop()
164 .ok_or_else(|| RuntimeError::Sandbox("read path has no components".to_string()))?;
165 let stat = match fstat(leaf_owned.as_fd()) {
169 Ok(s) => s,
170 Err(e) => return Err(RuntimeError::Io(std::io::Error::from(e))),
171 };
172 if (stat.st_mode as u32 & ST_MODE_TYPE_MASK) != ST_MODE_REGULAR {
173 return Err(RuntimeError::Sandbox(format!(
174 "not a regular file: `{}`",
175 rel.display()
176 )));
177 }
178 if stat.st_nlink > 1 {
179 return Err(RuntimeError::Sandbox(format!(
183 "multiple hard links — can't safely confine: `{}`",
184 rel.display()
185 )));
186 }
187 let size = stat.st_size.max(0) as u64;
188 Ok((std::fs::File::from(leaf_owned), size))
189 }
190}
191
192#[async_trait]
193impl SessionEnv for LocalSessionEnv {
194 async fn read_file(
195 &self,
196 path: &Path,
197 max_lines: usize,
198 max_bytes: usize,
199 ) -> RuntimeResult<String> {
200 let (file, _size) = self.open_anchored_read(path)?;
203 let mut file = tokio::fs::File::from_std(file);
204 let mut raw = String::new();
205 file.read_to_string(&mut raw)
206 .await
207 .map_err(RuntimeError::Io)?;
208 Ok(apply_read_limits(raw, max_lines, max_bytes))
209 }
210
211 async fn read_file_full(&self, path: &Path, max_bytes: usize) -> RuntimeResult<String> {
212 let (file, size) = self.open_anchored_read(path)?;
216 let size = size as usize;
217 if size > max_bytes {
218 return Err(RuntimeError::FileTooLarge {
219 path: path.display().to_string(),
220 size,
221 max: max_bytes,
222 });
223 }
224 let mut file = tokio::fs::File::from_std(file);
225 let mut raw = String::new();
226 file.read_to_string(&mut raw)
227 .await
228 .map_err(RuntimeError::Io)?;
229 Ok(raw)
230 }
231
232 async fn write_file(&self, path: &Path, content: &str) -> RuntimeResult<()> {
233 let resolved = self.resolve(path)?;
234 if let Some(parent) = resolved.parent() {
235 tokio::fs::create_dir_all(parent)
236 .await
237 .map_err(RuntimeError::Io)?;
238 }
239 tokio::fs::write(&resolved, content)
240 .await
241 .map_err(RuntimeError::Io)?;
242 Ok(())
243 }
244
245 async fn exec(
246 &self,
247 command: &str,
248 cwd: &Path,
249 timeout_ms: Option<u64>,
250 cancel: &CancellationToken,
251 ) -> RuntimeResult<ShellResult> {
252 let cwd_resolved = self.resolve(cwd)?;
254
255 let mut child = Command::new("sh")
256 .arg("-c")
257 .arg(command)
258 .current_dir(&cwd_resolved)
259 .stdout(std::process::Stdio::piped())
260 .stderr(std::process::Stdio::piped())
261 .spawn()
262 .map_err(RuntimeError::Io)?;
263
264 let timeout_ms_value = timeout_ms;
265 let timeout_fut = match timeout_ms {
266 Some(ms) => Box::pin(tokio::time::sleep(std::time::Duration::from_millis(ms)))
267 as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
268 None => Box::pin(std::future::pending()),
269 };
270 let cancel_fut = cancel.cancelled();
271
272 tokio::select! {
273 _ = timeout_fut => {
274 let _ = child.kill().await;
276 return Ok(ShellResult {
277 exit_code: 124,
278 stdout: String::new(),
279 stderr: format!("command timed out after {}ms", timeout_ms_value.unwrap_or(0)),
280 });
281 }
282 _ = cancel_fut => {
283 let _ = child.kill().await;
284 return Err(RuntimeError::Sandbox("command cancelled".into()));
285 }
286 status = child.wait() => {
287 let status = status.map_err(RuntimeError::Io)?;
288 let output = child.wait_with_output().await.map_err(RuntimeError::Io)?;
289 Ok(ShellResult {
290 exit_code: status.code().unwrap_or(-1),
291 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
292 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
293 })
294 }
295 }
296 }
297
298 async fn glob(&self, pattern: &str, limit: usize) -> RuntimeResult<Vec<String>> {
299 validate_search_pattern(pattern)?;
302 let full = self.root.join(pattern);
304 let matched: Vec<PathBuf> = glob_match(&full, limit);
305 let stripped: Vec<String> = matched
307 .iter()
308 .filter_map(|p| p.strip_prefix(&self.root).ok())
309 .map(|p| p.display().to_string())
310 .collect();
311 Ok(stripped)
312 }
313
314 async fn grep(
315 &self,
316 pattern: &str,
317 paths: &[&str],
318 max_matches: usize,
319 ) -> RuntimeResult<Vec<String>> {
320 let mut validated: Vec<String> = Vec::new();
323 if paths.is_empty() {
324 validated.push(".".to_string());
325 } else {
326 for p in paths {
327 validate_search_pattern(p)?;
328 validated.push(shell_quote(p));
329 }
330 }
331 let search = validated.join(" ");
332 let rg = std::process::Command::new("sh")
334 .arg("-c")
335 .arg(format!(
336 "rg -n -- {pat} {search} 2>/dev/null || grep -rn -- {pat} {search} 2>/dev/null",
337 pat = shell_quote(pattern),
338 ))
339 .current_dir(&self.root)
340 .output()
341 .map_err(RuntimeError::Io)?;
342 let out = String::from_utf8_lossy(&rg.stdout);
343 Ok(out.lines().take(max_matches).map(String::from).collect())
344 }
345}
346
347fn apply_read_limits(raw: String, max_lines: usize, max_bytes: usize) -> String {
349 let mut bytes_left = max_bytes;
350 let mut out = String::new();
351 let mut truncated = false;
352 for (i, line) in raw.split_inclusive('\n').enumerate() {
353 if i >= max_lines {
354 out.push_str(&format!("\n[... truncated at {max_lines} lines ...]"));
355 truncated = true;
356 break;
357 }
358 if bytes_left < line.len() {
359 let take = line
361 .char_indices()
362 .map(|(i, _)| i)
363 .find(|&pos| pos > bytes_left)
364 .unwrap_or(line.len());
365 out.push_str(line.get(..take).unwrap_or(line));
366 out.push_str(&format!("\n[... truncated at {max_bytes} bytes ...]"));
367 truncated = true;
368 break;
369 }
370 out.push_str(line);
371 bytes_left -= line.len();
372 }
373 if truncated {
374 out
375 } else {
376 raw
377 }
378}
379
380fn glob_match(pattern: &Path, limit: usize) -> Vec<PathBuf> {
382 let mut results = Vec::new();
383 let base = pattern
384 .parent()
385 .map(Path::to_path_buf)
386 .unwrap_or_else(|| PathBuf::from("."));
387 let pat = pattern.file_name().and_then(|s| s.to_str()).unwrap_or("*");
388 walk_glob(&base, pat, &mut results, limit);
389 results.sort();
390 results
391}
392
393fn walk_glob(dir: &Path, pat: &str, out: &mut Vec<PathBuf>, limit: usize) {
394 if out.len() >= limit {
395 return;
396 }
397 let Ok(entries) = std::fs::read_dir(dir) else {
398 return;
399 };
400 for entry in entries.flatten() {
401 if out.len() >= limit {
402 return;
403 }
404 let path = entry.path();
405 if matches_glob(entry.file_name().to_string_lossy().as_ref(), pat) {
406 out.push(path.clone());
407 }
408 if path.is_dir() {
409 walk_glob(&path, pat, out, limit);
410 }
411 }
412}
413
414fn matches_glob(name: &str, pat: &str) -> bool {
416 let name_b = name.as_bytes();
417 let pat_b = pat.as_bytes();
418 matches_at(name_b, pat_b, 0, 0)
419}
420
421fn matches_at(n: &[u8], p: &[u8], mut ni: usize, mut pi: usize) -> bool {
422 let mut star: Option<(usize, usize)> = None;
423 while ni < n.len() {
424 if pi < p.len() && (p[pi] == b'?' || p[pi] == b'*') {
425 if p[pi] == b'*' {
426 star = Some((pi, ni));
427 pi += 1;
428 continue;
429 }
430 pi += 1;
431 ni += 1;
432 } else if pi < p.len() && p[pi] == n[ni] {
433 pi += 1;
434 ni += 1;
435 } else if let Some((sp, sn)) = star {
436 pi = sp + 1;
437 ni = sn + 1;
438 star = Some((sp, sn + 1));
439 } else {
440 return false;
441 }
442 }
443 while pi < p.len() && p[pi] == b'*' {
444 pi += 1;
445 }
446 pi == p.len()
447}
448
449fn validate_search_pattern(input: &str) -> RuntimeResult<()> {
455 if input.starts_with('/') || input.starts_with('\\') {
457 return Err(RuntimeError::Sandbox(format!(
458 "absolute paths are not allowed: `{input}`"
459 )));
460 }
461 for seg in input.split('/') {
463 if seg == ".." {
464 return Err(RuntimeError::Sandbox(format!(
465 "`..` is not allowed in search paths: `{input}`"
466 )));
467 }
468 }
469 Ok(())
470}
471
472fn shell_quote(s: &str) -> String {
474 format!("'{}'", s.replace('\'', "'\\''"))
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
482
483 #[tokio::test]
484 async fn read_file_within_root_works() {
485 let dir = tempfile::tempdir().unwrap();
486 let env = LocalSessionEnv::new(dir.path(), Limits::default())
487 .await
488 .unwrap();
489 tokio::fs::write(dir.path().join("hello.txt"), "hi there\n")
490 .await
491 .unwrap();
492 let got = env
493 .read_file(Path::new("hello.txt"), 100, 1024)
494 .await
495 .unwrap();
496 assert_eq!(got, "hi there\n");
497 }
498
499 #[tokio::test]
500 async fn read_file_rejects_absolute_path() {
501 let dir = tempfile::tempdir().unwrap();
502 let env = LocalSessionEnv::new(dir.path(), Limits::default())
503 .await
504 .unwrap();
505 let res = env.read_file(Path::new("/etc/passwd"), 100, 1024).await;
506 assert!(res.is_err(), "absolute paths must be rejected");
507 }
508
509 #[tokio::test]
510 async fn read_file_rejects_parent_dir() {
511 let dir = tempfile::tempdir().unwrap();
512 let env = LocalSessionEnv::new(dir.path(), Limits::default())
513 .await
514 .unwrap();
515 let res = env.read_file(Path::new("../escape.txt"), 100, 1024).await;
516 assert!(res.is_err(), "`..` must be rejected");
517 }
518
519 #[tokio::test]
520 async fn read_file_full_returns_complete_content_without_truncation() {
521 let dir = tempfile::tempdir().unwrap();
522 let env = LocalSessionEnv::new(dir.path(), Limits::default())
523 .await
524 .unwrap();
525 let body = (0..10)
529 .map(|i| format!("line number {i:02} with some padding text\n"))
530 .collect::<String>();
531 tokio::fs::write(dir.path().join("big.txt"), &body)
532 .await
533 .unwrap();
534 let got = env
535 .read_file_full(Path::new("big.txt"), 1024)
536 .await
537 .unwrap();
538 assert_eq!(got, body);
539 assert!(!got.contains("[... truncated"));
540 }
541
542 #[tokio::test]
543 async fn read_file_full_rejects_absolute_path() {
544 let dir = tempfile::tempdir().unwrap();
545 let env = LocalSessionEnv::new(dir.path(), Limits::default())
546 .await
547 .unwrap();
548 let res = env.read_file_full(Path::new("/etc/passwd"), 1024).await;
549 assert!(res.is_err(), "absolute paths must be rejected");
550 }
551
552 #[tokio::test]
553 async fn read_file_full_rejects_parent_dir() {
554 let dir = tempfile::tempdir().unwrap();
555 let env = LocalSessionEnv::new(dir.path(), Limits::default())
556 .await
557 .unwrap();
558 let res = env.read_file_full(Path::new("../escape.txt"), 1024).await;
559 assert!(res.is_err(), "`..` must be rejected");
560 }
561
562 #[tokio::test]
563 async fn read_file_full_errors_when_too_large_not_truncated() {
564 let dir = tempfile::tempdir().unwrap();
565 let env = LocalSessionEnv::new(dir.path(), Limits::default())
566 .await
567 .unwrap();
568 tokio::fs::write(dir.path().join("over.txt"), &"a".repeat(100))
571 .await
572 .unwrap();
573 let res = env.read_file_full(Path::new("over.txt"), 50).await;
574 assert!(res.is_err(), "oversized file must error, not truncate");
575 match res {
576 Err(RuntimeError::FileTooLarge { size, max, .. }) => {
577 assert_eq!(size, 100);
578 assert_eq!(max, 50);
579 }
580 other => panic!("expected FileTooLarge, got {other:?}"),
581 }
582 }
583
584 #[tokio::test]
585 async fn write_then_read_roundtrips() {
586 let dir = tempfile::tempdir().unwrap();
587 let env = LocalSessionEnv::new(dir.path(), Limits::default())
588 .await
589 .unwrap();
590 env.write_file(Path::new("sub/nested/file.txt"), "deep content")
591 .await
592 .unwrap();
593 let got = env
594 .read_file(Path::new("sub/nested/file.txt"), 100, 1024)
595 .await
596 .unwrap();
597 assert_eq!(got, "deep content");
598 }
599
600 #[tokio::test]
601 async fn exec_runs_shell_command() {
602 let dir = tempfile::tempdir().unwrap();
603 let env = LocalSessionEnv::new(dir.path(), Limits::default())
604 .await
605 .unwrap();
606 let res = env
607 .exec(
608 "echo hello",
609 Path::new("."),
610 None,
611 &CancellationToken::new(),
612 )
613 .await
614 .unwrap();
615 assert_eq!(res.exit_code, 0);
616 assert_eq!(res.stdout.trim(), "hello");
617 }
618
619 #[tokio::test]
620 async fn exec_timeout_returns_124() {
621 let dir = tempfile::tempdir().unwrap();
622 let env = LocalSessionEnv::new(dir.path(), Limits::default())
623 .await
624 .unwrap();
625 let res = env
626 .exec(
627 "sleep 5",
628 Path::new("."),
629 Some(200),
630 &CancellationToken::new(),
631 )
632 .await
633 .unwrap();
634 assert_eq!(res.exit_code, 124, "timeout must yield exit 124");
635 }
636
637 #[test]
638 fn glob_matcher_basics() {
639 assert!(matches_glob("foo.txt", "*.txt"));
640 assert!(matches_glob("foo.txt", "foo.*"));
641 assert!(!matches_glob("foo.txt", "*.md"));
642 assert!(matches_glob("a", "?"));
643 }
644
645 #[test]
646 fn read_limit_truncates() {
647 let got = apply_read_limits("a\nb\nc\nd\n".into(), 2, 1024);
648 assert!(got.contains("a"));
649 assert!(got.contains("b"));
650 assert!(got.contains("truncated"));
651 }
652
653 #[tokio::test]
654 async fn glob_rejects_absolute_pattern() {
655 let dir = tempfile::tempdir().unwrap();
656 let env = LocalSessionEnv::new(dir.path(), Limits::default())
657 .await
658 .unwrap();
659 let res = env.glob("/etc/*", 10).await;
660 assert!(res.is_err(), "absolute glob patterns must be rejected");
661 }
662
663 #[tokio::test]
664 async fn glob_rejects_parent_dir_pattern() {
665 let dir = tempfile::tempdir().unwrap();
666 let env = LocalSessionEnv::new(dir.path(), Limits::default())
667 .await
668 .unwrap();
669 let res = env.glob("../**/*", 10).await;
670 assert!(res.is_err(), "`..` in glob patterns must be rejected");
671 }
672
673 #[tokio::test]
674 async fn grep_rejects_absolute_path() {
675 let dir = tempfile::tempdir().unwrap();
676 let env = LocalSessionEnv::new(dir.path(), Limits::default())
677 .await
678 .unwrap();
679 let res = env.grep("foo", &["/etc/passwd"], 10).await;
680 assert!(res.is_err(), "absolute grep paths must be rejected");
681 }
682
683 #[tokio::test]
684 async fn grep_rejects_parent_dir_path() {
685 let dir = tempfile::tempdir().unwrap();
686 let env = LocalSessionEnv::new(dir.path(), Limits::default())
687 .await
688 .unwrap();
689 let res = env.grep("foo", &["../.env"], 10).await;
690 assert!(res.is_err(), "`..` grep paths must be rejected");
691 }
692
693 #[cfg(unix)]
701 fn outside_secret(body: &str) -> (tempfile::TempDir, PathBuf) {
702 use std::io::Write;
703 let dir = tempfile::tempdir().unwrap();
704 let path = dir.path().join("secret.txt");
705 let mut f = std::fs::File::create(&path).unwrap();
706 f.write_all(body.as_bytes()).unwrap();
707 (dir, path)
708 }
709
710 #[cfg(unix)]
711 #[tokio::test]
712 async fn read_file_rejects_symlink_leaf_even_when_target_inside_root() {
713 use std::os::unix::fs::symlink;
714 let dir = tempfile::tempdir().unwrap();
715 let env = LocalSessionEnv::new(dir.path(), Limits::default())
716 .await
717 .unwrap();
718 tokio::fs::write(dir.path().join("inside.txt"), "ok\n")
719 .await
720 .unwrap();
721 symlink("inside.txt", dir.path().join("link.txt")).unwrap();
722 let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
723 assert!(
724 res.is_err(),
725 "a symlink leaf must be rejected even if its target is inside the root"
726 );
727 }
728
729 #[cfg(unix)]
730 #[tokio::test]
731 async fn read_file_rejects_symlink_leaf_to_outside_root() {
732 use std::os::unix::fs::symlink;
736 let dir = tempfile::tempdir().unwrap();
737 let env = LocalSessionEnv::new(dir.path(), Limits::default())
738 .await
739 .unwrap();
740 let (_outside, secret) = outside_secret("TOPSECRET");
741 symlink(&secret, dir.path().join("link.txt")).unwrap();
742 let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
743 assert!(
744 res.is_err(),
745 "a symlink to outside the root must be rejected"
746 );
747 if let Ok(s) = res {
748 assert!(!s.contains("TOPSECRET"), "the secret must not leak");
749 }
750 }
751
752 #[cfg(unix)]
753 #[tokio::test]
754 async fn read_file_rejects_intermediate_symlink_dir() {
755 use std::os::unix::fs::symlink;
759 let dir = tempfile::tempdir().unwrap();
760 let env = LocalSessionEnv::new(dir.path(), Limits::default())
761 .await
762 .unwrap();
763 tokio::fs::create_dir_all(dir.path().join("realdir"))
764 .await
765 .unwrap();
766 tokio::fs::write(dir.path().join("realdir/file.txt"), "ok\n")
767 .await
768 .unwrap();
769 symlink("realdir", dir.path().join("linkdir")).unwrap();
770 let res = env
771 .read_file(Path::new("linkdir/file.txt"), 100, 1024)
772 .await;
773 assert!(
774 res.is_err(),
775 "a symlinked intermediate dir must be rejected"
776 );
777 }
778
779 #[cfg(unix)]
780 #[tokio::test]
781 async fn read_file_rejects_hardlink_to_outside_secret() {
782 let dir = tempfile::tempdir().unwrap();
786 let env = LocalSessionEnv::new(dir.path(), Limits::default())
787 .await
788 .unwrap();
789 let (_outside, secret) = outside_secret("TOPSECRET");
790 std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
791 let res = env.read_file(Path::new("link.txt"), 100, 1024).await;
792 assert!(res.is_err(), "a hardlink (st_nlink > 1) must be rejected");
793 if let Ok(s) = res {
794 assert!(!s.contains("TOPSECRET"), "the secret must not leak");
795 }
796 }
797
798 #[cfg(unix)]
799 #[tokio::test]
800 async fn read_file_full_rejects_symlink_leaf() {
801 use std::os::unix::fs::symlink;
802 let dir = tempfile::tempdir().unwrap();
803 let env = LocalSessionEnv::new(dir.path(), Limits::default())
804 .await
805 .unwrap();
806 let (_outside, secret) = outside_secret("TOPSECRET");
807 symlink(&secret, dir.path().join("link.txt")).unwrap();
808 let res = env.read_file_full(Path::new("link.txt"), 1024).await;
809 assert!(res.is_err(), "read_file_full must reject a symlink leaf");
810 if let Ok(s) = res {
811 assert!(!s.contains("TOPSECRET"));
812 }
813 }
814
815 #[cfg(unix)]
816 #[tokio::test]
817 async fn read_file_full_rejects_hardlink() {
818 let dir = tempfile::tempdir().unwrap();
819 let env = LocalSessionEnv::new(dir.path(), Limits::default())
820 .await
821 .unwrap();
822 let (_outside, secret) = outside_secret("TOPSECRET");
823 std::fs::hard_link(&secret, dir.path().join("link.txt")).unwrap();
824 let res = env.read_file_full(Path::new("link.txt"), 1024).await;
825 assert!(
826 res.is_err(),
827 "read_file_full must reject a hardlink (st_nlink > 1)"
828 );
829 }
830
831 #[cfg(unix)]
832 #[tokio::test]
833 async fn read_anchored_nested_relative_path_still_works() {
834 let dir = tempfile::tempdir().unwrap();
837 let env = LocalSessionEnv::new(dir.path(), Limits::default())
838 .await
839 .unwrap();
840 tokio::fs::create_dir_all(dir.path().join("a/b"))
841 .await
842 .unwrap();
843 tokio::fs::write(dir.path().join("a/b/c.txt"), "deep\n")
844 .await
845 .unwrap();
846 let got = env
847 .read_file(Path::new("a/b/c.txt"), 100, 1024)
848 .await
849 .unwrap();
850 assert_eq!(got, "deep\n");
851 }
852}