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 % page_size != 0 || 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                return Ok(());
599            }
600            Err(e) if e.raw_os_error() == Some(libc::EEXIST) => continue,
601            Err(e) => return Err(e),
602        }
603    }
604
605    Err(CoreError::sys(libc::EEXIST, "temp:exhausted"))
606}
607
608/// Read a file to a string, refusing to follow a symlink at the final or any
609/// parent component.
610///
611/// Fails with `ELOOP` if `path` is a symlink or passes through one. Used for
612/// reading attacker-placed state files so their contents can never be injected
613/// through a link.
614pub fn read_nofollow(path: impl AsRef<Path>) -> Result<String, CoreError> {
615    let target = path.as_ref();
616    let file_name = basename(target)?;
617    let dir = open_parent_nofollow(target)?;
618    let fd = openat_raw(
619        dir.as_raw_fd(),
620        &file_name,
621        libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
622        0,
623    )?;
624    // SAFETY: `fd` is uniquely owned; it becomes an owned File.
625    let mut file = unsafe { fs::File::from_raw_fd(fd) };
626    let mut content = String::new();
627    file.read_to_string(&mut content)?;
628    Ok(content)
629}
630
631/// Open an existing-or-create append stream that refuses to follow symlinks.
632///
633/// Appends to a regular file at `path`. If `path` is a symlink — or sits under
634/// a symlinked parent — the open fails with `ELOOP` rather than writing
635/// through it.
636pub fn open_append_nofollow(path: impl AsRef<Path>) -> Result<fs::File, CoreError> {
637    let target = path.as_ref();
638    let file_name = basename(target)?;
639    let dir = open_parent_nofollow(target)?;
640    let fd = openat_raw(
641        dir.as_raw_fd(),
642        &file_name,
643        libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_NOFOLLOW | libc::O_CLOEXEC,
644        0o644,
645    )?;
646    // SAFETY: `fd` is uniquely owned; it becomes an owned File.
647    Ok(unsafe { fs::File::from_raw_fd(fd) })
648}
649
650/// Remove a directory entry without following a final symlink, anchored to its
651/// (symlink-free) parent. The entry itself is removed even if it is a symlink;
652/// its target is never touched.
653pub fn remove_nofollow(path: impl AsRef<Path>) -> Result<(), CoreError> {
654    let target = path.as_ref();
655    let file_name = basename(target)?;
656    let dir = open_parent_nofollow(target)?;
657    unlink_name(dir.as_raw_fd(), &file_name)
658}
659
660/// Create `dir` if needed, then verify it is a real directory with no symlink
661/// component and that it is owned by the current effective uid. Returning the
662/// anchored fd lets callers refuse to operate inside an attacker-pre-created
663/// or attacker-redirected state directory.
664pub fn ensure_state_dir(dir: impl AsRef<Path>) -> Result<OwnedFd, CoreError> {
665    let dir = dir.as_ref();
666    // Anchored create-then-verify: missing components are made with mkdirat
667    // relative to the held fd; a symlink at any component ELOOPs rather than
668    // being followed, and the opened fd below is the authority for ownership.
669    let fd = walk_dir(dir, Some(0o700))?;
670    let st = fstat(fd.as_raw_fd())?;
671    let euid = unsafe { libc::geteuid() };
672    if st.st_uid != euid {
673        return Err(CoreError::sys(libc::EACCES, "state_dir_owner"));
674    }
675    Ok(fd)
676}
677
678#[cfg(test)]
679mod tests {
680    #[cfg(target_os = "linux")]
681    #[test]
682    fn test_readahead_syscall_number_linux_matches_libc() {
683        assert_eq!(super::readahead_syscall_number(), libc::SYS_readahead);
684    }
685
686    #[cfg(target_os = "linux")]
687    #[test]
688    fn test_mmap_madvise_touch_past_eof_does_not_sigbus() {
689        use std::os::unix::io::{AsRawFd, FromRawFd};
690
691        // A file whose length is not page-aligned; requesting a touch range
692        // that extends far past EOF must be clamped to the file size instead of
693        // SIGBUS-ing the process on the out-of-file pages.
694        let dir =
695            std::env::temp_dir().join(format!("coreshift_mmap_past_eof_{}", std::process::id()));
696        let _ = std::fs::remove_file(&dir);
697        std::fs::write(&dir, b"x").unwrap();
698
699        let f = std::fs::File::open(&dir).unwrap();
700        let fd = f.as_raw_fd();
701        // Reopen to hand a raw fd to the helper without double-closing.
702        let dup = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
703        assert!(dup >= 0);
704        let owned = unsafe { std::fs::File::from_raw_fd(dup) };
705
706        let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64;
707        let result = super::mmap_madvise(owned.as_raw_fd(), 0, (page * 4) as usize, true);
708        drop(owned);
709        let _ = std::fs::remove_file(&dir);
710
711        // Clamped to 1 byte; MADV_WILLNEED on a tiny range is a no-op that
712        // still reports success on Linux.
713        assert!(result.is_ok(), "touch past EOF must not SIGBUS: {result:?}");
714    }
715
716    #[cfg(all(target_os = "android", target_arch = "aarch64"))]
717    #[test]
718    fn test_readahead_syscall_number_android_aarch64() {
719        assert_eq!(super::readahead_syscall_number(), 213);
720    }
721
722    #[cfg(all(target_os = "android", target_arch = "arm"))]
723    #[test]
724    fn test_readahead_syscall_number_android_arm() {
725        assert_eq!(super::readahead_syscall_number(), 225);
726    }
727
728    #[cfg(all(target_os = "android", target_arch = "x86_64"))]
729    #[test]
730    fn test_readahead_syscall_number_android_x86_64() {
731        assert_eq!(super::readahead_syscall_number(), 187);
732    }
733
734    #[cfg(all(target_os = "android", target_arch = "x86"))]
735    #[test]
736    fn test_readahead_syscall_number_android_x86() {
737        assert_eq!(super::readahead_syscall_number(), 225);
738    }
739}
740
741#[cfg(test)]
742mod safe_fs_tests {
743
744    use super::*;
745    use std::os::unix::fs::symlink;
746    use std::path::PathBuf;
747
748    fn tmpdir(name: &str) -> PathBuf {
749        let d = std::env::temp_dir().join(format!(
750            "coreshift_safe_fs_dir_{}_{name}",
751            std::process::id()
752        ));
753        let _ = fs::remove_dir_all(&d);
754        fs::create_dir_all(&d).unwrap();
755        d
756    }
757
758    #[test]
759    fn test_write_atomic_creates_regular_file() {
760        let dir = tmpdir("w");
761        let p = dir.join("out.txt");
762
763        write_atomic(&p, b"hello").unwrap();
764        assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
765        assert!(fs::symlink_metadata(&p).unwrap().file_type().is_file());
766    }
767
768    #[test]
769    fn test_write_atomic_replaces_existing_symlink_not_target() {
770        let dir = tmpdir("s1");
771        let target = dir.join("victim");
772        let link = dir.join("link");
773
774        fs::write(&target, b"precious").unwrap();
775        symlink(&target, &link).unwrap();
776
777        // Write through the symlink: the victim must stay untouched and the
778        // path must become a regular file (the symlink is replaced).
779        write_atomic(&link, b"new").unwrap();
780
781        assert_eq!(fs::read_to_string(&target).unwrap(), "precious");
782        assert_eq!(fs::read_to_string(&link).unwrap(), "new");
783        assert!(fs::symlink_metadata(&link).unwrap().file_type().is_file());
784    }
785
786    #[test]
787    fn test_read_nofollow_refuses_symlink() {
788        let dir = tmpdir("r");
789        let target = dir.join("victim2");
790        let link = dir.join("link2");
791
792        fs::write(&target, b"secret").unwrap();
793        symlink(&target, &link).unwrap();
794
795        assert_eq!(read_nofollow(&target).unwrap(), "secret");
796        assert!(read_nofollow(&link).is_err());
797    }
798
799    #[test]
800    fn test_path_fingerprint_nofollow_refuses_symlink() {
801        let dir = tmpdir("fp");
802        let target = dir.join("target_fp");
803        let link = dir.join("link_fp");
804
805        fs::write(&target, b"secret").unwrap();
806        symlink(&target, &link).unwrap();
807
808        // Fingerprinting the regular file works; a symlink must fail.
809        assert!(path_fingerprint_nofollow(&target).is_ok());
810        assert!(path_fingerprint_nofollow(&link).is_err());
811        // A symlinked parent must fail too (component-level O_NOFOLLOW).
812        let parent_link = dir.join("plink");
813        symlink(&dir, &parent_link).unwrap();
814        assert!(path_fingerprint_nofollow(&parent_link.join("target_fp")).is_err());
815    }
816
817    #[test]
818    fn test_open_append_nofollow_refuses_symlink() {
819        let dir = tmpdir("a");
820        let target = dir.join("target3");
821        let link = dir.join("link3");
822
823        fs::write(&target, b"x").unwrap();
824        symlink(&target, &link).unwrap();
825
826        // Append to the regular file is fine.
827        assert!(open_append_nofollow(&target).is_ok());
828        // Appending through a symlink must fail.
829        assert!(open_append_nofollow(&link).is_err());
830
831        let _ = fs::remove_file(&target);
832        let _ = fs::remove_file(&link);
833    }
834
835    #[test]
836    fn test_write_atomic_refuses_symlinked_parent() {
837        // An intermediate directory replaced by a symlink must make the
838        // anchored open fail, and nothing may be written through to the
839        // linked directory.
840        let dir = tmpdir("parent_symlink");
841        let elsewhere = tmpdir("parent_dest");
842        let link = dir.join("coreshift");
843        symlink(&elsewhere, &link).unwrap();
844
845        assert!(write_atomic(link.join("payload.txt"), b"boom").is_err());
846        assert!(!elsewhere.join("payload.txt").exists());
847        assert!(!link.join("payload.txt").exists());
848    }
849
850    #[test]
851    fn test_read_nofollow_refuses_symlinked_parent() {
852        let dir = tmpdir("read_parent_symlink");
853        let elsewhere = tmpdir("read_parent_dest");
854        fs::write(elsewhere.join("conf"), b"injected").unwrap();
855        let link = dir.join("coreshift");
856        symlink(&elsewhere, &link).unwrap();
857
858        assert!(read_nofollow(link.join("conf")).is_err());
859    }
860
861    #[test]
862    fn test_open_append_nofollow_refuses_symlinked_parent() {
863        let dir = tmpdir("append_parent_symlink");
864        let elsewhere = tmpdir("append_parent_dest");
865        let link = dir.join("coreshift");
866        symlink(&elsewhere, &link).unwrap();
867
868        assert!(open_append_nofollow(link.join("daemon.log")).is_err());
869        assert!(!elsewhere.join("daemon.log").exists());
870    }
871
872    #[test]
873    fn test_ensure_state_dir_refuses_symlink() {
874        let dir = tmpdir("state_symlink");
875        let elsewhere = tmpdir("state_dest");
876        let link = dir.join("state");
877        symlink(&elsewhere, &link).unwrap();
878
879        assert!(ensure_state_dir(&link).is_err());
880        // A real directory is accepted (owned by the current euid).
881        let real = tmpdir("state_real");
882        assert!(ensure_state_dir(&real).is_ok());
883    }
884
885    #[test]
886    fn test_remove_nofollow_removes_entry_not_target() {
887        let dir = tmpdir("unlink");
888        let target = dir.join("victim4");
889        let link = dir.join("link4");
890        fs::write(&target, b"keep").unwrap();
891        symlink(&target, &link).unwrap();
892
893        remove_nofollow(&link).unwrap();
894        assert!(!link.exists());
895        assert_eq!(fs::read_to_string(&target).unwrap(), "keep");
896    }
897
898    #[test]
899    fn test_write_atomic_requires_parent_to_exist() {
900        let dir = tmpdir("missing_parent");
901        let p = dir.join("nope").join("file.txt");
902
903        assert!(write_atomic(&p, b"x").is_err());
904        assert!(!p.exists());
905    }
906
907    #[test]
908    fn test_ops_refuse_foreign_owned_parent() {
909        // Simulates an attacker who renames the root-owned state dir and
910        // substitutes their own: the real (non-symlink) substituted dir must
911        // fail every operation because it is not owned by the effective uid.
912        // Only exercisable as root (chown); otherwise skipped.
913        if unsafe { libc::geteuid() } != 0 {
914            return;
915        }
916        let dir = tmpdir("foreign_owner");
917        let path = dir.join("f");
918        let owned = cstr(&dir.to_string_lossy()).unwrap();
919        // SAFETY: chown to 65534 (nobody) while running as root.
920        assert_eq!(unsafe { libc::chown(owned.as_ptr(), 65534, 65534) }, 0);
921
922        assert!(write_atomic(&path, b"x").is_err());
923        assert!(read_nofollow(&path).is_err());
924        assert!(open_append_nofollow(&path).is_err());
925        assert!(remove_nofollow(&path).is_err());
926        assert!(ensure_state_dir(&dir).is_err());
927        assert!(!path.exists());
928    }
929
930    fn temp_leftovers(dir: &Path, file_name: &str) -> Vec<String> {
931        let prefix = format!(".{file_name}.");
932        fs::read_dir(dir)
933            .map(|rd| {
934                rd.filter_map(|e| e.ok())
935                    .filter_map(|e| e.file_name().to_str().map(str::to_owned))
936                    .filter(|n| n.starts_with(&prefix))
937                    .collect()
938            })
939            .unwrap_or_default()
940    }
941
942    #[test]
943    fn test_write_atomic_cleans_temp_on_rename_failure() {
944        // A non-empty directory at the destination forces renameat to fail
945        // (EISDIR/ENOTEMPTY) after the temp file has been fully written; the
946        // TmpGuard must remove the temp entry.
947        let dir = tmpdir("rename_fail");
948        let dest = dir.join("dest");
949        fs::create_dir_all(&dest).unwrap();
950        fs::write(dest.join("keep"), b"x").unwrap();
951
952        assert!(write_atomic(&dest, b"boom").is_err());
953        assert_eq!(fs::read_to_string(dest.join("keep")).unwrap(), "x");
954        assert!(
955            temp_leftovers(&dir, "dest").is_empty(),
956            "temp file must be cleaned up"
957        );
958    }
959
960    #[test]
961    fn test_write_atomic_leaves_no_temp_on_success() {
962        let dir = tmpdir("no_temp_success");
963        let p = dir.join("out.txt");
964
965        write_atomic(&p, b"hello").unwrap();
966        assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
967        assert!(
968            temp_leftovers(&dir, "out.txt").is_empty(),
969            "no temp left behind"
970        );
971    }
972
973    #[test]
974    fn test_write_atomic_retries_when_temp_name_exists() {
975        let dir = tmpdir("temp_collision");
976        let p = dir.join("out.txt");
977        let pid = std::process::id();
978        let collided = dir.join(format!(".out.txt.{pid}.0"));
979        fs::write(&collided, b"not mine").unwrap();
980
981        write_atomic(&p, b"hello").unwrap();
982        assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
983        // The pre-existing colliding temp is neither clobbered nor mistaken for
984        // ours; a later attempt slot is used.
985        assert_eq!(fs::read_to_string(&collided).unwrap(), "not mine");
986        let leftovers = temp_leftovers(&dir, "out.txt");
987        assert_eq!(
988            leftovers.len(),
989            1,
990            "only the pre-existing colliding temp remains"
991        );
992        assert_eq!(leftovers[0], format!(".out.txt.{pid}.0"));
993    }
994
995    #[test]
996    fn test_ops_refuse_foreign_owned_writable_parent() {
997        // A world-writable directory owned by a uid != euid (e.g. /tmp owned by
998        // root) is a privilege-free proxy for a swapped state dir: a vulnerable
999        // implementation with no ownership check would happily write into it,
1000        // while the fixed one must refuse every operation. Runs whenever such a
1001        // directory is available; skips as root (root owns /tmp).
1002        use std::os::unix::fs::MetadataExt;
1003        let euid = unsafe { libc::geteuid() };
1004        if euid == 0 {
1005            return;
1006        }
1007        let tmp = std::env::temp_dir();
1008        let meta = match fs::symlink_metadata(&tmp) {
1009            Ok(m) => m,
1010            Err(_) => return,
1011        };
1012        if meta.uid() == euid {
1013            return; // no ownership mismatch available in this environment
1014        }
1015        if meta.mode() & 0o002 == 0 && meta.mode() & 0o020 == 0 {
1016            return; // not writable by us; the proxy doesn't apply
1017        }
1018        let p = tmp.join(format!(
1019            "coreshift_fs_foreign_{}_{}",
1020            std::process::id(),
1021            "out"
1022        ));
1023        let _ = fs::remove_file(&p);
1024        fs::write(&p, b"probe").unwrap();
1025
1026        assert!(read_nofollow(&p).is_err());
1027        assert!(open_append_nofollow(&p).is_err());
1028        assert!(write_atomic(&p, b"boom").is_err());
1029        assert!(remove_nofollow(&p).is_err());
1030        assert!(ensure_state_dir(&tmp).is_err());
1031        assert_eq!(fs::read_to_string(&p).unwrap(), "probe");
1032        let _ = fs::remove_file(&p);
1033    }
1034
1035    #[test]
1036    fn test_ensure_state_dir_creates_fresh_dir() {
1037        let base = tmpdir("fresh_base");
1038        let nested = base.join("a").join("b").join("state");
1039
1040        let fd = ensure_state_dir(&nested).unwrap();
1041        assert!(nested.is_dir());
1042        drop(fd);
1043        assert!(ensure_state_dir(&nested).is_ok());
1044    }
1045
1046    #[test]
1047    fn test_open_append_nofollow_refuses_dangling_symlink() {
1048        let dir = tmpdir("dangling_append");
1049        let missing = dir.join("not_there.txt");
1050        let link = dir.join("linkd");
1051        symlink(&missing, &link).unwrap();
1052
1053        assert!(open_append_nofollow(&link).is_err());
1054        assert!(
1055            !missing.exists(),
1056            "must not create the target through a dangling link"
1057        );
1058    }
1059
1060    #[test]
1061    fn test_read_nofollow_refuses_dangling_symlink() {
1062        let dir = tmpdir("dangling_read");
1063        let missing = dir.join("not_there2.txt");
1064        let link = dir.join("linkd2");
1065        symlink(&missing, &link).unwrap();
1066
1067        assert!(read_nofollow(&link).is_err());
1068        assert!(!missing.exists());
1069    }
1070
1071    #[test]
1072    fn test_ops_refuse_regular_file_parent() {
1073        // A parent component that is a regular file must yield ENOTDIR for
1074        // every anchored operation — no write-through, no creation.
1075        let dir = tmpdir("regfile_parent");
1076        let f = dir.join("notadir");
1077        fs::write(&f, b"x").unwrap();
1078
1079        assert!(write_atomic(f.join("out"), b"y").is_err());
1080        assert!(read_nofollow(f.join("out")).is_err());
1081        assert!(open_append_nofollow(f.join("out")).is_err());
1082        assert!(remove_nofollow(f.join("out")).is_err());
1083        assert!(ensure_state_dir(&f).is_err());
1084        assert_eq!(fs::read_to_string(&f).unwrap(), "x");
1085    }
1086}