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> {
131 #[cfg(target_os = "linux")]
132 {
133 match linux::open_beneath(root, rel, intent) {
134 Err(e) if e == rustix::io::Errno::NOSYS => {}, other => return other.map_err(io::Error::from),
136 }
137 }
138 fallback::open(root, rel, intent)
139}
140
141pub fn create_dir_all_beneath(root: &Path, rel: &Path) -> io::Result<()> {
146 #[cfg(target_os = "linux")]
147 {
148 match linux::create_dir_all_beneath(root, rel) {
149 Err(e) if e == rustix::io::Errno::NOSYS => {},
150 other => return other.map_err(io::Error::from),
151 }
152 }
153 fallback::create_dir_all(root, rel)
154}
155
156pub fn remove_file_beneath(root: &Path, rel: &Path) -> io::Result<()> {
160 #[cfg(target_os = "linux")]
161 {
162 match linux::remove_file_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::remove_file(root, rel)
168}
169
170pub fn write_atomic_beneath(root: &Path, rel: &Path, bytes: &[u8]) -> io::Result<()> {
180 #[cfg(target_os = "linux")]
181 {
182 match linux::write_atomic_beneath(root, rel, bytes) {
183 Err(e) if e == rustix::io::Errno::NOSYS => {},
184 other => return other.map_err(io::Error::from),
185 }
186 }
187 fallback::write_atomic(root, rel, bytes)
188}
189
190#[cfg(target_os = "linux")]
194mod linux {
195 use std::fs::File;
196 use std::io::Write;
197 use std::path::{Component, Path};
198 use std::sync::atomic::{AtomicU64, Ordering};
199
200 use rustix::fd::OwnedFd;
201 use rustix::fs::{
202 AtFlags, Mode, OFlags, ResolveFlags, fchmod, mkdirat, open, openat2, renameat, statat,
203 unlinkat,
204 };
205 use rustix::io::Errno;
206
207 use super::OpenIntent;
208
209 fn open_dir(dir: &Path) -> Result<OwnedFd, Errno> {
212 open(
213 dir,
214 OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
215 Mode::empty(),
216 )
217 }
218
219 fn open_subdir(dir_fd: &OwnedFd, name: &Path) -> Result<OwnedFd, Errno> {
221 openat2(
222 dir_fd,
223 name,
224 OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
225 Mode::empty(),
226 ResolveFlags::BENEATH,
227 )
228 }
229
230 pub(super) fn open_beneath(root: &Path, rel: &Path, intent: OpenIntent) -> Result<File, Errno> {
231 let root_fd = open_dir(root)?;
232 let (flags, mode) = match intent {
233 OpenIntent::Read => (OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty()),
234 OpenIntent::WriteTruncate => (
235 OFlags::WRONLY | OFlags::CREATE | OFlags::TRUNC | OFlags::CLOEXEC,
236 Mode::from_raw_mode(0o644),
237 ),
238 };
239 let fd = openat2(&root_fd, rel, flags, mode, ResolveFlags::BENEATH)?;
240 Ok(File::from(fd))
241 }
242
243 pub(super) fn create_dir_all_beneath(root: &Path, rel: &Path) -> Result<(), Errno> {
244 let mut dir = open_dir(root)?;
245 for comp in rel.components() {
246 let name: &Path = match comp {
247 Component::Normal(n) => Path::new(n),
248 Component::CurDir => continue,
249 _ => return Err(Errno::INVAL),
252 };
253 match mkdirat(&dir, name, Mode::from_raw_mode(0o755)) {
254 Ok(()) | Err(Errno::EXIST) => {},
255 Err(e) => return Err(e),
256 }
257 dir = open_subdir(&dir, name)?;
258 }
259 Ok(())
260 }
261
262 pub(super) fn remove_file_beneath(root: &Path, rel: &Path) -> Result<(), Errno> {
263 let root_fd = open_dir(root)?;
264 let leaf = rel.file_name().ok_or(Errno::INVAL)?;
265 let parent = rel.parent().unwrap_or_else(|| Path::new(""));
266 let parent_fd = if parent.as_os_str().is_empty() {
267 root_fd
268 } else {
269 open_subdir(&root_fd, parent)?
270 };
271 unlinkat(&parent_fd, Path::new(leaf), AtFlags::empty())
272 }
273
274 pub(super) fn write_atomic_beneath(root: &Path, rel: &Path, bytes: &[u8]) -> Result<(), Errno> {
280 static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
281
282 let root_fd = open_dir(root)?;
283 let leaf = rel.file_name().ok_or(Errno::INVAL)?;
284 let parent = rel.parent().unwrap_or_else(|| Path::new(""));
285 let parent_fd = if parent.as_os_str().is_empty() {
286 root_fd
287 } else {
288 open_subdir(&root_fd, parent)?
289 };
290
291 let n = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
292 let tmp_name = format!(".mermaid.{}.{}.tmp", std::process::id(), n);
293 let tmp_path = Path::new(&tmp_name);
294
295 let existing_mode = statat(&parent_fd, Path::new(leaf), AtFlags::empty())
301 .ok()
302 .map(|st| st.st_mode & 0o7777);
303
304 let fd = openat2(
306 &parent_fd,
307 tmp_path,
308 OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC,
309 Mode::from_raw_mode(0o644),
310 ResolveFlags::BENEATH,
311 )?;
312
313 if let Some(mode) = existing_mode {
316 let _ = fchmod(&fd, Mode::from_raw_mode(mode));
317 }
318
319 let written = (|| -> std::io::Result<()> {
322 let mut file = File::from(fd);
323 file.write_all(bytes)?;
324 file.sync_all()
325 })();
326 if let Err(e) = written {
327 let _ = unlinkat(&parent_fd, tmp_path, AtFlags::empty());
328 return Err(io_to_errno(e));
329 }
330
331 if let Err(e) = renameat(&parent_fd, tmp_path, &parent_fd, Path::new(leaf)) {
333 let _ = unlinkat(&parent_fd, tmp_path, AtFlags::empty());
334 return Err(e);
335 }
336
337 if let Ok(dir) = openat2(
339 &parent_fd,
340 Path::new("."),
341 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
342 Mode::empty(),
343 ResolveFlags::BENEATH,
344 ) {
345 let _ = File::from(dir).sync_all();
346 }
347 Ok(())
348 }
349
350 fn io_to_errno(e: std::io::Error) -> Errno {
353 Errno::from_io_error(&e).unwrap_or(Errno::IO)
354 }
355}
356
357mod fallback {
371 use std::fs::{File, OpenOptions};
372 use std::io;
373 use std::path::{Path, PathBuf};
374 use std::sync::Once;
375
376 use super::{OpenIntent, contain_within_canonical};
377
378 fn warn_fallback_once() {
386 static ONCE: Once = Once::new();
387 ONCE.call_once(|| {
388 tracing::warn!(
389 "path confinement is using the by-path fallback (no openat2 \
390 RESOLVE_BENEATH on this platform/kernel); a check-then-use symlink \
391 TOCTOU window remains for in-workspace writes/deletes"
392 );
393 });
394 }
395
396 fn validated(root: &Path, rel: &Path) -> io::Result<PathBuf> {
397 warn_fallback_once();
398 let rel_str = rel
399 .to_str()
400 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "non-UTF-8 path"))?;
401 contain_within_canonical(root, rel_str)
402 .map_err(|e| io::Error::new(io::ErrorKind::PermissionDenied, e.to_string()))
403 }
404
405 pub(super) fn open(root: &Path, rel: &Path, intent: OpenIntent) -> io::Result<File> {
406 let path = validated(root, rel)?;
407 match intent {
408 OpenIntent::Read => File::open(&path),
409 OpenIntent::WriteTruncate => OpenOptions::new()
410 .write(true)
411 .create(true)
412 .truncate(true)
413 .open(&path),
414 }
415 }
416
417 pub(super) fn create_dir_all(root: &Path, rel: &Path) -> io::Result<()> {
418 let path = validated(root, rel)?;
419 std::fs::create_dir_all(&path)
420 }
421
422 pub(super) fn remove_file(root: &Path, rel: &Path) -> io::Result<()> {
423 let path = validated(root, rel)?;
424 std::fs::remove_file(&path)
425 }
426
427 pub(super) fn write_atomic(root: &Path, rel: &Path, bytes: &[u8]) -> io::Result<()> {
428 let path = validated(root, rel)?;
429 crate::write_atomic(&path, bytes)
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[test]
438 fn rejects_parent_escape() {
439 let root = std::env::temp_dir().join("mermaid_pathguard_root");
440 assert!(contain_within(&root, "../escape").is_err());
441 }
442
443 #[test]
444 fn rejects_absolute_outside_root() {
445 let root = std::env::temp_dir().join("mermaid_pathguard_root2");
446 #[cfg(unix)]
447 assert!(contain_within(&root, "/etc/passwd").is_err());
448 #[cfg(windows)]
449 assert!(contain_within(&root, "C:\\Windows\\System32\\drivers\\etc\\hosts").is_err());
450 }
451
452 #[test]
453 fn accepts_in_root_relative() {
454 let root = std::env::temp_dir().join("mermaid_pathguard_root3");
455 let p = contain_within(&root, "a/b.txt").unwrap();
456 assert!(p.starts_with(normalize_lexical(&root)));
457 assert!(p.ends_with("b.txt"));
458 }
459
460 #[test]
461 fn collapses_interior_parent_within_root() {
462 let root = std::env::temp_dir().join("mermaid_pathguard_root4");
463 let p = contain_within(&root, "a/../b.txt").unwrap();
465 assert!(p.ends_with("b.txt"));
466 assert!(p.starts_with(normalize_lexical(&root)));
467 }
468}
469
470#[cfg(all(test, target_os = "linux"))]
471mod confined_tests {
472 use std::io::{Read, Write};
473
474 use super::*;
475
476 fn unique_dir(tag: &str) -> PathBuf {
478 let dir =
479 std::env::temp_dir().join(format!("mermaid_beneath_{tag}_{}", std::process::id()));
480 let _ = std::fs::remove_dir_all(&dir);
481 std::fs::create_dir_all(&dir).unwrap();
482 dir
483 }
484
485 #[test]
486 fn create_write_read_roundtrip_stays_in_root() {
487 let root = unique_dir("rw");
488 create_dir_all_beneath(&root, Path::new("sub/inner")).unwrap();
489 {
490 let mut f = open_beneath(
491 &root,
492 Path::new("sub/inner/file.txt"),
493 OpenIntent::WriteTruncate,
494 )
495 .unwrap();
496 f.write_all(b"hello").unwrap();
497 }
498 assert_eq!(
500 std::fs::read_to_string(root.join("sub/inner/file.txt")).unwrap(),
501 "hello"
502 );
503 let mut buf = String::new();
505 open_beneath(&root, Path::new("sub/inner/file.txt"), OpenIntent::Read)
506 .unwrap()
507 .read_to_string(&mut buf)
508 .unwrap();
509 assert_eq!(buf, "hello");
510 let _ = std::fs::remove_dir_all(&root);
511 }
512
513 #[test]
514 fn open_beneath_refuses_write_through_escaping_symlink() {
515 let root = unique_dir("escape_root");
516 let outside = unique_dir("escape_outside");
517 std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
520
521 let res = open_beneath(
522 &root,
523 Path::new("escape/evil.txt"),
524 OpenIntent::WriteTruncate,
525 );
526 assert!(
527 res.is_err(),
528 "write through escaping symlink must be refused"
529 );
530 assert!(
531 !outside.join("evil.txt").exists(),
532 "nothing should have been written outside the root"
533 );
534
535 let _ = std::fs::remove_dir_all(&root);
536 let _ = std::fs::remove_dir_all(&outside);
537 }
538
539 #[test]
540 fn open_beneath_follows_in_tree_symlink() {
541 let root = unique_dir("intree");
546 std::fs::create_dir(root.join("real")).unwrap();
547 std::os::unix::fs::symlink("real", root.join("link")).unwrap();
549
550 {
551 let mut f =
552 open_beneath(&root, Path::new("link/file.txt"), OpenIntent::WriteTruncate).unwrap();
553 f.write_all(b"via-symlink").unwrap();
554 }
555 assert_eq!(
557 std::fs::read_to_string(root.join("real/file.txt")).unwrap(),
558 "via-symlink"
559 );
560 let _ = std::fs::remove_dir_all(&root);
561 }
562
563 #[test]
564 fn create_dir_all_beneath_refuses_escape() {
565 let root = unique_dir("mkdir_root");
566 let outside = unique_dir("mkdir_outside");
567 std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
568
569 let res = create_dir_all_beneath(&root, Path::new("escape/newdir"));
570 assert!(
571 res.is_err(),
572 "mkdir through escaping symlink must be refused"
573 );
574 assert!(!outside.join("newdir").exists());
575
576 let _ = std::fs::remove_dir_all(&root);
577 let _ = std::fs::remove_dir_all(&outside);
578 }
579
580 #[test]
581 fn remove_file_beneath_deletes_in_root_and_refuses_escape() {
582 let root = unique_dir("rm_root");
583 {
584 let mut f =
585 open_beneath(&root, Path::new("gone.txt"), OpenIntent::WriteTruncate).unwrap();
586 f.write_all(b"x").unwrap();
587 }
588 assert!(root.join("gone.txt").exists());
589 remove_file_beneath(&root, Path::new("gone.txt")).unwrap();
590 assert!(!root.join("gone.txt").exists());
591
592 let outside = unique_dir("rm_outside");
595 std::fs::write(outside.join("victim.txt"), b"keep").unwrap();
596 std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
597 let res = remove_file_beneath(&root, Path::new("escape/victim.txt"));
598 assert!(
599 res.is_err(),
600 "unlink through escaping symlink must be refused"
601 );
602 assert!(outside.join("victim.txt").exists());
603
604 let _ = std::fs::remove_dir_all(&root);
605 let _ = std::fs::remove_dir_all(&outside);
606 }
607
608 #[test]
609 fn write_atomic_beneath_roundtrips_and_replaces_without_temp_residue() {
610 let root = unique_dir("atomic_rw");
611 create_dir_all_beneath(&root, Path::new("sub")).unwrap();
612 write_atomic_beneath(&root, Path::new("sub/file.txt"), b"first").unwrap();
613 assert_eq!(
614 std::fs::read_to_string(root.join("sub/file.txt")).unwrap(),
615 "first"
616 );
617 write_atomic_beneath(&root, Path::new("sub/file.txt"), b"second").unwrap();
619 assert_eq!(
620 std::fs::read_to_string(root.join("sub/file.txt")).unwrap(),
621 "second"
622 );
623 let leftovers = std::fs::read_dir(root.join("sub"))
625 .unwrap()
626 .flatten()
627 .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
628 .count();
629 assert_eq!(leftovers, 0, "atomic write must not leak a temp file");
630 let _ = std::fs::remove_dir_all(&root);
631 }
632
633 #[test]
634 fn write_atomic_beneath_preserves_existing_mode() {
635 use std::os::unix::fs::PermissionsExt;
636 let root = unique_dir("atomic_mode");
637 let rel = Path::new("script.sh");
638 write_atomic_beneath(&root, rel, b"#!/bin/sh\n").unwrap();
639 std::fs::set_permissions(
643 root.join("script.sh"),
644 std::fs::Permissions::from_mode(0o755),
645 )
646 .unwrap();
647 write_atomic_beneath(&root, rel, b"#!/bin/sh\necho hi\n").unwrap();
648 let mode = std::fs::metadata(root.join("script.sh"))
649 .unwrap()
650 .permissions()
651 .mode()
652 & 0o777;
653 assert_eq!(mode, 0o755, "atomic write must preserve the executable bit");
654 let _ = std::fs::remove_dir_all(&root);
655 }
656
657 #[test]
658 fn write_atomic_beneath_refuses_write_through_escaping_symlink() {
659 let root = unique_dir("atomic_escape_root");
660 let outside = unique_dir("atomic_escape_outside");
661 std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
662
663 let res = write_atomic_beneath(&root, Path::new("escape/evil.txt"), b"x");
664 assert!(
665 res.is_err(),
666 "atomic write through escaping symlink must be refused"
667 );
668 assert!(!outside.join("evil.txt").exists());
669
670 let _ = std::fs::remove_dir_all(&root);
671 let _ = std::fs::remove_dir_all(&outside);
672 }
673
674 #[test]
675 fn write_atomic_beneath_follows_in_tree_symlink() {
676 let root = unique_dir("atomic_intree");
677 std::fs::create_dir(root.join("real")).unwrap();
678 std::os::unix::fs::symlink("real", root.join("link")).unwrap();
679
680 write_atomic_beneath(&root, Path::new("link/file.txt"), b"via-symlink").unwrap();
681 assert_eq!(
682 std::fs::read_to_string(root.join("real/file.txt")).unwrap(),
683 "via-symlink"
684 );
685 let _ = std::fs::remove_dir_all(&root);
686 }
687}