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}