Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Opening and closing: openat2, openat, close.
  2. Synchronous I/O: read, write, pread, pwrite, readv, writev, preadv, pwritev.
  3. Positioning: lseek.
  4. Metadata: statx, faccessat.
  5. Directories: mkdirat, getdents64, unlinkat, renameat2, linkat, symlinkat, readlinkat, mknodat.
  6. Permissions: fchmodat, fchownat, utimensat.
  7. Special operations: ftruncate, truncate, fsync, fdatasync, sync_file_range, fallocate, copy_file_range, splice, tee, vmsplice, flock, statfs, fstatfs.
  8. fcntl (operations exposed individually, see ADR-021 convention 3).
  9. Persistent handles: name_to_handle_at, open_by_handle_at.

Cross-cutting characteristics of the family.

  1. *at variants preferred systematically. openat2 rather than open, mkdirat rather than mkdir, unlinkat rather than unlink, etc. The *at variants take a directory FD as their base, which eliminates path-resolution races.

  2. statx preferred over fstat/fstatat/lstat/stat. statx is the modern unified API that covers every case and lets you select the fields to retrieve.

  3. openat2 preferred over openat. openat2 (Linux 5.6+) allows stricter path resolution via the resolve field of OpenHow. The Air wrapper detects at runtime whether openat2 is available and falls back to openat otherwise.

  4. Universal CLOEXEC by default. Every FD opened through the Air wrapper has O_CLOEXEC enabled by default. For the rare cases where you want to disable CLOEXEC, the option must be passed explicitly.

  5. DirFd type for the *at calls. Instead of using AT_FDCWD (the kernel sentinel -100), Air exposes a DirFd type:

#![allow(unused)]
fn main() {
pub enum DirFd<'fd> {
    Cwd,
    Fd(BorrowedFd<'fd>),
}
}

Consistent with convention 1 of ADR-021.

  1. No support for relative paths without *at. The wrapper functions require an explicit base (DirFd::Cwd or an FD); no magic around the implicit cwd.

Cross-cutting types used.

  • OwnedFd, BorrowedFd: FD ownership.
  • DirFd: base for *at operations.
  • OpenFlags, Mode: open flags and permissions.
  • OpenHow: complete structure for openat2.
  • StatxResult, StatxMask: statx results 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 beyond dirfd. Prevents “../../” attacks.
  • ResolveFlags::NO_SYMLINKS: refuses to follow symbolic links.
  • ResolveFlags::NO_MAGICLINKS: refuses magic links (/proc/self/fd/X).
  • ResolveFlags::IN_ROOT: treats dirfd as 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 and EXCL was requested.
  • EISDIR: attempt to open a directory for writing without DIRECTORY.
  • ENOENT: nonexistent path.
  • ENOTDIR: a path component is not a directory.
  • EXDEV: NO_XDEV violation.
  • EAGAIN: BENEATH or NO_SYMLINKS violation (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.name is an owned copy of raw bytes (alloc::vec::Vec<u8>), without the trailing NUL. The initial design (borrowed &'buf CStr) assumed core::ffi::CStr and a borrow on the read buffer; the implementation keeps an owned copy so the entry is decoupled from the read buffer (reused across getdents64 calls). The bytes type (not OsString/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_dirents yields impl Iterator<Item = DirEntry> (owned entries, no lifetime parameter on DirEntry).

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.

CategoryMain functions
Openingopenat2, openat, close
Sync I/Oread, write, pread, pwrite, readv, writev, preadv, pwritev
Positionlseek
Metadatastatx, faccessat
Directoriesmkdirat, getdents64, unlinkat, renameat2, linkat, symlinkat, readlinkat, mknodat
Permissionsfchmodat, fchownat, utimensat
Specialftruncate, truncate, fsync, fdatasync, fallocate, copy_file_range, flock, statfs, fstatfs
fcntl~10 individual operations
Handlesname_to_handle_at, open_by_handle_at

Non-wrapped syscalls (listed in UNSUPPORTED.md):

  • open (without at): replaced by openat2/openat.
  • stat, fstat, lstat, fstatat: replaced by statx.
  • mkdir (without at): replaced by mkdirat.
  • unlink, rmdir (without at): replaced by unlinkat.
  • rename, renameat: replaced by renameat2.
  • link, symlink, readlink, mknod, access (without at): replaced by at variants.
  • chmod, chown, utimes, lchown: replaced by at variants.
  • getdents (without 64): deprecated, replaced by getdents64.
  • chdir, fchdir: Air does not expose manipulation of the implicit cwd (see convention discussion).

Types added to air-sys-types

  • DirFd
  • OpenFlags, Mode, OpenHow, ResolveFlags
  • StatxResult, StatxMask, StatxFlags, StatxTimestamp
  • AccessMode, AccessFlags
  • RenameFlags, FallocateMode
  • SeekWhence
  • DirEntry, DirEntryType, DirEntryIter
  • UtimeValue
  • FdFlags, StatusFlags, FileLock, Seals
  • FileHandle
  • NameToHandleFlags
  • IoSlice, 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).