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