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