Skip to main content

mermaid_runtime/
pathguard.rs

1//! Lexical path containment for the runtime crate.
2//!
3//! Resolves a caller- or manifest-supplied path against a trusted root,
4//! collapsing `.`/`..` *without* touching the filesystem, and rejects anything
5//! that escapes the root. Used by approval replay ([`crate::approval`]) and
6//! checkpoint restore ([`crate::checkpoint`]) to confine writes/deletes whose
7//! paths come from stored (and therefore potentially tampered) state.
8//!
9//! This is the runtime-crate sibling of the main crate's `path_safety` helper;
10//! `mermaid-runtime` is the lower crate and cannot depend on it.
11
12use std::fs::File;
13use std::io;
14use std::path::{Component, Path, PathBuf};
15
16use anyhow::Result;
17
18/// Resolve `raw` against `root` and confirm it stays inside `root`.
19///
20/// `raw` may be relative (joined onto `root`) or absolute (taken as-is). Both
21/// the candidate and the root are normalized lexically — `.` is dropped and
22/// `..` pops the previous component — so traversal is resolved without symlink
23/// expansion or filesystem access (the target may not exist yet, as on a fresh
24/// checkout). Returns the normalized in-root path, or `Err` if it escapes.
25pub(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
40/// Like [`contain_within`], but also defeats a symlink planted *inside* the
41/// root that would redirect a write/delete outside it: the canonical form of
42/// the target's nearest existing ancestor must stay within the canonical root.
43///
44/// The target itself may not exist yet (restoring a deleted file), so we walk
45/// up to the nearest existing ancestor and canonicalize that. Falls back to the
46/// lexical result when the root doesn't yet exist on disk (nothing to escape
47/// through).
48pub(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
75/// Collapse `.` and `..` components lexically (no filesystem access, no symlink
76/// resolution).
77fn 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/// What kind of access an [`open_beneath`] call needs.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum OpenIntent {
94    /// Open an existing file read-only.
95    Read,
96    /// Create-or-truncate for writing (`O_WRONLY|O_CREAT|O_TRUNC`).
97    WriteTruncate,
98}
99
100/// Open `root`-relative `rel` for `intent` **without ever traversing out of
101/// `root`** — closing the check-then-write TOCTOU where an intermediate
102/// directory is swapped for a symlink after a lexical path check but before the
103/// operation (#77). The returned [`File`] is bound to the exact inode the
104/// confinement resolved, so subsequent reads/writes can't be redirected.
105///
106/// On Linux this is enforced atomically by the kernel via
107/// `openat2(RESOLVE_BENEATH)` relative to a directory fd for `root`. We use
108/// `RESOLVE_BENEATH` (which forbids escaping the root via absolute paths, `..`,
109/// or magic links) but **not** `RESOLVE_NO_SYMLINKS`, so legitimate *in-tree*
110/// symlinks keep working — only escapes are refused, matching the existing
111/// [`contain_within_canonical`] semantics. On pre-5.6 kernels (`ENOSYS`) or
112/// non-Linux targets it falls back to `contain_within_canonical` + a plain open
113/// (documented best-effort: the lexical check still holds, but the open is by
114/// path).
115pub 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 => {}, // pre-5.6 kernel: fall through
120            other => return other.map_err(io::Error::from),
121        }
122    }
123    fallback::open(root, rel, intent)
124}
125
126/// Confined `mkdir -p` for a `root`-relative directory path: creates each
127/// missing component beneath `root`, refusing to descend through a symlink that
128/// escapes (#77). On Linux: `mkdirat` + `openat2(RESOLVE_BENEATH)` per
129/// component; otherwise the `contain_within_canonical` + `std::fs` fallback.
130pub 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
141/// Confined unlink of a `root`-relative file (#77): opens the parent under
142/// `RESOLVE_BENEATH` and `unlinkat`s the leaf, so a swapped-in symlink parent
143/// can't redirect the delete outside `root`.
144pub 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/// Linux `openat2(RESOLVE_BENEATH)` implementation. Each fn returns
156/// `Err(Errno::NOSYS)` on a kernel that predates `openat2` (5.6) so the public
157/// wrapper can fall back.
158#[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    /// Open a path-only (`O_PATH`) directory fd for `dir`, used as the anchor
170    /// for the confined `openat2` calls below.
171    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    /// Descend into `name` beneath `dir_fd`, refusing any escape.
180    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                // Absolute prefixes / `..` would be rejected by BENEATH anyway;
210                // surface a clean error rather than attempting them.
211                _ => 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
235/// Best-effort fallback used on non-Linux targets and pre-`openat2` kernels:
236/// the lexical + canonical-ancestor containment check, then a plain `std::fs`
237/// open by path. The TOCTOU window remains here, but the lexical guarantee does
238/// not regress relative to the prior implementation.
239///
240/// One deliberate behavioral difference from the Linux path: because this leans
241/// on `std::fs::canonicalize`, it *follows* an absolute symlink that resolves
242/// back inside the root, whereas `RESOLVE_BENEATH` rejects every absolute
243/// symlink outright. Both still refuse escapes — the fallback is simply more
244/// permissive on that one in-tree edge case. Reconciling it would mean
245/// hand-rolling per-component symlink inspection here, exactly the fragile
246/// logic `openat2` exists to replace, so the mismatch is documented rather than
247/// papered over.
248mod 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        // `a/../b.txt` stays inside the root.
317        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    /// A throwaway directory unique to this test run + `tag` (tests share a PID).
330    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        // The bytes landed at the confined inode.
352        assert_eq!(
353            std::fs::read_to_string(root.join("sub/inner/file.txt")).unwrap(),
354            "hello"
355        );
356        // And read back through the confined opener.
357        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        // Plant a symlink *inside* the root that redirects outside it — the
371        // exact TOCTOU shape #77 is about.
372        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        // The crux of choosing RESOLVE_BENEATH over RESOLVE_NO_SYMLINKS: a
395        // symlink that stays *inside* the root (the node_modules / monorepo
396        // case) must still resolve. A relative in-tree link is followed; only
397        // escapes are refused.
398        let root = unique_dir("intree");
399        std::fs::create_dir(root.join("real")).unwrap();
400        // Relative target so resolution stays beneath the root.
401        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        // The bytes landed in the real directory the in-tree link points at.
409        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        // A victim outside the root, reachable only via an escaping symlink, is
446        // safe from a confined unlink.
447        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}