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
75fn normalize_lexical(path: &Path) -> PathBuf {
78 let mut out = PathBuf::new();
79 for component in path.components() {
80 match component {
81 Component::CurDir => {},
82 Component::ParentDir => {
83 out.pop();
84 },
85 other => out.push(other.as_os_str()),
86 }
87 }
88 out
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum OpenIntent {
94 Read,
96 WriteTruncate,
98}
99
100pub fn open_beneath(root: &Path, rel: &Path, intent: OpenIntent) -> io::Result<File> {
116 #[cfg(target_os = "linux")]
117 {
118 match linux::open_beneath(root, rel, intent) {
119 Err(e) if e == rustix::io::Errno::NOSYS => {}, other => return other.map_err(io::Error::from),
121 }
122 }
123 fallback::open(root, rel, intent)
124}
125
126pub fn create_dir_all_beneath(root: &Path, rel: &Path) -> io::Result<()> {
131 #[cfg(target_os = "linux")]
132 {
133 match linux::create_dir_all_beneath(root, rel) {
134 Err(e) if e == rustix::io::Errno::NOSYS => {},
135 other => return other.map_err(io::Error::from),
136 }
137 }
138 fallback::create_dir_all(root, rel)
139}
140
141pub fn remove_file_beneath(root: &Path, rel: &Path) -> io::Result<()> {
145 #[cfg(target_os = "linux")]
146 {
147 match linux::remove_file_beneath(root, rel) {
148 Err(e) if e == rustix::io::Errno::NOSYS => {},
149 other => return other.map_err(io::Error::from),
150 }
151 }
152 fallback::remove_file(root, rel)
153}
154
155#[cfg(target_os = "linux")]
159mod linux {
160 use std::fs::File;
161 use std::path::{Component, Path};
162
163 use rustix::fd::OwnedFd;
164 use rustix::fs::{AtFlags, Mode, OFlags, ResolveFlags, mkdirat, open, openat2, unlinkat};
165 use rustix::io::Errno;
166
167 use super::OpenIntent;
168
169 fn open_dir(dir: &Path) -> Result<OwnedFd, Errno> {
172 open(
173 dir,
174 OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
175 Mode::empty(),
176 )
177 }
178
179 fn open_subdir(dir_fd: &OwnedFd, name: &Path) -> Result<OwnedFd, Errno> {
181 openat2(
182 dir_fd,
183 name,
184 OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
185 Mode::empty(),
186 ResolveFlags::BENEATH,
187 )
188 }
189
190 pub(super) fn open_beneath(root: &Path, rel: &Path, intent: OpenIntent) -> Result<File, Errno> {
191 let root_fd = open_dir(root)?;
192 let (flags, mode) = match intent {
193 OpenIntent::Read => (OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty()),
194 OpenIntent::WriteTruncate => (
195 OFlags::WRONLY | OFlags::CREATE | OFlags::TRUNC | OFlags::CLOEXEC,
196 Mode::from_raw_mode(0o644),
197 ),
198 };
199 let fd = openat2(&root_fd, rel, flags, mode, ResolveFlags::BENEATH)?;
200 Ok(File::from(fd))
201 }
202
203 pub(super) fn create_dir_all_beneath(root: &Path, rel: &Path) -> Result<(), Errno> {
204 let mut dir = open_dir(root)?;
205 for comp in rel.components() {
206 let name: &Path = match comp {
207 Component::Normal(n) => Path::new(n),
208 Component::CurDir => continue,
209 _ => return Err(Errno::INVAL),
212 };
213 match mkdirat(&dir, name, Mode::from_raw_mode(0o755)) {
214 Ok(()) | Err(Errno::EXIST) => {},
215 Err(e) => return Err(e),
216 }
217 dir = open_subdir(&dir, name)?;
218 }
219 Ok(())
220 }
221
222 pub(super) fn remove_file_beneath(root: &Path, rel: &Path) -> Result<(), Errno> {
223 let root_fd = open_dir(root)?;
224 let leaf = rel.file_name().ok_or(Errno::INVAL)?;
225 let parent = rel.parent().unwrap_or_else(|| Path::new(""));
226 let parent_fd = if parent.as_os_str().is_empty() {
227 root_fd
228 } else {
229 open_subdir(&root_fd, parent)?
230 };
231 unlinkat(&parent_fd, Path::new(leaf), AtFlags::empty())
232 }
233}
234
235mod fallback {
249 use std::fs::{File, OpenOptions};
250 use std::io;
251 use std::path::{Path, PathBuf};
252
253 use super::{OpenIntent, contain_within_canonical};
254
255 fn validated(root: &Path, rel: &Path) -> io::Result<PathBuf> {
256 let rel_str = rel
257 .to_str()
258 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "non-UTF-8 path"))?;
259 contain_within_canonical(root, rel_str)
260 .map_err(|e| io::Error::new(io::ErrorKind::PermissionDenied, e.to_string()))
261 }
262
263 pub(super) fn open(root: &Path, rel: &Path, intent: OpenIntent) -> io::Result<File> {
264 let path = validated(root, rel)?;
265 match intent {
266 OpenIntent::Read => File::open(&path),
267 OpenIntent::WriteTruncate => OpenOptions::new()
268 .write(true)
269 .create(true)
270 .truncate(true)
271 .open(&path),
272 }
273 }
274
275 pub(super) fn create_dir_all(root: &Path, rel: &Path) -> io::Result<()> {
276 let path = validated(root, rel)?;
277 std::fs::create_dir_all(&path)
278 }
279
280 pub(super) fn remove_file(root: &Path, rel: &Path) -> io::Result<()> {
281 let path = validated(root, rel)?;
282 std::fs::remove_file(&path)
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn rejects_parent_escape() {
292 let root = std::env::temp_dir().join("mermaid_pathguard_root");
293 assert!(contain_within(&root, "../escape").is_err());
294 }
295
296 #[test]
297 fn rejects_absolute_outside_root() {
298 let root = std::env::temp_dir().join("mermaid_pathguard_root2");
299 #[cfg(unix)]
300 assert!(contain_within(&root, "/etc/passwd").is_err());
301 #[cfg(windows)]
302 assert!(contain_within(&root, "C:\\Windows\\System32\\drivers\\etc\\hosts").is_err());
303 }
304
305 #[test]
306 fn accepts_in_root_relative() {
307 let root = std::env::temp_dir().join("mermaid_pathguard_root3");
308 let p = contain_within(&root, "a/b.txt").unwrap();
309 assert!(p.starts_with(normalize_lexical(&root)));
310 assert!(p.ends_with("b.txt"));
311 }
312
313 #[test]
314 fn collapses_interior_parent_within_root() {
315 let root = std::env::temp_dir().join("mermaid_pathguard_root4");
316 let p = contain_within(&root, "a/../b.txt").unwrap();
318 assert!(p.ends_with("b.txt"));
319 assert!(p.starts_with(normalize_lexical(&root)));
320 }
321}
322
323#[cfg(all(test, target_os = "linux"))]
324mod confined_tests {
325 use std::io::{Read, Write};
326
327 use super::*;
328
329 fn unique_dir(tag: &str) -> PathBuf {
331 let dir =
332 std::env::temp_dir().join(format!("mermaid_beneath_{tag}_{}", std::process::id()));
333 let _ = std::fs::remove_dir_all(&dir);
334 std::fs::create_dir_all(&dir).unwrap();
335 dir
336 }
337
338 #[test]
339 fn create_write_read_roundtrip_stays_in_root() {
340 let root = unique_dir("rw");
341 create_dir_all_beneath(&root, Path::new("sub/inner")).unwrap();
342 {
343 let mut f = open_beneath(
344 &root,
345 Path::new("sub/inner/file.txt"),
346 OpenIntent::WriteTruncate,
347 )
348 .unwrap();
349 f.write_all(b"hello").unwrap();
350 }
351 assert_eq!(
353 std::fs::read_to_string(root.join("sub/inner/file.txt")).unwrap(),
354 "hello"
355 );
356 let mut buf = String::new();
358 open_beneath(&root, Path::new("sub/inner/file.txt"), OpenIntent::Read)
359 .unwrap()
360 .read_to_string(&mut buf)
361 .unwrap();
362 assert_eq!(buf, "hello");
363 let _ = std::fs::remove_dir_all(&root);
364 }
365
366 #[test]
367 fn open_beneath_refuses_write_through_escaping_symlink() {
368 let root = unique_dir("escape_root");
369 let outside = unique_dir("escape_outside");
370 std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
373
374 let res = open_beneath(
375 &root,
376 Path::new("escape/evil.txt"),
377 OpenIntent::WriteTruncate,
378 );
379 assert!(
380 res.is_err(),
381 "write through escaping symlink must be refused"
382 );
383 assert!(
384 !outside.join("evil.txt").exists(),
385 "nothing should have been written outside the root"
386 );
387
388 let _ = std::fs::remove_dir_all(&root);
389 let _ = std::fs::remove_dir_all(&outside);
390 }
391
392 #[test]
393 fn open_beneath_follows_in_tree_symlink() {
394 let root = unique_dir("intree");
399 std::fs::create_dir(root.join("real")).unwrap();
400 std::os::unix::fs::symlink("real", root.join("link")).unwrap();
402
403 {
404 let mut f =
405 open_beneath(&root, Path::new("link/file.txt"), OpenIntent::WriteTruncate).unwrap();
406 f.write_all(b"via-symlink").unwrap();
407 }
408 assert_eq!(
410 std::fs::read_to_string(root.join("real/file.txt")).unwrap(),
411 "via-symlink"
412 );
413 let _ = std::fs::remove_dir_all(&root);
414 }
415
416 #[test]
417 fn create_dir_all_beneath_refuses_escape() {
418 let root = unique_dir("mkdir_root");
419 let outside = unique_dir("mkdir_outside");
420 std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
421
422 let res = create_dir_all_beneath(&root, Path::new("escape/newdir"));
423 assert!(
424 res.is_err(),
425 "mkdir through escaping symlink must be refused"
426 );
427 assert!(!outside.join("newdir").exists());
428
429 let _ = std::fs::remove_dir_all(&root);
430 let _ = std::fs::remove_dir_all(&outside);
431 }
432
433 #[test]
434 fn remove_file_beneath_deletes_in_root_and_refuses_escape() {
435 let root = unique_dir("rm_root");
436 {
437 let mut f =
438 open_beneath(&root, Path::new("gone.txt"), OpenIntent::WriteTruncate).unwrap();
439 f.write_all(b"x").unwrap();
440 }
441 assert!(root.join("gone.txt").exists());
442 remove_file_beneath(&root, Path::new("gone.txt")).unwrap();
443 assert!(!root.join("gone.txt").exists());
444
445 let outside = unique_dir("rm_outside");
448 std::fs::write(outside.join("victim.txt"), b"keep").unwrap();
449 std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap();
450 let res = remove_file_beneath(&root, Path::new("escape/victim.txt"));
451 assert!(
452 res.is_err(),
453 "unlink through escaping symlink must be refused"
454 );
455 assert!(outside.join("victim.txt").exists());
456
457 let _ = std::fs::remove_dir_all(&root);
458 let _ = std::fs::remove_dir_all(&outside);
459 }
460}