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    // Clamp the range to the file's actual size. Mapping beyond EOF is legal,
182    // but touching a page with no backing data raises SIGBUS, so a `touch`
183    // request that extends past EOF would crash the process.
184    let file_len = {
185        let mut st: libc::stat = unsafe { std::mem::zeroed() };
186        if unsafe { libc::fstat(fd, &mut st) } == -1 {
187            let code = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
188            return Err(CoreError::sys(code, "fstat"));
189        }
190        st.st_size.max(0) as u64
191    };
192    let len = len.min(file_len.saturating_sub(offset) as usize);
193    if len == 0 {
194        return Ok(());
195    }
196
197    let ptr = unsafe {
198        libc::mmap(
199            std::ptr::null_mut(),
200            len,
201            libc::PROT_READ,
202            libc::MAP_PRIVATE,
203            fd,
204            offset as libc::off_t,
205        )
206    };
207    if ptr == libc::MAP_FAILED {
208        let code = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
209        return Err(CoreError::sys(code, "mmap"));
210    }
211
212    let result = if unsafe { libc::madvise(ptr, len, libc::MADV_WILLNEED) } == -1 {
213        let code = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
214        Err(CoreError::sys(code, "madvise"))
215    } else {
216        if touch {
217            // Touching a page with no backing data (a file truncated after the
218            // initial fstat clamp) raises SIGBUS and would kill the daemon. A
219            // SIGBUS handler is not an option here (process-wide signal state
220            // is unsafe in a multithreaded root daemon), so re-validate the
221            // current file size on every page and stop as soon as the file
222            // shrank below the page about to be touched. This narrows the
223            // crash window from "any truncate during the whole loop" to a
224            // single fstat→read race on the boundary page.
225            let page_size = page_size as usize;
226            let mut pos = 0usize;
227            while pos < len {
228                let mut st: libc::stat = unsafe { std::mem::zeroed() };
229                if unsafe { libc::fstat(fd, &mut st) } == -1 {
230                    break;
231                }
232                let file_len = st.st_size.max(0) as u64;
233                let page_start = offset.saturating_add(pos as u64);
234                if page_start >= file_len {
235                    break;
236                }
237                unsafe {
238                    std::ptr::read_volatile((ptr as *const u8).add(pos));
239                }
240                pos = pos.saturating_add(page_size);
241            }
242        }
243        Ok(())
244    };
245
246    if unsafe { libc::munmap(ptr, len) } == -1 {
247        let code = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
248        return Err(CoreError::sys(code, "munmap"));
249    }
250    result
251}
252
253#[cfg(not(any(target_os = "linux", target_os = "android")))]
254fn mmap_madvise_raw(
255    _fd: libc::c_int,
256    _offset: u64,
257    _len: usize,
258    _touch: bool,
259) -> Result<(), CoreError> {
260    Err(CoreError::sys(libc::ENOSYS, "mmap"))
261}
262
263#[cfg(any(target_os = "linux", target_os = "android"))]
264fn readahead_raw(fd: libc::c_int, offset: u64, len: usize) -> Result<(), CoreError> {
265    if offset > libc::off64_t::MAX as u64 {
266        return Err(CoreError::sys(libc::EINVAL, "readahead"));
267    }
268
269    let count = len as libc::size_t;
270    let offset = offset as libc::off64_t;
271
272    loop {
273        let ret = unsafe { libc::syscall(readahead_syscall_number(), fd, offset, count) };
274        if ret == -1 {
275            let code = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
276            if code == libc::EINTR {
277                continue;
278            }
279            return Err(CoreError::sys(code, "readahead"));
280        }
281        return Ok(());
282    }
283}
284
285#[cfg(not(any(target_os = "linux", target_os = "android")))]
286fn readahead_raw(_fd: libc::c_int, _offset: u64, _len: usize) -> Result<(), CoreError> {
287    Err(CoreError::sys(libc::ENOSYS, "readahead"))
288}
289
290#[cfg(target_os = "linux")]
291#[inline(always)]
292const fn readahead_syscall_number() -> libc::c_long {
293    libc::SYS_readahead
294}
295
296#[cfg(all(target_os = "android", target_arch = "aarch64"))]
297#[inline(always)]
298const fn readahead_syscall_number() -> libc::c_long {
299    213
300}
301
302#[cfg(all(target_os = "android", target_arch = "arm"))]
303#[inline(always)]
304const fn readahead_syscall_number() -> libc::c_long {
305    225
306}
307
308#[cfg(all(target_os = "android", target_arch = "x86_64"))]
309#[inline(always)]
310const fn readahead_syscall_number() -> libc::c_long {
311    187
312}
313
314#[cfg(all(target_os = "android", target_arch = "x86"))]
315#[inline(always)]
316const fn readahead_syscall_number() -> libc::c_long {
317    225
318}
319
320const TEMP_ATTEMPTS: usize = 32;
321
322fn cstr(s: &str) -> Result<CString, CoreError> {
323    CString::new(s).map_err(|_| CoreError::sys(libc::EINVAL, "path:nul_byte"))
324}
325
326fn openat_raw(dirfd: RawFd, name: &str, flags: i32, mode: u32) -> Result<RawFd, CoreError> {
327    let c = cstr(name)?;
328    // SAFETY: `name` is a single path component (no `/`) converted to a
329    // NUL-terminated string, and `dirfd` is a valid open directory fd.
330    let fd = unsafe { libc::openat(dirfd, c.as_ptr(), flags, mode as libc::c_uint) };
331    if fd < 0 {
332        Err(std::io::Error::last_os_error().into())
333    } else {
334        Ok(fd)
335    }
336}
337
338fn openat_dir(dirfd: RawFd, name: &str) -> Result<OwnedFd, CoreError> {
339    let fd = openat_raw(
340        dirfd,
341        name,
342        libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
343        0,
344    )?;
345    // SAFETY: `fd` came from openat and is uniquely owned here.
346    Ok(unsafe { OwnedFd::from_raw_fd(fd) })
347}
348
349/// Resolve `path` from the root down to a directory fd, refusing to traverse
350/// Walk `path` from the root down to a directory fd, refusing to traverse any
351/// symlink component (ELOOP) or non-directory (ENOTDIR). When `create_mode` is
352/// `Some(mode)`, missing components are created via `mkdirat` relative to the
353/// previously held fd — never by re-resolving a path, so an attacker link can
354/// never make root create a directory at an attacker-chosen location (N5).
355fn walk_dir(path: &Path, create_mode: Option<u32>) -> Result<OwnedFd, CoreError> {
356    let abs = if path.is_absolute() {
357        path.to_path_buf()
358    } else {
359        std::env::current_dir()?.join(path)
360    };
361    let mut dir = openat_dir(libc::AT_FDCWD, "/")?;
362    for comp in abs.components() {
363        use std::path::Component;
364        match comp {
365            Component::RootDir | Component::CurDir => {}
366            Component::Normal(name) => {
367                let name = name
368                    .to_str()
369                    .ok_or_else(|| CoreError::sys(libc::EINVAL, "path:non_utf8"))?;
370                let next = match openat_dir(dir.as_raw_fd(), name) {
371                    Ok(fd) => fd,
372                    Err(e) if e.raw_os_error() == Some(libc::ENOENT) && create_mode.is_some() => {
373                        let c = cstr(name)?;
374                        // SAFETY: mkdirat creates a single directory entry
375                        // relative to the held fd; no pathname resolution of
376                        // attacker components.
377                        if unsafe {
378                            libc::mkdirat(
379                                dir.as_raw_fd(),
380                                c.as_ptr(),
381                                create_mode.unwrap() as libc::mode_t,
382                            )
383                        } != 0
384                        {
385                            // Raced with another creator; if it now exists as a
386                            // real directory, use it. A symlink raced into place
387                            // still fails O_NOFOLLOW below.
388                            if std::io::Error::last_os_error().raw_os_error() == Some(libc::EEXIST)
389                            {
390                                openat_dir(dir.as_raw_fd(), name)?
391                            } else {
392                                return Err(std::io::Error::last_os_error().into());
393                            }
394                        } else {
395                            openat_dir(dir.as_raw_fd(), name)?
396                        }
397                    }
398                    Err(e) => return Err(e),
399                };
400                dir = next;
401            }
402            Component::ParentDir => {
403                return Err(CoreError::sys(libc::EINVAL, "path:parent_component"));
404            }
405            Component::Prefix(_) => unreachable!("non-Windows path"),
406        }
407    }
408    Ok(dir)
409}
410
411/// Resolve `path` from the root down to a directory fd, refusing to traverse
412/// any symlink component (ELOOP) or non-directory (ENOTDIR).
413fn open_dir_nofollow(path: &Path) -> Result<OwnedFd, CoreError> {
414    walk_dir(path, None)
415}
416
417fn fstat(fd: RawFd) -> Result<libc::stat, CoreError> {
418    let mut st: libc::stat = unsafe { std::mem::zeroed() };
419    // SAFETY: `fd` is valid and `st` remains alive for the call.
420    if unsafe { libc::fstat(fd, &mut st) } != 0 {
421        Err(std::io::Error::last_os_error().into())
422    } else {
423        Ok(st)
424    }
425}
426
427fn unlink_name(dirfd: RawFd, name: &str) -> Result<(), CoreError> {
428    let c = cstr(name)?;
429    // SAFETY: unlinkat without AT_SYMLINK_FOLLOW removes the directory entry
430    // itself and never follows a final symlink.
431    if unsafe { libc::unlinkat(dirfd, c.as_ptr(), 0) } != 0 {
432        Err(std::io::Error::last_os_error().into())
433    } else {
434        Ok(())
435    }
436}
437
438fn basename(target: &Path) -> Result<String, CoreError> {
439    target
440        .file_name()
441        .map(|n| n.to_string_lossy().into_owned())
442        .ok_or_else(|| CoreError::sys(libc::EINVAL, "path:no_file_name"))
443}
444
445fn parent_dir(target: &Path) -> &Path {
446    target
447        .parent()
448        .filter(|p| !p.as_os_str().is_empty())
449        .unwrap_or_else(|| Path::new("."))
450}
451
452/// Open the parent directory of `target` fd-anchored (O_NOFOLLOW walk) and
453/// verify it is owned by the current effective uid.
454///
455/// Requiring ownership on every operation closes the directory-swap vector:
456/// an attacker who renames the state dir and substitutes their own can never
457/// make the daemon read from or write into the substituted directory, because
458/// the substituted dir fails the ownership check regardless of the path.
459fn open_parent_nofollow(target: &Path) -> Result<OwnedFd, CoreError> {
460    let dir = open_dir_nofollow(parent_dir(target))?;
461    let st = fstat(dir.as_raw_fd())?;
462    let euid = unsafe { libc::geteuid() };
463    if st.st_uid != euid {
464        return Err(CoreError::sys(libc::EACCES, "parent_dir_owner"));
465    }
466    Ok(dir)
467}
468
469struct TmpGuard {
470    dirfd: RawFd,
471    name: String,
472}
473
474impl Drop for TmpGuard {
475    fn drop(&mut self) {
476        let _ = unlink_name(self.dirfd, &self.name);
477    }
478}
479
480/// Atomically replace `path` with `content`.
481///
482/// The parent directory is opened `O_NOFOLLOW` component-by-component and held
483/// as a directory fd; the content is written to a fresh `O_CREAT|O_EXCL`
484/// temp file via `openat` and then `renameat`d over the destination. A symlink
485/// anywhere in the parent path fails the open; a symlink at the destination is
486/// replaced by the rename, never followed (N5).
487pub fn write_atomic(path: impl AsRef<Path>, content: &[u8]) -> Result<(), CoreError> {
488    let target = path.as_ref();
489    let file_name = basename(target)?;
490    let dir = open_parent_nofollow(target)?;
491    let dirfd = dir.as_raw_fd();
492
493    for attempt in 0..TEMP_ATTEMPTS {
494        let tmp_name = format!(".{file_name}.{}.{attempt}", std::process::id());
495        match openat_raw(
496            dirfd,
497            &tmp_name,
498            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC,
499            0o600,
500        ) {
501            Ok(raw) => {
502                // SAFETY: `raw` came from openat with O_EXCL and is uniquely
503                // owned; it becomes an owned File for the write.
504                let mut file = unsafe { fs::File::from_raw_fd(raw) };
505                let _guard = TmpGuard {
506                    dirfd,
507                    name: tmp_name.clone(),
508                };
509                file.write_all(content)?;
510                file.sync_all()?;
511                drop(file);
512                let c_tmp = cstr(&tmp_name)?;
513                let c_final = cstr(&file_name)?;
514                // SAFETY: renameat replaces the destination entry and never
515                // follows a final symlink; both names are single components
516                // relative to the anchored directory fd.
517                if unsafe { libc::renameat(dirfd, c_tmp.as_ptr(), dirfd, c_final.as_ptr()) } != 0 {
518                    return Err(std::io::Error::last_os_error().into());
519                }
520                // rename succeeded; the temp entry no longer exists.
521                std::mem::forget(_guard);
522                return Ok(());
523            }
524            Err(e) if e.raw_os_error() == Some(libc::EEXIST) => continue,
525            Err(e) => return Err(e),
526        }
527    }
528
529    Err(CoreError::sys(libc::EEXIST, "temp:exhausted"))
530}
531
532/// Read a file to a string, refusing to follow a symlink at the final or any
533/// parent component.
534///
535/// Fails with `ELOOP` if `path` is a symlink or passes through one. Used for
536/// reading attacker-placed state files so their contents can never be injected
537/// through a link.
538pub fn read_nofollow(path: impl AsRef<Path>) -> Result<String, CoreError> {
539    let target = path.as_ref();
540    let file_name = basename(target)?;
541    let dir = open_parent_nofollow(target)?;
542    let fd = openat_raw(
543        dir.as_raw_fd(),
544        &file_name,
545        libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
546        0,
547    )?;
548    // SAFETY: `fd` is uniquely owned; it becomes an owned File.
549    let mut file = unsafe { fs::File::from_raw_fd(fd) };
550    let mut content = String::new();
551    file.read_to_string(&mut content)?;
552    Ok(content)
553}
554
555/// Open an existing-or-create append stream that refuses to follow symlinks.
556///
557/// Appends to a regular file at `path`. If `path` is a symlink — or sits under
558/// a symlinked parent — the open fails with `ELOOP` rather than writing
559/// through it.
560pub fn open_append_nofollow(path: impl AsRef<Path>) -> Result<fs::File, CoreError> {
561    let target = path.as_ref();
562    let file_name = basename(target)?;
563    let dir = open_parent_nofollow(target)?;
564    let fd = openat_raw(
565        dir.as_raw_fd(),
566        &file_name,
567        libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_NOFOLLOW | libc::O_CLOEXEC,
568        0o644,
569    )?;
570    // SAFETY: `fd` is uniquely owned; it becomes an owned File.
571    Ok(unsafe { fs::File::from_raw_fd(fd) })
572}
573
574/// Remove a directory entry without following a final symlink, anchored to its
575/// (symlink-free) parent. The entry itself is removed even if it is a symlink;
576/// its target is never touched.
577pub fn remove_nofollow(path: impl AsRef<Path>) -> Result<(), CoreError> {
578    let target = path.as_ref();
579    let file_name = basename(target)?;
580    let dir = open_parent_nofollow(target)?;
581    unlink_name(dir.as_raw_fd(), &file_name)
582}
583
584/// Create `dir` if needed, then verify it is a real directory with no symlink
585/// component and that it is owned by the current effective uid. Returning the
586/// anchored fd lets callers refuse to operate inside an attacker-pre-created
587/// or attacker-redirected state directory.
588pub fn ensure_state_dir(dir: impl AsRef<Path>) -> Result<OwnedFd, CoreError> {
589    let dir = dir.as_ref();
590    // Anchored create-then-verify: missing components are made with mkdirat
591    // relative to the held fd; a symlink at any component ELOOPs rather than
592    // being followed, and the opened fd below is the authority for ownership.
593    let fd = walk_dir(dir, Some(0o700))?;
594    let st = fstat(fd.as_raw_fd())?;
595    let euid = unsafe { libc::geteuid() };
596    if st.st_uid != euid {
597        return Err(CoreError::sys(libc::EACCES, "state_dir_owner"));
598    }
599    Ok(fd)
600}
601
602#[cfg(test)]
603mod tests {
604    #[cfg(target_os = "linux")]
605    #[test]
606    fn test_readahead_syscall_number_linux_matches_libc() {
607        assert_eq!(super::readahead_syscall_number(), libc::SYS_readahead);
608    }
609
610    #[cfg(target_os = "linux")]
611    #[test]
612    fn test_mmap_madvise_touch_past_eof_does_not_sigbus() {
613        use std::os::unix::io::{AsRawFd, FromRawFd};
614
615        // A file whose length is not page-aligned; requesting a touch range
616        // that extends far past EOF must be clamped to the file size instead of
617        // SIGBUS-ing the process on the out-of-file pages.
618        let dir =
619            std::env::temp_dir().join(format!("coreshift_mmap_past_eof_{}", std::process::id()));
620        let _ = std::fs::remove_file(&dir);
621        std::fs::write(&dir, b"x").unwrap();
622
623        let f = std::fs::File::open(&dir).unwrap();
624        let fd = f.as_raw_fd();
625        // Reopen to hand a raw fd to the helper without double-closing.
626        let dup = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
627        assert!(dup >= 0);
628        let owned = unsafe { std::fs::File::from_raw_fd(dup) };
629
630        let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64;
631        let result = super::mmap_madvise(owned.as_raw_fd(), 0, (page * 4) as usize, true);
632        drop(owned);
633        let _ = std::fs::remove_file(&dir);
634
635        // Clamped to 1 byte; MADV_WILLNEED on a tiny range is a no-op that
636        // still reports success on Linux.
637        assert!(result.is_ok(), "touch past EOF must not SIGBUS: {result:?}");
638    }
639
640    #[cfg(all(target_os = "android", target_arch = "aarch64"))]
641    #[test]
642    fn test_readahead_syscall_number_android_aarch64() {
643        assert_eq!(super::readahead_syscall_number(), 213);
644    }
645
646    #[cfg(all(target_os = "android", target_arch = "arm"))]
647    #[test]
648    fn test_readahead_syscall_number_android_arm() {
649        assert_eq!(super::readahead_syscall_number(), 225);
650    }
651
652    #[cfg(all(target_os = "android", target_arch = "x86_64"))]
653    #[test]
654    fn test_readahead_syscall_number_android_x86_64() {
655        assert_eq!(super::readahead_syscall_number(), 187);
656    }
657
658    #[cfg(all(target_os = "android", target_arch = "x86"))]
659    #[test]
660    fn test_readahead_syscall_number_android_x86() {
661        assert_eq!(super::readahead_syscall_number(), 225);
662    }
663}
664
665#[cfg(test)]
666mod safe_fs_tests {
667
668    use super::*;
669    use std::os::unix::fs::symlink;
670    use std::path::PathBuf;
671
672    fn tmpdir(name: &str) -> PathBuf {
673        let d = std::env::temp_dir().join(format!(
674            "coreshift_safe_fs_dir_{}_{name}",
675            std::process::id()
676        ));
677        let _ = fs::remove_dir_all(&d);
678        fs::create_dir_all(&d).unwrap();
679        d
680    }
681
682    #[test]
683    fn test_write_atomic_creates_regular_file() {
684        let dir = tmpdir("w");
685        let p = dir.join("out.txt");
686
687        write_atomic(&p, b"hello").unwrap();
688        assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
689        assert!(fs::symlink_metadata(&p).unwrap().file_type().is_file());
690    }
691
692    #[test]
693    fn test_write_atomic_replaces_existing_symlink_not_target() {
694        let dir = tmpdir("s1");
695        let target = dir.join("victim");
696        let link = dir.join("link");
697
698        fs::write(&target, b"precious").unwrap();
699        symlink(&target, &link).unwrap();
700
701        // Write through the symlink: the victim must stay untouched and the
702        // path must become a regular file (the symlink is replaced).
703        write_atomic(&link, b"new").unwrap();
704
705        assert_eq!(fs::read_to_string(&target).unwrap(), "precious");
706        assert_eq!(fs::read_to_string(&link).unwrap(), "new");
707        assert!(fs::symlink_metadata(&link).unwrap().file_type().is_file());
708    }
709
710    #[test]
711    fn test_read_nofollow_refuses_symlink() {
712        let dir = tmpdir("r");
713        let target = dir.join("victim2");
714        let link = dir.join("link2");
715
716        fs::write(&target, b"secret").unwrap();
717        symlink(&target, &link).unwrap();
718
719        assert_eq!(read_nofollow(&target).unwrap(), "secret");
720        assert!(read_nofollow(&link).is_err());
721    }
722
723    #[test]
724    fn test_open_append_nofollow_refuses_symlink() {
725        let dir = tmpdir("a");
726        let target = dir.join("target3");
727        let link = dir.join("link3");
728
729        fs::write(&target, b"x").unwrap();
730        symlink(&target, &link).unwrap();
731
732        // Append to the regular file is fine.
733        assert!(open_append_nofollow(&target).is_ok());
734        // Appending through a symlink must fail.
735        assert!(open_append_nofollow(&link).is_err());
736
737        let _ = fs::remove_file(&target);
738        let _ = fs::remove_file(&link);
739    }
740
741    #[test]
742    fn test_write_atomic_refuses_symlinked_parent() {
743        // An intermediate directory replaced by a symlink must make the
744        // anchored open fail, and nothing may be written through to the
745        // linked directory.
746        let dir = tmpdir("parent_symlink");
747        let elsewhere = tmpdir("parent_dest");
748        let link = dir.join("coreshift");
749        symlink(&elsewhere, &link).unwrap();
750
751        assert!(write_atomic(link.join("payload.txt"), b"boom").is_err());
752        assert!(!elsewhere.join("payload.txt").exists());
753        assert!(!link.join("payload.txt").exists());
754    }
755
756    #[test]
757    fn test_read_nofollow_refuses_symlinked_parent() {
758        let dir = tmpdir("read_parent_symlink");
759        let elsewhere = tmpdir("read_parent_dest");
760        fs::write(elsewhere.join("conf"), b"injected").unwrap();
761        let link = dir.join("coreshift");
762        symlink(&elsewhere, &link).unwrap();
763
764        assert!(read_nofollow(link.join("conf")).is_err());
765    }
766
767    #[test]
768    fn test_open_append_nofollow_refuses_symlinked_parent() {
769        let dir = tmpdir("append_parent_symlink");
770        let elsewhere = tmpdir("append_parent_dest");
771        let link = dir.join("coreshift");
772        symlink(&elsewhere, &link).unwrap();
773
774        assert!(open_append_nofollow(link.join("daemon.log")).is_err());
775        assert!(!elsewhere.join("daemon.log").exists());
776    }
777
778    #[test]
779    fn test_ensure_state_dir_refuses_symlink() {
780        let dir = tmpdir("state_symlink");
781        let elsewhere = tmpdir("state_dest");
782        let link = dir.join("state");
783        symlink(&elsewhere, &link).unwrap();
784
785        assert!(ensure_state_dir(&link).is_err());
786        // A real directory is accepted (owned by the current euid).
787        let real = tmpdir("state_real");
788        assert!(ensure_state_dir(&real).is_ok());
789    }
790
791    #[test]
792    fn test_remove_nofollow_removes_entry_not_target() {
793        let dir = tmpdir("unlink");
794        let target = dir.join("victim4");
795        let link = dir.join("link4");
796        fs::write(&target, b"keep").unwrap();
797        symlink(&target, &link).unwrap();
798
799        remove_nofollow(&link).unwrap();
800        assert!(!link.exists());
801        assert_eq!(fs::read_to_string(&target).unwrap(), "keep");
802    }
803
804    #[test]
805    fn test_write_atomic_requires_parent_to_exist() {
806        let dir = tmpdir("missing_parent");
807        let p = dir.join("nope").join("file.txt");
808
809        assert!(write_atomic(&p, b"x").is_err());
810        assert!(!p.exists());
811    }
812
813    #[test]
814    fn test_ops_refuse_foreign_owned_parent() {
815        // Simulates an attacker who renames the root-owned state dir and
816        // substitutes their own: the real (non-symlink) substituted dir must
817        // fail every operation because it is not owned by the effective uid.
818        // Only exercisable as root (chown); otherwise skipped.
819        if unsafe { libc::geteuid() } != 0 {
820            return;
821        }
822        let dir = tmpdir("foreign_owner");
823        let path = dir.join("f");
824        let owned = cstr(&dir.to_string_lossy()).unwrap();
825        // SAFETY: chown to 65534 (nobody) while running as root.
826        assert_eq!(unsafe { libc::chown(owned.as_ptr(), 65534, 65534) }, 0);
827
828        assert!(write_atomic(&path, b"x").is_err());
829        assert!(read_nofollow(&path).is_err());
830        assert!(open_append_nofollow(&path).is_err());
831        assert!(remove_nofollow(&path).is_err());
832        assert!(ensure_state_dir(&dir).is_err());
833        assert!(!path.exists());
834    }
835
836    fn temp_leftovers(dir: &Path, file_name: &str) -> Vec<String> {
837        let prefix = format!(".{file_name}.");
838        fs::read_dir(dir)
839            .map(|rd| {
840                rd.filter_map(|e| e.ok())
841                    .filter_map(|e| e.file_name().to_str().map(str::to_owned))
842                    .filter(|n| n.starts_with(&prefix))
843                    .collect()
844            })
845            .unwrap_or_default()
846    }
847
848    #[test]
849    fn test_write_atomic_cleans_temp_on_rename_failure() {
850        // A non-empty directory at the destination forces renameat to fail
851        // (EISDIR/ENOTEMPTY) after the temp file has been fully written; the
852        // TmpGuard must remove the temp entry.
853        let dir = tmpdir("rename_fail");
854        let dest = dir.join("dest");
855        fs::create_dir_all(&dest).unwrap();
856        fs::write(dest.join("keep"), b"x").unwrap();
857
858        assert!(write_atomic(&dest, b"boom").is_err());
859        assert_eq!(fs::read_to_string(dest.join("keep")).unwrap(), "x");
860        assert!(
861            temp_leftovers(&dir, "dest").is_empty(),
862            "temp file must be cleaned up"
863        );
864    }
865
866    #[test]
867    fn test_write_atomic_leaves_no_temp_on_success() {
868        let dir = tmpdir("no_temp_success");
869        let p = dir.join("out.txt");
870
871        write_atomic(&p, b"hello").unwrap();
872        assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
873        assert!(
874            temp_leftovers(&dir, "out.txt").is_empty(),
875            "no temp left behind"
876        );
877    }
878
879    #[test]
880    fn test_write_atomic_retries_when_temp_name_exists() {
881        let dir = tmpdir("temp_collision");
882        let p = dir.join("out.txt");
883        let pid = std::process::id();
884        let collided = dir.join(format!(".out.txt.{pid}.0"));
885        fs::write(&collided, b"not mine").unwrap();
886
887        write_atomic(&p, b"hello").unwrap();
888        assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
889        // The pre-existing colliding temp is neither clobbered nor mistaken for
890        // ours; a later attempt slot is used.
891        assert_eq!(fs::read_to_string(&collided).unwrap(), "not mine");
892        let leftovers = temp_leftovers(&dir, "out.txt");
893        assert_eq!(
894            leftovers.len(),
895            1,
896            "only the pre-existing colliding temp remains"
897        );
898        assert_eq!(leftovers[0], format!(".out.txt.{pid}.0"));
899    }
900
901    #[test]
902    fn test_ops_refuse_foreign_owned_writable_parent() {
903        // A world-writable directory owned by a uid != euid (e.g. /tmp owned by
904        // root) is a privilege-free proxy for a swapped state dir: a vulnerable
905        // implementation with no ownership check would happily write into it,
906        // while the fixed one must refuse every operation. Runs whenever such a
907        // directory is available; skips as root (root owns /tmp).
908        use std::os::unix::fs::MetadataExt;
909        let euid = unsafe { libc::geteuid() };
910        if euid == 0 {
911            return;
912        }
913        let tmp = std::env::temp_dir();
914        let meta = match fs::symlink_metadata(&tmp) {
915            Ok(m) => m,
916            Err(_) => return,
917        };
918        if meta.uid() == euid {
919            return; // no ownership mismatch available in this environment
920        }
921        if meta.mode() & 0o002 == 0 && meta.mode() & 0o020 == 0 {
922            return; // not writable by us; the proxy doesn't apply
923        }
924        let p = tmp.join(format!(
925            "coreshift_fs_foreign_{}_{}",
926            std::process::id(),
927            "out"
928        ));
929        let _ = fs::remove_file(&p);
930        fs::write(&p, b"probe").unwrap();
931
932        assert!(read_nofollow(&p).is_err());
933        assert!(open_append_nofollow(&p).is_err());
934        assert!(write_atomic(&p, b"boom").is_err());
935        assert!(remove_nofollow(&p).is_err());
936        assert!(ensure_state_dir(&tmp).is_err());
937        assert_eq!(fs::read_to_string(&p).unwrap(), "probe");
938        let _ = fs::remove_file(&p);
939    }
940
941    #[test]
942    fn test_ensure_state_dir_creates_fresh_dir() {
943        let base = tmpdir("fresh_base");
944        let nested = base.join("a").join("b").join("state");
945
946        let fd = ensure_state_dir(&nested).unwrap();
947        assert!(nested.is_dir());
948        drop(fd);
949        assert!(ensure_state_dir(&nested).is_ok());
950    }
951
952    #[test]
953    fn test_open_append_nofollow_refuses_dangling_symlink() {
954        let dir = tmpdir("dangling_append");
955        let missing = dir.join("not_there.txt");
956        let link = dir.join("linkd");
957        symlink(&missing, &link).unwrap();
958
959        assert!(open_append_nofollow(&link).is_err());
960        assert!(
961            !missing.exists(),
962            "must not create the target through a dangling link"
963        );
964    }
965
966    #[test]
967    fn test_read_nofollow_refuses_dangling_symlink() {
968        let dir = tmpdir("dangling_read");
969        let missing = dir.join("not_there2.txt");
970        let link = dir.join("linkd2");
971        symlink(&missing, &link).unwrap();
972
973        assert!(read_nofollow(&link).is_err());
974        assert!(!missing.exists());
975    }
976
977    #[test]
978    fn test_ops_refuse_regular_file_parent() {
979        // A parent component that is a regular file must yield ENOTDIR for
980        // every anchored operation — no write-through, no creation.
981        let dir = tmpdir("regfile_parent");
982        let f = dir.join("notadir");
983        fs::write(&f, b"x").unwrap();
984
985        assert!(write_atomic(f.join("out"), b"y").is_err());
986        assert!(read_nofollow(f.join("out")).is_err());
987        assert!(open_append_nofollow(f.join("out")).is_err());
988        assert!(remove_nofollow(f.join("out")).is_err());
989        assert!(ensure_state_dir(&f).is_err());
990        assert_eq!(fs::read_to_string(&f).unwrap(), "x");
991    }
992}