Skip to main content

hotl_platform/openat/
mod.rs

1//! [`DirHandle`] — the syscall layer under `fsguard`'s containment descent.
2
3use std::ffi::OsStr;
4use std::fs::File;
5use std::io;
6use std::path::{Path, PathBuf};
7
8#[cfg(unix)]
9mod unix;
10#[cfg(unix)]
11pub use unix::UnixDirHandle;
12#[cfg(unix)]
13pub type ActiveDirHandle = UnixDirHandle;
14
15#[cfg(windows)]
16mod windows;
17#[cfg(windows)]
18pub use windows::WindowsDirHandle;
19#[cfg(windows)]
20pub type ActiveDirHandle = WindowsDirHandle;
21
22/// A directory the caller already proved is inside the guard root, and the
23/// *only* way to go one step deeper.
24///
25/// INVARIANT, enforced by the type and not by review: **no method accepts an
26/// absolute path, and no method accepts a multi-component path.** Every
27/// operation names one component, relative to `self`. That is what makes
28/// `fsguard`'s one-door property structural — code handed a `DirHandle` still
29/// cannot reach outside it, which is strictly stronger than free functions,
30/// where nothing but discipline stops a second `open()` on a full path.
31///
32/// The one exception proves the rule: [`resolve_beneath`](DirHandle::resolve_beneath)
33/// takes a multi-component *relative* path, and the whole point of it is that
34/// the **kernel** enforces beneath-ness in a single syscall. It is not a
35/// weakening of the invariant, it is the invariant delegated to the one place
36/// that can honor it atomically.
37///
38/// Sealed, because the guarantees above are statements about *all* implementors.
39pub trait DirHandle: Sized + Send + Sync + crate::sealed::Sealed {
40    /// Attributes worth carrying across an atomic replace. Unix: the mode, so
41    /// editing a script does not silently drop its executable bit. Windows: the
42    /// `FILE_ATTRIBUTE_*` bits, where read-only and hidden play the same role.
43    /// Not a shared shape, because there is no shared shape to have.
44    type Attrs: Copy;
45
46    /// Open the guard root itself, by name. The root is ours, not the model's —
47    /// this is the only place a full path enters, and it is the anchor every
48    /// other method is relative to.
49    fn open_root(path: &Path) -> Result<Self, GuardIo>;
50
51    /// Open a child directory without following any link at the final
52    /// component. Unix `O_NOFOLLOW|O_DIRECTORY`; Windows
53    /// `FILE_OPEN_REPARSE_POINT|FILE_DIRECTORY_FILE` relative to the handle.
54    fn open_child_dir(&self, name: &OsStr) -> Result<Self, GuardIo>;
55
56    /// `mkdirat`. Succeeding when it already exists is the caller's business,
57    /// not this method's.
58    fn make_child_dir(&self, name: &OsStr) -> Result<(), GuardIo>;
59
60    fn open_child_file(&self, name: &OsStr, mode: OpenMode) -> Result<File, GuardIo>;
61    fn create_child_file(&self, name: &OsStr, excl: Excl) -> Result<File, GuardIo>;
62    fn rename_child(&self, from: &OsStr, to: &OsStr) -> Result<(), GuardIo>;
63    /// Best-effort removal of our own temp file. A failure here has nothing to
64    /// report, which is why it returns nothing.
65    fn unlink_child(&self, name: &OsStr);
66
67    /// Classify a child **without following** it, or `None` if it does not
68    /// exist. See [`NodeKind`] — the fail-closed default is the whole point.
69    fn child_kind(&self, name: &OsStr) -> Option<NodeKind>;
70
71    fn child_attrs(&self, name: &OsStr) -> Option<Self::Attrs>;
72    fn apply_attrs(&self, file: &File, attrs: Self::Attrs) -> io::Result<()>;
73
74    /// Stable identity, for the belt-and-braces check that the handle we
75    /// validated and the path we return are the same object.
76    fn identity(&self) -> Result<NodeId, GuardIo>;
77
78    /// Single-syscall beneath-resolution where the OS has one: Linux `openat2`
79    /// with `RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS|RESOLVE_NO_MAGICLINKS`,
80    /// Windows `NtCreateFile` with `OBJ_DONT_REPARSE`.
81    ///
82    /// `Ok(None)` means **this kernel does not offer it** (pre-5.6 Linux, a
83    /// seccomp filter returning ENOSYS/EPERM, pre-1607 Windows) and the caller
84    /// must fall through to the component-wise descent. It does **not** mean
85    /// "allowed" — an actual refusal is `Err`.
86    fn resolve_beneath(&self, rel: &Path, mode: OpenMode) -> Result<Option<File>, GuardIo>;
87
88    /// Hand back the handle for this directory itself, for the one case where
89    /// the caller's target *is* the directory it already descended to.
90    /// Widens nothing: it returns the object the caller already holds.
91    fn into_file(self) -> File;
92
93    /// After this returns, the most recent rename in this directory survives a
94    /// crash.
95    ///
96    /// CONTRACT is the *guarantee*, not the mechanism, and the two platforms
97    /// reach it differently: Unix must `fsync` the directory fd, because
98    /// without it the rename can be lost even though the data was synced. NTFS
99    /// journals the metadata operation itself, so on Windows the guarantee
100    /// already holds when the rename returns. Windows returning `Ok` is
101    /// therefore a claim that the property is satisfied — not a no-op standing
102    /// in for a capability the platform lacks.
103    fn sync_name_durability(&self) -> Result<(), GuardIo>;
104}
105
106/// A failed syscall, plus the one classification only the adapter can make.
107#[derive(Debug)]
108pub struct GuardIo {
109    pub error: io::Error,
110    /// The OS refused because a component was a link or other reparse point,
111    /// as distinct from any other failure.
112    ///
113    /// Each adapter decides this from its own quirks — POSIX says `ELOOP` but
114    /// macOS says `ENOTDIR` whenever `O_DIRECTORY` is also set, and Windows
115    /// says `STATUS_REPARSE_POINT_ENCOUNTERED` or reports the attribute on a
116    /// handle it opened without following. That is translation, not policy, so
117    /// it belongs in the adapter; what the caller *does* about it is policy,
118    /// and stays in `fsguard`.
119    pub refused_a_link: bool,
120}
121
122impl GuardIo {
123    pub fn io(error: io::Error) -> Self {
124        Self {
125            error,
126            refused_a_link: false,
127        }
128    }
129
130    pub fn link(error: io::Error) -> Self {
131        Self {
132            error,
133            refused_a_link: true,
134        }
135    }
136
137    pub fn last_os_error() -> Self {
138        Self::io(io::Error::last_os_error())
139    }
140}
141
142impl From<io::Error> for GuardIo {
143    fn from(error: io::Error) -> Self {
144        Self::io(error)
145    }
146}
147
148/// What a child is, decided without following it.
149///
150/// `#[non_exhaustive]`, and every reader must match with a catch-all arm that
151/// **refuses**. A variant added later for diagnostics has to fail closed on
152/// every existing reader rather than fall into an `is_dir()`-shaped assumption.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154#[non_exhaustive]
155pub enum NodeKind {
156    Dir,
157    RegularFile,
158    /// Symlink, NTFS junction, app-exec-link, WCI layer, OneDrive placeholder,
159    /// or any other reparse tag.
160    ///
161    /// **One variant on purpose.** The Windows trap is that a junction reports
162    /// `is_symlink() == false` and `is_dir() == true`, so any type that lets a
163    /// caller tell "symlink" from "some other reparse point" invites the wrong
164    /// branch. There is no branch here to get wrong.
165    NotFollowable {
166        tag: Option<u32>,
167    },
168    /// FIFO, socket, device, `NUL`, a named pipe — anything a read would block
169    /// on, or that is not a file at all.
170    NotAFile,
171}
172
173/// Device plus file identity.
174///
175/// 128 bits for the file half because ReFS needs it; Unix fills the low half
176/// with the inode. Compared, never interpreted.
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub struct NodeId {
179    pub volume: u64,
180    pub file: u128,
181}
182
183/// How a leaf is opened for reading.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum OpenMode {
186    /// A regular file. Unix adds `O_NONBLOCK` so a FIFO cannot block the open
187    /// *before* the caller gets a chance to refuse it — see [`unblock`].
188    File,
189    /// A directory, for a walk root.
190    Dir,
191}
192
193/// Whether a create may land on an existing file.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum Excl {
196    /// `O_TRUNC` — replace the contents of whatever is there.
197    Truncate,
198    /// `O_EXCL` — fail if anything is there at all.
199    MustNotExist,
200}
201
202/// Stable identity of an open handle, for the belt-and-braces check that the
203/// handle the guard validated and the path it returns name the same object.
204pub fn identity_of(file: &File) -> io::Result<NodeId> {
205    #[cfg(unix)]
206    {
207        unix::identity_of(file)
208    }
209    #[cfg(windows)]
210    {
211        windows::identity_of(file)
212    }
213}
214
215/// The same identity, taken by name **without following a final link**. The
216/// other half of the comparison above.
217pub fn identity_at(path: &Path) -> io::Result<NodeId> {
218    #[cfg(unix)]
219    {
220        unix::identity_at(path)
221    }
222    #[cfg(windows)]
223    {
224        windows::identity_at(path)
225    }
226}
227
228/// The OS's own normalized name for an open handle.
229///
230/// `Ok(None)` where the platform has no such call — Unix, where `/proc/self/fd`
231/// resolves through the very magic links the guard refuses and so is not the
232/// same answer. Windows uses
233/// `GetFinalPathNameByHandleW(FILE_NAME_NORMALIZED|VOLUME_NAME_DOS)`, which
234/// catches 8.3 short names, case, and trailing dot/space forms in one call.
235pub fn normalized_name(file: &File) -> io::Result<Option<PathBuf>> {
236    #[cfg(unix)]
237    {
238        let _ = file;
239        Ok(None)
240    }
241    #[cfg(windows)]
242    {
243        windows::normalized_name(file)
244    }
245}
246
247/// Undo [`OpenMode::File`]'s non-blocking open once the handle is known to be a
248/// regular file.
249///
250/// Windows is a genuine no-op here, and that is a statement about the platform
251/// rather than a gap: `CreateFile` on a named pipe does not block waiting for a
252/// peer the way `open(2)` on a FIFO blocks waiting for a writer, so there was
253/// never a flag to set and there is nothing to clear.
254pub fn unblock(file: &File) -> io::Result<()> {
255    #[cfg(unix)]
256    {
257        unix::clear_nonblock(file)
258    }
259    #[cfg(windows)]
260    {
261        let _ = file;
262        Ok(())
263    }
264}
265
266/// The lexical refusals, applied to one path component before it is ever
267/// handed to the OS.
268///
269/// These are Windows filename semantics, and they are checked on **every**
270/// platform on purpose: a deny rule, a glob and an `execute_later_reason`
271/// match all compare the name the model wrote, while Win32 opens something
272/// else. Refusing the divergent forms outright is cheaper than teaching four
273/// matchers about them, and a name hotl refuses is a name no matcher can be
274/// wrong about.
275///
276/// Returns the reason, or `None` when the component is fine.
277pub fn refuse_component(name: &OsStr) -> Option<&'static str> {
278    let Some(text) = name.to_str() else {
279        // Not UTF-8: nothing below can be reasoned about, and every caller
280        // treats an unnameable component as an escape.
281        return Some("is not valid UTF-8");
282    };
283    if text.is_empty() {
284        return Some("is empty");
285    }
286    // An NTFS alternate data stream. `Path::components()` does not split on
287    // `:`, so a matcher sees `agents.md:evil` as one name while Win32 opens the
288    // `evil` stream of `agents.md` — the deny rule and the write disagree about
289    // which object is involved.
290    if text.contains(':') {
291        return Some("names an alternate data stream (`:`)");
292    }
293    // Win32 strips these and opens the stripped name, so `AGENTS.md.` writes
294    // `AGENTS.md` while every matcher sees a different file.
295    if text.ends_with('.') || text.ends_with(' ') {
296        return Some("ends with a dot or space, which Windows silently strips");
297    }
298    if is_reserved_device_name(text) {
299        return Some("is a reserved device name");
300    }
301    None
302}
303
304/// `CON`, `NUL`, `AUX`, `PRN`, `COM1-9`, `LPT1-9` — including with an
305/// extension (`CON.txt`) and in any directory, because Win32 redirects the name
306/// to a device wherever it appears.
307fn is_reserved_device_name(text: &str) -> bool {
308    let stem = text
309        .split('.')
310        .next()
311        .unwrap_or(text)
312        .trim_end_matches([' ', '.']);
313    if matches!(
314        stem.to_ascii_uppercase().as_str(),
315        "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
316    ) {
317        return true;
318    }
319    let upper = stem.to_ascii_uppercase();
320    for prefix in ["COM", "LPT"] {
321        if let Some(rest) = upper.strip_prefix(prefix) {
322            if rest.len() == 1 && matches!(rest.as_bytes()[0], b'1'..=b'9') {
323                return true;
324            }
325        }
326    }
327    false
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    /// Checked on every platform, so the assertions are too. A name hotl
335    /// refuses is a name no path matcher can disagree with Win32 about.
336    #[test]
337    fn the_lexical_refusals_hold_on_every_platform() {
338        for bad in [
339            "AGENTS.md:evil",
340            "AGENTS.md.",
341            "AGENTS.md ",
342            "CON",
343            "con.txt",
344            "NUL",
345            "COM1",
346            "lpt9.log",
347            "",
348        ] {
349            assert!(
350                refuse_component(OsStr::new(bad)).is_some(),
351                "`{bad}` must be refused"
352            );
353        }
354        // And the near-misses are not over-refused: these are ordinary names.
355        for ok in [
356            "AGENTS.md",
357            "console.log",
358            "COM0",
359            "COM10",
360            "nulls.rs",
361            "a.b.c",
362            "LPTX",
363        ] {
364            assert_eq!(
365                refuse_component(OsStr::new(ok)),
366                None,
367                "`{ok}` must be allowed"
368            );
369        }
370    }
371
372    /// One body, both syscall backends: the root opens, a child directory and
373    /// a child file round-trip, and identity distinguishes two objects.
374    #[test]
375    fn active_adapter_upholds_the_contract() {
376        use std::io::{Read, Write};
377        let scratch = std::env::temp_dir().join(format!("hotl-dirhandle-{}", std::process::id()));
378        let _ = std::fs::remove_dir_all(&scratch);
379        std::fs::create_dir_all(scratch.join("sub")).unwrap();
380        std::fs::write(scratch.join("sub").join("f.txt"), b"hello").unwrap();
381
382        let root = ActiveDirHandle::open_root(&scratch).unwrap();
383        assert_eq!(root.child_kind(OsStr::new("sub")), Some(NodeKind::Dir));
384        assert_eq!(root.child_kind(OsStr::new("nope")), None);
385
386        let sub = root.open_child_dir(OsStr::new("sub")).unwrap();
387        assert_eq!(
388            sub.child_kind(OsStr::new("f.txt")),
389            Some(NodeKind::RegularFile)
390        );
391
392        let mut f = sub
393            .open_child_file(OsStr::new("f.txt"), OpenMode::File)
394            .unwrap();
395        unblock(&f).unwrap();
396        let mut s = String::new();
397        f.read_to_string(&mut s).unwrap();
398        assert_eq!(s, "hello");
399
400        // Two different directories are two different objects.
401        assert_ne!(root.identity().unwrap(), sub.identity().unwrap());
402
403        // `MustNotExist` really refuses an existing name.
404        assert!(sub
405            .create_child_file(OsStr::new("f.txt"), Excl::MustNotExist)
406            .is_err());
407        let mut new = sub
408            .create_child_file(OsStr::new("g.txt"), Excl::MustNotExist)
409            .unwrap();
410        new.write_all(b"g").unwrap();
411        drop(new);
412
413        // Rename and unlink are relative to the handle, both ways.
414        sub.rename_child(OsStr::new("g.txt"), OsStr::new("h.txt"))
415            .unwrap();
416        assert_eq!(sub.child_kind(OsStr::new("g.txt")), None);
417        sub.sync_name_durability().unwrap();
418        sub.unlink_child(OsStr::new("h.txt"));
419        assert_eq!(sub.child_kind(OsStr::new("h.txt")), None);
420
421        let _ = std::fs::remove_dir_all(&scratch);
422    }
423}