Skip to main content

coreshift_core/
safe_fs.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Symlink-safe file primitives.
6//!
7//! The daemon runs as root and writes state into `/data/local/tmp`
8//! (attacker-writable). Plain `fs::write`/`OpenOptions` follow symlinks, so an
9//! attacker who pre-creates a symlink at the target path — or swaps an
10//! intermediate directory for a symlink — causes a root write-through to an
11//! arbitrary file (N5).
12//!
13//! Every primitive here is fd-anchored: the parent directory is resolved with
14//! `O_NOFOLLOW` at every path component and held as an open directory fd, then
15//! all operations (`openat`/`renameat`/`unlinkat`) run relative to that fd.
16//! A swapped parent directory or symlink therefore cannot redirect the write.
17
18use std::ffi::CString;
19use std::fs;
20use std::io::{self, Read, Write};
21use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd, RawFd};
22use std::path::Path;
23
24const TEMP_ATTEMPTS: usize = 32;
25
26fn cstr(s: &str) -> io::Result<CString> {
27    CString::new(s)
28        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL byte in path component"))
29}
30
31fn openat_raw(dirfd: RawFd, name: &str, flags: i32, mode: u32) -> io::Result<RawFd> {
32    let c = cstr(name)?;
33    // SAFETY: `name` is a single path component (no `/`) converted to a
34    // NUL-terminated string, and `dirfd` is a valid open directory fd.
35    let fd = unsafe { libc::openat(dirfd, c.as_ptr(), flags, mode as libc::c_uint) };
36    if fd < 0 {
37        Err(io::Error::last_os_error())
38    } else {
39        Ok(fd)
40    }
41}
42
43fn openat_dir(dirfd: RawFd, name: &str) -> io::Result<OwnedFd> {
44    let fd = openat_raw(
45        dirfd,
46        name,
47        libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
48        0,
49    )?;
50    // SAFETY: `fd` came from openat and is uniquely owned here.
51    Ok(unsafe { OwnedFd::from_raw_fd(fd) })
52}
53
54/// Resolve `path` from the root down to a directory fd, refusing to traverse
55/// Walk `path` from the root down to a directory fd, refusing to traverse any
56/// symlink component (ELOOP) or non-directory (ENOTDIR). When `create_mode` is
57/// `Some(mode)`, missing components are created via `mkdirat` relative to the
58/// previously held fd — never by re-resolving a path, so an attacker link can
59/// never make root create a directory at an attacker-chosen location (N5).
60fn walk_dir(path: &Path, create_mode: Option<u32>) -> io::Result<OwnedFd> {
61    let abs = if path.is_absolute() {
62        path.to_path_buf()
63    } else {
64        std::env::current_dir()?.join(path)
65    };
66    let mut dir = openat_dir(libc::AT_FDCWD, "/")?;
67    for comp in abs.components() {
68        use std::path::Component;
69        match comp {
70            Component::RootDir | Component::CurDir => {}
71            Component::Normal(name) => {
72                let name = name.to_str().ok_or_else(|| {
73                    io::Error::new(io::ErrorKind::InvalidInput, "non-UTF-8 path component")
74                })?;
75                let next = match openat_dir(dir.as_raw_fd(), name) {
76                    Ok(fd) => fd,
77                    Err(e) if e.kind() == io::ErrorKind::NotFound && create_mode.is_some() => {
78                        let c = cstr(name)?;
79                        // SAFETY: mkdirat creates a single directory entry
80                        // relative to the held fd; no pathname resolution of
81                        // attacker components.
82                        if unsafe {
83                            libc::mkdirat(
84                                dir.as_raw_fd(),
85                                c.as_ptr(),
86                                create_mode.unwrap() as libc::mode_t,
87                            )
88                        } != 0
89                        {
90                            // Raced with another creator; if it now exists as a
91                            // real directory, use it. A symlink raced into place
92                            // still fails O_NOFOLLOW below.
93                            if io::Error::last_os_error().raw_os_error() == Some(libc::EEXIST) {
94                                openat_dir(dir.as_raw_fd(), name)?
95                            } else {
96                                return Err(io::Error::last_os_error());
97                            }
98                        } else {
99                            openat_dir(dir.as_raw_fd(), name)?
100                        }
101                    }
102                    Err(e) => return Err(e),
103                };
104                dir = next;
105            }
106            Component::ParentDir => {
107                return Err(io::Error::new(
108                    io::ErrorKind::InvalidInput,
109                    ".. in state path not allowed",
110                ));
111            }
112            Component::Prefix(_) => unreachable!("non-Windows path"),
113        }
114    }
115    Ok(dir)
116}
117
118/// Resolve `path` from the root down to a directory fd, refusing to traverse
119/// any symlink component (ELOOP) or non-directory (ENOTDIR).
120fn open_dir_nofollow(path: &Path) -> io::Result<OwnedFd> {
121    walk_dir(path, None)
122}
123
124fn fstat(fd: RawFd) -> io::Result<libc::stat> {
125    let mut st: libc::stat = unsafe { std::mem::zeroed() };
126    // SAFETY: `fd` is valid and `st` remains alive for the call.
127    if unsafe { libc::fstat(fd, &mut st) } != 0 {
128        Err(io::Error::last_os_error())
129    } else {
130        Ok(st)
131    }
132}
133
134fn unlink_name(dirfd: RawFd, name: &str) -> io::Result<()> {
135    let c = cstr(name)?;
136    // SAFETY: unlinkat without AT_SYMLINK_FOLLOW removes the directory entry
137    // itself and never follows a final symlink.
138    if unsafe { libc::unlinkat(dirfd, c.as_ptr(), 0) } != 0 {
139        Err(io::Error::last_os_error())
140    } else {
141        Ok(())
142    }
143}
144
145fn basename(target: &Path) -> io::Result<String> {
146    target
147        .file_name()
148        .map(|n| n.to_string_lossy().into_owned())
149        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))
150}
151
152fn parent_dir(target: &Path) -> &Path {
153    target
154        .parent()
155        .filter(|p| !p.as_os_str().is_empty())
156        .unwrap_or_else(|| Path::new("."))
157}
158
159/// Open the parent directory of `target` fd-anchored (O_NOFOLLOW walk) and
160/// verify it is owned by the current effective uid.
161///
162/// Requiring ownership on every operation closes the directory-swap vector:
163/// an attacker who renames the state dir and substitutes their own can never
164/// make the daemon read from or write into the substituted directory, because
165/// the substituted dir fails the ownership check regardless of the path.
166fn open_parent_nofollow(target: &Path) -> io::Result<OwnedFd> {
167    let dir = open_dir_nofollow(parent_dir(target))?;
168    let st = fstat(dir.as_raw_fd())?;
169    let euid = unsafe { libc::geteuid() };
170    if st.st_uid != euid {
171        return Err(io::Error::new(
172            io::ErrorKind::PermissionDenied,
173            format!(
174                "parent directory is owned by uid {}, not euid {}",
175                st.st_uid, euid
176            ),
177        ));
178    }
179    Ok(dir)
180}
181
182struct TmpGuard {
183    dirfd: RawFd,
184    name: String,
185}
186
187impl Drop for TmpGuard {
188    fn drop(&mut self) {
189        let _ = unlink_name(self.dirfd, &self.name);
190    }
191}
192
193/// Atomically replace `path` with `content`.
194///
195/// The parent directory is opened `O_NOFOLLOW` component-by-component and held
196/// as a directory fd; the content is written to a fresh `O_CREAT|O_EXCL`
197/// temp file via `openat` and then `renameat`d over the destination. A symlink
198/// anywhere in the parent path fails the open; a symlink at the destination is
199/// replaced by the rename, never followed (N5).
200pub fn write_atomic(path: impl AsRef<Path>, content: &[u8]) -> io::Result<()> {
201    let target = path.as_ref();
202    let file_name = basename(target)?;
203    let dir = open_parent_nofollow(target)?;
204    let dirfd = dir.as_raw_fd();
205
206    for attempt in 0..TEMP_ATTEMPTS {
207        let tmp_name = format!(".{file_name}.{}.{attempt}", std::process::id());
208        match openat_raw(
209            dirfd,
210            &tmp_name,
211            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC,
212            0o600,
213        ) {
214            Ok(raw) => {
215                // SAFETY: `raw` came from openat with O_EXCL and is uniquely
216                // owned; it becomes an owned File for the write.
217                let mut file = unsafe { fs::File::from_raw_fd(raw) };
218                let _guard = TmpGuard {
219                    dirfd,
220                    name: tmp_name.clone(),
221                };
222                file.write_all(content)?;
223                file.sync_all()?;
224                drop(file);
225                let c_tmp = cstr(&tmp_name)?;
226                let c_final = cstr(&file_name)?;
227                // SAFETY: renameat replaces the destination entry and never
228                // follows a final symlink; both names are single components
229                // relative to the anchored directory fd.
230                if unsafe { libc::renameat(dirfd, c_tmp.as_ptr(), dirfd, c_final.as_ptr()) } != 0 {
231                    return Err(io::Error::last_os_error());
232                }
233                // rename succeeded; the temp entry no longer exists.
234                std::mem::forget(_guard);
235                return Ok(());
236            }
237            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
238            Err(e) => return Err(e),
239        }
240    }
241
242    Err(io::Error::new(
243        io::ErrorKind::AlreadyExists,
244        "could not reserve a unique temp name",
245    ))
246}
247
248/// Read a file to a string, refusing to follow a symlink at the final or any
249/// parent component.
250///
251/// Fails with `ELOOP` if `path` is a symlink or passes through one. Used for
252/// reading attacker-placed state files so their contents can never be injected
253/// through a link.
254pub fn read_nofollow(path: impl AsRef<Path>) -> io::Result<String> {
255    let target = path.as_ref();
256    let file_name = basename(target)?;
257    let dir = open_parent_nofollow(target)?;
258    let fd = openat_raw(
259        dir.as_raw_fd(),
260        &file_name,
261        libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
262        0,
263    )?;
264    // SAFETY: `fd` is uniquely owned; it becomes an owned File.
265    let mut file = unsafe { fs::File::from_raw_fd(fd) };
266    let mut content = String::new();
267    file.read_to_string(&mut content)?;
268    Ok(content)
269}
270
271/// Open an existing-or-create append stream that refuses to follow symlinks.
272///
273/// Appends to a regular file at `path`. If `path` is a symlink — or sits under
274/// a symlinked parent — the open fails with `ELOOP` rather than writing
275/// through it.
276pub fn open_append_nofollow(path: impl AsRef<Path>) -> io::Result<fs::File> {
277    let target = path.as_ref();
278    let file_name = basename(target)?;
279    let dir = open_parent_nofollow(target)?;
280    let fd = openat_raw(
281        dir.as_raw_fd(),
282        &file_name,
283        libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_NOFOLLOW | libc::O_CLOEXEC,
284        0o644,
285    )?;
286    // SAFETY: `fd` is uniquely owned; it becomes an owned File.
287    Ok(unsafe { fs::File::from_raw_fd(fd) })
288}
289
290/// Remove a directory entry without following a final symlink, anchored to its
291/// (symlink-free) parent. The entry itself is removed even if it is a symlink;
292/// its target is never touched.
293pub fn remove_nofollow(path: impl AsRef<Path>) -> io::Result<()> {
294    let target = path.as_ref();
295    let file_name = basename(target)?;
296    let dir = open_parent_nofollow(target)?;
297    unlink_name(dir.as_raw_fd(), &file_name)
298}
299
300/// Create `dir` if needed, then verify it is a real directory with no symlink
301/// component and that it is owned by the current effective uid. Returning the
302/// anchored fd lets callers refuse to operate inside an attacker-pre-created
303/// or attacker-redirected state directory.
304pub fn ensure_state_dir(dir: impl AsRef<Path>) -> io::Result<OwnedFd> {
305    let dir = dir.as_ref();
306    // Anchored create-then-verify: missing components are made with mkdirat
307    // relative to the held fd; a symlink at any component ELOOPs rather than
308    // being followed, and the opened fd below is the authority for ownership.
309    let fd = walk_dir(dir, Some(0o700))?;
310    let st = fstat(fd.as_raw_fd())?;
311    let euid = unsafe { libc::geteuid() };
312    if st.st_uid != euid {
313        return Err(io::Error::new(
314            io::ErrorKind::PermissionDenied,
315            format!(
316                "state directory is owned by uid {}, not euid {}",
317                st.st_uid, euid
318            ),
319        ));
320    }
321    Ok(fd)
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use std::os::unix::fs::symlink;
328    use std::path::PathBuf;
329
330    fn tmpdir(name: &str) -> PathBuf {
331        let d = std::env::temp_dir().join(format!(
332            "coreshift_safe_fs_dir_{}_{name}",
333            std::process::id()
334        ));
335        let _ = fs::remove_dir_all(&d);
336        fs::create_dir_all(&d).unwrap();
337        d
338    }
339
340    #[test]
341    fn test_write_atomic_creates_regular_file() {
342        let dir = tmpdir("w");
343        let p = dir.join("out.txt");
344
345        write_atomic(&p, b"hello").unwrap();
346        assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
347        assert!(fs::symlink_metadata(&p).unwrap().file_type().is_file());
348    }
349
350    #[test]
351    fn test_write_atomic_replaces_existing_symlink_not_target() {
352        let dir = tmpdir("s1");
353        let target = dir.join("victim");
354        let link = dir.join("link");
355
356        fs::write(&target, b"precious").unwrap();
357        symlink(&target, &link).unwrap();
358
359        // Write through the symlink: the victim must stay untouched and the
360        // path must become a regular file (the symlink is replaced).
361        write_atomic(&link, b"new").unwrap();
362
363        assert_eq!(fs::read_to_string(&target).unwrap(), "precious");
364        assert_eq!(fs::read_to_string(&link).unwrap(), "new");
365        assert!(fs::symlink_metadata(&link).unwrap().file_type().is_file());
366    }
367
368    #[test]
369    fn test_read_nofollow_refuses_symlink() {
370        let dir = tmpdir("r");
371        let target = dir.join("victim2");
372        let link = dir.join("link2");
373
374        fs::write(&target, b"secret").unwrap();
375        symlink(&target, &link).unwrap();
376
377        assert_eq!(read_nofollow(&target).unwrap(), "secret");
378        assert!(read_nofollow(&link).is_err());
379    }
380
381    #[test]
382    fn test_open_append_nofollow_refuses_symlink() {
383        let dir = tmpdir("a");
384        let target = dir.join("target3");
385        let link = dir.join("link3");
386
387        fs::write(&target, b"x").unwrap();
388        symlink(&target, &link).unwrap();
389
390        // Append to the regular file is fine.
391        assert!(open_append_nofollow(&target).is_ok());
392        // Appending through a symlink must fail.
393        assert!(open_append_nofollow(&link).is_err());
394
395        let _ = fs::remove_file(&target);
396        let _ = fs::remove_file(&link);
397    }
398
399    #[test]
400    fn test_write_atomic_refuses_symlinked_parent() {
401        // An intermediate directory replaced by a symlink must make the
402        // anchored open fail, and nothing may be written through to the
403        // linked directory.
404        let dir = tmpdir("parent_symlink");
405        let elsewhere = tmpdir("parent_dest");
406        let link = dir.join("coreshift");
407        symlink(&elsewhere, &link).unwrap();
408
409        assert!(write_atomic(link.join("payload.txt"), b"boom").is_err());
410        assert!(!elsewhere.join("payload.txt").exists());
411        assert!(!link.join("payload.txt").exists());
412    }
413
414    #[test]
415    fn test_read_nofollow_refuses_symlinked_parent() {
416        let dir = tmpdir("read_parent_symlink");
417        let elsewhere = tmpdir("read_parent_dest");
418        fs::write(elsewhere.join("conf"), b"injected").unwrap();
419        let link = dir.join("coreshift");
420        symlink(&elsewhere, &link).unwrap();
421
422        assert!(read_nofollow(link.join("conf")).is_err());
423    }
424
425    #[test]
426    fn test_open_append_nofollow_refuses_symlinked_parent() {
427        let dir = tmpdir("append_parent_symlink");
428        let elsewhere = tmpdir("append_parent_dest");
429        let link = dir.join("coreshift");
430        symlink(&elsewhere, &link).unwrap();
431
432        assert!(open_append_nofollow(link.join("daemon.log")).is_err());
433        assert!(!elsewhere.join("daemon.log").exists());
434    }
435
436    #[test]
437    fn test_ensure_state_dir_refuses_symlink() {
438        let dir = tmpdir("state_symlink");
439        let elsewhere = tmpdir("state_dest");
440        let link = dir.join("state");
441        symlink(&elsewhere, &link).unwrap();
442
443        assert!(ensure_state_dir(&link).is_err());
444        // A real directory is accepted (owned by the current euid).
445        let real = tmpdir("state_real");
446        assert!(ensure_state_dir(&real).is_ok());
447    }
448
449    #[test]
450    fn test_remove_nofollow_removes_entry_not_target() {
451        let dir = tmpdir("unlink");
452        let target = dir.join("victim4");
453        let link = dir.join("link4");
454        fs::write(&target, b"keep").unwrap();
455        symlink(&target, &link).unwrap();
456
457        remove_nofollow(&link).unwrap();
458        assert!(!link.exists());
459        assert_eq!(fs::read_to_string(&target).unwrap(), "keep");
460    }
461
462    #[test]
463    fn test_write_atomic_requires_parent_to_exist() {
464        let dir = tmpdir("missing_parent");
465        let p = dir.join("nope").join("file.txt");
466
467        assert!(write_atomic(&p, b"x").is_err());
468        assert!(!p.exists());
469    }
470
471    #[test]
472    fn test_ops_refuse_foreign_owned_parent() {
473        // Simulates an attacker who renames the root-owned state dir and
474        // substitutes their own: the real (non-symlink) substituted dir must
475        // fail every operation because it is not owned by the effective uid.
476        // Only exercisable as root (chown); otherwise skipped.
477        if unsafe { libc::geteuid() } != 0 {
478            return;
479        }
480        let dir = tmpdir("foreign_owner");
481        let path = dir.join("f");
482        let owned = cstr(&dir.to_string_lossy()).unwrap();
483        // SAFETY: chown to 65534 (nobody) while running as root.
484        assert_eq!(unsafe { libc::chown(owned.as_ptr(), 65534, 65534) }, 0);
485
486        assert!(write_atomic(&path, b"x").is_err());
487        assert!(read_nofollow(&path).is_err());
488        assert!(open_append_nofollow(&path).is_err());
489        assert!(remove_nofollow(&path).is_err());
490        assert!(ensure_state_dir(&dir).is_err());
491        assert!(!path.exists());
492    }
493
494    fn temp_leftovers(dir: &Path, file_name: &str) -> Vec<String> {
495        let prefix = format!(".{file_name}.");
496        fs::read_dir(dir)
497            .map(|rd| {
498                rd.filter_map(|e| e.ok())
499                    .filter_map(|e| e.file_name().to_str().map(str::to_owned))
500                    .filter(|n| n.starts_with(&prefix))
501                    .collect()
502            })
503            .unwrap_or_default()
504    }
505
506    #[test]
507    fn test_write_atomic_cleans_temp_on_rename_failure() {
508        // A non-empty directory at the destination forces renameat to fail
509        // (EISDIR/ENOTEMPTY) after the temp file has been fully written; the
510        // TmpGuard must remove the temp entry.
511        let dir = tmpdir("rename_fail");
512        let dest = dir.join("dest");
513        fs::create_dir_all(&dest).unwrap();
514        fs::write(dest.join("keep"), b"x").unwrap();
515
516        assert!(write_atomic(&dest, b"boom").is_err());
517        assert_eq!(fs::read_to_string(dest.join("keep")).unwrap(), "x");
518        assert!(
519            temp_leftovers(&dir, "dest").is_empty(),
520            "temp file must be cleaned up"
521        );
522    }
523
524    #[test]
525    fn test_write_atomic_leaves_no_temp_on_success() {
526        let dir = tmpdir("no_temp_success");
527        let p = dir.join("out.txt");
528
529        write_atomic(&p, b"hello").unwrap();
530        assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
531        assert!(
532            temp_leftovers(&dir, "out.txt").is_empty(),
533            "no temp left behind"
534        );
535    }
536
537    #[test]
538    fn test_write_atomic_retries_when_temp_name_exists() {
539        let dir = tmpdir("temp_collision");
540        let p = dir.join("out.txt");
541        let pid = std::process::id();
542        let collided = dir.join(format!(".out.txt.{pid}.0"));
543        fs::write(&collided, b"not mine").unwrap();
544
545        write_atomic(&p, b"hello").unwrap();
546        assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
547        // The pre-existing colliding temp is neither clobbered nor mistaken for
548        // ours; a later attempt slot is used.
549        assert_eq!(fs::read_to_string(&collided).unwrap(), "not mine");
550        let leftovers = temp_leftovers(&dir, "out.txt");
551        assert_eq!(
552            leftovers.len(),
553            1,
554            "only the pre-existing colliding temp remains"
555        );
556        assert_eq!(leftovers[0], format!(".out.txt.{pid}.0"));
557    }
558
559    #[test]
560    fn test_ops_refuse_foreign_owned_writable_parent() {
561        // A world-writable directory owned by a uid != euid (e.g. /tmp owned by
562        // root) is a privilege-free proxy for a swapped state dir: a vulnerable
563        // implementation with no ownership check would happily write into it,
564        // while the fixed one must refuse every operation. Runs whenever such a
565        // directory is available; skips as root (root owns /tmp).
566        use std::os::unix::fs::MetadataExt;
567        let euid = unsafe { libc::geteuid() };
568        if euid == 0 {
569            return;
570        }
571        let tmp = std::env::temp_dir();
572        let meta = match fs::symlink_metadata(&tmp) {
573            Ok(m) => m,
574            Err(_) => return,
575        };
576        if meta.uid() == euid {
577            return; // no ownership mismatch available in this environment
578        }
579        if meta.mode() & 0o002 == 0 && meta.mode() & 0o020 == 0 {
580            return; // not writable by us; the proxy doesn't apply
581        }
582        let p = tmp.join(format!(
583            "coreshift_fs_foreign_{}_{}",
584            std::process::id(),
585            "out"
586        ));
587        let _ = fs::remove_file(&p);
588        fs::write(&p, b"probe").unwrap();
589
590        assert!(read_nofollow(&p).is_err());
591        assert!(open_append_nofollow(&p).is_err());
592        assert!(write_atomic(&p, b"boom").is_err());
593        assert!(remove_nofollow(&p).is_err());
594        assert!(ensure_state_dir(&tmp).is_err());
595        assert_eq!(fs::read_to_string(&p).unwrap(), "probe");
596        let _ = fs::remove_file(&p);
597    }
598
599    #[test]
600    fn test_ensure_state_dir_creates_fresh_dir() {
601        let base = tmpdir("fresh_base");
602        let nested = base.join("a").join("b").join("state");
603
604        let fd = ensure_state_dir(&nested).unwrap();
605        assert!(nested.is_dir());
606        drop(fd);
607        assert!(ensure_state_dir(&nested).is_ok());
608    }
609
610    #[test]
611    fn test_open_append_nofollow_refuses_dangling_symlink() {
612        let dir = tmpdir("dangling_append");
613        let missing = dir.join("not_there.txt");
614        let link = dir.join("linkd");
615        symlink(&missing, &link).unwrap();
616
617        assert!(open_append_nofollow(&link).is_err());
618        assert!(
619            !missing.exists(),
620            "must not create the target through a dangling link"
621        );
622    }
623
624    #[test]
625    fn test_read_nofollow_refuses_dangling_symlink() {
626        let dir = tmpdir("dangling_read");
627        let missing = dir.join("not_there2.txt");
628        let link = dir.join("linkd2");
629        symlink(&missing, &link).unwrap();
630
631        assert!(read_nofollow(&link).is_err());
632        assert!(!missing.exists());
633    }
634
635    #[test]
636    fn test_ops_refuse_regular_file_parent() {
637        // A parent component that is a regular file must yield ENOTDIR for
638        // every anchored operation — no write-through, no creation.
639        let dir = tmpdir("regfile_parent");
640        let f = dir.join("notadir");
641        fs::write(&f, b"x").unwrap();
642
643        assert!(write_atomic(f.join("out"), b"y").is_err());
644        assert!(read_nofollow(f.join("out")).is_err());
645        assert!(open_append_nofollow(f.join("out")).is_err());
646        assert!(remove_nofollow(f.join("out")).is_err());
647        assert!(ensure_state_dir(&f).is_err());
648        assert_eq!(fs::read_to_string(&f).unwrap(), "x");
649    }
650}