Skip to main content

libdd_common/unix_utils/
restricted_file.rs

1// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3
4//! Constrained opens for paths supplied by workers.
5//!
6//! A sidecar thread that drops to a worker's UID still shares the PHP master's address space and
7//! file descriptors. Paths such as `/proc/self/maps` and `/dev/fd/N` can expose the master's memory
8//! layout or bypass directory permissions. Ordinary symlinks can lead to these paths too.
9//!
10//! On Linux, resolve paths with `openat2(RESOLVE_NO_MAGICLINKS)` and pin the target with `O_PATH`.
11//! Reject procfs/sysfs and check the object type before opening it for I/O, so device drivers are
12//! never invoked. Reads follow ordinary symlinks; writes refuse a symlink as the final component.
13//!
14//! When `openat2` is unavailable or blocked by seccomp, walk one component at a time with
15//! `O_PATH | O_NOFOLLOW`, checking each pinned object before following symlinks. For Linux 3.10,
16//! where `fstatfs` rejects `O_PATH`, inspect the pinned object through our own `/proc/self/fd`
17//! entry.
18//!
19//! On macOS, `/dev/fd/N` is not a symlink. Open with `O_NOFOLLOW`, get the real path with
20//! `F_GETPATH`, then reopen that path to check directory permissions and verify that the inode
21//! still matches. This also refuses ordinary symlinks as the final component.
22
23// File and syscall operations here require std.
24#![allow(clippy::std_instead_of_alloc, clippy::std_instead_of_core)]
25// `statfs::f_type` and the `*_MAGIC` constants have different integer types across libc targets
26// (gnu vs musl), so the `as i64` normalisation is a no-op only on some of them.
27#![allow(clippy::unnecessary_cast)]
28
29use std::ffi::CString;
30use std::fs::File;
31use std::io;
32use std::os::unix::ffi::OsStrExt;
33use std::path::Path;
34use std::sync::atomic::{AtomicBool, Ordering};
35
36/// Process-wide restrictions for paths supplied by workers. The thread listener enables this
37/// after dropping to a different UID; individual requests cannot select this policy. Crash
38/// attachments also carry an explicit policy per report.
39static RESTRICT_WORKER_FILE_OUTPUTS: AtomicBool = AtomicBool::new(false);
40
41/// Set the process-wide policy for worker paths.
42pub fn set_restrict_worker_file_outputs(on: bool) {
43    RESTRICT_WORKER_FILE_OUTPUTS.store(on, Ordering::Relaxed);
44}
45
46/// Whether paths supplied by workers need constrained opens in this process.
47pub fn worker_file_outputs_restricted() -> bool {
48    RESTRICT_WORKER_FILE_OUTPUTS.load(Ordering::Relaxed)
49}
50
51/// Why an open was refused. Diagnostics must not reveal the target's contents or resolved path.
52#[derive(Debug, thiserror::Error)]
53pub enum RestrictedOpenError {
54    /// The platform cannot enforce the restriction. Never retry with an unrestricted open.
55    #[error("constrained file access is not supported on this kernel")]
56    Unsupported,
57    /// The target was refused by policy.
58    #[error("refused worker-selected path: {0}")]
59    Rejected(&'static str),
60    /// A syscall failed.
61    #[error(transparent)]
62    Io(#[from] io::Error),
63}
64
65impl From<RestrictedOpenError> for io::Error {
66    fn from(value: RestrictedOpenError) -> Self {
67        match value {
68            RestrictedOpenError::Io(e) => e,
69            RestrictedOpenError::Unsupported => {
70                io::Error::new(io::ErrorKind::Unsupported, value.to_string())
71            }
72            RestrictedOpenError::Rejected(_) => {
73                io::Error::new(io::ErrorKind::PermissionDenied, value.to_string())
74            }
75        }
76    }
77}
78
79type Result<T> = std::result::Result<T, RestrictedOpenError>;
80
81/// Require an absolute path: relative paths use the master's working directory, which may be
82/// inside a directory the worker cannot traverse.
83fn checked_cpath(path: &Path) -> Result<CString> {
84    if !path.is_absolute() {
85        return Err(RestrictedOpenError::Rejected("relative worker path"));
86    }
87    CString::new(path.as_os_str().as_bytes())
88        .map_err(|_| RestrictedOpenError::Rejected("path contains an interior NUL"))
89}
90
91/// Open a regular file for reading, refusing magic links, procfs and sysfs.
92pub fn open_regular_for_read(path: &Path) -> Result<File> {
93    platform::open_regular_for_read(&checked_cpath(path)?)
94}
95
96/// Open or create a regular file for appending. Refuse magic links, procfs/sysfs and a symlink as
97/// the final component. Use exclusive creation so a file planted after inspection is not opened.
98pub fn open_regular_for_append(path: &Path) -> Result<File> {
99    platform::open_regular_for_write(path, &checked_cpath(path)?, WriteMode::Append)
100}
101
102/// Create or truncate a regular file, with the same restrictions as [`open_regular_for_append`].
103pub fn open_regular_for_create(path: &Path) -> Result<File> {
104    platform::open_regular_for_write(path, &checked_cpath(path)?, WriteMode::Truncate)
105}
106
107#[derive(Clone, Copy)]
108enum WriteMode {
109    Append,
110    Truncate,
111}
112
113/// Validate an absolute Unix-socket path and return a handle and its `/proc/self/fd` path.
114/// Keep the handle alive for every use of that path, including datagram sends, so it continues to
115/// name the validated socket. Refuse magic links, procfs/sysfs and non-socket targets.
116///
117/// Supported on Linux only. Callers handle abstract sockets separately, without filesystem checks.
118#[cfg(target_os = "linux")]
119pub fn constrained_unix_socket_fd(
120    path: &Path,
121) -> Result<(std::os::fd::OwnedFd, std::path::PathBuf)> {
122    platform::constrained_unix_socket_fd(&checked_cpath(path)?)
123}
124
125#[cfg(all(unix, not(target_os = "linux")))]
126pub fn constrained_unix_socket_fd(
127    _path: &Path,
128) -> Result<(std::os::fd::OwnedFd, std::path::PathBuf)> {
129    Err(RestrictedOpenError::Unsupported)
130}
131
132#[cfg(target_os = "linux")]
133mod platform {
134    use super::{RestrictedOpenError, Result};
135    use std::ffi::{CStr, CString};
136    use std::fs::File;
137    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
138    use std::os::unix::ffi::OsStrExt;
139
140    /// Prefer `openat2`, falling back to a constrained walk on older kernels or when seccomp
141    /// blocks this syscall. All callers request `O_PATH`; this never opens a target for I/O.
142    fn open_path(
143        dir_fd: RawFd,
144        path: &CStr,
145        flags: libc::c_int,
146        reject_msg: &'static str,
147    ) -> Result<OwnedFd> {
148        // open_how is #[non_exhaustive]; zero-initialise then set the public fields.
149        // SAFETY: open_how is a plain POD struct; an all-zero value is valid.
150        let mut how: libc::open_how = unsafe { std::mem::zeroed() };
151        how.flags = (flags | libc::O_CLOEXEC) as u64;
152        how.resolve = libc::RESOLVE_NO_MAGICLINKS;
153
154        // SAFETY: `path` is a valid NUL-terminated C string, `&how` points to a correctly sized
155        // open_how, and the size argument matches. openat2 returns a new fd or -1.
156        let rc = unsafe {
157            libc::syscall(
158                libc::SYS_openat2,
159                dir_fd,
160                path.as_ptr(),
161                &how as *const libc::open_how,
162                std::mem::size_of::<libc::open_how>(),
163            )
164        };
165        if rc >= 0 {
166            // SAFETY: rc is a fresh, owned fd from openat2.
167            return Ok(unsafe { OwnedFd::from_raw_fd(rc as RawFd) });
168        }
169        let err = std::io::Error::last_os_error();
170        match err.raw_os_error() {
171            Some(libc::ENOSYS) | Some(libc::EPERM) => open_path_fallback(dir_fd, path, flags),
172            Some(libc::ELOOP) => Err(RestrictedOpenError::Rejected(reject_msg)),
173            _ => Err(RestrictedOpenError::Io(err)),
174        }
175    }
176
177    fn open_component(dir_fd: RawFd, path: &CStr, flags: libc::c_int) -> Result<OwnedFd> {
178        // Callers pass a single component (or "/" for the root). O_NOFOLLOW therefore prevents
179        // every automatic symlink traversal, including magic links, without opening devices.
180        // SAFETY: path is NUL-terminated; openat returns a new owned fd or -1.
181        let fd = unsafe {
182            libc::openat(
183                dir_fd,
184                path.as_ptr(),
185                flags | libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC,
186            )
187        };
188        if fd < 0 {
189            return Err(RestrictedOpenError::Io(std::io::Error::last_os_error()));
190        }
191        // SAFETY: fd is a fresh, owned descriptor.
192        Ok(unsafe { OwnedFd::from_raw_fd(fd) })
193    }
194
195    fn open_path_fallback(dir_fd: RawFd, path: &CStr, flags: libc::c_int) -> Result<OwnedFd> {
196        // Bound both work and storage for attacker-controlled paths and symlink chains.
197        const MAX_PATH: usize = libc::PATH_MAX as usize;
198        const MAX_SYMLINKS: usize = 40;
199        let bytes = path.to_bytes();
200        if bytes.is_empty() {
201            return Err(RestrictedOpenError::Io(std::io::Error::from_raw_os_error(
202                libc::ENOENT,
203            )));
204        }
205        if bytes.len() >= MAX_PATH {
206            return Err(RestrictedOpenError::Io(std::io::Error::from_raw_os_error(
207                libc::ENAMETOOLONG,
208            )));
209        }
210        let mut pending = bytes.to_vec();
211        let start = if pending[0] == b'/' { c"/" } else { c"." };
212        let mut current = open_component(dir_fd, start, libc::O_DIRECTORY)?;
213        reject_pseudo_filesystem(current.as_raw_fd())?;
214        let mut offset = 0;
215        let mut symlinks = 0;
216        while offset < pending.len() {
217            if pending[offset] == b'/' {
218                offset += 1;
219                continue;
220            }
221            let end = pending[offset..]
222                .iter()
223                .position(|&b| b == b'/')
224                .map_or(pending.len(), |n| offset + n);
225            let component = CString::new(&pending[offset..end])
226                .map_err(|_| RestrictedOpenError::Rejected("path contains an interior NUL"))?;
227            let next = open_component(current.as_raw_fd(), &component, 0)?;
228            reject_pseudo_filesystem(next.as_raw_fd())?;
229            let kind = file_type(next.as_raw_fd())?;
230            // Keep separators in the remaining path: a trailing slash requires a directory,
231            // and "file/.." must fail instead of being lexically simplified away.
232            let has_suffix = end < pending.len();
233            if kind == libc::S_IFLNK && (has_suffix || flags & libc::O_NOFOLLOW == 0) {
234                symlinks += 1;
235                if symlinks > MAX_SYMLINKS {
236                    return Err(RestrictedOpenError::Rejected("too many symlinks"));
237                }
238                let mut target = vec![0u8; MAX_PATH];
239                // SAFETY: next pins this exact symlink; the empty path reads that handle, not
240                // a name an attacker can swap. readlinkat never follows the link for us.
241                let len = unsafe {
242                    libc::readlinkat(
243                        next.as_raw_fd(),
244                        c"".as_ptr(),
245                        target.as_mut_ptr().cast(),
246                        target.len(),
247                    )
248                };
249                if len < 0 {
250                    return Err(RestrictedOpenError::Io(std::io::Error::last_os_error()));
251                }
252                // SAFETY of the conversion: a nonnegative readlinkat result fits in usize.
253                let len = len as usize;
254                if len == 0 || len + pending.len() - end >= MAX_PATH {
255                    return Err(RestrictedOpenError::Rejected(
256                        "invalid or overlong symlink target",
257                    ));
258                }
259                target.truncate(len);
260                target.extend_from_slice(&pending[end..]);
261                if target[0] == b'/' {
262                    current = open_component(libc::AT_FDCWD, c"/", libc::O_DIRECTORY)?;
263                    reject_pseudo_filesystem(current.as_raw_fd())?;
264                }
265                pending = target;
266                offset = 0;
267                continue;
268            }
269            if (has_suffix || flags & libc::O_DIRECTORY != 0) && kind != libc::S_IFDIR {
270                return Err(RestrictedOpenError::Io(std::io::Error::from_raw_os_error(
271                    libc::ENOTDIR,
272                )));
273            }
274            current = next;
275            offset = end;
276        }
277        Ok(current)
278    }
279
280    fn proc_fd_path(fd: RawFd) -> Result<CString> {
281        CString::new(format!("/proc/self/fd/{fd}"))
282            .map_err(|_| RestrictedOpenError::Rejected("fd path contains an interior NUL"))
283    }
284
285    /// Procfs/sysfs can expose process state through ordinary-looking regular files.
286    fn reject_pseudo_filesystem(fd: RawFd) -> Result<()> {
287        // SAFETY: statfs is POD; fstatfs fills it or returns -1.
288        let mut sfs: libc::statfs = unsafe { std::mem::zeroed() };
289        let rc = unsafe { libc::fstatfs(fd, &mut sfs) };
290        if rc != 0 {
291            let err = std::io::Error::last_os_error();
292            if err.raw_os_error() != Some(libc::EBADF) {
293                return Err(RestrictedOpenError::Io(err));
294            }
295            // Before Linux 3.12, fstatfs rejects O_PATH. Our live descriptor still pins the object,
296            // so statfs through its proc entry is safe after a rename or unlink. For an O_NOFOLLOW
297            // symlink handle, this inspects the symlink's filesystem without following its target.
298            let proc_path = proc_fd_path(fd)?;
299            // SAFETY: proc_path is NUL-terminated and sfs is writable.
300            if unsafe { libc::statfs(proc_path.as_ptr(), &mut sfs) } != 0 {
301                return Err(RestrictedOpenError::Io(std::io::Error::last_os_error()));
302            }
303        }
304        let f_type = sfs.f_type as i64;
305        if f_type == libc::PROC_SUPER_MAGIC as i64 {
306            return Err(RestrictedOpenError::Rejected("procfs path"));
307        }
308        if f_type == libc::SYSFS_MAGIC as i64 {
309            return Err(RestrictedOpenError::Rejected("sysfs path"));
310        }
311        Ok(())
312    }
313
314    fn file_type(fd: RawFd) -> Result<libc::mode_t> {
315        // SAFETY: stat is POD; fstat fills it or returns -1.
316        let mut st: libc::stat = unsafe { std::mem::zeroed() };
317        let rc = unsafe { libc::fstat(fd, &mut st) };
318        if rc != 0 {
319            return Err(RestrictedOpenError::Io(std::io::Error::last_os_error()));
320        }
321        Ok(st.st_mode & libc::S_IFMT)
322    }
323
324    fn require_regular_file(fd: RawFd) -> Result<()> {
325        if file_type(fd)? != libc::S_IFREG {
326            return Err(RestrictedOpenError::Rejected("not a regular file"));
327        }
328        Ok(())
329    }
330
331    /// Open the validated regular file for I/O through our own live descriptor. The pinned handle
332    /// keeps a pathname swap from changing the target.
333    fn reopen_opath(opath: &OwnedFd, flags: libc::c_int) -> Result<File> {
334        let proc_path = proc_fd_path(opath.as_raw_fd())?;
335        // SAFETY: proc_path is a valid C string; open returns a new fd or -1.
336        let rc = unsafe { libc::open(proc_path.as_ptr(), flags | libc::O_CLOEXEC) };
337        if rc < 0 {
338            return Err(RestrictedOpenError::Io(std::io::Error::last_os_error()));
339        }
340        // SAFETY: rc is a fresh, owned fd.
341        Ok(File::from(unsafe { OwnedFd::from_raw_fd(rc) }))
342    }
343
344    pub(super) fn constrained_unix_socket_fd(
345        cpath: &CStr,
346    ) -> Result<(OwnedFd, std::path::PathBuf)> {
347        let opath = open_path(
348            libc::AT_FDCWD,
349            cpath,
350            libc::O_PATH,
351            "magic link in socket path",
352        )?;
353        reject_pseudo_filesystem(opath.as_raw_fd())?;
354        if file_type(opath.as_raw_fd())? != libc::S_IFSOCK {
355            return Err(RestrictedOpenError::Rejected("not a socket"));
356        }
357        let connect_path = std::path::PathBuf::from(format!("/proc/self/fd/{}", opath.as_raw_fd()));
358        Ok((opath, connect_path))
359    }
360
361    pub(super) fn open_regular_for_read(cpath: &CStr) -> Result<File> {
362        let opath = open_path(libc::AT_FDCWD, cpath, libc::O_PATH, "magic link in path")?;
363        reject_pseudo_filesystem(opath.as_raw_fd())?;
364        require_regular_file(opath.as_raw_fd())?;
365        reopen_opath(&opath, libc::O_RDONLY)
366    }
367
368    pub(super) fn open_regular_for_write(
369        path: &std::path::Path,
370        _cpath: &CStr,
371        mode: super::WriteMode,
372    ) -> Result<File> {
373        let parent = path
374            .parent()
375            .filter(|p| !p.as_os_str().is_empty())
376            .unwrap_or_else(|| std::path::Path::new("."));
377        let file_name = path.file_name().ok_or(RestrictedOpenError::Rejected(
378            "output path has no file name",
379        ))?;
380        let cparent = CString::new(parent.as_os_str().as_bytes())
381            .map_err(|_| RestrictedOpenError::Rejected("path contains an interior NUL"))?;
382        let cname = CString::new(file_name.as_bytes())
383            .map_err(|_| RestrictedOpenError::Rejected("path contains an interior NUL"))?;
384
385        let write_flag = match mode {
386            super::WriteMode::Append => libc::O_APPEND,
387            super::WriteMode::Truncate => libc::O_TRUNC,
388        };
389
390        // Pin the validated parent so a rename or symlink swap cannot redirect the final open.
391        let dir = open_path(
392            libc::AT_FDCWD,
393            &cparent,
394            libc::O_PATH | libc::O_DIRECTORY,
395            "magic link in output directory",
396        )?;
397        reject_pseudo_filesystem(dir.as_raw_fd())?;
398
399        // Inspect without following the final symlink or opening devices. If the file is absent,
400        // create it exclusively to prevent another file from being planted after this check.
401        let inspect = open_path(
402            dir.as_raw_fd(),
403            &cname,
404            libc::O_PATH | libc::O_NOFOLLOW,
405            "magic link as output file",
406        );
407        match inspect {
408            Ok(existing) => {
409                reject_pseudo_filesystem(existing.as_raw_fd())?;
410                // O_PATH | O_NOFOLLOW pins the symlink itself, so the type check rejects it.
411                require_regular_file(existing.as_raw_fd())?;
412                reopen_opath(&existing, libc::O_WRONLY | write_flag)
413            }
414            Err(RestrictedOpenError::Io(e)) if e.raw_os_error() == Some(libc::ENOENT) => {
415                // SAFETY: cname is a valid C string relative to the O_PATH directory handle.
416                let rc = unsafe {
417                    libc::openat(
418                        dir.as_raw_fd(),
419                        cname.as_ptr(),
420                        libc::O_WRONLY
421                            | libc::O_CREAT
422                            | libc::O_EXCL
423                            | write_flag
424                            | libc::O_NOFOLLOW
425                            | libc::O_CLOEXEC,
426                        0o600,
427                    )
428                };
429                if rc < 0 {
430                    return Err(RestrictedOpenError::Io(std::io::Error::last_os_error()));
431                }
432                // SAFETY: rc is a fresh, owned fd.
433                Ok(File::from(unsafe { OwnedFd::from_raw_fd(rc) }))
434            }
435            Err(other) => Err(other),
436        }
437    }
438}
439
440#[cfg(all(unix, not(target_os = "linux")))]
441mod platform {
442    use super::{RestrictedOpenError, Result};
443    use std::ffi::{CStr, CString};
444    use std::fs::File;
445    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
446
447    // O_NOFOLLOW does not stop macOS descriptor aliases such as /dev/fd/N. Reopening the path from
448    // F_GETPATH checks directory permissions; comparing device and inode numbers detects a swap.
449
450    fn fstat_regular(fd: RawFd) -> Result<libc::stat> {
451        // SAFETY: stat is POD; fstat fills it or returns -1.
452        let mut st: libc::stat = unsafe { std::mem::zeroed() };
453        if unsafe { libc::fstat(fd, &mut st) } != 0 {
454            return Err(RestrictedOpenError::Io(std::io::Error::last_os_error()));
455        }
456        if st.st_mode & libc::S_IFMT != libc::S_IFREG {
457            return Err(RestrictedOpenError::Rejected("not a regular file"));
458        }
459        Ok(st)
460    }
461
462    fn same_object(a: &libc::stat, b: &libc::stat) -> bool {
463        a.st_dev == b.st_dev && a.st_ino == b.st_ino
464    }
465
466    /// Get the target's filesystem path so reopening it checks directory permissions.
467    fn canonical_path(fd: RawFd) -> Result<CString> {
468        let mut buf = [0 as libc::c_char; libc::PATH_MAX as usize];
469        // SAFETY: buf holds PATH_MAX bytes; F_GETPATH writes a NUL-terminated path into it.
470        if unsafe { libc::fcntl(fd, libc::F_GETPATH, buf.as_mut_ptr()) } != 0 {
471            return Err(RestrictedOpenError::Io(std::io::Error::last_os_error()));
472        }
473        // SAFETY: F_GETPATH NUL-terminates within PATH_MAX.
474        Ok(unsafe { CStr::from_ptr(buf.as_ptr()) }.to_owned())
475    }
476
477    fn open_nofollow(cpath: &CStr, flags: libc::c_int) -> Result<OwnedFd> {
478        // O_NONBLOCK avoids waiting on a FIFO before we can check its type.
479        let full = flags | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC;
480        let rc = unsafe { libc::open(cpath.as_ptr(), full, 0o600 as libc::c_int) };
481        if rc >= 0 {
482            // SAFETY: rc is a fresh, owned fd.
483            return Ok(unsafe { OwnedFd::from_raw_fd(rc) });
484        }
485        let err = std::io::Error::last_os_error();
486        match err.raw_os_error() {
487            Some(libc::ELOOP) => Err(RestrictedOpenError::Rejected("symlink final component")),
488            _ => Err(RestrictedOpenError::Io(err)),
489        }
490    }
491
492    /// Reopen the canonical path and check that it still names the same regular file.
493    fn reopen_canonical(fd: &OwnedFd, st1: &libc::stat, flags: libc::c_int) -> Result<File> {
494        let canonical = canonical_path(fd.as_raw_fd())?;
495        let reopened = open_nofollow(&canonical, flags)?;
496        let st2 = fstat_regular(reopened.as_raw_fd())?;
497        if !same_object(st1, &st2) {
498            return Err(RestrictedOpenError::Rejected(
499                "target changed identity during resolution",
500            ));
501        }
502        Ok(File::from(reopened))
503    }
504
505    pub(super) fn open_regular_for_read(cpath: &CStr) -> Result<File> {
506        let fd = open_nofollow(cpath, libc::O_RDONLY)?;
507        let st1 = fstat_regular(fd.as_raw_fd())?;
508        reopen_canonical(&fd, &st1, libc::O_RDONLY)
509    }
510
511    pub(super) fn open_regular_for_write(
512        _path: &std::path::Path,
513        cpath: &CStr,
514        mode: super::WriteMode,
515    ) -> Result<File> {
516        let write_flag = match mode {
517            super::WriteMode::Append => libc::O_APPEND,
518            super::WriteMode::Truncate => libc::O_TRUNC,
519        };
520        // Inspect read-only first, so descriptor aliases are resolved before opening for writes.
521        match open_nofollow(cpath, libc::O_RDONLY) {
522            Ok(fd) => {
523                let st1 = fstat_regular(fd.as_raw_fd())?;
524                reopen_canonical(&fd, &st1, libc::O_WRONLY | write_flag)
525            }
526            Err(RestrictedOpenError::Io(e)) if e.raw_os_error() == Some(libc::ENOENT) => {
527                // Exclusive creation refuses a file or symlink planted after inspection.
528                let fd = open_nofollow(
529                    cpath,
530                    libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | write_flag,
531                )?;
532                let _ = fstat_regular(fd.as_raw_fd())?;
533                Ok(File::from(fd))
534            }
535            Err(other) => Err(other),
536        }
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use std::io::{Read, Write};
544    #[cfg(target_os = "linux")]
545    use std::os::unix::fs::symlink;
546
547    fn tmp_dir() -> std::path::PathBuf {
548        let dir = std::env::temp_dir().join(format!("ddrestrict-{}", std::process::id()));
549        let _ = std::fs::create_dir_all(&dir);
550        dir
551    }
552
553    fn read_all(path: &std::path::Path) -> Vec<u8> {
554        let mut buf = Vec::new();
555        open_regular_for_read(path)
556            .unwrap()
557            .read_to_end(&mut buf)
558            .unwrap();
559        buf
560    }
561
562    #[test]
563    #[cfg_attr(miri, ignore)]
564    fn reads_an_ordinary_file() {
565        let dir = tmp_dir();
566        let path = dir.join("ordinary.txt");
567        std::fs::write(&path, b"hello canary\n").unwrap();
568        assert_eq!(read_all(&path), b"hello canary\n");
569    }
570
571    // The macOS fallback refuses symlinks as the final component.
572    #[cfg(target_os = "linux")]
573    #[test]
574    #[cfg_attr(miri, ignore)]
575    fn follows_an_ordinary_symlink_to_a_regular_file() {
576        let dir = tmp_dir();
577        let target = dir.join("real.txt");
578        std::fs::write(&target, b"through symlink\n").unwrap();
579        let link = dir.join("link.txt");
580        let _ = std::fs::remove_file(&link);
581        symlink(&target, &link).unwrap();
582        assert_eq!(read_all(&link), b"through symlink\n");
583    }
584
585    #[cfg(target_os = "linux")]
586    #[test]
587    #[cfg_attr(miri, ignore)]
588    fn rejects_procfs_regular_file() {
589        // This is a regular file, not a magic link.
590        let err = open_regular_for_read(std::path::Path::new("/proc/self/status")).unwrap_err();
591        assert!(
592            matches!(
593                err,
594                RestrictedOpenError::Rejected(_) | RestrictedOpenError::Unsupported
595            ),
596            "unexpected: {err:?}"
597        );
598    }
599
600    #[cfg(target_os = "linux")]
601    #[test]
602    #[cfg_attr(miri, ignore)]
603    fn rejects_proc_self_fd_magic_link() {
604        let dir = tmp_dir();
605        let secret = dir.join("secret.txt");
606        std::fs::write(&secret, b"SECRET\n").unwrap();
607        let held = std::fs::File::open(&secret).unwrap();
608        let via_fd = format!("/proc/self/fd/{}", std::os::fd::AsRawFd::as_raw_fd(&held));
609        let err = open_regular_for_read(std::path::Path::new(&via_fd)).unwrap_err();
610        assert!(
611            matches!(
612                err,
613                RestrictedOpenError::Rejected(_) | RestrictedOpenError::Unsupported
614            ),
615            "unexpected: {err:?}"
616        );
617    }
618
619    #[cfg(target_os = "linux")]
620    #[test]
621    #[cfg_attr(miri, ignore)]
622    fn rejects_symlink_to_proc_self_fd() {
623        let dir = tmp_dir();
624        let secret = dir.join("secret2.txt");
625        std::fs::write(&secret, b"SECRET2\n").unwrap();
626        let held = std::fs::File::open(&secret).unwrap();
627        let alias = dir.join("fd-alias");
628        let _ = std::fs::remove_file(&alias);
629        symlink(
630            format!("/proc/self/fd/{}", std::os::fd::AsRawFd::as_raw_fd(&held)),
631            &alias,
632        )
633        .unwrap();
634        let err = open_regular_for_read(&alias).unwrap_err();
635        assert!(
636            matches!(
637                err,
638                RestrictedOpenError::Rejected(_) | RestrictedOpenError::Unsupported
639            ),
640            "unexpected: {err:?}"
641        );
642    }
643
644    #[cfg(target_os = "linux")]
645    #[test]
646    #[cfg_attr(miri, ignore)]
647    fn rejects_a_fifo() {
648        let dir = tmp_dir();
649        let fifo = dir.join("fifo");
650        let _ = std::fs::remove_file(&fifo);
651        let cfifo = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
652        assert_eq!(unsafe { libc::mkfifo(cfifo.as_ptr(), 0o600) }, 0);
653        let err = open_regular_for_read(&fifo).unwrap_err();
654        assert!(
655            matches!(
656                err,
657                RestrictedOpenError::Rejected(_) | RestrictedOpenError::Unsupported
658            ),
659            "unexpected: {err:?}"
660        );
661    }
662
663    #[test]
664    #[cfg_attr(miri, ignore)]
665    fn append_writes_to_an_ordinary_file() {
666        let dir = tmp_dir();
667        let path = dir.join("append.log");
668        let _ = std::fs::remove_file(&path);
669        {
670            let mut f = open_regular_for_append(&path).unwrap();
671            f.write_all(b"line1\n").unwrap();
672        }
673        {
674            let mut f = open_regular_for_append(&path).unwrap();
675            f.write_all(b"line2\n").unwrap();
676        }
677        assert_eq!(std::fs::read(&path).unwrap(), b"line1\nline2\n");
678    }
679
680    #[cfg(target_os = "linux")]
681    #[test]
682    #[cfg_attr(miri, ignore)]
683    fn append_rejects_symlinked_final_component() {
684        let dir = tmp_dir();
685        let real = dir.join("real-target.log");
686        std::fs::write(&real, b"pre\n").unwrap();
687        let link = dir.join("link-target.log");
688        let _ = std::fs::remove_file(&link);
689        symlink(&real, &link).unwrap();
690        let err = open_regular_for_append(&link).unwrap_err();
691        assert!(
692            matches!(
693                err,
694                RestrictedOpenError::Rejected(_) | RestrictedOpenError::Unsupported
695            ),
696            "unexpected: {err:?}"
697        );
698        assert_eq!(std::fs::read(&real).unwrap(), b"pre\n");
699    }
700
701    #[cfg(target_os = "linux")]
702    #[test]
703    #[cfg_attr(miri, ignore)]
704    fn follows_relative_symlinks_and_parent_components() {
705        let dir = tmp_dir().join("relative-links");
706        std::fs::create_dir_all(dir.join("real/subdir")).unwrap();
707        std::fs::write(dir.join("real/attachment.txt"), b"attachment\n").unwrap();
708        symlink("real/subdir", dir.join("directory-link")).unwrap();
709        symlink("directory-link/../attachment.txt", dir.join("file-link")).unwrap();
710        assert_eq!(read_all(&dir.join("./file-link")), b"attachment\n");
711    }
712
713    #[cfg(target_os = "linux")]
714    #[test]
715    #[cfg_attr(miri, ignore)]
716    fn creates_appends_and_truncates_through_directory_symlinks() {
717        let dir = tmp_dir().join("output-directory-links");
718        std::fs::create_dir_all(dir.join("real")).unwrap();
719        symlink("real", dir.join("link")).unwrap();
720        let path = dir.join("link/output.log");
721        open_regular_for_append(&path)
722            .unwrap()
723            .write_all(b"first\n")
724            .unwrap();
725        open_regular_for_append(&path)
726            .unwrap()
727            .write_all(b"second\n")
728            .unwrap();
729        assert_eq!(std::fs::read(&path).unwrap(), b"first\nsecond\n");
730        open_regular_for_create(&path)
731            .unwrap()
732            .write_all(b"replacement\n")
733            .unwrap();
734        assert_eq!(std::fs::read(&path).unwrap(), b"replacement\n");
735
736        let alias = dir.join("output-alias");
737        symlink("real/output.log", &alias).unwrap();
738        assert!(open_regular_for_create(&alias).is_err());
739        assert_eq!(std::fs::read(&path).unwrap(), b"replacement\n");
740    }
741
742    #[cfg(target_os = "linux")]
743    #[test]
744    #[cfg_attr(miri, ignore)]
745    fn refuses_symlink_loops_and_non_directory_components() {
746        let dir = tmp_dir().join("invalid-paths");
747        std::fs::create_dir_all(&dir).unwrap();
748        symlink("loop", dir.join("loop")).unwrap();
749        assert!(open_regular_for_read(&dir.join("loop")).is_err());
750        std::fs::write(dir.join("file"), b"ordinary\n").unwrap();
751        for suffix in ["file/", "file/.", "file/../file"] {
752            let err = open_regular_for_read(&dir.join(suffix)).unwrap_err();
753            assert!(
754                matches!(err, RestrictedOpenError::Io(ref e)
755                    if e.raw_os_error() == Some(libc::ENOTDIR)),
756                "{suffix}: {err:?}"
757            );
758        }
759    }
760
761    #[cfg(target_os = "linux")]
762    #[test]
763    #[cfg_attr(miri, ignore)]
764    fn rejects_devices_and_symlinked_pseudo_filesystems() {
765        for path in ["/dev/null", "/dev/zero", "/sys/kernel/uevent_seqnum"] {
766            assert!(matches!(
767                open_regular_for_read(std::path::Path::new(path)),
768                Err(RestrictedOpenError::Rejected(_))
769            ));
770        }
771        let dir = tmp_dir().join("pseudo-filesystem-links");
772        std::fs::create_dir_all(&dir).unwrap();
773        symlink("/proc", dir.join("proc-alias")).unwrap();
774        symlink("/dev/fd", dir.join("fd-alias")).unwrap();
775        let ordinary = dir.join("ordinary");
776        std::fs::write(&ordinary, b"ordinary\n").unwrap();
777        let held = std::fs::File::open(&ordinary).unwrap();
778        let fd = std::os::fd::AsRawFd::as_raw_fd(&held);
779        for path in [
780            dir.join("proc-alias/self/maps"),
781            dir.join(format!("fd-alias/{fd}")),
782        ] {
783            assert!(matches!(
784                open_regular_for_read(&path),
785                Err(RestrictedOpenError::Rejected(_))
786            ));
787        }
788    }
789}