use std::fs::File;
use std::io;
use std::path::{Component, Path, PathBuf};
use anyhow::Result;
pub(crate) fn contain_within(root: &Path, raw: &str) -> Result<PathBuf> {
let candidate = if Path::new(raw).is_absolute() {
PathBuf::from(raw)
} else {
root.join(raw)
};
let lexical = normalize_lexical(&candidate);
let root = normalize_lexical(root);
anyhow::ensure!(
lexical.starts_with(&root),
"path escapes the project root: {raw}"
);
Ok(lexical)
}
pub(crate) fn contain_within_canonical(root: &Path, raw: &str) -> Result<PathBuf> {
let lexical = contain_within(root, raw)?;
let canon_root = match std::fs::canonicalize(root) {
Ok(r) => r,
Err(_) => return Ok(lexical),
};
let mut ancestor = lexical.as_path();
let existing = loop {
if ancestor.exists() {
break Some(ancestor);
}
match ancestor.parent() {
Some(p) => ancestor = p,
None => break None,
}
};
if let Some(existing) = existing
&& let Ok(canon) = std::fs::canonicalize(existing)
{
anyhow::ensure!(
canon.starts_with(&canon_root),
"path escapes the project root via a symlink: {raw}"
);
}
Ok(lexical)
}
pub(crate) fn relative_within(root: &Path, raw: &str) -> Result<PathBuf> {
let abs = contain_within(root, raw)?;
let root_norm = normalize_lexical(root);
let rel = abs
.strip_prefix(&root_norm)
.map_err(|_| anyhow::anyhow!("path escapes the project root: {raw}"))?;
Ok(rel.to_path_buf())
}
fn normalize_lexical(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {},
Component::ParentDir => {
out.pop();
},
other => out.push(other.as_os_str()),
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenIntent {
Read,
WriteTruncate,
}
pub fn open_beneath(root: &Path, rel: &Path, intent: OpenIntent) -> io::Result<File> {
#[cfg(target_os = "linux")]
{
match linux::open_beneath(root, rel, intent) {
Err(e) if e == rustix::io::Errno::NOSYS => {}, other => return other.map_err(io::Error::from),
}
}
fallback::open(root, rel, intent)
}
pub fn create_dir_all_beneath(root: &Path, rel: &Path) -> io::Result<()> {
#[cfg(target_os = "linux")]
{
match linux::create_dir_all_beneath(root, rel) {
Err(e) if e == rustix::io::Errno::NOSYS => {},
other => return other.map_err(io::Error::from),
}
}
fallback::create_dir_all(root, rel)
}
pub fn remove_file_beneath(root: &Path, rel: &Path) -> io::Result<()> {
#[cfg(target_os = "linux")]
{
match linux::remove_file_beneath(root, rel) {
Err(e) if e == rustix::io::Errno::NOSYS => {},
other => return other.map_err(io::Error::from),
}
}
fallback::remove_file(root, rel)
}
pub fn write_atomic_beneath(root: &Path, rel: &Path, bytes: &[u8]) -> io::Result<()> {
#[cfg(target_os = "linux")]
{
match linux::write_atomic_beneath(root, rel, bytes) {
Err(e) if e == rustix::io::Errno::NOSYS => {},
other => return other.map_err(io::Error::from),
}
}
fallback::write_atomic(root, rel, bytes)
}
#[cfg(target_os = "linux")]
mod linux {
use std::fs::File;
use std::io::Write;
use std::path::{Component, Path};
use std::sync::atomic::{AtomicU64, Ordering};
use rustix::fd::OwnedFd;
use rustix::fs::{
AtFlags, Mode, OFlags, ResolveFlags, fchmod, mkdirat, open, openat2, renameat, statat,
unlinkat,
};
use rustix::io::Errno;
use super::OpenIntent;
fn open_dir(dir: &Path) -> Result<OwnedFd, Errno> {
open(
dir,
OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
)
}
fn open_subdir(dir_fd: &OwnedFd, name: &Path) -> Result<OwnedFd, Errno> {
openat2(
dir_fd,
name,
OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
ResolveFlags::BENEATH,
)
}
pub(super) fn open_beneath(root: &Path, rel: &Path, intent: OpenIntent) -> Result<File, Errno> {
let root_fd = open_dir(root)?;
let (flags, mode) = match intent {
OpenIntent::Read => (OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty()),
OpenIntent::WriteTruncate => (
OFlags::WRONLY | OFlags::CREATE | OFlags::TRUNC | OFlags::CLOEXEC,
Mode::from_raw_mode(0o644),
),
};
let fd = openat2(&root_fd, rel, flags, mode, ResolveFlags::BENEATH)?;
Ok(File::from(fd))
}
pub(super) fn create_dir_all_beneath(root: &Path, rel: &Path) -> Result<(), Errno> {
let mut dir = open_dir(root)?;
for comp in rel.components() {
let name: &Path = match comp {
Component::Normal(n) => Path::new(n),
Component::CurDir => continue,
_ => return Err(Errno::INVAL),
};
match mkdirat(&dir, name, Mode::from_raw_mode(0o755)) {
Ok(()) | Err(Errno::EXIST) => {},
Err(e) => return Err(e),
}
dir = open_subdir(&dir, name)?;
}
Ok(())
}
pub(super) fn remove_file_beneath(root: &Path, rel: &Path) -> Result<(), Errno> {
let root_fd = open_dir(root)?;
let leaf = rel.file_name().ok_or(Errno::INVAL)?;
let parent = rel.parent().unwrap_or_else(|| Path::new(""));
let parent_fd = if parent.as_os_str().is_empty() {
root_fd
} else {
open_subdir(&root_fd, parent)?
};
unlinkat(&parent_fd, Path::new(leaf), AtFlags::empty())
}
pub(super) fn write_atomic_beneath(root: &Path, rel: &Path, bytes: &[u8]) -> Result<(), Errno> {
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
let root_fd = open_dir(root)?;
let leaf = rel.file_name().ok_or(Errno::INVAL)?;
let parent = rel.parent().unwrap_or_else(|| Path::new(""));
let parent_fd = if parent.as_os_str().is_empty() {
root_fd
} else {
open_subdir(&root_fd, parent)?
};
let n = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp_name = format!(".mermaid.{}.{}.tmp", std::process::id(), n);
let tmp_path = Path::new(&tmp_name);
let existing_mode = statat(&parent_fd, Path::new(leaf), AtFlags::empty())
.ok()
.map(|st| st.st_mode & 0o7777);
let fd = openat2(
&parent_fd,
tmp_path,
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC,
Mode::from_raw_mode(0o644),
ResolveFlags::BENEATH,
)?;
if let Some(mode) = existing_mode {
let _ = fchmod(&fd, Mode::from_raw_mode(mode));
}
let written = (|| -> std::io::Result<()> {
let mut file = File::from(fd);
file.write_all(bytes)?;
file.sync_all()
})();
if let Err(e) = written {
let _ = unlinkat(&parent_fd, tmp_path, AtFlags::empty());
return Err(io_to_errno(e));
}
if let Err(e) = renameat(&parent_fd, tmp_path, &parent_fd, Path::new(leaf)) {
let _ = unlinkat(&parent_fd, tmp_path, AtFlags::empty());
return Err(e);
}
if let Ok(dir) = openat2(
&parent_fd,
Path::new("."),
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
ResolveFlags::BENEATH,
) {
let _ = File::from(dir).sync_all();
}
Ok(())
}
fn io_to_errno(e: std::io::Error) -> Errno {
Errno::from_io_error(&e).unwrap_or(Errno::IO)
}
}
mod fallback {
use std::fs::{File, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Once;
use super::{OpenIntent, contain_within_canonical};
fn warn_fallback_once() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
tracing::warn!(
"path confinement is using the by-path fallback (no openat2 \
RESOLVE_BENEATH on this platform/kernel); a check-then-use symlink \
TOCTOU window remains for in-workspace writes/deletes"
);
});
}
fn validated(root: &Path, rel: &Path) -> io::Result<PathBuf> {
warn_fallback_once();
let rel_str = rel
.to_str()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "non-UTF-8 path"))?;
contain_within_canonical(root, rel_str)
.map_err(|e| io::Error::new(io::ErrorKind::PermissionDenied, e.to_string()))
}
pub(super) fn open(root: &Path, rel: &Path, intent: OpenIntent) -> io::Result<File> {
let path = validated(root, rel)?;
match intent {
OpenIntent::Read => File::open(&path),
OpenIntent::WriteTruncate => OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&path),
}
}
pub(super) fn create_dir_all(root: &Path, rel: &Path) -> io::Result<()> {
let path = validated(root, rel)?;
std::fs::create_dir_all(&path)
}
pub(super) fn remove_file(root: &Path, rel: &Path) -> io::Result<()> {
let path = validated(root, rel)?;
std::fs::remove_file(&path)
}
pub(super) fn write_atomic(root: &Path, rel: &Path, bytes: &[u8]) -> io::Result<()> {
let path = validated(root, rel)?;
crate::write_atomic(&path, bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_parent_escape() {
let root = std::env::temp_dir().join("mermaid_pathguard_root");
assert!(contain_within(&root, "../escape").is_err());
}
#[test]
fn rejects_absolute_outside_root() {
let root = std::env::temp_dir().join("mermaid_pathguard_root2");
#[cfg(unix)]
assert!(contain_within(&root, "/etc/passwd").is_err());
#[cfg(windows)]
assert!(contain_within(&root, "C:\\Windows\\System32\\drivers\\etc\\hosts").is_err());
}
#[test]
fn accepts_in_root_relative() {
let root = std::env::temp_dir().join("mermaid_pathguard_root3");
let p = contain_within(&root, "a/b.txt").unwrap();
assert!(p.starts_with(normalize_lexical(&root)));
assert!(p.ends_with("b.txt"));
}
#[test]
fn collapses_interior_parent_within_root() {
let root = std::env::temp_dir().join("mermaid_pathguard_root4");
let p = contain_within(&root, "a/../b.txt").unwrap();
assert!(p.ends_with("b.txt"));
assert!(p.starts_with(normalize_lexical(&root)));
}
}
#[cfg(all(test, target_os = "linux"))]
mod confined_tests {
use std::io::{Read, Write};
use super::*;
fn unique_dir(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("mermaid_beneath_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn create_write_read_roundtrip_stays_in_root() {
let root = unique_dir("rw");
create_dir_all_beneath(&root, Path::new("sub/inner")).unwrap();
{
let mut f = open_beneath(
&root,
Path::new("sub/inner/file.txt"),
OpenIntent::WriteTruncate,
)
.unwrap();
f.write_all(b"hello").unwrap();
}
assert_eq!(
std::fs::read_to_string(root.join("sub/inner/file.txt")).unwrap(),
"hello"
);
let mut buf = String::new();
open_beneath(&root, Path::new("sub/inner/file.txt"), OpenIntent::Read)
.unwrap()
.read_to_string(&mut buf)
.unwrap();
assert_eq!(buf, "hello");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn open_beneath_refuses_write_through_escaping_symlink() {
let root = unique_dir("escape_root");
let outside = unique_dir("escape_outside");
std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
let res = open_beneath(
&root,
Path::new("escape/evil.txt"),
OpenIntent::WriteTruncate,
);
assert!(
res.is_err(),
"write through escaping symlink must be refused"
);
assert!(
!outside.join("evil.txt").exists(),
"nothing should have been written outside the root"
);
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&outside);
}
#[test]
fn open_beneath_follows_in_tree_symlink() {
let root = unique_dir("intree");
std::fs::create_dir(root.join("real")).unwrap();
std::os::unix::fs::symlink("real", root.join("link")).unwrap();
{
let mut f =
open_beneath(&root, Path::new("link/file.txt"), OpenIntent::WriteTruncate).unwrap();
f.write_all(b"via-symlink").unwrap();
}
assert_eq!(
std::fs::read_to_string(root.join("real/file.txt")).unwrap(),
"via-symlink"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn create_dir_all_beneath_refuses_escape() {
let root = unique_dir("mkdir_root");
let outside = unique_dir("mkdir_outside");
std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
let res = create_dir_all_beneath(&root, Path::new("escape/newdir"));
assert!(
res.is_err(),
"mkdir through escaping symlink must be refused"
);
assert!(!outside.join("newdir").exists());
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&outside);
}
#[test]
fn remove_file_beneath_deletes_in_root_and_refuses_escape() {
let root = unique_dir("rm_root");
{
let mut f =
open_beneath(&root, Path::new("gone.txt"), OpenIntent::WriteTruncate).unwrap();
f.write_all(b"x").unwrap();
}
assert!(root.join("gone.txt").exists());
remove_file_beneath(&root, Path::new("gone.txt")).unwrap();
assert!(!root.join("gone.txt").exists());
let outside = unique_dir("rm_outside");
std::fs::write(outside.join("victim.txt"), b"keep").unwrap();
std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
let res = remove_file_beneath(&root, Path::new("escape/victim.txt"));
assert!(
res.is_err(),
"unlink through escaping symlink must be refused"
);
assert!(outside.join("victim.txt").exists());
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&outside);
}
#[test]
fn write_atomic_beneath_roundtrips_and_replaces_without_temp_residue() {
let root = unique_dir("atomic_rw");
create_dir_all_beneath(&root, Path::new("sub")).unwrap();
write_atomic_beneath(&root, Path::new("sub/file.txt"), b"first").unwrap();
assert_eq!(
std::fs::read_to_string(root.join("sub/file.txt")).unwrap(),
"first"
);
write_atomic_beneath(&root, Path::new("sub/file.txt"), b"second").unwrap();
assert_eq!(
std::fs::read_to_string(root.join("sub/file.txt")).unwrap(),
"second"
);
let leftovers = std::fs::read_dir(root.join("sub"))
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.count();
assert_eq!(leftovers, 0, "atomic write must not leak a temp file");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn write_atomic_beneath_preserves_existing_mode() {
use std::os::unix::fs::PermissionsExt;
let root = unique_dir("atomic_mode");
let rel = Path::new("script.sh");
write_atomic_beneath(&root, rel, b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(
root.join("script.sh"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
write_atomic_beneath(&root, rel, b"#!/bin/sh\necho hi\n").unwrap();
let mode = std::fs::metadata(root.join("script.sh"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o755, "atomic write must preserve the executable bit");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn write_atomic_beneath_refuses_write_through_escaping_symlink() {
let root = unique_dir("atomic_escape_root");
let outside = unique_dir("atomic_escape_outside");
std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
let res = write_atomic_beneath(&root, Path::new("escape/evil.txt"), b"x");
assert!(
res.is_err(),
"atomic write through escaping symlink must be refused"
);
assert!(!outside.join("evil.txt").exists());
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&outside);
}
#[test]
fn write_atomic_beneath_follows_in_tree_symlink() {
let root = unique_dir("atomic_intree");
std::fs::create_dir(root.join("real")).unwrap();
std::os::unix::fs::symlink("real", root.join("link")).unwrap();
write_atomic_beneath(&root, Path::new("link/file.txt"), b"via-symlink").unwrap();
assert_eq!(
std::fs::read_to_string(root.join("real/file.txt")).unwrap(),
"via-symlink"
);
let _ = std::fs::remove_dir_all(&root);
}
}