1use std::fs::File;
13use std::io;
14use std::path::{Component, Path, PathBuf};
15
16use anyhow::Result;
17
18pub(crate) fn contain_within(root: &Path, raw: &str) -> Result<PathBuf> {
26 let candidate = if Path::new(raw).is_absolute() {
27 PathBuf::from(raw)
28 } else {
29 root.join(raw)
30 };
31 let lexical = normalize_lexical(&candidate);
32 let root = normalize_lexical(root);
33 anyhow::ensure!(
34 lexical.starts_with(&root),
35 "path escapes the project root: {raw}"
36 );
37 Ok(lexical)
38}
39
40pub(crate) fn contain_within_canonical(root: &Path, raw: &str) -> Result<PathBuf> {
49 let lexical = contain_within(root, raw)?;
50 let canon_root = match std::fs::canonicalize(root) {
51 Ok(r) => r,
52 Err(_) => return Ok(lexical),
53 };
54 let mut ancestor = lexical.as_path();
55 let existing = loop {
56 if ancestor.exists() {
57 break Some(ancestor);
58 }
59 match ancestor.parent() {
60 Some(p) => ancestor = p,
61 None => break None,
62 }
63 };
64 if let Some(existing) = existing
65 && let Ok(canon) = std::fs::canonicalize(existing)
66 {
67 anyhow::ensure!(
68 canon.starts_with(&canon_root),
69 "path escapes the project root via a symlink: {raw}"
70 );
71 }
72 Ok(lexical)
73}
74
75pub(crate) fn relative_within(root: &Path, raw: &str) -> Result<PathBuf> {
82 let abs = contain_within(root, raw)?;
83 let root_norm = normalize_lexical(root);
84 let rel = abs
85 .strip_prefix(&root_norm)
86 .map_err(|_| anyhow::anyhow!("path escapes the project root: {raw}"))?;
87 Ok(rel.to_path_buf())
88}
89
90fn normalize_lexical(path: &Path) -> PathBuf {
93 let mut out = PathBuf::new();
94 for component in path.components() {
95 match component {
96 Component::CurDir => {},
97 Component::ParentDir => {
98 out.pop();
99 },
100 other => out.push(other.as_os_str()),
101 }
102 }
103 out
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum OpenIntent {
109 Read,
111 WriteTruncate,
113}
114
115pub fn open_beneath(root: &Path, rel: &Path, intent: OpenIntent) -> io::Result<File> {
139 #[cfg(target_os = "linux")]
140 {
141 match linux::open_beneath(root, rel, intent) {
142 Err(e) if e == rustix::io::Errno::NOSYS => {}, other => return other.map_err(io::Error::from),
144 }
145 }
146 fallback::open(root, rel, intent)
147}
148
149pub fn create_dir_all_beneath(root: &Path, rel: &Path) -> io::Result<()> {
160 #[cfg(target_os = "linux")]
161 {
162 match linux::create_dir_all_beneath(root, rel) {
163 Err(e) if e == rustix::io::Errno::NOSYS => {},
164 other => return other.map_err(io::Error::from),
165 }
166 }
167 fallback::create_dir_all(root, rel)
168}
169
170pub fn remove_file_beneath(root: &Path, rel: &Path) -> io::Result<()> {
179 #[cfg(target_os = "linux")]
180 {
181 match linux::remove_file_beneath(root, rel) {
182 Err(e) if e == rustix::io::Errno::NOSYS => {},
183 other => return other.map_err(io::Error::from),
184 }
185 }
186 fallback::remove_file(root, rel)
187}
188
189pub fn write_atomic_beneath(root: &Path, rel: &Path, bytes: &[u8]) -> io::Result<()> {
206 #[cfg(target_os = "linux")]
207 {
208 match linux::write_atomic_beneath(root, rel, bytes) {
209 Err(e) if e == rustix::io::Errno::NOSYS => {},
210 other => return other.map_err(io::Error::from),
211 }
212 }
213 fallback::write_atomic(root, rel, bytes)
214}
215
216#[cfg(target_os = "linux")]
220mod linux {
221 use std::fs::File;
222 use std::io::Write;
223 use std::path::{Component, Path};
224 use std::sync::atomic::{AtomicU64, Ordering};
225
226 use rustix::fd::OwnedFd;
227 use rustix::fs::{
228 AtFlags, Mode, OFlags, ResolveFlags, fchmod, mkdirat, open, openat2, renameat, statat,
229 unlinkat,
230 };
231 use rustix::io::Errno;
232
233 use super::OpenIntent;
234
235 fn open_dir(dir: &Path) -> Result<OwnedFd, Errno> {
238 open(
239 dir,
240 OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
241 Mode::empty(),
242 )
243 }
244
245 fn open_subdir(dir_fd: &OwnedFd, name: &Path) -> Result<OwnedFd, Errno> {
247 openat2(
248 dir_fd,
249 name,
250 OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
251 Mode::empty(),
252 ResolveFlags::BENEATH,
253 )
254 }
255
256 pub(super) fn open_beneath(root: &Path, rel: &Path, intent: OpenIntent) -> Result<File, Errno> {
257 let root_fd = open_dir(root)?;
258 let (flags, mode) = match intent {
259 OpenIntent::Read => (OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty()),
260 OpenIntent::WriteTruncate => (
261 OFlags::WRONLY | OFlags::CREATE | OFlags::TRUNC | OFlags::CLOEXEC,
262 Mode::from_raw_mode(0o644),
263 ),
264 };
265 let fd = openat2(&root_fd, rel, flags, mode, ResolveFlags::BENEATH)?;
266 Ok(File::from(fd))
267 }
268
269 pub(super) fn create_dir_all_beneath(root: &Path, rel: &Path) -> Result<(), Errno> {
270 let mut dir = open_dir(root)?;
271 for comp in rel.components() {
272 let name: &Path = match comp {
273 Component::Normal(n) => Path::new(n),
274 Component::CurDir => continue,
275 _ => return Err(Errno::INVAL),
278 };
279 match mkdirat(&dir, name, Mode::from_raw_mode(0o755)) {
280 Ok(()) | Err(Errno::EXIST) => {},
281 Err(e) => return Err(e),
282 }
283 dir = open_subdir(&dir, name)?;
284 }
285 Ok(())
286 }
287
288 pub(super) fn remove_file_beneath(root: &Path, rel: &Path) -> Result<(), Errno> {
289 let root_fd = open_dir(root)?;
290 let leaf = rel.file_name().ok_or(Errno::INVAL)?;
291 let parent = rel.parent().unwrap_or_else(|| Path::new(""));
292 let parent_fd = if parent.as_os_str().is_empty() {
293 root_fd
294 } else {
295 open_subdir(&root_fd, parent)?
296 };
297 unlinkat(&parent_fd, Path::new(leaf), AtFlags::empty())
298 }
299
300 pub(super) fn write_atomic_beneath(root: &Path, rel: &Path, bytes: &[u8]) -> Result<(), Errno> {
306 static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
307
308 let root_fd = open_dir(root)?;
309 let leaf = rel.file_name().ok_or(Errno::INVAL)?;
310 let parent = rel.parent().unwrap_or_else(|| Path::new(""));
311 let parent_fd = if parent.as_os_str().is_empty() {
312 root_fd
313 } else {
314 open_subdir(&root_fd, parent)?
315 };
316
317 let n = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
318 let tmp_name = format!(".mermaid.{}.{}.tmp", std::process::id(), n);
319 let tmp_path = Path::new(&tmp_name);
320
321 let existing_mode = statat(&parent_fd, Path::new(leaf), AtFlags::empty())
327 .ok()
328 .map(|st| st.st_mode & 0o7777);
329
330 let fd = openat2(
332 &parent_fd,
333 tmp_path,
334 OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC,
335 Mode::from_raw_mode(0o644),
336 ResolveFlags::BENEATH,
337 )?;
338
339 if let Some(mode) = existing_mode {
342 let _ = fchmod(&fd, Mode::from_raw_mode(mode));
343 }
344
345 let written = (|| -> std::io::Result<()> {
348 let mut file = File::from(fd);
349 file.write_all(bytes)?;
350 file.sync_all()
351 })();
352 if let Err(e) = written {
353 let _ = unlinkat(&parent_fd, tmp_path, AtFlags::empty());
354 return Err(io_to_errno(e));
355 }
356
357 if let Err(e) = renameat(&parent_fd, tmp_path, &parent_fd, Path::new(leaf)) {
359 let _ = unlinkat(&parent_fd, tmp_path, AtFlags::empty());
360 return Err(e);
361 }
362
363 if let Ok(dir) = openat2(
365 &parent_fd,
366 Path::new("."),
367 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
368 Mode::empty(),
369 ResolveFlags::BENEATH,
370 ) {
371 let _ = File::from(dir).sync_all();
372 }
373 Ok(())
374 }
375
376 fn io_to_errno(e: std::io::Error) -> Errno {
379 Errno::from_io_error(&e).unwrap_or(Errno::IO)
380 }
381}
382
383mod fallback {
397 use std::fs::{File, OpenOptions};
398 use std::io;
399 use std::path::{Path, PathBuf};
400 use std::sync::Once;
401
402 use super::{OpenIntent, contain_within_canonical};
403
404 fn warn_fallback_once() {
412 static ONCE: Once = Once::new();
413 ONCE.call_once(|| {
414 tracing::warn!(
415 "path confinement is using the by-path fallback (no openat2 \
416 RESOLVE_BENEATH on this platform/kernel); a check-then-use symlink \
417 TOCTOU window remains for in-workspace writes/deletes"
418 );
419 });
420 }
421
422 fn validated(root: &Path, rel: &Path) -> io::Result<PathBuf> {
423 warn_fallback_once();
424 let rel_str = rel
425 .to_str()
426 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "non-UTF-8 path"))?;
427 contain_within_canonical(root, rel_str)
428 .map_err(|e| io::Error::new(io::ErrorKind::PermissionDenied, e.to_string()))
429 }
430
431 pub(super) fn open(root: &Path, rel: &Path, intent: OpenIntent) -> io::Result<File> {
432 let path = validated(root, rel)?;
433 match intent {
434 OpenIntent::Read => File::open(&path),
435 OpenIntent::WriteTruncate => OpenOptions::new()
436 .write(true)
437 .create(true)
438 .truncate(true)
439 .open(&path),
440 }
441 }
442
443 pub(super) fn create_dir_all(root: &Path, rel: &Path) -> io::Result<()> {
444 let path = validated(root, rel)?;
445 std::fs::create_dir_all(&path)
446 }
447
448 pub(super) fn remove_file(root: &Path, rel: &Path) -> io::Result<()> {
449 let path = validated(root, rel)?;
450 std::fs::remove_file(&path)
451 }
452
453 pub(super) fn write_atomic(root: &Path, rel: &Path, bytes: &[u8]) -> io::Result<()> {
454 let path = validated(root, rel)?;
455 crate::write_atomic(&path, bytes)
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 #[test]
464 fn rejects_parent_escape() {
465 let root = std::env::temp_dir().join("mermaid_pathguard_root");
466 assert!(contain_within(&root, "../escape").is_err());
467 }
468
469 #[test]
470 fn rejects_absolute_outside_root() {
471 let root = std::env::temp_dir().join("mermaid_pathguard_root2");
472 #[cfg(unix)]
473 assert!(contain_within(&root, "/etc/passwd").is_err());
474 #[cfg(windows)]
475 assert!(contain_within(&root, "C:\\Windows\\System32\\drivers\\etc\\hosts").is_err());
476 }
477
478 #[test]
479 fn accepts_in_root_relative() {
480 let root = std::env::temp_dir().join("mermaid_pathguard_root3");
481 let p = contain_within(&root, "a/b.txt").unwrap();
482 assert!(p.starts_with(normalize_lexical(&root)));
483 assert!(p.ends_with("b.txt"));
484 }
485
486 #[test]
487 fn collapses_interior_parent_within_root() {
488 let root = std::env::temp_dir().join("mermaid_pathguard_root4");
489 let p = contain_within(&root, "a/../b.txt").unwrap();
491 assert!(p.ends_with("b.txt"));
492 assert!(p.starts_with(normalize_lexical(&root)));
493 }
494}
495
496#[cfg(all(test, target_os = "linux"))]
497mod confined_tests {
498 use std::io::{Read, Write};
499
500 use super::*;
501
502 fn unique_dir(tag: &str) -> PathBuf {
504 let dir =
505 std::env::temp_dir().join(format!("mermaid_beneath_{tag}_{}", std::process::id()));
506 let _ = std::fs::remove_dir_all(&dir);
507 std::fs::create_dir_all(&dir).unwrap();
508 dir
509 }
510
511 #[test]
512 fn create_write_read_roundtrip_stays_in_root() {
513 let root = unique_dir("rw");
514 create_dir_all_beneath(&root, Path::new("sub/inner")).unwrap();
515 {
516 let mut f = open_beneath(
517 &root,
518 Path::new("sub/inner/file.txt"),
519 OpenIntent::WriteTruncate,
520 )
521 .unwrap();
522 f.write_all(b"hello").unwrap();
523 }
524 assert_eq!(
526 std::fs::read_to_string(root.join("sub/inner/file.txt")).unwrap(),
527 "hello"
528 );
529 let mut buf = String::new();
531 open_beneath(&root, Path::new("sub/inner/file.txt"), OpenIntent::Read)
532 .unwrap()
533 .read_to_string(&mut buf)
534 .unwrap();
535 assert_eq!(buf, "hello");
536 let _ = std::fs::remove_dir_all(&root);
537 }
538
539 #[test]
540 fn open_beneath_refuses_write_through_escaping_symlink() {
541 let root = unique_dir("escape_root");
542 let outside = unique_dir("escape_outside");
543 std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
546
547 let res = open_beneath(
548 &root,
549 Path::new("escape/evil.txt"),
550 OpenIntent::WriteTruncate,
551 );
552 assert!(
553 res.is_err(),
554 "write through escaping symlink must be refused"
555 );
556 assert!(
557 !outside.join("evil.txt").exists(),
558 "nothing should have been written outside the root"
559 );
560
561 let _ = std::fs::remove_dir_all(&root);
562 let _ = std::fs::remove_dir_all(&outside);
563 }
564
565 #[test]
566 fn open_beneath_follows_in_tree_symlink() {
567 let root = unique_dir("intree");
572 std::fs::create_dir(root.join("real")).unwrap();
573 std::os::unix::fs::symlink("real", root.join("link")).unwrap();
575
576 {
577 let mut f =
578 open_beneath(&root, Path::new("link/file.txt"), OpenIntent::WriteTruncate).unwrap();
579 f.write_all(b"via-symlink").unwrap();
580 }
581 assert_eq!(
583 std::fs::read_to_string(root.join("real/file.txt")).unwrap(),
584 "via-symlink"
585 );
586 let _ = std::fs::remove_dir_all(&root);
587 }
588
589 #[test]
590 fn create_dir_all_beneath_refuses_escape() {
591 let root = unique_dir("mkdir_root");
592 let outside = unique_dir("mkdir_outside");
593 std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
594
595 let res = create_dir_all_beneath(&root, Path::new("escape/newdir"));
596 assert!(
597 res.is_err(),
598 "mkdir through escaping symlink must be refused"
599 );
600 assert!(!outside.join("newdir").exists());
601
602 let _ = std::fs::remove_dir_all(&root);
603 let _ = std::fs::remove_dir_all(&outside);
604 }
605
606 #[test]
607 fn remove_file_beneath_deletes_in_root_and_refuses_escape() {
608 let root = unique_dir("rm_root");
609 {
610 let mut f =
611 open_beneath(&root, Path::new("gone.txt"), OpenIntent::WriteTruncate).unwrap();
612 f.write_all(b"x").unwrap();
613 }
614 assert!(root.join("gone.txt").exists());
615 remove_file_beneath(&root, Path::new("gone.txt")).unwrap();
616 assert!(!root.join("gone.txt").exists());
617
618 let outside = unique_dir("rm_outside");
621 std::fs::write(outside.join("victim.txt"), b"keep").unwrap();
622 std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
623 let res = remove_file_beneath(&root, Path::new("escape/victim.txt"));
624 assert!(
625 res.is_err(),
626 "unlink through escaping symlink must be refused"
627 );
628 assert!(outside.join("victim.txt").exists());
629
630 let _ = std::fs::remove_dir_all(&root);
631 let _ = std::fs::remove_dir_all(&outside);
632 }
633
634 #[test]
635 fn write_atomic_beneath_roundtrips_and_replaces_without_temp_residue() {
636 let root = unique_dir("atomic_rw");
637 create_dir_all_beneath(&root, Path::new("sub")).unwrap();
638 write_atomic_beneath(&root, Path::new("sub/file.txt"), b"first").unwrap();
639 assert_eq!(
640 std::fs::read_to_string(root.join("sub/file.txt")).unwrap(),
641 "first"
642 );
643 write_atomic_beneath(&root, Path::new("sub/file.txt"), b"second").unwrap();
645 assert_eq!(
646 std::fs::read_to_string(root.join("sub/file.txt")).unwrap(),
647 "second"
648 );
649 let leftovers = std::fs::read_dir(root.join("sub"))
651 .unwrap()
652 .flatten()
653 .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
654 .count();
655 assert_eq!(leftovers, 0, "atomic write must not leak a temp file");
656 let _ = std::fs::remove_dir_all(&root);
657 }
658
659 #[test]
660 fn write_atomic_beneath_preserves_existing_mode() {
661 use std::os::unix::fs::PermissionsExt;
662 let root = unique_dir("atomic_mode");
663 let rel = Path::new("script.sh");
664 write_atomic_beneath(&root, rel, b"#!/bin/sh\n").unwrap();
665 std::fs::set_permissions(
669 root.join("script.sh"),
670 std::fs::Permissions::from_mode(0o755),
671 )
672 .unwrap();
673 write_atomic_beneath(&root, rel, b"#!/bin/sh\necho hi\n").unwrap();
674 let mode = std::fs::metadata(root.join("script.sh"))
675 .unwrap()
676 .permissions()
677 .mode()
678 & 0o777;
679 assert_eq!(mode, 0o755, "atomic write must preserve the executable bit");
680 let _ = std::fs::remove_dir_all(&root);
681 }
682
683 #[test]
684 fn write_atomic_beneath_refuses_write_through_escaping_symlink() {
685 let root = unique_dir("atomic_escape_root");
686 let outside = unique_dir("atomic_escape_outside");
687 std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
688
689 let res = write_atomic_beneath(&root, Path::new("escape/evil.txt"), b"x");
690 assert!(
691 res.is_err(),
692 "atomic write through escaping symlink must be refused"
693 );
694 assert!(!outside.join("evil.txt").exists());
695
696 let _ = std::fs::remove_dir_all(&root);
697 let _ = std::fs::remove_dir_all(&outside);
698 }
699
700 #[test]
701 fn write_atomic_beneath_follows_in_tree_symlink() {
702 let root = unique_dir("atomic_intree");
703 std::fs::create_dir(root.join("real")).unwrap();
704 std::os::unix::fs::symlink("real", root.join("link")).unwrap();
705
706 write_atomic_beneath(&root, Path::new("link/file.txt"), b"via-symlink").unwrap();
707 assert_eq!(
708 std::fs::read_to_string(root.join("real/file.txt")).unwrap(),
709 "via-symlink"
710 );
711 let _ = std::fs::remove_dir_all(&root);
712 }
713}