openat and openat-ext
The minimal dirfd wrapper — one O_PATH descriptor, one *at syscall per method, O_NOFOLLOW everywhere, and no path policy at all — plus the utility layer CoreOS stacked on it before moving both to cap-std.
| Kind | library (two crates) |
| Year | openat 2016-12-10 → 2021-04-16 (v0.1.21); openat-ext 2019-08-20 → 2023-07-13 (v0.2.3, deprecated) |
| Authors / Maintainers | Paul Colomiets (tailhook) — openat; Colin Walters and the CoreOS team — openat-ext |
| Language | Rust (openat: raw libc, unsafe per call; openat-ext: #![deny(unsafe_code)] except one syncfs) |
| License | MIT OR Apache-2.0 (both) |
| Repository | tailhook/openat, coreos/openat-ext |
| Platforms | Unix only; O_PATH on Linux, O_DIRECTORY on FreeBSD, bare O_CLOEXEC elsewhere |
| Primitive | openat(dirfd, name, O_PATH | O_CLOEXEC | O_NOFOLLOW) and siblings; no resolution algorithm |
| Source read | openat/src/lib.rs, dir.rs, list.rs, metadata.rs, name.rs; openat-ext/README.md, src/lib.rs |
Overview
What it solves
openat is a binding, not a sandbox. Its own summary (README.md):
The interface to
openat,symlinkat, and other functions in*atfamily. […] This crate is a thin wrapper for the underlying system calls.
The module doc says what a Dir is and what it promises (src/lib.rs):
Main concept here is a
Dirwhich holdsO_PATHfile descriptor […] Note after opening file descriptors refer to same directory regardless of where it's moved or mounted (withpivot_rootormount --move). It may also be unmounted or be out of chroot and you will still be able to access files relative to it.
openat-ext exists because rpm-ostree needed more than the syscalls: "This code originated from https://github.com/projectatomic/rpm-ostree/blob/016c1c5e627fc2a8cd3266ccda3a47a5f8992594/rust/src/openat_utils.rs" (openat-ext/README.md). It adds "the common file utility functions that many real applications need" — optional-open helpers, ensure_dir_all, remove_all, reflink-aware copy, and an atomic-replace FileWriter.
Design philosophy
Safety is delegated to the caller in one sentence (src/lib.rs):
Note that if path supplied to any method of dir is absolute the Dir file descriptor is ignored.
Also while all methods of dir accept any path if you want to prevent certain symlink attacks and race condition you should only use a single-component path. I.e. open one part of a chain at a time.
That is the whole contract: the crate exposes the kernel's *at semantics verbatim, and the only hardening it applies unconditionally is O_NOFOLLOW on the final component. The one guard it wanted is a TODO on Dir::open: "maybe accept only absolute paths?" (src/dir.rs).
How it works
The minimal wrapper
Dir is a newtype over RawFd (src/lib.rs). Every method is to_cstr(path) followed by one libc call (src/dir.rs):
#[cfg(target_os="linux")]
const BASE_OPEN_FLAGS: libc::c_int = libc::O_PATH|libc::O_CLOEXEC;
#[cfg(target_os="freebsd")]
const BASE_OPEN_FLAGS: libc::c_int = libc::O_DIRECTORY|libc::O_CLOEXEC;
#[cfg(not(any(target_os="linux", target_os="freebsd")))]
const BASE_OPEN_FLAGS: libc::c_int = libc::O_CLOEXEC;
fn _open(path: &CStr) -> io::Result<Dir> { // Dir::open
let fd = unsafe { libc::open(path.as_ptr(), BASE_OPEN_FLAGS) };
…
}
fn _sub_dir(&self, path: &CStr) -> io::Result<Dir> { // Dir::sub_dir
let fd = unsafe { libc::openat(self.0, path.as_ptr(),
BASE_OPEN_FLAGS|libc::O_NOFOLLOW) };
…
}
fn _open_file(&self, path: &CStr, flags: libc::c_int, mode: libc::mode_t)
-> io::Result<File>
{
let res = libc::openat(self.0, path.as_ptr(),
flags|libc::O_CLOEXEC|libc::O_NOFOLLOW,
mode as libc::c_uint);
…
}| Method | Syscall | Note |
|---|---|---|
Dir::open(path) | open(path, O_PATH | O_CLOEXEC) | No O_DIRECTORY on Linux — test_open_file expects opening a file to succeed, and only FreeBSD panics with "Not a directory" |
sub_dir(name) | openat(fd, name, O_PATH | O_CLOEXEC | O_NOFOLLOW) | "does not resolve symlinks by default, so you may have to call read_link" |
open_file / write_file / new_file | openat(fd, name, flags | O_CLOEXEC | O_NOFOLLOW) | new_file is O_CREAT | O_EXCL; a symlink at the destination makes write_file fail |
new_unnamed_file / link_file_at | openat(fd, ".", O_TMPFILE | O_WRONLY); linkat(AT_FDCWD, "/proc/self/fd/N", fd, name, AT_SYMLINK_FOLLOW) | Linux only; needs /proc |
list_dir(name) / list_self() | openat(fd, name, O_DIRECTORY | O_CLOEXEC) → fdopendir; dup(fd) → fdopendir | list_dir has no O_NOFOLLOW; DirIter skips . and .., reads d_type |
metadata(name) | fstatat(fd, name, AT_SYMLINK_NOFOLLOW) | "If the destination path is a symlink, this will return the metadata of the symlink itself" |
read_link, symlink, create_dir | readlinkat (4096-byte buffer), symlinkat, mkdirat | |
remove_file / remove_dir | unlinkat(fd, name, 0 / AT_REMOVEDIR) | |
local_rename / rename / hardlink | renameat, linkat(…, 0) | |
local_exchange / rename_flags | syscall(SYS_renameat2, …, RENAME_EXCHANGE) | Linux only |
recover_path() | readlink("/proc/self/fd/N") | "they sometimes may not be available so use with care" |
from_raw_fd_checked(fd) | fstat + S_IFDIR check | the only place the crate checks that the fd is a directory |
AsPath (src/name.rs) converts &Path, &str, String, &CStr and &Entry to a CStr, returning None on an embedded NUL, which to_cstr turns into InvalidInput "nul byte in file name". An &Entry from list_dir is passed straight through — "the latter is faster as underlying system call wants CString and we keep that in entry".
What openat-ext adds
OpenatDirExt is a trait on openat::Dir (src/lib.rs). The methods worth reading for this catalog:
open_file_optional,metadata_optional,sub_dir_optional,remove_file_optional,remove_dir_optional,exists— each mapsio::ErrorKind::NotFoundtoOk(None)/Ok(false), because "Checking for nonexistent files (ENOENT) is by far the most common case of inspecting error codes in Unix."ensure_dir_all— one optimisticmkdirat; onNotFoundit recurses onPath::parent()andensure_dirs each prefix ("This is pessimistic, assuming no components exist. But we already handled the optimal case").remove_all—unlinkatfirst; onEISDIR,list_dir+sub_dirand recurse:rustpub(crate) fn remove_children(d: &openat::Dir, iter: openat::DirIter) -> io::Result<()> { for entry in iter { let entry = entry?; match d.get_file_type(&entry)? { openat::SimpleType::Dir => { let subd = d.sub_dir(&entry)?; remove_children(&subd, subd.list_dir(".")?)?; let _ = d.remove_dir_optional(&entry)?; } _ => { let _ = d.remove_file_optional(entry.file_name())?; } } } Ok(()) }get_file_typetrustsd_typewhen present and falls back tofstatat;sub_dirisO_NOFOLLOW, so a symlink-to-directory entry classifiedSymlinkis unlinked, not descended.FileWriter/new_file_writer/write_file_with— the atomic-replace pattern: trynew_unnamed_file(O_TMPFILE); if that fails,tempfile_increates.tmp<8 random alphanumerics>.tmpwithnew_file(O_EXCL), up toTEMPFILE_ATTEMPTS = 100times.complete_with(dest, f)flushes, runsfon the raw fd ("change the mode, extended attributes, or invokefchmod()"), then for theO_TMPFILEcaselink_file_ats it under a fresh random name ("there's nolinkat(LINKAT_REPLACE)yet, so we need to generate a tempfile as the penultimate step"), and finallylocal_renames it overdest, unlinking the temp name on failure.copy_file/copy_file_at— open sourceO_NOFOLLOW, write through aFileWritercreated0o600,copy_file_rangewith apread/pwritefallback onENOSYS/EXDEV/EINVAL/EPERM(latching theENOSYSin a static),fchmodto the source mode before the rename. "Symbolic links will not be followed; instead an error is returned."set_mode—lstats and silently skips symlinks, because "AT_SYMLINK_NOFOLLOWused to short-circuit toENOTSUPin older glibc versions, so we don't use it".syncfs— "does not work withO_PATHFDs, soselfcannot be directly used" — it reopens"."to get a real fd first. This is the one#[allow(unsafe_code)]in the crate.
Dimension 1 — Threat model
Not stated in either crate. openat's only nod is the "single-component path" advice in its module doc; openat-ext inherits it. In practice the adversary these crates were written against is the filesystem being moved under you — the pivot_root/mount --move note — not a hostile local user planting symlinks. Everything symlink-shaped that they do defend against (O_NOFOLLOW, AT_SYMLINK_NOFOLLOW, sub_dir refusing links) is a property of the final component only.
Dimension 2 — Resolution primitive
The kernel's openat with a caller-supplied path. Atomicity is therefore exactly the kernel's: whole-path, but with every intermediate component resolved by ordinary namei rules (symlinks followed, .. honoured, mounts crossed). There is no resolver in either crate; nothing splits a path into components; openat2 and RESOLVE_* do not appear anywhere in openat/src/.
Dimension 3 — Symlink and .. policy
Read from the code, not the docs:
..: not rejected.sub_dir("..")opens the parent of the dirfd — wherever that currently is.- Absolute paths: not rejected; the module doc says so ("the Dir file descriptor is ignored").
Dir::open("/")followed by any absolute path is the ambient filesystem. - Final-component symlinks: refused by
O_NOFOLLOWinsub_dir,_open_file(all ofopen_file,write_file,append_file,new_file,update_file), and byAT_SYMLINK_NOFOLLOWinmetadata. Not refused bylist_dir(O_DIRECTORY | O_CLOEXEConly),create_dir,remove_*,rename, orhardlink— for the last four the underlying syscalls do not follow the final component anyway. - Intermediate symlinks: followed by the kernel in every method that takes a multi-component path. Hence the doc's "open one part of a chain at a time" — the safe usage is a caller-written component walk on top of
sub_dir. openat-extnever walks components either:ensure_dir_allrecurses overPath::parent()but eachmkdiratstill receives the full prefix.
Dimension 4 — Boundaries
None. No st_dev comparison, no procfs check (the crate uses /proc/self/fd for link_file_at and recover_path without verifying it), no special handling of magic links. copy_to's EXDEV handling is about copy_file_range across filesystems, not about traversal.
Dimension 5 — Portability and fallback
The crate assumes *at and adapts only the base flags: O_PATH is Linux-only ("Some OS's (e.g., macOS) do not provide O_PATH, in which case the file descriptor is of regular type"), FreeBSD gets O_DIRECTORY, and everything else opens the directory O_RDONLY. O_TMPFILE, link_file_at, local_exchange and rename_flags are #[cfg(target_os="linux")] with always-Err stubs elsewhere. openat-ext's FileWriter falls back from O_TMPFILE to a named O_EXCL temp file on any error, and copy_to falls back from copy_file_range to a userspace loop. No feature of either crate depends on kernel version detection beyond those latches.
Dimension 6 — Failure and partiality
Raw io::Error::last_os_error() from every call; openat-ext layers ErrorKind matching on top. remove_all stops at the first error and leaves a partially deleted tree. FileWriter::complete_with cleans up its temp name if the final renameat fails, but not if the process dies between linkat and renameat (the random .tmp… name is then orphaned). local_rename_optional carries the honest note: "this isn't strictly an atomic operation, because unfortunately renameat overloads ENOENT for multiple error cases".
Dimension 7 — Enumeration and deletion
DirIter wraps fdopendir over an fd from openat(O_DIRECTORY) and yields Entry { name: CString, file_type: Option<SimpleType> } from readdir, skipping ./.. by byte comparison (src/list.rs). Readdir errors are detected by zeroing errno before the call. telldir/seekdir/ rewinddir are exposed as current_position/seek/rewind.
Deletion is openat-ext's remove_children above: fd-relative (sub_dir + list_dir(".")), d_type-classified, O_NOFOLLOW on descent, no re-verification of the opened child against the entry, no depth or fd limit. The recursion holds one Dir and one DirIter per level.
Why CoreOS built it — and left
openat-ext was rpm-ostree's openat_utils.rs promoted to a crate, and its successor is named in the first line of both its README and its crate doc (openat-ext/README.md):
This project is deprecated
Still maintained for now, but deprecated. Instead, we are focusing on cap-std and the successor to this crate is cap-std-ext.
The openat side never announced a deprecation; its last commit (2021-04-16) is a version bump, and the README still says "Status: Beta". cap-std's README explains the difference from its side: "cap-std's Dir type performs sandboxing, including for multiple-component paths. And cap-std supports symlinks as long as they remain within the sandbox, while openat doesn't support following symlinks."
Strengths
- Exactly the syscall surface, nothing hidden: a reader can map every method to one
libccall and know its flags. O_NOFOLLOWby default on every open of a file or subdirectory — the single most common symlink trap is closed without the caller asking.O_PATHbase handles cost no read permission and cannot be used to read the directory by accident.openat-extdocuments two idioms worth carrying:ENOENT → Option, and theO_TMPFILE → linkat → renameatatomic-replace ladder with its named-temp fallback.
Weaknesses
- No containment at all:
.., absolute paths and intermediate symlinks all resolve wherever the kernel takes them; the doc pushes the component walk onto the caller and provides no helper for it. list_dirlacksO_NOFOLLOW, unlike every other opener in the crate.Dir::opendoes not require a directory on Linux; onlyfrom_raw_fd_checkedchecksS_IFDIR./procis assumed, not verified, forlink_file_atandrecover_path.remove_allis best-effort: stops on first error, no re-verification, trustsd_type.- Unmaintained (
openat) and deprecated (openat-ext).
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
Thin *at wrapper, no resolver | "This crate is a thin wrapper for the underlying system calls" | Every multi-component path is unsandboxed; safety is a usage discipline |
O_PATH | O_CLOEXEC base handle on Linux | Survives pivot_root/mount --move; no read permission needed | syncfs, fdopendir etc. need a re-open of "."; not portable, so three cfg variants |
O_NOFOLLOW on sub_dir and every file open | Closes the final-component symlink swap | Legitimate final symlinks need a manual read_link; list_dir was left out |
metadata = fstatat(AT_SYMLINK_NOFOLLOW) | Consistent with the openers | No following variant; callers read_link and re-stat |
&Entry accepted as a path | The CString from readdir is reused without copying | Entry is bound to the directory it was read from only by convention |
openat-ext: O_TMPFILE → linkat → renameat | Content is complete and mode-set before it has a name | Needs /proc; two random names per write; orphaned temp on crash between the last two steps |
openat-ext: ENOENT → Ok(None) | The dominant error-inspection case in Unix code | renameat's overloaded ENOENT makes local_rename_optional non-atomic |
Deprecate in favour of cap-std / cap-std-ext | Multi-component sandboxing and in-sandbox symlink following without a hand-rolled walk | A heavier dependency; cap-std-ext re-implements the atomic-write helpers on the new Dir |
Sources
openat/README.md— "thin wrapper", beta status, pointer toopenat-extopenat/src/lib.rs—O_PATHconcept, thepivot_rootnote, the absolute-path and single-component caveatsopenat/src/dir.rs—BASE_OPEN_FLAGS, every method's flags,new_unnamed_file/link_file_at,from_raw_fd_checked, theTODO(tailhook)openat/src/list.rs—fdopendir,./..skipping,d_typemapping,errnoresetopenat/src/metadata.rs,openat/src/name.rs—Metadataover rawstat;AsPathand the NUL checkopenat-ext/README.md— the deprecation notice and the rpm-ostree originopenat-ext/src/lib.rs—OpenatDirExt,remove_children,FileWriter,tempfile_in,copy_to,set_mode,syncfs- cap-std
README.md— the "Similar crates" comparison withopenat