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/// Root-relative, lexically-normalized form of `raw`, erroring if it escapes
76/// `root`. This is the `rel` argument for the confined `*_beneath` helpers:
77/// they resolve it under a directory fd for `root` with `RESOLVE_BENEATH`, so a
78/// swapped-in symlink can't redirect the operation outside `root`. Used by
79/// [`crate::approval`] so the replay path gets the same symlink-safe confinement
80/// the live tool path has, instead of by-path `std::fs` that follows symlinks.
81pub(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
90/// Collapse `.` and `..` components lexically (no filesystem access, no symlink
91/// resolution).
92fn 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/// What kind of access an [`open_beneath`] call needs.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum OpenIntent {
109    /// Open an existing file read-only.
110    Read,
111    /// Create-or-truncate for writing (`O_WRONLY|O_CREAT|O_TRUNC`).
112    WriteTruncate,
113}
114
115/// Open `root`-relative `rel` for `intent` **without ever traversing out of
116/// `root`** — closing the check-then-write TOCTOU where an intermediate
117/// directory is swapped for a symlink after a lexical path check but before the
118/// operation (#77). The returned [`File`] is bound to the exact inode the
119/// confinement resolved, so subsequent reads/writes can't be redirected.
120///
121/// On Linux this is enforced atomically by the kernel via
122/// `openat2(RESOLVE_BENEATH)` relative to a directory fd for `root`. We use
123/// `RESOLVE_BENEATH` (which forbids escaping the root via absolute paths, `..`,
124/// or magic links) but **not** `RESOLVE_NO_SYMLINKS`, so legitimate *in-tree*
125/// symlinks keep working — only escapes are refused, matching the existing
126/// [`contain_within_canonical`] semantics. On pre-5.6 kernels (`ENOSYS`) or
127/// non-Linux targets it falls back to `contain_within_canonical` + a plain open
128/// (documented best-effort: the lexical check still holds, but the open is by
129/// path).
130///
131/// # Errors
132///
133/// A `rel` that resolves outside `root` — via `..`, an absolute path, or an
134/// escaping symlink — comes back as `EXDEV` from the kernel on Linux and as a
135/// confinement rejection on the fallback path. Otherwise the ordinary open
136/// errors for `intent`: not found for `Read`, permission, a directory in place
137/// of a file. `ENOSYS` is never returned: a pre-5.6 kernel falls back instead.
138pub 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 => {}, // pre-5.6 kernel: fall through
143            other => return other.map_err(io::Error::from),
144        }
145    }
146    fallback::open(root, rel, intent)
147}
148
149/// Confined `mkdir -p` for a `root`-relative directory path: creates each
150/// missing component beneath `root`, refusing to descend through a symlink that
151/// escapes (#77). On Linux: `mkdirat` + `openat2(RESOLVE_BENEATH)` per
152/// component; otherwise the `contain_within_canonical` + `std::fs` fallback.
153///
154/// # Errors
155///
156/// A `rel` that resolves outside `root`, and the ordinary `mkdir` errors on
157/// any component: permission, a non-directory already in the way, a full
158/// filesystem. Like `mkdir -p`, a directory that already exists is not one.
159pub 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
170/// Confined unlink of a `root`-relative file (#77): opens the parent under
171/// `RESOLVE_BENEATH` and `unlinkat`s the leaf, so a swapped-in symlink parent
172/// can't redirect the delete outside `root`.
173///
174/// # Errors
175///
176/// A `rel` that resolves outside `root`, and the ordinary unlink errors: not
177/// found, permission, `rel` naming a directory.
178pub 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
189/// Atomically write `bytes` to `root`-relative `rel` without ever traversing out
190/// of `root`. The temp file is created and `renameat`-swapped beneath the *same*
191/// confined directory fd as the destination (Linux `openat2(RESOLVE_BENEATH)`),
192/// so a parent dir swapped for an escaping symlink after a path check can't
193/// redirect the write (#77), AND a crash/kill/disk-full mid-write leaves the
194/// previous file intact instead of a truncated one — `open_beneath` +
195/// `WriteTruncate` gives the first guarantee but not the second. Falls back to
196/// `contain_within_canonical` + by-path [`crate::write_atomic`] on non-Linux /
197/// pre-`openat2` kernels (the same best-effort posture as the other helpers).
198///
199/// # Errors
200///
201/// A `rel` that resolves outside `root`, then creating or writing the temp
202/// sibling, the fsync, and the rename. A failure leaves the previous file
203/// intact — that is the guarantee this has over `open_beneath` +
204/// `WriteTruncate`.
205pub 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/// Linux `openat2(RESOLVE_BENEATH)` implementation. Each fn returns
217/// `Err(Errno::NOSYS)` on a kernel that predates `openat2` (5.6) so the public
218/// wrapper can fall back.
219#[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    /// Open a path-only (`O_PATH`) directory fd for `dir`, used as the anchor
236    /// for the confined `openat2` calls below.
237    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    /// Descend into `name` beneath `dir_fd`, refusing any escape.
246    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                // Absolute prefixes / `..` would be rejected by BENEATH anyway;
276                // surface a clean error rather than attempting them.
277                _ => 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    /// Confined atomic write: create a temp beneath the target's parent under
301    /// `RESOLVE_BENEATH`, write+fsync it, then `renameat` it over the target — so
302    /// the bytes hit the same inode the kernel confined (a swapped-in symlink
303    /// can't redirect them) and a crash/kill mid-write leaves the previous file
304    /// intact (the rename is atomic).
305    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        // Preserve the destination's existing permission bits. The replaced
322        // `open_beneath(WriteTruncate)` kept them (O_TRUNC doesn't touch an
323        // existing file's mode), so without this an `apply_patch` on a `0o755`
324        // script or a `0o600` secret would silently reset it to `0o644`. A fresh
325        // file keeps the `0o644` default. Statted under the confined parent fd.
326        let existing_mode = statat(&parent_fd, Path::new(leaf), AtFlags::empty())
327            .ok()
328            .map(|st| st.st_mode & 0o7777);
329
330        // Create the temp exclusively, beneath the confined parent.
331        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        // Match the destination's prior mode when replacing an existing file
340        // (fchmod isn't subject to umask, so the bits are exact).
341        if let Some(mode) = existing_mode {
342            let _ = fchmod(&fd, Mode::from_raw_mode(mode));
343        }
344
345        // Write + fsync the data; on any IO failure, unlink the temp so we don't
346        // leak it, mapping the error back into an `Errno`.
347        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        // Atomic swap within the confined parent dir.
358        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        // Best-effort durability of the rename (the directory entry itself).
364        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    /// Map a std IO error to the nearest `Errno` so the confined writer can share
377    /// the `NOSYS`-fallback dispatch shape of its sibling helpers.
378    fn io_to_errno(e: std::io::Error) -> Errno {
379        Errno::from_io_error(&e).unwrap_or(Errno::IO)
380    }
381}
382
383/// Best-effort fallback used on non-Linux targets and pre-`openat2` kernels:
384/// the lexical + canonical-ancestor containment check, then a plain `std::fs`
385/// open by path. The TOCTOU window remains here, but the lexical guarantee does
386/// not regress relative to the prior implementation.
387///
388/// One deliberate behavioral difference from the Linux path: because this leans
389/// on `std::fs::canonicalize`, it *follows* an absolute symlink that resolves
390/// back inside the root, whereas `RESOLVE_BENEATH` rejects every absolute
391/// symlink outright. Both still refuse escapes — the fallback is simply more
392/// permissive on that one in-tree edge case. Reconciling it would mean
393/// hand-rolling per-component symlink inspection here, exactly the fragile
394/// logic `openat2` exists to replace, so the mismatch is documented rather than
395/// papered over.
396mod 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    /// Warn once per process that the kernel-confined `openat2(RESOLVE_BENEATH)`
405    /// path is unavailable (non-Linux target or pre-5.6 kernel), so confinement
406    /// falls back to the lexical/canonical check plus a by-path operation — which
407    /// leaves the documented check-then-use symlink TOCTOU window (#142). This is
408    /// by design: closing it here would mean hand-rolled per-component symlink
409    /// inspection, exactly the fragile logic `openat2` exists to replace. The warn
410    /// makes the residual visible to operators on those platforms.
411    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        // `a/../b.txt` stays inside the root.
490        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    /// A throwaway directory unique to this test run + `tag` (tests share a PID).
503    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        // The bytes landed at the confined inode.
525        assert_eq!(
526            std::fs::read_to_string(root.join("sub/inner/file.txt")).unwrap(),
527            "hello"
528        );
529        // And read back through the confined opener.
530        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        // Plant a symlink *inside* the root that redirects outside it — the
544        // exact TOCTOU shape #77 is about.
545        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        // The crux of choosing RESOLVE_BENEATH over RESOLVE_NO_SYMLINKS: a
568        // symlink that stays *inside* the root (the node_modules / monorepo
569        // case) must still resolve. A relative in-tree link is followed; only
570        // escapes are refused.
571        let root = unique_dir("intree");
572        std::fs::create_dir(root.join("real")).unwrap();
573        // Relative target so resolution stays beneath the root.
574        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        // The bytes landed in the real directory the in-tree link points at.
582        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        // A victim outside the root, reachable only via an escaping symlink, is
619        // safe from a confined unlink.
620        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        // Overwriting is an atomic swap, not a truncate-in-place.
644        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        // No `.tmp` sibling left behind.
650        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        // Make it executable, then rewrite it: the atomic swap must keep 0o755
666        // rather than resetting to the temp's 0o644 (an apply_patch on a script
667        // must not strip the executable bit).
668        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}