Skip to main content

hotl_platform/openat/
unix.rs

1//! `openat`/`mkdirat`/`fstatat`/`unlinkat`/`renameat` from a directory fd,
2//! plus Linux's `openat2` as the single-syscall path.
3//!
4//! This is the code `fsguard` carried inline before there was a second
5//! platform; the behavior is unchanged.
6
7use super::{DirHandle, Excl, GuardIo, NodeId, NodeKind, OpenMode};
8use std::ffi::{CString, OsStr};
9use std::fs::File;
10use std::io;
11use std::os::unix::ffi::OsStrExt;
12use std::os::unix::io::{AsRawFd, FromRawFd};
13use std::path::Path;
14
15pub struct UnixDirHandle(File);
16
17impl crate::sealed::Sealed for UnixDirHandle {}
18
19const DIR_FLAGS: libc::c_int =
20    libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY;
21
22/// `O_NONBLOCK` is not decoration: `open(2)` on a FIFO blocks until a writer
23/// appears, so without it the guard would hang *before* it ever got the chance
24/// to refuse the FIFO. [`super::unblock`] clears it once the handle is known to
25/// be a regular file.
26const FILE_FLAGS: libc::c_int =
27    libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK;
28
29fn cstr(name: &OsStr) -> Result<CString, GuardIo> {
30    CString::new(name.as_bytes())
31        .map_err(|_| GuardIo::io(io::Error::from(io::ErrorKind::InvalidInput)))
32}
33
34/// POSIX says `O_NOFOLLOW` on a symlink is `ELOOP`, but macOS returns `ENOTDIR`
35/// whenever `O_DIRECTORY` is also set, and an intermediate link can surface
36/// either. So classify by asking the *same dirfd* whether the component is a
37/// link. The open has already failed — this only picks the classification, and
38/// nothing is ever opened on its strength, so it is not a check-then-open.
39fn classify_failure(dir: &File, name: &OsStr, err: io::Error) -> GuardIo {
40    let looks_like_a_link = matches!(err.raw_os_error(), Some(libc::ELOOP) | Some(libc::ENOTDIR))
41        && matches!(
42            kind_at(dir, name),
43            Some(NodeKind::NotFollowable { .. }) | Some(NodeKind::NotAFile)
44        );
45    if looks_like_a_link {
46        GuardIo::link(err)
47    } else {
48        GuardIo::io(err)
49    }
50}
51
52fn stat_at(dir: &File, name: &OsStr) -> Option<libc::stat> {
53    let c = cstr(name).ok()?;
54    // SAFETY: `stat` is a plain repr(C) struct; all-zero is a valid instance.
55    let mut st: libc::stat = unsafe { std::mem::zeroed() };
56    // SAFETY: `dir` is an open directory fd we own; `c` is NUL-terminated.
57    let rc = unsafe {
58        libc::fstatat(
59            dir.as_raw_fd(),
60            c.as_ptr(),
61            &mut st,
62            libc::AT_SYMLINK_NOFOLLOW,
63        )
64    };
65    (rc == 0).then_some(st)
66}
67
68fn kind_at(dir: &File, name: &OsStr) -> Option<NodeKind> {
69    let st = stat_at(dir, name)?;
70    Some(match st.st_mode & libc::S_IFMT {
71        libc::S_IFDIR => NodeKind::Dir,
72        libc::S_IFREG => NodeKind::RegularFile,
73        libc::S_IFLNK => NodeKind::NotFollowable { tag: None },
74        _ => NodeKind::NotAFile,
75    })
76}
77
78pub(super) fn identity_of(file: &File) -> io::Result<NodeId> {
79    use std::os::unix::fs::MetadataExt;
80    let m = file.metadata()?;
81    Ok(NodeId {
82        volume: m.dev(),
83        file: m.ino() as u128,
84    })
85}
86
87pub(super) fn identity_at(path: &Path) -> io::Result<NodeId> {
88    use std::os::unix::fs::MetadataExt;
89    let m = std::fs::symlink_metadata(path)?;
90    Ok(NodeId {
91        volume: m.dev(),
92        file: m.ino() as u128,
93    })
94}
95
96pub(super) fn clear_nonblock(file: &File) -> io::Result<()> {
97    // SAFETY: plain fcntl(2) on a fd we own.
98    let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) };
99    if flags < 0 {
100        return Err(io::Error::last_os_error());
101    }
102    // SAFETY: same fd, clearing one flag.
103    if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFL, flags & !libc::O_NONBLOCK) } < 0 {
104        return Err(io::Error::last_os_error());
105    }
106    Ok(())
107}
108
109impl DirHandle for UnixDirHandle {
110    /// The full `st_mode`, so an atomic replace does not silently drop the
111    /// executable bit off every script it edits.
112    type Attrs = libc::mode_t;
113
114    fn open_root(path: &Path) -> Result<Self, GuardIo> {
115        let c = CString::new(path.as_os_str().as_bytes())
116            .map_err(|_| GuardIo::io(io::Error::from(io::ErrorKind::InvalidInput)))?;
117        // The root is opened by name — it is ours, not the model's — but with
118        // `O_DIRECTORY` so a swapped-out root fails loudly rather than silently
119        // rebasing the whole guard.
120        // SAFETY: plain open(2) on a NUL-terminated path we own.
121        let fd = unsafe {
122            libc::open(
123                c.as_ptr(),
124                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY,
125            )
126        };
127        if fd < 0 {
128            return Err(GuardIo::last_os_error());
129        }
130        // SAFETY: `fd` was just opened and is owned by nothing else.
131        Ok(Self(unsafe { File::from_raw_fd(fd) }))
132    }
133
134    fn open_child_dir(&self, name: &OsStr) -> Result<Self, GuardIo> {
135        let c = cstr(name)?;
136        // SAFETY: `self.0` is an open directory fd we own; `c` is NUL-terminated.
137        let fd = unsafe { libc::openat(self.0.as_raw_fd(), c.as_ptr(), DIR_FLAGS) };
138        if fd < 0 {
139            return Err(classify_failure(&self.0, name, io::Error::last_os_error()));
140        }
141        // SAFETY: freshly opened fd, owned by nothing else.
142        Ok(Self(unsafe { File::from_raw_fd(fd) }))
143    }
144
145    fn make_child_dir(&self, name: &OsStr) -> Result<(), GuardIo> {
146        let c = cstr(name)?;
147        // SAFETY: plain mkdirat(2) on a fd we own.
148        if unsafe { libc::mkdirat(self.0.as_raw_fd(), c.as_ptr(), 0o755) } < 0 {
149            return Err(GuardIo::last_os_error());
150        }
151        Ok(())
152    }
153
154    fn open_child_file(&self, name: &OsStr, mode: OpenMode) -> Result<File, GuardIo> {
155        let flags = match mode {
156            OpenMode::File => FILE_FLAGS,
157            OpenMode::Dir => DIR_FLAGS,
158        };
159        let c = cstr(name)?;
160        // SAFETY: `self.0` is an open directory fd we own; `c` is NUL-terminated.
161        let fd = unsafe { libc::openat(self.0.as_raw_fd(), c.as_ptr(), flags) };
162        if fd < 0 {
163            return Err(classify_failure(&self.0, name, io::Error::last_os_error()));
164        }
165        // SAFETY: freshly opened fd, owned by nothing else.
166        Ok(unsafe { File::from_raw_fd(fd) })
167    }
168
169    fn create_child_file(&self, name: &OsStr, excl: Excl) -> Result<File, GuardIo> {
170        let extra = match excl {
171            Excl::Truncate => libc::O_TRUNC,
172            Excl::MustNotExist => libc::O_EXCL,
173        };
174        // `O_NOFOLLOW` on the leaf is what makes an existing symlink there fail
175        // with `ELOOP` instead of writing through to its target.
176        let flags = libc::O_WRONLY | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW | extra;
177        let c = cstr(name)?;
178        // SAFETY: `self.0` is an open directory fd we own; `c` is
179        // NUL-terminated; `openat` is variadic and takes the mode as its third
180        // argument when `O_CREAT` is set.
181        let fd =
182            unsafe { libc::openat(self.0.as_raw_fd(), c.as_ptr(), flags, 0o644 as libc::c_uint) };
183        if fd < 0 {
184            return Err(classify_failure(&self.0, name, io::Error::last_os_error()));
185        }
186        // SAFETY: freshly opened fd, owned by nothing else.
187        Ok(unsafe { File::from_raw_fd(fd) })
188    }
189
190    fn rename_child(&self, from: &OsStr, to: &OsStr) -> Result<(), GuardIo> {
191        let (old, new) = (cstr(from)?, cstr(to)?);
192        // SAFETY: an open directory fd we own on both sides; both names are
193        // NUL-terminated.
194        if unsafe {
195            libc::renameat(
196                self.0.as_raw_fd(),
197                old.as_ptr(),
198                self.0.as_raw_fd(),
199                new.as_ptr(),
200            )
201        } < 0
202        {
203            return Err(GuardIo::last_os_error());
204        }
205        Ok(())
206    }
207
208    fn unlink_child(&self, name: &OsStr) {
209        let Ok(c) = cstr(name) else { return };
210        // SAFETY: plain unlinkat(2) on a fd we own.
211        unsafe {
212            libc::unlinkat(self.0.as_raw_fd(), c.as_ptr(), 0);
213        }
214    }
215
216    fn child_kind(&self, name: &OsStr) -> Option<NodeKind> {
217        kind_at(&self.0, name)
218    }
219
220    fn child_attrs(&self, name: &OsStr) -> Option<Self::Attrs> {
221        Some(stat_at(&self.0, name)?.st_mode)
222    }
223
224    fn apply_attrs(&self, file: &File, attrs: Self::Attrs) -> io::Result<()> {
225        // SAFETY: plain fchmod(2) on a fd we own.
226        if unsafe { libc::fchmod(file.as_raw_fd(), attrs & 0o7777) } < 0 {
227            return Err(io::Error::last_os_error());
228        }
229        Ok(())
230    }
231
232    fn identity(&self) -> Result<NodeId, GuardIo> {
233        use std::os::unix::fs::MetadataExt;
234        let meta = self.0.metadata().map_err(GuardIo::io)?;
235        Ok(NodeId {
236            volume: meta.dev(),
237            file: meta.ino() as u128,
238        })
239    }
240
241    #[cfg(target_os = "linux")]
242    fn resolve_beneath(&self, rel: &Path, mode: OpenMode) -> Result<Option<File>, GuardIo> {
243        // Force layer 3 even where layer 2 exists, so CI exercises the portable
244        // descent on Linux too. Without it the fallback — the layer that runs on
245        // every pre-5.6 kernel and every seccomp-restricted container — would
246        // only ever be tested on macOS runners, and would rot.
247        if std::env::var("HOTL_FSGUARD_FORCE_DESCEND").is_ok_and(|v| !v.is_empty() && v != "0") {
248            return Ok(None);
249        }
250        let c = CString::new(rel.as_os_str().as_bytes())
251            .map_err(|_| GuardIo::io(io::Error::from(io::ErrorKind::InvalidInput)))?;
252        let flags = match mode {
253            OpenMode::File => FILE_FLAGS,
254            OpenMode::Dir => DIR_FLAGS,
255        };
256        // `libc::open_how` is `#[non_exhaustive]`: it cannot be built with a
257        // struct literal outside libc, so zero it and set the three fields.
258        // SAFETY: `open_how` is a plain repr(C) struct of integers; all-zero is
259        // a valid instance of it.
260        let mut how: libc::open_how = unsafe { std::mem::zeroed() };
261        how.flags = (flags & !libc::O_NOFOLLOW) as u64;
262        how.resolve =
263            libc::RESOLVE_BENEATH | libc::RESOLVE_NO_MAGICLINKS | libc::RESOLVE_NO_SYMLINKS;
264        // SAFETY: raw syscall with a valid dirfd, a NUL-terminated path, and a
265        // correctly-sized `open_how` we own.
266        let ret = unsafe {
267            libc::syscall(
268                libc::SYS_openat2,
269                self.0.as_raw_fd(),
270                c.as_ptr(),
271                &how as *const libc::open_how,
272                std::mem::size_of::<libc::open_how>(),
273            )
274        };
275        if ret >= 0 {
276            // SAFETY: the syscall returned a fresh fd owned by nothing else.
277            return Ok(Some(unsafe {
278                File::from_raw_fd(ret as std::os::unix::io::RawFd)
279            }));
280        }
281        let err = io::Error::last_os_error();
282        match err.raw_os_error() {
283            // Pre-5.6 kernel, or a seccomp filter that blocks unknown syscalls.
284            // Fall through to the portable descent, which is equally safe — it
285            // just costs one syscall per component.
286            Some(libc::ENOSYS) | Some(libc::EPERM) => Ok(None),
287            Some(libc::ELOOP) | Some(libc::EXDEV) => Err(GuardIo::link(err)),
288            _ => Err(GuardIo::io(err)),
289        }
290    }
291
292    #[cfg(not(target_os = "linux"))]
293    fn resolve_beneath(&self, _rel: &Path, _mode: OpenMode) -> Result<Option<File>, GuardIo> {
294        // No `openat2` analogue outside Linux. `Ok(None)` is "this kernel does
295        // not offer it", which is exactly true, and the caller descends.
296        Ok(None)
297    }
298
299    fn into_file(self) -> File {
300        self.0
301    }
302
303    fn sync_name_durability(&self) -> Result<(), GuardIo> {
304        self.0.sync_all().map_err(GuardIo::io)
305    }
306}