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).
130pub 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 => {}, // pre-5.6 kernel: fall through
135            other => return other.map_err(io::Error::from),
136        }
137    }
138    fallback::open(root, rel, intent)
139}
140
141/// Confined `mkdir -p` for a `root`-relative directory path: creates each
142/// missing component beneath `root`, refusing to descend through a symlink that
143/// escapes (#77). On Linux: `mkdirat` + `openat2(RESOLVE_BENEATH)` per
144/// component; otherwise the `contain_within_canonical` + `std::fs` fallback.
145pub 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
156/// Confined unlink of a `root`-relative file (#77): opens the parent under
157/// `RESOLVE_BENEATH` and `unlinkat`s the leaf, so a swapped-in symlink parent
158/// can't redirect the delete outside `root`.
159pub 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
170/// Atomically write `bytes` to `root`-relative `rel` without ever traversing out
171/// of `root`. The temp file is created and `renameat`-swapped beneath the *same*
172/// confined directory fd as the destination (Linux `openat2(RESOLVE_BENEATH)`),
173/// so a parent dir swapped for an escaping symlink after a path check can't
174/// redirect the write (#77), AND a crash/kill/disk-full mid-write leaves the
175/// previous file intact instead of a truncated one — `open_beneath` +
176/// `WriteTruncate` gives the first guarantee but not the second. Falls back to
177/// `contain_within_canonical` + by-path [`crate::write_atomic`] on non-Linux /
178/// pre-`openat2` kernels (the same best-effort posture as the other helpers).
179pub 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/// Linux `openat2(RESOLVE_BENEATH)` implementation. Each fn returns
191/// `Err(Errno::NOSYS)` on a kernel that predates `openat2` (5.6) so the public
192/// wrapper can fall back.
193#[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    /// Open a path-only (`O_PATH`) directory fd for `dir`, used as the anchor
210    /// for the confined `openat2` calls below.
211    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    /// Descend into `name` beneath `dir_fd`, refusing any escape.
220    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                // Absolute prefixes / `..` would be rejected by BENEATH anyway;
250                // surface a clean error rather than attempting them.
251                _ => 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    /// Confined atomic write: create a temp beneath the target's parent under
275    /// `RESOLVE_BENEATH`, write+fsync it, then `renameat` it over the target — so
276    /// the bytes hit the same inode the kernel confined (a swapped-in symlink
277    /// can't redirect them) and a crash/kill mid-write leaves the previous file
278    /// intact (the rename is atomic).
279    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        // Preserve the destination's existing permission bits. The replaced
296        // `open_beneath(WriteTruncate)` kept them (O_TRUNC doesn't touch an
297        // existing file's mode), so without this an `apply_patch` on a `0o755`
298        // script or a `0o600` secret would silently reset it to `0o644`. A fresh
299        // file keeps the `0o644` default. Statted under the confined parent fd.
300        let existing_mode = statat(&parent_fd, Path::new(leaf), AtFlags::empty())
301            .ok()
302            .map(|st| st.st_mode & 0o7777);
303
304        // Create the temp exclusively, beneath the confined parent.
305        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        // Match the destination's prior mode when replacing an existing file
314        // (fchmod isn't subject to umask, so the bits are exact).
315        if let Some(mode) = existing_mode {
316            let _ = fchmod(&fd, Mode::from_raw_mode(mode));
317        }
318
319        // Write + fsync the data; on any IO failure, unlink the temp so we don't
320        // leak it, mapping the error back into an `Errno`.
321        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        // Atomic swap within the confined parent dir.
332        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        // Best-effort durability of the rename (the directory entry itself).
338        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    /// Map a std IO error to the nearest `Errno` so the confined writer can share
351    /// the `NOSYS`-fallback dispatch shape of its sibling helpers.
352    fn io_to_errno(e: std::io::Error) -> Errno {
353        Errno::from_io_error(&e).unwrap_or(Errno::IO)
354    }
355}
356
357/// Best-effort fallback used on non-Linux targets and pre-`openat2` kernels:
358/// the lexical + canonical-ancestor containment check, then a plain `std::fs`
359/// open by path. The TOCTOU window remains here, but the lexical guarantee does
360/// not regress relative to the prior implementation.
361///
362/// One deliberate behavioral difference from the Linux path: because this leans
363/// on `std::fs::canonicalize`, it *follows* an absolute symlink that resolves
364/// back inside the root, whereas `RESOLVE_BENEATH` rejects every absolute
365/// symlink outright. Both still refuse escapes — the fallback is simply more
366/// permissive on that one in-tree edge case. Reconciling it would mean
367/// hand-rolling per-component symlink inspection here, exactly the fragile
368/// logic `openat2` exists to replace, so the mismatch is documented rather than
369/// papered over.
370mod 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    /// Warn once per process that the kernel-confined `openat2(RESOLVE_BENEATH)`
379    /// path is unavailable (non-Linux target or pre-5.6 kernel), so confinement
380    /// falls back to the lexical/canonical check plus a by-path operation — which
381    /// leaves the documented check-then-use symlink TOCTOU window (#142). This is
382    /// by design: closing it here would mean hand-rolled per-component symlink
383    /// inspection, exactly the fragile logic `openat2` exists to replace. The warn
384    /// makes the residual visible to operators on those platforms.
385    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        // `a/../b.txt` stays inside the root.
464        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    /// A throwaway directory unique to this test run + `tag` (tests share a PID).
477    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        // The bytes landed at the confined inode.
499        assert_eq!(
500            std::fs::read_to_string(root.join("sub/inner/file.txt")).unwrap(),
501            "hello"
502        );
503        // And read back through the confined opener.
504        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        // Plant a symlink *inside* the root that redirects outside it — the
518        // exact TOCTOU shape #77 is about.
519        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        // The crux of choosing RESOLVE_BENEATH over RESOLVE_NO_SYMLINKS: a
542        // symlink that stays *inside* the root (the node_modules / monorepo
543        // case) must still resolve. A relative in-tree link is followed; only
544        // escapes are refused.
545        let root = unique_dir("intree");
546        std::fs::create_dir(root.join("real")).unwrap();
547        // Relative target so resolution stays beneath the root.
548        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        // The bytes landed in the real directory the in-tree link points at.
556        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        // A victim outside the root, reachable only via an escaping symlink, is
593        // safe from a confined unlink.
594        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        // Overwriting is an atomic swap, not a truncate-in-place.
618        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        // No `.tmp` sibling left behind.
624        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        // Make it executable, then rewrite it: the atomic swap must keep 0o755
640        // rather than resetting to the temp's 0o644 (an apply_patch on a script
641        // must not strip the executable bit).
642        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}