Skip to main content

coreshift_core/
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//! Filesystem-oriented low-level helpers.
6//!
7//! This module contains lightweight Linux and Android file probes and helpers
8//! that are useful near the OS boundary, including path existence checks,
9//! page-cache read-ahead hints, and symlink-safe read/write primitives.
10
11use crate::CoreError;
12use std::ffi::CString;
13use std::fs;
14use std::io::{Read, Write};
15use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd, RawFd};
16use std::path::Path;
17use std::time::UNIX_EPOCH;
18
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct PathFingerprint {
21    pub len: u64,
22    pub modified_ns: u128,
23}
24
25/// Return a fingerprint of the file metadata at the specified path.
26///
27/// ### Errors
28/// - `EACCES`: Permission denied.
29/// - `ENOENT`: The path does not exist.
30pub fn path_fingerprint(path: &Path) -> Result<PathFingerprint, CoreError> {
31    let metadata = std::fs::metadata(path).map_err(|err| {
32        CoreError::sys(err.raw_os_error().unwrap_or(libc::EIO), "path_fingerprint")
33    })?;
34    let modified_ns = metadata
35        .modified()
36        .ok()
37        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
38        .map(|duration| duration.as_nanos())
39        .unwrap_or_default();
40    Ok(PathFingerprint {
41        len: metadata.len(),
42        modified_ns,
43    })
44}
45
46/// Probe whether a filesystem path is accessible and exists.
47///
48/// NOTE: This follows symbolic links. It uses `libc::access` with `F_OK`
49/// so the check is a single syscall with no Rust allocator involvement.
50/// Returns `true` if the path is accessible or visible, `false` on any error
51/// (including `ENOENT`, `EACCES`, or invalid path bytes).
52pub fn path_exists(path: &str) -> bool {
53    match std::ffi::CString::new(path) {
54        Ok(c) => unsafe { libc::access(c.as_ptr(), libc::F_OK) == 0 },
55        Err(_) => false,
56    }
57}
58
59/// Probe whether a path exists without following symbolic links.
60///
61/// Returns `true` if the path exists, including a dangling symlink.
62pub fn path_lstat_exists(path: &str) -> bool {
63    match std::ffi::CString::new(path) {
64        Ok(c) => unsafe {
65            let mut stat = std::mem::zeroed();
66            libc::lstat(c.as_ptr(), &mut stat) == 0
67        },
68        Err(_) => false,
69    }
70}
71
72/// Read a file into a string.
73///
74/// This stays as a small convenience helper for low-level modules that treat
75/// blocking filesystem or procfs reads as an acceptable boundary cost.
76/// Read the entire contents of a file into a string.
77///
78/// ### Errors
79/// - `EACCES`: Permission denied.
80/// - `ENOENT`: The path does not exist.
81/// - `EIO`: Low-level I/O error.
82pub fn read_to_string(path: &str) -> Result<String, CoreError> {
83    std::fs::read_to_string(path)
84        .map_err(|err| CoreError::sys(err.raw_os_error().unwrap_or(libc::EIO), "read_to_string"))
85}
86
87/// Advise the kernel to begin reading file data into the page cache.
88///
89/// This is an advisory hint only. It can help warm likely-needed file ranges,
90/// but the kernel may ignore the request, perform only part of it, or return
91/// before the data is fully resident in memory.
92///
93/// The `offset` and `len` identify the byte range to prefetch for `fd`.
94/// Success means the kernel accepted the request, not that subsequent reads
95/// are guaranteed to be cache hits.
96/// Advise the kernel to begin reading file data into the page cache.
97///
98/// ### Errors
99/// - `EBADF`: The file descriptor is invalid.
100/// - `EINVAL`: The offset or length is invalid.
101pub fn readahead(fd: impl AsRawFd, offset: u64, len: usize) -> Result<(), CoreError> {
102    readahead_raw(fd.as_raw_fd(), offset, len)
103}
104
105// ── fadvise ──────────────────────────────────────────────────────────────────
106
107pub const FADV_NORMAL: i32 = libc::POSIX_FADV_NORMAL;
108pub const FADV_RANDOM: i32 = libc::POSIX_FADV_RANDOM;
109pub const FADV_SEQUENTIAL: i32 = libc::POSIX_FADV_SEQUENTIAL;
110pub const FADV_WILLNEED: i32 = libc::POSIX_FADV_WILLNEED;
111pub const FADV_DONTNEED: i32 = libc::POSIX_FADV_DONTNEED;
112pub const FADV_NOREUSE: i32 = libc::POSIX_FADV_NOREUSE;
113
114/// Advise the kernel on the expected access pattern for a file range.
115///
116/// `offset` and `len` define the byte range; `len = 0` means "to end of file".
117/// `advice` is one of the `FADV_*` constants.
118///
119/// Unlike most syscalls, `posix_fadvise` returns the error code directly
120/// rather than setting `errno`.
121///
122/// ### Errors
123/// - `EBADF`: invalid file descriptor.
124/// - `EINVAL`: invalid advice value or unsupported `len`.
125/// - `ESPIPE`: the fd refers to a pipe.
126pub fn fadvise(fd: impl AsRawFd, offset: u64, len: usize, advice: i32) -> Result<(), CoreError> {
127    let ret = unsafe {
128        libc::posix_fadvise(
129            fd.as_raw_fd(),
130            offset as libc::off_t,
131            len as libc::off_t,
132            advice,
133        )
134    };
135    if ret == 0 {
136        Ok(())
137    } else {
138        Err(CoreError::sys(ret, "posix_fadvise"))
139    }
140}
141
142/// Map a file range, advise the kernel that it will be needed, then unmap it.
143///
144/// `offset` must be page-aligned. This low-level primitive rejects unaligned
145/// offsets with `EINVAL` instead of silently widening the requested range.
146/// Map a file range and advise the kernel with `MADV_WILLNEED`.
147///
148/// ### Errors
149/// - `EBADF`: The file descriptor is invalid.
150/// - `EINVAL`: The offset is not page-aligned or the range is invalid.
151/// - `ENOMEM`: Insufficient kernel memory.
152pub fn mmap_madvise(
153    fd: impl AsRawFd,
154    offset: u64,
155    len: usize,
156    touch: bool,
157) -> Result<(), CoreError> {
158    mmap_madvise_raw(fd.as_raw_fd(), offset, len, touch)
159}
160
161#[cfg(any(target_os = "linux", target_os = "android"))]
162fn mmap_madvise_raw(
163    fd: libc::c_int,
164    offset: u64,
165    len: usize,
166    touch: bool,
167) -> Result<(), CoreError> {
168    if len == 0 {
169        return Ok(());
170    }
171
172    let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
173    if page_size <= 0 {
174        return Err(CoreError::sys(libc::EINVAL, "sysconf(_SC_PAGESIZE)"));
175    }
176    let page_size = page_size as u64;
177    if offset % page_size != 0 || offset > libc::off_t::MAX as u64 {
178        return Err(CoreError::sys(libc::EINVAL, "mmap"));
179    }
180
181    let ptr = unsafe {
182        libc::mmap(
183            std::ptr::null_mut(),
184            len,
185            libc::PROT_READ,
186            libc::MAP_PRIVATE,
187            fd,
188            offset as libc::off_t,
189        )
190    };
191    if ptr == libc::MAP_FAILED {
192        let code = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
193        return Err(CoreError::sys(code, "mmap"));
194    }
195
196    let result = if unsafe { libc::madvise(ptr, len, libc::MADV_WILLNEED) } == -1 {
197        let code = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
198        Err(CoreError::sys(code, "madvise"))
199    } else {
200        if touch {
201            let mut pos = 0usize;
202            let page_size = page_size as usize;
203            while pos < len {
204                unsafe {
205                    std::ptr::read_volatile((ptr as *const u8).add(pos));
206                }
207                pos = pos.saturating_add(page_size);
208            }
209        }
210        Ok(())
211    };
212
213    if unsafe { libc::munmap(ptr, len) } == -1 {
214        let code = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
215        return Err(CoreError::sys(code, "munmap"));
216    }
217    result
218}
219
220#[cfg(not(any(target_os = "linux", target_os = "android")))]
221fn mmap_madvise_raw(
222    _fd: libc::c_int,
223    _offset: u64,
224    _len: usize,
225    _touch: bool,
226) -> Result<(), CoreError> {
227    Err(CoreError::sys(libc::ENOSYS, "mmap"))
228}
229
230#[cfg(any(target_os = "linux", target_os = "android"))]
231fn readahead_raw(fd: libc::c_int, offset: u64, len: usize) -> Result<(), CoreError> {
232    if offset > libc::off64_t::MAX as u64 {
233        return Err(CoreError::sys(libc::EINVAL, "readahead"));
234    }
235
236    let count = len as libc::size_t;
237    let offset = offset as libc::off64_t;
238
239    loop {
240        let ret = unsafe { libc::syscall(readahead_syscall_number(), fd, offset, count) };
241        if ret == -1 {
242            let code = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
243            if code == libc::EINTR {
244                continue;
245            }
246            return Err(CoreError::sys(code, "readahead"));
247        }
248        return Ok(());
249    }
250}
251
252#[cfg(not(any(target_os = "linux", target_os = "android")))]
253fn readahead_raw(_fd: libc::c_int, _offset: u64, _len: usize) -> Result<(), CoreError> {
254    Err(CoreError::sys(libc::ENOSYS, "readahead"))
255}
256
257#[cfg(target_os = "linux")]
258#[inline(always)]
259const fn readahead_syscall_number() -> libc::c_long {
260    libc::SYS_readahead
261}
262
263#[cfg(all(target_os = "android", target_arch = "aarch64"))]
264#[inline(always)]
265const fn readahead_syscall_number() -> libc::c_long {
266    213
267}
268
269#[cfg(all(target_os = "android", target_arch = "arm"))]
270#[inline(always)]
271const fn readahead_syscall_number() -> libc::c_long {
272    225
273}
274
275#[cfg(all(target_os = "android", target_arch = "x86_64"))]
276#[inline(always)]
277const fn readahead_syscall_number() -> libc::c_long {
278    187
279}
280
281#[cfg(all(target_os = "android", target_arch = "x86"))]
282#[inline(always)]
283const fn readahead_syscall_number() -> libc::c_long {
284    225
285}
286
287const TEMP_ATTEMPTS: usize = 32;
288
289fn cstr(s: &str) -> Result<CString, CoreError> {
290    CString::new(s).map_err(|_| CoreError::sys(libc::EINVAL, "path:nul_byte"))
291}
292
293fn openat_raw(dirfd: RawFd, name: &str, flags: i32, mode: u32) -> Result<RawFd, CoreError> {
294    let c = cstr(name)?;
295    // SAFETY: `name` is a single path component (no `/`) converted to a
296    // NUL-terminated string, and `dirfd` is a valid open directory fd.
297    let fd = unsafe { libc::openat(dirfd, c.as_ptr(), flags, mode as libc::c_uint) };
298    if fd < 0 {
299        Err(std::io::Error::last_os_error().into())
300    } else {
301        Ok(fd)
302    }
303}
304
305fn openat_dir(dirfd: RawFd, name: &str) -> Result<OwnedFd, CoreError> {
306    let fd = openat_raw(
307        dirfd,
308        name,
309        libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
310        0,
311    )?;
312    // SAFETY: `fd` came from openat and is uniquely owned here.
313    Ok(unsafe { OwnedFd::from_raw_fd(fd) })
314}
315
316/// Resolve `path` from the root down to a directory fd, refusing to traverse
317/// Walk `path` from the root down to a directory fd, refusing to traverse any
318/// symlink component (ELOOP) or non-directory (ENOTDIR). When `create_mode` is
319/// `Some(mode)`, missing components are created via `mkdirat` relative to the
320/// previously held fd — never by re-resolving a path, so an attacker link can
321/// never make root create a directory at an attacker-chosen location (N5).
322fn walk_dir(path: &Path, create_mode: Option<u32>) -> Result<OwnedFd, CoreError> {
323    let abs = if path.is_absolute() {
324        path.to_path_buf()
325    } else {
326        std::env::current_dir()?.join(path)
327    };
328    let mut dir = openat_dir(libc::AT_FDCWD, "/")?;
329    for comp in abs.components() {
330        use std::path::Component;
331        match comp {
332            Component::RootDir | Component::CurDir => {}
333            Component::Normal(name) => {
334                let name = name
335                    .to_str()
336                    .ok_or_else(|| CoreError::sys(libc::EINVAL, "path:non_utf8"))?;
337                let next = match openat_dir(dir.as_raw_fd(), name) {
338                    Ok(fd) => fd,
339                    Err(e) if e.raw_os_error() == Some(libc::ENOENT) && create_mode.is_some() => {
340                        let c = cstr(name)?;
341                        // SAFETY: mkdirat creates a single directory entry
342                        // relative to the held fd; no pathname resolution of
343                        // attacker components.
344                        if unsafe {
345                            libc::mkdirat(
346                                dir.as_raw_fd(),
347                                c.as_ptr(),
348                                create_mode.unwrap() as libc::mode_t,
349                            )
350                        } != 0
351                        {
352                            // Raced with another creator; if it now exists as a
353                            // real directory, use it. A symlink raced into place
354                            // still fails O_NOFOLLOW below.
355                            if std::io::Error::last_os_error().raw_os_error() == Some(libc::EEXIST)
356                            {
357                                openat_dir(dir.as_raw_fd(), name)?
358                            } else {
359                                return Err(std::io::Error::last_os_error().into());
360                            }
361                        } else {
362                            openat_dir(dir.as_raw_fd(), name)?
363                        }
364                    }
365                    Err(e) => return Err(e),
366                };
367                dir = next;
368            }
369            Component::ParentDir => {
370                return Err(CoreError::sys(libc::EINVAL, "path:parent_component"));
371            }
372            Component::Prefix(_) => unreachable!("non-Windows path"),
373        }
374    }
375    Ok(dir)
376}
377
378/// Resolve `path` from the root down to a directory fd, refusing to traverse
379/// any symlink component (ELOOP) or non-directory (ENOTDIR).
380fn open_dir_nofollow(path: &Path) -> Result<OwnedFd, CoreError> {
381    walk_dir(path, None)
382}
383
384fn fstat(fd: RawFd) -> Result<libc::stat, CoreError> {
385    let mut st: libc::stat = unsafe { std::mem::zeroed() };
386    // SAFETY: `fd` is valid and `st` remains alive for the call.
387    if unsafe { libc::fstat(fd, &mut st) } != 0 {
388        Err(std::io::Error::last_os_error().into())
389    } else {
390        Ok(st)
391    }
392}
393
394fn unlink_name(dirfd: RawFd, name: &str) -> Result<(), CoreError> {
395    let c = cstr(name)?;
396    // SAFETY: unlinkat without AT_SYMLINK_FOLLOW removes the directory entry
397    // itself and never follows a final symlink.
398    if unsafe { libc::unlinkat(dirfd, c.as_ptr(), 0) } != 0 {
399        Err(std::io::Error::last_os_error().into())
400    } else {
401        Ok(())
402    }
403}
404
405fn basename(target: &Path) -> Result<String, CoreError> {
406    target
407        .file_name()
408        .map(|n| n.to_string_lossy().into_owned())
409        .ok_or_else(|| CoreError::sys(libc::EINVAL, "path:no_file_name"))
410}
411
412fn parent_dir(target: &Path) -> &Path {
413    target
414        .parent()
415        .filter(|p| !p.as_os_str().is_empty())
416        .unwrap_or_else(|| Path::new("."))
417}
418
419/// Open the parent directory of `target` fd-anchored (O_NOFOLLOW walk) and
420/// verify it is owned by the current effective uid.
421///
422/// Requiring ownership on every operation closes the directory-swap vector:
423/// an attacker who renames the state dir and substitutes their own can never
424/// make the daemon read from or write into the substituted directory, because
425/// the substituted dir fails the ownership check regardless of the path.
426fn open_parent_nofollow(target: &Path) -> Result<OwnedFd, CoreError> {
427    let dir = open_dir_nofollow(parent_dir(target))?;
428    let st = fstat(dir.as_raw_fd())?;
429    let euid = unsafe { libc::geteuid() };
430    if st.st_uid != euid {
431        return Err(CoreError::sys(libc::EACCES, "parent_dir_owner"));
432    }
433    Ok(dir)
434}
435
436struct TmpGuard {
437    dirfd: RawFd,
438    name: String,
439}
440
441impl Drop for TmpGuard {
442    fn drop(&mut self) {
443        let _ = unlink_name(self.dirfd, &self.name);
444    }
445}
446
447/// Atomically replace `path` with `content`.
448///
449/// The parent directory is opened `O_NOFOLLOW` component-by-component and held
450/// as a directory fd; the content is written to a fresh `O_CREAT|O_EXCL`
451/// temp file via `openat` and then `renameat`d over the destination. A symlink
452/// anywhere in the parent path fails the open; a symlink at the destination is
453/// replaced by the rename, never followed (N5).
454pub fn write_atomic(path: impl AsRef<Path>, content: &[u8]) -> Result<(), CoreError> {
455    let target = path.as_ref();
456    let file_name = basename(target)?;
457    let dir = open_parent_nofollow(target)?;
458    let dirfd = dir.as_raw_fd();
459
460    for attempt in 0..TEMP_ATTEMPTS {
461        let tmp_name = format!(".{file_name}.{}.{attempt}", std::process::id());
462        match openat_raw(
463            dirfd,
464            &tmp_name,
465            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC,
466            0o600,
467        ) {
468            Ok(raw) => {
469                // SAFETY: `raw` came from openat with O_EXCL and is uniquely
470                // owned; it becomes an owned File for the write.
471                let mut file = unsafe { fs::File::from_raw_fd(raw) };
472                let _guard = TmpGuard {
473                    dirfd,
474                    name: tmp_name.clone(),
475                };
476                file.write_all(content)?;
477                file.sync_all()?;
478                drop(file);
479                let c_tmp = cstr(&tmp_name)?;
480                let c_final = cstr(&file_name)?;
481                // SAFETY: renameat replaces the destination entry and never
482                // follows a final symlink; both names are single components
483                // relative to the anchored directory fd.
484                if unsafe { libc::renameat(dirfd, c_tmp.as_ptr(), dirfd, c_final.as_ptr()) } != 0 {
485                    return Err(std::io::Error::last_os_error().into());
486                }
487                // rename succeeded; the temp entry no longer exists.
488                std::mem::forget(_guard);
489                return Ok(());
490            }
491            Err(e) if e.raw_os_error() == Some(libc::EEXIST) => continue,
492            Err(e) => return Err(e),
493        }
494    }
495
496    Err(CoreError::sys(libc::EEXIST, "temp:exhausted"))
497}
498
499/// Read a file to a string, refusing to follow a symlink at the final or any
500/// parent component.
501///
502/// Fails with `ELOOP` if `path` is a symlink or passes through one. Used for
503/// reading attacker-placed state files so their contents can never be injected
504/// through a link.
505pub fn read_nofollow(path: impl AsRef<Path>) -> Result<String, CoreError> {
506    let target = path.as_ref();
507    let file_name = basename(target)?;
508    let dir = open_parent_nofollow(target)?;
509    let fd = openat_raw(
510        dir.as_raw_fd(),
511        &file_name,
512        libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
513        0,
514    )?;
515    // SAFETY: `fd` is uniquely owned; it becomes an owned File.
516    let mut file = unsafe { fs::File::from_raw_fd(fd) };
517    let mut content = String::new();
518    file.read_to_string(&mut content)?;
519    Ok(content)
520}
521
522/// Open an existing-or-create append stream that refuses to follow symlinks.
523///
524/// Appends to a regular file at `path`. If `path` is a symlink — or sits under
525/// a symlinked parent — the open fails with `ELOOP` rather than writing
526/// through it.
527pub fn open_append_nofollow(path: impl AsRef<Path>) -> Result<fs::File, CoreError> {
528    let target = path.as_ref();
529    let file_name = basename(target)?;
530    let dir = open_parent_nofollow(target)?;
531    let fd = openat_raw(
532        dir.as_raw_fd(),
533        &file_name,
534        libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_NOFOLLOW | libc::O_CLOEXEC,
535        0o644,
536    )?;
537    // SAFETY: `fd` is uniquely owned; it becomes an owned File.
538    Ok(unsafe { fs::File::from_raw_fd(fd) })
539}
540
541/// Remove a directory entry without following a final symlink, anchored to its
542/// (symlink-free) parent. The entry itself is removed even if it is a symlink;
543/// its target is never touched.
544pub fn remove_nofollow(path: impl AsRef<Path>) -> Result<(), CoreError> {
545    let target = path.as_ref();
546    let file_name = basename(target)?;
547    let dir = open_parent_nofollow(target)?;
548    unlink_name(dir.as_raw_fd(), &file_name)
549}
550
551/// Create `dir` if needed, then verify it is a real directory with no symlink
552/// component and that it is owned by the current effective uid. Returning the
553/// anchored fd lets callers refuse to operate inside an attacker-pre-created
554/// or attacker-redirected state directory.
555pub fn ensure_state_dir(dir: impl AsRef<Path>) -> Result<OwnedFd, CoreError> {
556    let dir = dir.as_ref();
557    // Anchored create-then-verify: missing components are made with mkdirat
558    // relative to the held fd; a symlink at any component ELOOPs rather than
559    // being followed, and the opened fd below is the authority for ownership.
560    let fd = walk_dir(dir, Some(0o700))?;
561    let st = fstat(fd.as_raw_fd())?;
562    let euid = unsafe { libc::geteuid() };
563    if st.st_uid != euid {
564        return Err(CoreError::sys(libc::EACCES, "state_dir_owner"));
565    }
566    Ok(fd)
567}
568
569#[cfg(test)]
570mod tests {
571    #[cfg(target_os = "linux")]
572    #[test]
573    fn test_readahead_syscall_number_linux_matches_libc() {
574        assert_eq!(super::readahead_syscall_number(), libc::SYS_readahead);
575    }
576
577    #[cfg(all(target_os = "android", target_arch = "aarch64"))]
578    #[test]
579    fn test_readahead_syscall_number_android_aarch64() {
580        assert_eq!(super::readahead_syscall_number(), 213);
581    }
582
583    #[cfg(all(target_os = "android", target_arch = "arm"))]
584    #[test]
585    fn test_readahead_syscall_number_android_arm() {
586        assert_eq!(super::readahead_syscall_number(), 225);
587    }
588
589    #[cfg(all(target_os = "android", target_arch = "x86_64"))]
590    #[test]
591    fn test_readahead_syscall_number_android_x86_64() {
592        assert_eq!(super::readahead_syscall_number(), 187);
593    }
594
595    #[cfg(all(target_os = "android", target_arch = "x86"))]
596    #[test]
597    fn test_readahead_syscall_number_android_x86() {
598        assert_eq!(super::readahead_syscall_number(), 225);
599    }
600}
601
602#[cfg(test)]
603mod safe_fs_tests {
604
605    use super::*;
606    use std::os::unix::fs::symlink;
607    use std::path::PathBuf;
608
609    fn tmpdir(name: &str) -> PathBuf {
610        let d = std::env::temp_dir().join(format!(
611            "coreshift_safe_fs_dir_{}_{name}",
612            std::process::id()
613        ));
614        let _ = fs::remove_dir_all(&d);
615        fs::create_dir_all(&d).unwrap();
616        d
617    }
618
619    #[test]
620    fn test_write_atomic_creates_regular_file() {
621        let dir = tmpdir("w");
622        let p = dir.join("out.txt");
623
624        write_atomic(&p, b"hello").unwrap();
625        assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
626        assert!(fs::symlink_metadata(&p).unwrap().file_type().is_file());
627    }
628
629    #[test]
630    fn test_write_atomic_replaces_existing_symlink_not_target() {
631        let dir = tmpdir("s1");
632        let target = dir.join("victim");
633        let link = dir.join("link");
634
635        fs::write(&target, b"precious").unwrap();
636        symlink(&target, &link).unwrap();
637
638        // Write through the symlink: the victim must stay untouched and the
639        // path must become a regular file (the symlink is replaced).
640        write_atomic(&link, b"new").unwrap();
641
642        assert_eq!(fs::read_to_string(&target).unwrap(), "precious");
643        assert_eq!(fs::read_to_string(&link).unwrap(), "new");
644        assert!(fs::symlink_metadata(&link).unwrap().file_type().is_file());
645    }
646
647    #[test]
648    fn test_read_nofollow_refuses_symlink() {
649        let dir = tmpdir("r");
650        let target = dir.join("victim2");
651        let link = dir.join("link2");
652
653        fs::write(&target, b"secret").unwrap();
654        symlink(&target, &link).unwrap();
655
656        assert_eq!(read_nofollow(&target).unwrap(), "secret");
657        assert!(read_nofollow(&link).is_err());
658    }
659
660    #[test]
661    fn test_open_append_nofollow_refuses_symlink() {
662        let dir = tmpdir("a");
663        let target = dir.join("target3");
664        let link = dir.join("link3");
665
666        fs::write(&target, b"x").unwrap();
667        symlink(&target, &link).unwrap();
668
669        // Append to the regular file is fine.
670        assert!(open_append_nofollow(&target).is_ok());
671        // Appending through a symlink must fail.
672        assert!(open_append_nofollow(&link).is_err());
673
674        let _ = fs::remove_file(&target);
675        let _ = fs::remove_file(&link);
676    }
677
678    #[test]
679    fn test_write_atomic_refuses_symlinked_parent() {
680        // An intermediate directory replaced by a symlink must make the
681        // anchored open fail, and nothing may be written through to the
682        // linked directory.
683        let dir = tmpdir("parent_symlink");
684        let elsewhere = tmpdir("parent_dest");
685        let link = dir.join("coreshift");
686        symlink(&elsewhere, &link).unwrap();
687
688        assert!(write_atomic(link.join("payload.txt"), b"boom").is_err());
689        assert!(!elsewhere.join("payload.txt").exists());
690        assert!(!link.join("payload.txt").exists());
691    }
692
693    #[test]
694    fn test_read_nofollow_refuses_symlinked_parent() {
695        let dir = tmpdir("read_parent_symlink");
696        let elsewhere = tmpdir("read_parent_dest");
697        fs::write(elsewhere.join("conf"), b"injected").unwrap();
698        let link = dir.join("coreshift");
699        symlink(&elsewhere, &link).unwrap();
700
701        assert!(read_nofollow(link.join("conf")).is_err());
702    }
703
704    #[test]
705    fn test_open_append_nofollow_refuses_symlinked_parent() {
706        let dir = tmpdir("append_parent_symlink");
707        let elsewhere = tmpdir("append_parent_dest");
708        let link = dir.join("coreshift");
709        symlink(&elsewhere, &link).unwrap();
710
711        assert!(open_append_nofollow(link.join("daemon.log")).is_err());
712        assert!(!elsewhere.join("daemon.log").exists());
713    }
714
715    #[test]
716    fn test_ensure_state_dir_refuses_symlink() {
717        let dir = tmpdir("state_symlink");
718        let elsewhere = tmpdir("state_dest");
719        let link = dir.join("state");
720        symlink(&elsewhere, &link).unwrap();
721
722        assert!(ensure_state_dir(&link).is_err());
723        // A real directory is accepted (owned by the current euid).
724        let real = tmpdir("state_real");
725        assert!(ensure_state_dir(&real).is_ok());
726    }
727
728    #[test]
729    fn test_remove_nofollow_removes_entry_not_target() {
730        let dir = tmpdir("unlink");
731        let target = dir.join("victim4");
732        let link = dir.join("link4");
733        fs::write(&target, b"keep").unwrap();
734        symlink(&target, &link).unwrap();
735
736        remove_nofollow(&link).unwrap();
737        assert!(!link.exists());
738        assert_eq!(fs::read_to_string(&target).unwrap(), "keep");
739    }
740
741    #[test]
742    fn test_write_atomic_requires_parent_to_exist() {
743        let dir = tmpdir("missing_parent");
744        let p = dir.join("nope").join("file.txt");
745
746        assert!(write_atomic(&p, b"x").is_err());
747        assert!(!p.exists());
748    }
749
750    #[test]
751    fn test_ops_refuse_foreign_owned_parent() {
752        // Simulates an attacker who renames the root-owned state dir and
753        // substitutes their own: the real (non-symlink) substituted dir must
754        // fail every operation because it is not owned by the effective uid.
755        // Only exercisable as root (chown); otherwise skipped.
756        if unsafe { libc::geteuid() } != 0 {
757            return;
758        }
759        let dir = tmpdir("foreign_owner");
760        let path = dir.join("f");
761        let owned = cstr(&dir.to_string_lossy()).unwrap();
762        // SAFETY: chown to 65534 (nobody) while running as root.
763        assert_eq!(unsafe { libc::chown(owned.as_ptr(), 65534, 65534) }, 0);
764
765        assert!(write_atomic(&path, b"x").is_err());
766        assert!(read_nofollow(&path).is_err());
767        assert!(open_append_nofollow(&path).is_err());
768        assert!(remove_nofollow(&path).is_err());
769        assert!(ensure_state_dir(&dir).is_err());
770        assert!(!path.exists());
771    }
772
773    fn temp_leftovers(dir: &Path, file_name: &str) -> Vec<String> {
774        let prefix = format!(".{file_name}.");
775        fs::read_dir(dir)
776            .map(|rd| {
777                rd.filter_map(|e| e.ok())
778                    .filter_map(|e| e.file_name().to_str().map(str::to_owned))
779                    .filter(|n| n.starts_with(&prefix))
780                    .collect()
781            })
782            .unwrap_or_default()
783    }
784
785    #[test]
786    fn test_write_atomic_cleans_temp_on_rename_failure() {
787        // A non-empty directory at the destination forces renameat to fail
788        // (EISDIR/ENOTEMPTY) after the temp file has been fully written; the
789        // TmpGuard must remove the temp entry.
790        let dir = tmpdir("rename_fail");
791        let dest = dir.join("dest");
792        fs::create_dir_all(&dest).unwrap();
793        fs::write(dest.join("keep"), b"x").unwrap();
794
795        assert!(write_atomic(&dest, b"boom").is_err());
796        assert_eq!(fs::read_to_string(dest.join("keep")).unwrap(), "x");
797        assert!(
798            temp_leftovers(&dir, "dest").is_empty(),
799            "temp file must be cleaned up"
800        );
801    }
802
803    #[test]
804    fn test_write_atomic_leaves_no_temp_on_success() {
805        let dir = tmpdir("no_temp_success");
806        let p = dir.join("out.txt");
807
808        write_atomic(&p, b"hello").unwrap();
809        assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
810        assert!(
811            temp_leftovers(&dir, "out.txt").is_empty(),
812            "no temp left behind"
813        );
814    }
815
816    #[test]
817    fn test_write_atomic_retries_when_temp_name_exists() {
818        let dir = tmpdir("temp_collision");
819        let p = dir.join("out.txt");
820        let pid = std::process::id();
821        let collided = dir.join(format!(".out.txt.{pid}.0"));
822        fs::write(&collided, b"not mine").unwrap();
823
824        write_atomic(&p, b"hello").unwrap();
825        assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
826        // The pre-existing colliding temp is neither clobbered nor mistaken for
827        // ours; a later attempt slot is used.
828        assert_eq!(fs::read_to_string(&collided).unwrap(), "not mine");
829        let leftovers = temp_leftovers(&dir, "out.txt");
830        assert_eq!(
831            leftovers.len(),
832            1,
833            "only the pre-existing colliding temp remains"
834        );
835        assert_eq!(leftovers[0], format!(".out.txt.{pid}.0"));
836    }
837
838    #[test]
839    fn test_ops_refuse_foreign_owned_writable_parent() {
840        // A world-writable directory owned by a uid != euid (e.g. /tmp owned by
841        // root) is a privilege-free proxy for a swapped state dir: a vulnerable
842        // implementation with no ownership check would happily write into it,
843        // while the fixed one must refuse every operation. Runs whenever such a
844        // directory is available; skips as root (root owns /tmp).
845        use std::os::unix::fs::MetadataExt;
846        let euid = unsafe { libc::geteuid() };
847        if euid == 0 {
848            return;
849        }
850        let tmp = std::env::temp_dir();
851        let meta = match fs::symlink_metadata(&tmp) {
852            Ok(m) => m,
853            Err(_) => return,
854        };
855        if meta.uid() == euid {
856            return; // no ownership mismatch available in this environment
857        }
858        if meta.mode() & 0o002 == 0 && meta.mode() & 0o020 == 0 {
859            return; // not writable by us; the proxy doesn't apply
860        }
861        let p = tmp.join(format!(
862            "coreshift_fs_foreign_{}_{}",
863            std::process::id(),
864            "out"
865        ));
866        let _ = fs::remove_file(&p);
867        fs::write(&p, b"probe").unwrap();
868
869        assert!(read_nofollow(&p).is_err());
870        assert!(open_append_nofollow(&p).is_err());
871        assert!(write_atomic(&p, b"boom").is_err());
872        assert!(remove_nofollow(&p).is_err());
873        assert!(ensure_state_dir(&tmp).is_err());
874        assert_eq!(fs::read_to_string(&p).unwrap(), "probe");
875        let _ = fs::remove_file(&p);
876    }
877
878    #[test]
879    fn test_ensure_state_dir_creates_fresh_dir() {
880        let base = tmpdir("fresh_base");
881        let nested = base.join("a").join("b").join("state");
882
883        let fd = ensure_state_dir(&nested).unwrap();
884        assert!(nested.is_dir());
885        drop(fd);
886        assert!(ensure_state_dir(&nested).is_ok());
887    }
888
889    #[test]
890    fn test_open_append_nofollow_refuses_dangling_symlink() {
891        let dir = tmpdir("dangling_append");
892        let missing = dir.join("not_there.txt");
893        let link = dir.join("linkd");
894        symlink(&missing, &link).unwrap();
895
896        assert!(open_append_nofollow(&link).is_err());
897        assert!(
898            !missing.exists(),
899            "must not create the target through a dangling link"
900        );
901    }
902
903    #[test]
904    fn test_read_nofollow_refuses_dangling_symlink() {
905        let dir = tmpdir("dangling_read");
906        let missing = dir.join("not_there2.txt");
907        let link = dir.join("linkd2");
908        symlink(&missing, &link).unwrap();
909
910        assert!(read_nofollow(&link).is_err());
911        assert!(!missing.exists());
912    }
913
914    #[test]
915    fn test_ops_refuse_regular_file_parent() {
916        // A parent component that is a regular file must yield ENOTDIR for
917        // every anchored operation — no write-through, no creation.
918        let dir = tmpdir("regfile_parent");
919        let f = dir.join("notadir");
920        fs::write(&f, b"x").unwrap();
921
922        assert!(write_atomic(f.join("out"), b"y").is_err());
923        assert!(read_nofollow(f.join("out")).is_err());
924        assert!(open_append_nofollow(f.join("out")).is_err());
925        assert!(remove_nofollow(f.join("out")).is_err());
926        assert!(ensure_state_dir(&f).is_err());
927        assert_eq!(fs::read_to_string(&f).unwrap(), "x");
928    }
929}