Layer 0 Spec — fs Family
Technical specification — Version 1.0
Family overview
The air-sys-syscall::fs module exposes the kernel primitives for filesystem management: opening and closing files, reading and writing, metadata, directory operations, path manipulation, special operations (truncate, fsync, fallocate, copy_file_range, splice, etc.).
It is the largest layer 0 family by volume, with roughly 35 wrapped syscalls.
Family scope.
Operation categories:
- Opening and closing:
openat2,openat,close. - Synchronous I/O:
read,write,pread,pwrite,readv,writev,preadv,pwritev. - Positioning:
lseek. - Metadata:
statx,faccessat. - Directories:
mkdirat,getdents64,unlinkat,renameat2,linkat,symlinkat,readlinkat,mknodat. - Permissions:
fchmodat,fchownat,utimensat. - Special operations:
ftruncate,truncate,fsync,fdatasync,sync_file_range,fallocate,copy_file_range,splice,tee,vmsplice,flock,statfs,fstatfs. - fcntl (operations exposed individually, see ADR-021 convention 3).
- Persistent handles:
name_to_handle_at,open_by_handle_at.
Cross-cutting characteristics of the family.
-
*atvariants preferred systematically.openat2rather thanopen,mkdiratrather thanmkdir,unlinkatrather thanunlink, etc. The*atvariants take a directory FD as their base, which eliminates path-resolution races. -
statxpreferred overfstat/fstatat/lstat/stat.statxis the modern unified API that covers every case and lets you select the fields to retrieve. -
openat2preferred overopenat.openat2(Linux 5.6+) allows stricter path resolution via theresolvefield ofOpenHow. The Air wrapper detects at runtime whetheropenat2is available and falls back toopenatotherwise. -
Universal CLOEXEC by default. Every FD opened through the Air wrapper has
O_CLOEXECenabled by default. For the rare cases where you want to disable CLOEXEC, the option must be passed explicitly. -
DirFdtype for the*atcalls. Instead of usingAT_FDCWD(the kernel sentinel-100), Air exposes aDirFdtype:
#![allow(unused)]
fn main() {
pub enum DirFd<'fd> {
Cwd,
Fd(BorrowedFd<'fd>),
}
}
Consistent with convention 1 of ADR-021.
- No support for relative paths without
*at. The wrapper functions require an explicit base (DirFd::Cwdor an FD); no magic around the implicit cwd.
Cross-cutting types used.
OwnedFd,BorrowedFd: FD ownership.DirFd: base for*atoperations.OpenFlags,Mode: open flags and permissions.OpenHow: complete structure foropenat2.StatxResult,StatxMask:statxresults and masks.Errno: errors.
Subsection 1: Opening and closing
openat2
Signature.
#![allow(unused)]
fn main() {
pub fn openat2(
dirfd: DirFd<'_>,
path: &CStr,
how: OpenHow,
) -> Result<OwnedFd, Errno>;
#[derive(Debug, Clone, Copy, Default)]
pub struct OpenHow {
pub flags: OpenFlags,
pub mode: Mode,
pub resolve: ResolveFlags,
}
bitflags! {
pub struct OpenFlags: u64 {
const RDONLY = 0;
const WRONLY = 1;
const RDWR = 2;
const CREAT = 0o100;
const EXCL = 0o200;
const NOCTTY = 0o400;
const TRUNC = 0o1000;
const APPEND = 0o2000;
const NONBLOCK = 0o4000;
const DSYNC = 0o10000;
const ASYNC = 0o20000;
const DIRECT = 0o40000;
const LARGEFILE = 0o100000;
const DIRECTORY = 0o200000;
const NOFOLLOW = 0o400000;
const NOATIME = 0o1000000;
const CLOEXEC = 0o2000000;
const PATH = 0o10000000;
const TMPFILE = 0o20200000;
}
}
bitflags! {
pub struct ResolveFlags: u64 {
const NO_XDEV = 0x01;
const NO_MAGICLINKS = 0x02;
const NO_SYMLINKS = 0x04;
const BENEATH = 0x08;
const IN_ROOT = 0x10;
const CACHED = 0x20;
}
}
pub type Mode = u32;
}
Underlying syscall. openat2 (x86_64 #437, ARM64 #437). Available since Linux 5.6 (March 2020).
Preconditions.
path is a C-compatible string (NUL-terminated). For opens against a direct FD (no relative path), pass c"".
The Air wrapper systematically adds OpenFlags::CLOEXEC to the flags, unless the caller has explicitly used a raw mode that disables this discipline.
Behavior.
Opens a file (or a directory with DIRECTORY, or a path-only FD with PATH). Path resolution is controlled by resolve:
ResolveFlags::BENEATH: refuses to resolve beyonddirfd. Prevents “../../” attacks.ResolveFlags::NO_SYMLINKS: refuses to follow symbolic links.ResolveFlags::NO_MAGICLINKS: refuses magic links (/proc/self/fd/X).ResolveFlags::IN_ROOT: treatsdirfdas a new root.
These flags enable a strict resolution discipline, useful for sandboxed contexts.
Recommended pattern.
#![allow(unused)]
fn main() {
let how = OpenHow {
flags: OpenFlags::RDONLY,
mode: 0,
resolve: ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS,
};
let fd = openat2(DirFd::Cwd, c"./config.toml", how)?;
}
Errors.
EACCES: insufficient permissions.EEXIST: file exists andEXCLwas requested.EISDIR: attempt to open a directory for writing withoutDIRECTORY.ENOENT: nonexistent path.ENOTDIR: a path component is not a directory.EXDEV:NO_XDEVviolation.EAGAIN:BENEATHorNO_SYMLINKSviolation (resolution refused).
Performance. ~5-20 µs depending on path complexity.
Tests.
- Read-open test: existing file, verify valid FD and CLOEXEC.
- ENOENT test: nonexistent path.
- BENEATH test: attempt to traverse upward with
../, verify EAGAIN.
openat (fallback)
Signature.
#![allow(unused)]
fn main() {
pub fn openat(
dirfd: DirFd<'_>,
path: &CStr,
flags: OpenFlags,
mode: Mode,
) -> Result<OwnedFd, Errno>;
}
Behavior.
Version without resolve. For kernels that do not support openat2 (< 5.6). The Air wrapper uses openat automatically if openat2 returns ENOSYS.
close
Signature.
#![allow(unused)]
fn main() {
pub fn close(fd: OwnedFd) -> Result<(), Errno>;
}
Behavior.
Closes an FD. The wrapper consumes the OwnedFd (takes ownership), guaranteeing that it cannot be reused after close.
Note. The automatic Drop of OwnedFd calls close silently (ignoring errors). The explicit close function lets you recover a potential error (rare but possible: EIO, EINTR on some systems).
Subsection 2: Synchronous I/O
read, write
Signatures.
#![allow(unused)]
fn main() {
pub fn read(fd: BorrowedFd<'_>, buf: &mut [u8]) -> Result<usize, Errno>;
pub fn write(fd: BorrowedFd<'_>, buf: &[u8]) -> Result<usize, Errno>;
}
Underlying syscalls. read (x86_64 #0, ARM64 #63), write (x86_64 #1, ARM64 #64).
Behavior.
Sequential read/write on the FD. Returns the number of bytes actually read/written, which may be fewer than buf.len() (partial read for sockets, pipes, files at EOF).
Common errors.
EAGAIN: non-blocking FD and no data available (or pipe full).EINTR: interrupted by a signal (see ADR-021 convention 2).EIO: low-level I/O error.EBADF: invalid FD.
pread, pwrite
Signatures.
#![allow(unused)]
fn main() {
pub fn pread(fd: BorrowedFd<'_>, buf: &mut [u8], offset: u64) -> Result<usize, Errno>;
pub fn pwrite(fd: BorrowedFd<'_>, buf: &[u8], offset: u64) -> Result<usize, Errno>;
}
Behavior.
Positioned variants: read/write at a specific offset without modifying the FD’s current position. Safe for concurrent use from multiple threads.
readv, writev, preadv, pwritev
Signatures.
#![allow(unused)]
fn main() {
pub fn readv(fd: BorrowedFd<'_>, iov: &mut [IoSliceMut<'_>]) -> Result<usize, Errno>;
pub fn writev(fd: BorrowedFd<'_>, iov: &[IoSlice<'_>]) -> Result<usize, Errno>;
pub fn preadv(fd: BorrowedFd<'_>, iov: &mut [IoSliceMut<'_>], offset: u64) -> Result<usize, Errno>;
pub fn pwritev(fd: BorrowedFd<'_>, iov: &[IoSlice<'_>], offset: u64) -> Result<usize, Errno>;
}
Behavior.
Scatter-gather variants: read/write to/from multiple buffers in a single syscall. Useful for protocols with header + payload.
Subsection 3: Positioning
lseek
Signature.
#![allow(unused)]
fn main() {
pub fn lseek(fd: BorrowedFd<'_>, offset: i64, whence: SeekWhence) -> Result<u64, Errno>;
pub enum SeekWhence {
Set,
Current,
End,
Data, // Linux-specific: next data after offset
Hole, // Linux-specific: next hole after offset
}
}
Behavior.
Modifies the current position of a seekable FD. Returns the new absolute position.
Subsection 4: Metadata
statx
Signature.
#![allow(unused)]
fn main() {
pub fn statx(
dirfd: DirFd<'_>,
path: &CStr,
flags: StatxFlags,
mask: StatxMask,
) -> Result<StatxResult, Errno>;
bitflags! {
pub struct StatxFlags: u32 {
const EMPTY_PATH = 0x1000;
const NO_AUTOMOUNT = 0x800;
const SYMLINK_NOFOLLOW = 0x100;
}
}
bitflags! {
pub struct StatxMask: u32 {
const TYPE = 0x0001;
const MODE = 0x0002;
const NLINK = 0x0004;
const UID = 0x0008;
const GID = 0x0010;
const ATIME = 0x0020;
const MTIME = 0x0040;
const CTIME = 0x0080;
const INO = 0x0100;
const SIZE = 0x0200;
const BLOCKS = 0x0400;
const BTIME = 0x0800;
const MNT_ID = 0x1000;
const ALL = 0xfff;
}
}
pub struct StatxResult {
pub mask: StatxMask,
pub blksize: u32,
pub attributes: u64,
pub nlink: u32,
pub uid: u32,
pub gid: u32,
pub mode: u16,
pub ino: u64,
pub size: u64,
pub blocks: u64,
pub atime: StatxTimestamp,
pub btime: StatxTimestamp,
pub ctime: StatxTimestamp,
pub mtime: StatxTimestamp,
pub rdev_major: u32,
pub rdev_minor: u32,
pub dev_major: u32,
pub dev_minor: u32,
pub mount_id: u64,
}
pub struct StatxTimestamp {
pub seconds: i64,
pub nanoseconds: u32,
}
}
Underlying syscall. statx (x86_64 #332, ARM64 #291). Available since Linux 4.11.
Behavior.
Reads a file’s metadata. mask selects the desired fields (performance: the kernel reads only what is requested). The result’s mask field indicates the fields actually filled in (it may be a subset of the requested mask if some information is not available).
Pattern.
#![allow(unused)]
fn main() {
let result = statx(
DirFd::Cwd,
c"./config.toml",
StatxFlags::empty(),
StatxMask::SIZE | StatxMask::MTIME,
)?;
if result.mask.contains(StatxMask::SIZE) {
println!("Size: {}", result.size);
}
}
faccessat
Signature.
#![allow(unused)]
fn main() {
pub fn faccessat(
dirfd: DirFd<'_>,
path: &CStr,
mode: AccessMode,
flags: AccessFlags,
) -> Result<(), Errno>;
bitflags! {
pub struct AccessMode: u32 {
const F_OK = 0;
const R_OK = 4;
const W_OK = 2;
const X_OK = 1;
}
}
bitflags! {
pub struct AccessFlags: i32 {
const EACCESS = 0x200;
const SYMLINK_NOFOLLOW = 0x100;
}
}
}
Behavior.
Tests access permissions on a file without opening it. Subject to races (the result may be stale by the time you act on it): prefer to open directly and handle the error.
Subsection 5: Directories
mkdirat
#![allow(unused)]
fn main() {
pub fn mkdirat(dirfd: DirFd<'_>, path: &CStr, mode: Mode) -> Result<(), Errno>;
}
getdents64
#![allow(unused)]
fn main() {
pub fn getdents64(fd: BorrowedFd<'_>, buf: &mut [u8]) -> Result<usize, Errno>;
pub struct DirEntry {
pub inode: u64,
pub offset: i64,
pub file_type: DirEntryType,
pub name: Vec<u8>, // raw bytes (without NUL) — see std-free note below
}
pub enum DirEntryType {
Unknown,
Fifo,
Character,
Directory,
Block,
Regular,
Symlink,
Socket,
}
pub struct DirEntryIter<'buf> { /* ... */ }
impl<'buf> Iterator for DirEntryIter<'buf> {
type Item = DirEntry<'buf>;
// ...
}
pub fn parse_dirents<'buf>(buf: &'buf [u8], length: usize) -> DirEntryIter<'buf>;
}
Behavior.
Reads the entries of a directory opened with DIRECTORY. The kernel binary format is complex; the wrapper provides a typed iterator.
std-free note (ADR-048, re-seal layer-0-v1.6).DirEntry.nameis an owned copy of raw bytes (alloc::vec::Vec<u8>), without the trailingNUL. The initial design (borrowed&'buf CStr) assumedcore::ffi::CStrand a borrow on the read buffer; the implementation keeps an owned copy so the entry is decoupled from the read buffer (reused acrossgetdents64calls). The bytes type (notOsString/String) honors the “paths = bytes” doctrine: on Unix a file name is an opaque byte string, possibly non-UTF-8 (Principle 3, no encoding assumption).parse_direntsyieldsimpl Iterator<Item = DirEntry>(owned entries, no lifetime parameter onDirEntry).
unlinkat, renameat2, linkat, symlinkat, readlinkat, mknodat
Conventional signatures, following the *at convention. Standard details.
renameat2
#![allow(unused)]
fn main() {
pub fn renameat2(
old_dirfd: DirFd<'_>,
old_path: &CStr,
new_dirfd: DirFd<'_>,
new_path: &CStr,
flags: RenameFlags,
) -> Result<(), Errno>;
bitflags! {
pub struct RenameFlags: u32 {
const NOREPLACE = 1;
const EXCHANGE = 2;
const WHITEOUT = 4;
}
}
}
RenameFlags::EXCHANGE is particularly useful for atomic updates (swapping two files atomically).
Subsection 6: Permissions
fchmodat, fchownat, utimensat
Conventional signatures. utimensat can take UTIME_NOW or UTIME_OMIT as special values for timestamps, expressed via a typed enum:
#![allow(unused)]
fn main() {
pub enum UtimeValue {
Now,
Omit,
Time(StatxTimestamp),
}
}
Subsection 7: Special operations
ftruncate, truncate, fsync, fdatasync
Standard signatures.
fallocate
#![allow(unused)]
fn main() {
pub fn fallocate(
fd: BorrowedFd<'_>,
mode: FallocateMode,
offset: i64,
length: i64,
) -> Result<(), Errno>;
bitflags! {
pub struct FallocateMode: i32 {
const KEEP_SIZE = 0x01;
const PUNCH_HOLE = 0x02;
const COLLAPSE_RANGE = 0x08;
const ZERO_RANGE = 0x10;
const INSERT_RANGE = 0x20;
const UNSHARE_RANGE = 0x40;
}
}
}
copy_file_range
#![allow(unused)]
fn main() {
pub fn copy_file_range(
fd_in: BorrowedFd<'_>,
off_in: Option<&mut u64>,
fd_out: BorrowedFd<'_>,
off_out: Option<&mut u64>,
length: usize,
flags: u32,
) -> Result<usize, Errno>;
}
Zero-copy copy between files (on the same filesystem when possible).
splice, tee, vmsplice
Covered in the ipc family but referenced here because they are also FS operations.
flock, statfs, fstatfs
Standard signatures.
Subsection 8: fcntl (individual operations)
Like prctl, fcntl is a multiplexed syscall. Air exposes it operation by operation, in keeping with convention 3 of ADR-021:
#![allow(unused)]
fn main() {
// F_DUPFD, F_DUPFD_CLOEXEC
pub fn dup_fd(fd: BorrowedFd<'_>, min_fd: RawFd) -> Result<OwnedFd, Errno>;
// F_GETFD, F_SETFD
pub fn get_fd_flags(fd: BorrowedFd<'_>) -> Result<FdFlags, Errno>;
pub fn set_fd_flags(fd: BorrowedFd<'_>, flags: FdFlags) -> Result<(), Errno>;
// F_GETFL, F_SETFL
pub fn get_status_flags(fd: BorrowedFd<'_>) -> Result<StatusFlags, Errno>;
pub fn set_status_flags(fd: BorrowedFd<'_>, flags: StatusFlags) -> Result<(), Errno>;
// F_SETPIPE_SZ, F_GETPIPE_SZ
pub fn set_pipe_size(fd: BorrowedFd<'_>, size: usize) -> Result<usize, Errno>;
pub fn get_pipe_size(fd: BorrowedFd<'_>) -> Result<usize, Errno>;
// F_SETLK, F_SETLKW, F_GETLK
pub fn try_lock(fd: BorrowedFd<'_>, lock: &FileLock) -> Result<bool, Errno>;
pub fn lock(fd: BorrowedFd<'_>, lock: &FileLock) -> Result<(), Errno>;
// F_ADD_SEALS, F_GET_SEALS (for memfd)
pub fn add_seals(fd: BorrowedFd<'_>, seals: Seals) -> Result<(), Errno>;
pub fn get_seals(fd: BorrowedFd<'_>) -> Result<Seals, Errno>;
// ... other operations as Air needs them
}
Subsection 9: Persistent handles
name_to_handle_at, open_by_handle_at
#![allow(unused)]
fn main() {
pub fn name_to_handle_at(
dirfd: DirFd<'_>,
path: &CStr,
flags: NameToHandleFlags,
) -> Result<FileHandle, Errno>;
pub fn open_by_handle_at(
mount_fd: BorrowedFd<'_>,
handle: &FileHandle,
flags: OpenFlags,
) -> Result<OwnedFd, Errno>;
}
Allows referencing a file by an opaque persistent handle (even after a path change). Advanced use case.
fs family summary
Exposed functions: ~35 main syscalls + individual fcntl operations.
| Category | Main functions |
|---|---|
| Opening | openat2, openat, close |
| Sync I/O | read, write, pread, pwrite, readv, writev, preadv, pwritev |
| Position | lseek |
| Metadata | statx, faccessat |
| Directories | mkdirat, getdents64, unlinkat, renameat2, linkat, symlinkat, readlinkat, mknodat |
| Permissions | fchmodat, fchownat, utimensat |
| Special | ftruncate, truncate, fsync, fdatasync, fallocate, copy_file_range, flock, statfs, fstatfs |
| fcntl | ~10 individual operations |
| Handles | name_to_handle_at, open_by_handle_at |
Non-wrapped syscalls (listed in UNSUPPORTED.md):
open(withoutat): replaced byopenat2/openat.stat,fstat,lstat,fstatat: replaced bystatx.mkdir(withoutat): replaced bymkdirat.unlink,rmdir(withoutat): replaced byunlinkat.rename,renameat: replaced byrenameat2.link,symlink,readlink,mknod,access(withoutat): replaced byatvariants.chmod,chown,utimes,lchown: replaced byatvariants.getdents(without 64): deprecated, replaced bygetdents64.chdir,fchdir: Air does not expose manipulation of the implicit cwd (see convention discussion).
Types added to air-sys-types
DirFdOpenFlags,Mode,OpenHow,ResolveFlagsStatxResult,StatxMask,StatxFlags,StatxTimestampAccessMode,AccessFlagsRenameFlags,FallocateModeSeekWhenceDirEntry,DirEntryType,DirEntryIterUtimeValueFdFlags,StatusFlags,FileLock,SealsFileHandleNameToHandleFlagsIoSlice,IoSliceMut
That is ~25 types for this family, bringing the air-sys-types total to ~45 types after the process and fs families.
Underlying decisions that emerged in the fs family
1. Exclusive *at variants.
No wrapper for the non-at variants. Every filesystem operation takes an explicit base (DirFd::Cwd or FD). Consistent with security and convention 1 of ADR-021.
2. statx as the sole metadata function.
stat, fstat, lstat, fstatat are not wrapped. statx covers them all, and is more performant thanks to the mask.
3. openat2 preferred, transparent openat fallback.
The wrapper switches automatically if openat2 returns ENOSYS. No friction on the caller’s side.
4. Universal CLOEXEC.
Every FD opened through Air has CLOEXEC. For the rare exceptions (intentional inheritance across a fork), a raw API exists.
5. No cwd manipulation.
chdir/fchdir not exposed. The cwd is a process-global variable, a source of bugs in concurrent code. Air prefers that relative paths start from an explicit DirFd.
Document license: MPL 2.0
Status: Technical specification of the air-sys-syscall::fs module (layer 0).