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