Skip to content

rustix ​

The binding layer under cap-std and much of the Rust systems ecosystem: every *at call, openat2 with its ResolveFlags, and a directory iterator that reads from an fd — with I/O safety as the one policy it does impose.

Kindlibrary (syscall bindings)
Year2021–; version 1.1.5 at the pinned SHA (2026-09-16)
Authors / MaintainersDan Gohman (sunfishcode), Bytecode Alliance
LanguageRust (no_std-capable; alloc feature gates Dir)
LicenseApache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT
Repositorybytecodealliance/rustix at 287214b8
PlatformsLinux (linux_raw backend, no libc), every libc Unix, WASI, Redox; Windows only for net
Primitiveopenat/openat2(dirfd, path, open_how) returning OwnedFd; Dir over getdents64 or fdopendir
Source readsrc/fs/at.rs, src/fs/openat2.rs, src/fs/dir.rs, src/fs/raw_dir.rs, src/fs/special.rs, src/backend/linux_raw/fs/dir.rs, src/backend/libc/fs/dir.rs, …/linux_raw/fs/syscalls.rs, …/libc/fs/syscalls.rs, …/libc/fs/types.rs, …/linux_raw/fs/types.rs, issue #100

Overview ​

What it solves ​

Making raw syscalls usable from safe Rust without changing what they do. The README:

rustix provides efficient memory-safe and I/O-safe wrappers to POSIX-like, Unix-like, Linux, and Winsock syscall-like APIs, with configurable backends. It uses Rust references, slices, and return values instead of raw pointers, and I/O safety types instead of raw file descriptors, providing memory safety, I/O safety, and provenance.

The same paragraph sends policy elsewhere: "for higher-level and more portable APIs built on this functionality, see the cap-std, memfd, timerfd, and io-streams crates". For this catalog that sentence is the finding: rustix provides every primitive a safe traverser needs and decides nothing about symlinks, .., mounts or deletion order.

Design philosophy ​

One transformation per API, enumerated in src/lib.rs:

File descriptors are passed and returned via AsFd and OwnedFd instead of bare integers, ensuring I/O safety.

plus de-multiplexing (fcntl, ioctl), non-variadic openat, bitflags instead of bare integers, and Result instead of errno. The *at module's own framing is equally terse (at.rs): "The dirfd argument to these functions may be a file descriptor for a directory, the special value CWD, or the special value ABS."

How it works ​

The *at surface ​

at.rs is a flat list of pub fn wrappers, each #[inline], each taking dirfd: Fd where Fd: AsFd and path: P where P: path::Arg: openat, readlinkat (+ readlinkat_raw), mkdirat, linkat, unlinkat, renameat, renameat_with (= renameat2, Linux/Apple/Redox), symlinkat, statat, accessat, utimensat, chmodat, fclonefileat (Apple), mknodat, mkfifoat, chownat. path::Arg::into_with_c_str converts any string type to a CStr (stack-buffered up to SMALL_PATH_BUFFER_SIZE) and the backend does the call:

rust
pub fn openat<P: path::Arg, Fd: AsFd>(
    dirfd: Fd, path: P, oflags: OFlags, create_mode: Mode,
) -> io::Result<OwnedFd> {
    path.into_with_c_str(|path| {
        backend::fs::syscalls::openat(dirfd.as_fd(), path, oflags, create_mode)
    })
}

Two special dirfd values live in special.rs: CWD (AT_FDCWD, a BorrowedFd<'static>) and ABS, "a file descriptor which refers to no directory, which can be used as the directory argument in *at functions such as openat, which causes them to fail with BADF if the accompanying path is not absolute" — the -EBADF convention the comment attributes to lxc. ABS is the closest rustix comes to a policy knob: a caller can forbid relative resolution, but not restrict it.

openat2 ​

openat2.rs is 24 lines: openat2(dirfd, path, oflags, mode, resolve: ResolveFlags) -> io::Result<OwnedFd>. ResolveFlags carries the six kernel bits — NO_XDEV, NO_MAGICLINKS, NO_SYMLINKS, BENEATH, IN_ROOT, CACHED ("since Linux 5.12") — plus const _ = !0 so unknown future bits pass through (lr-types, libc-types). Both backends build the open_how struct themselves and issue syscall 437 directly — the libc backend declares fn openat2(...) via SYS_OPENAT2 with const SYS_OPENAT2 = 437 hard-coded (libc-sys) because no libc exposes a wrapper; the linux_raw backend adds one detail worth copying (lr-sys):

rust
// Enable support for large files, but not with `O_PATH` because
// `openat2` doesn't like those flags together.
if !flags.contains(OFlags::PATH) {
    flags |= OFlags::from_bits_retain(c::O_LARGEFILE);
}

openat2 is stricter than openat about unknown/contradictory flags, so the O_LARGEFILE the libc openat silently adds becomes an EINVAL here.

Outside Linux the same idea appears as target-gated flag constants: AtFlags::RESOLVE_BENEATH (AT_RESOLVE_BENEATH) and OFlags::RESOLVE_BENEATH (O_RESOLVE_BENEATH) on FreeBSD, OFlags::NOFOLLOW_ANY (O_NOFOLLOW_ANY) on Apple, OFlags::SYMLINK on Apple/Redox (libc-types). Nothing unifies them; a portable "beneath" open is left to cap-std.

Dir: iterating from an fd ​

src/fs/dir.rs is a re-export; the two backends differ in kind.

linux_raw (lr-dir) holds fd: OwnedFd, a Vec<u8>, and a position, and never touches libc: read() decodes linux_dirent64 records with unaligned loads, read_more() calls getdents with a buffer that grows from 32 to 1024 entries ("directories can contain more entries than we can allocate contiguous memory for, so we'll always need to cap the size"), rewind() and seek() are lseek(SEEK_SET). fd() — #[doc(alias = "dirfd")] — returns BorrowedFd<'a> borrowed from &'a self.

libc (libc-dir) holds NonNull<c::DIR> from fdopendir and calls readdir64/rewinddir/seekdir/closedir. Two constructors exist on both backends and they answer the dup question the same way:

rust
/// Take ownership of `fd` and construct a `Dir` that reads entries from
/// the given directory file descriptor.
pub fn new<Fd: Into<OwnedFd>>(fd: Fd) -> io::Result<Self>

/// Borrow `fd` and construct a `Dir` that reads entries from the given
/// directory file descriptor.
pub fn read_from<Fd: AsFd>(fd: Fd) -> io::Result<Self>

new consumes the OwnedFd — after fdopendir "the file descriptor is under the control of the system", so ownership must move. read_from does not dup; it re-opens:

Given an arbitrary OwnedFd, it's impossible to know whether the user holds a dup'd copy which could continue to modify the file description state, which would cause Undefined Behavior after our call to fdopendir. To prevent this, we obtain an independent OwnedFd.

i.e. openat(fd, ".", fcntl_getfl(fd) | CLOEXEC). A dup shares the open file description (and its offset, which readdir advances); openat(".") creates a fresh one. If . no longer exists (ENOENT, directory removed) the libc backend falls back to dup and marks any_errors so the iterator yields nothing. This is the exact question the Rust std Dir issue reached in 2026-09 (rust-std); rustix answered it in code years earlier.

RawDir (raw-dir) is the allocation-free variant on both backends: RawDir<'buf, Fd: AsFd> over a caller-supplied &mut [MaybeUninit<u8>], refilled by getdents64 (the libc backend also declares it via SYS_getdents64, libc-sys); entries borrow from the buffer, so next() cannot be Iterator::next "until GAT support" lands. A compile_fail doctest pins that an entry cannot outlive the buffer.

I/O safety ​

rustix predates and helped motivate RFC 3128; it links the RFC from lib.rs and the README. What the model buys a traverser: a Dir::fd() is a BorrowedFd<'a> tied to the Dir's lifetime, an openat(dirfd, …) takes impl AsFd so a caller cannot pass an integer that some other code may have closed and the kernel reused, and every constructor that assumes exclusive control (Dir::new) takes OwnedFd by value. The _read_from comment above is I/O safety reasoning applied to fdopendir: the type system proves nobody else closes the fd, but cannot prove nobody holds a dup, hence the re-open. Issue #100 shows the model's edge: std::process::Command lets non-CLOEXEC descriptors leak into a child, which "has the shape of an I/O safety violation", and in 2025-01-27 sunfishcode closed it — "I'm not currently pursuing setting CLOEXEC automatically … I don't think there's anything we can do about this issue, given the current state of platform APIs". Dir::_read_from therefore adds OFlags::CLOEXEC itself; openat does not.

Dimensions ​

Dimension 1 — Threat model ​

Does not apply, because rustix has none of its own: it neither follows nor refuses symlinks, and its documentation describes each function by the syscall it wraps. The one adversary it does model is in-process: code that closes or dups an fd another component relies on (I/O safety, and the _read_from re-open). Cross-process races are the caller's problem, with openat2's ResolveFlags as the tool.

Dimension 2 — Resolution primitive ​

Provides, does not decide: openat (per-component kernel walk, no restrictions), openat2 (kernel walk under ResolveFlags, atomic with respect to the whole path — the only whole-path atomic primitive in the catalog that a caller can reach from safe code), and the rest of the *at family for stat/unlink/rename/mkdir relative to a dirfd. The ABS sentinel lets a caller assert a path is absolute at the syscall.

Provides the flags, decides nothing: OFlags::NOFOLLOW, AtFlags::SYMLINK_NOFOLLOW, ResolveFlags::NO_SYMLINKS/BENEATH/IN_ROOT on Linux, AT_RESOLVE_BENEATH/O_RESOLVE_BENEATH on FreeBSD, O_NOFOLLOW_ANY on Apple. .. is passed to the kernel verbatim; there is no user-space path normalisation anywhere in src/fs/.

Dimension 4 — Boundaries ​

ResolveFlags::NO_XDEV and NO_MAGICLINKS are exposed with the kernel's semantics and no wrapper. Dir::statfs/statvfs return the filesystem of the open fd, which is what an st_dev-style check needs. No Windows filesystem support at all — "the rest of the APIs do not support Windows" (README) — so reparse points, ADS and device names do not arise.

Dimension 5 — Portability and fallback ​

Compile-time by target and backend. openat2 exists only under linux_kernel/linux_raw_dep; a caller on an older kernel receives Errno::NOSYS from the syscall and must fall back itself — rustix does not emulate. RESOLVE_BENEATH on FreeBSD and NOFOLLOW_ANY on Apple are separate constants on separate types, so a portable caller writes the cfg ladder. Dir needs alloc; RawDir does not. The libc backend reaches for raw syscall() (SYS_OPENAT2 = 437, SYS_getdents64) exactly where libc has no wrapper, so the presence of a libc does not lose openat2 or fd-based enumeration.

Dimension 6 — Failure and partiality ​

Every function returns io::Result<_> with io::Errno; io::retry_on_intr wraps getdents and lseek in the linux_raw Dir. Both Dir backends latch any_errors — after one failed read() the iterator returns None rather than looping on a broken stream — and the linux_raw test dir_iterator_handles_io_errors proves it by dup2-ing a file over the Dir's fd mid-iteration. read_more maps Errno::NOENT from getdents (directory deleted under the iterator) to end-of-stream. No multi-step operation exists, so there is no partial state to report.

Dimension 7 — Enumeration and deletion ​

Enumeration: Dir (fd-owning, getdents64 or fdopendir) and RawDir (borrowed buffer, getdents64), both yielding d_type, d_ino, d_off and the name as CStr. Deletion: unlinkat(dirfd, path, AtFlags::REMOVEDIR) per entry. There is no remove_dir_all; composing Dir + openat(…, NOFOLLOW | DIRECTORY) + unlinkat into the shape rust-std ships is left to the caller (cap-std does exactly this).

Strengths ​

  • Complete primitive coverage: every *at call, openat2 with all six ResolveFlags, FreeBSD RESOLVE_BENEATH, Apple NOFOLLOW_ANY, getdents64 — reachable from safe code, no libc required on Linux.
  • The fdopendir ownership problem is solved correctly: new consumes, read_from re-opens via openat(".") instead of dup, with the reasoning written down.
  • I/O safety throughout: BorrowedFd from Dir::fd(), OwnedFd from every open; a dirfd cannot be closed out from under a borrower.
  • RawDir gives fd-relative enumeration with zero heap allocation and borrowed names — the shape a @nogc D walker wants.
  • O_LARGEFILE ∧ O_PATH under openat2 is a documented trap, handled.

Weaknesses ​

  • No policy, by design — a caller gets no help choosing between RESOLVE_BENEATH, RESOLVE_IN_ROOT, NOFOLLOW, or the user-space walk when openat2 is ENOSYS.
  • No cross-platform "beneath" — FreeBSD's and Linux's flags live on different types and Apple has only NOFOLLOW_ANY.
  • No Windows filesystem backend, so the NtCreateFile-relative half of a portable design must come from elsewhere (rust-std or cap-std).
  • openat does not add CLOEXEC (issue #100, closed won't-fix), so a forgotten flag leaks a dirfd into every child process.
  • Dir requires alloc and copies each name into a CString.

Key design decisions and trade-offs ​

DecisionRationaleTrade-off
One wrapper per syscall, semantics unchangedA binding must not surprise; policy belongs in cap-std and friendsEvery safety decision is repeated by every consumer
OwnedFd/BorrowedFd everywhere (RFC 3128)Closing or reusing an fd out from under a holder becomes a type errorCannot express "no other dup exists", hence the openat(".") re-open
Dir::read_from re-opens rather than dupsfdopendir makes the shared file description's other uses undefinedOne extra syscall; ENOENT on a removed directory needs a dup fallback
linux_raw Dir on getdents64, no libc DIR*No ownership handoff, no readdir global state, seek on 32-bit worksHand-decoded linux_dirent64 with unaligned loads
openat2 via raw syscall 437 in both backendsNo libc wrapper exists; the open_how struct is stable ABIENOSYS is the caller's fallback signal, not rustix's
ResolveFlags carries const _ = !0Forward-compatible with kernel bits rustix does not know yetNo compile-time rejection of a nonsense bit
ABS sentinel (-EBADF as dirfd)Lets a caller require an absolute path at the syscall, matching lxc's conventionUndocumented kernel convention, relied on as stable
CLOEXEC not added automaticallyPlatforms cannot set it atomically everywhere; changing openat semantics is surprisingDescriptor leaks to children remain a caller footgun

Sources ​