Darwin / XNU
The *at family arrived in macOS 10.10 with no O_PATH and no openat2; what XNU added instead is a set of flag bits — O_NOFOLLOW_ANY, AT_SYMLINK_NOFOLLOW_ANY, and (in the current header) O_RESOLVE_BENEATH / AT_RESOLVE_BENEATH — spread across open, fstatat, renameatx_np, clonefileat and getattrlistbulk, so the same two ideas recur under five spellings.
| Kind | kernel mechanism |
| Year | openat family: macOS 10.10 (2014); renameatx_np / clonefileat: 10.12 (2016); O_NOFOLLOW_ANY, *_RESOLVE_BENEATH: present in the 2025 header, see Unverified |
| Maintainers | Apple (XNU, apple-oss-distributions) |
| Language | C |
| License | APSL 2.0 |
| Repository | apple-oss-distributions/xnu at tag xnu-12377.121.6 |
| Platforms | macOS, iOS, tvOS, watchOS, visionOS |
| Primitive | openat(dirfd, …) with O_NOFOLLOW per component, or whole-path O_NOFOLLOW_ANY; AT_RESOLVE_BENEATH on the *at calls |
| Source read | bsd/sys/fcntl.h, bsd/sys/stdio.h, bsd/sys/clonefile.h, bsd/sys/attr.h, bsd/kern/syscalls.master; the consumers listed under Sources |
Overview
What it solves
The same problem openat2 solves on Linux — resolving a path relative to an already-open directory so that a rename or symlink swap between two steps cannot redirect the walk — but with the policy expressed as bits on the existing calls rather than as a new syscall with a how struct. The header's own one-line explanations are the whole spec (fcntl.h):
#define O_NOFOLLOW 0x00000100 /* don't follow symlinks */
#define O_SYMLINK 0x00200000 /* allow open of a symlink */
#define O_RESOLVE_BENEATH 0x00001000 /* only for open(2), same value as FMARK */
#if __DARWIN_C_LEVEL >= __DARWIN_C_FULL
#define O_NOFOLLOW_ANY 0x20000000 /* no symlinks allowed in path */
#endif
#define AT_SYMLINK_NOFOLLOW 0x0020 /* Act on the symlink itself not the target */
#define AT_REMOVEDIR 0x0080 /* Path refers to directory */
#define AT_SYMLINK_NOFOLLOW_ANY 0x0800 /* Path should not contain any symlinks */
#define AT_RESOLVE_BENEATH 0x2000 /* Path must reside in the hierarchy beneath the starting directory */O_PATH, O_EMPTY_PATH and RESOLVE_NO_XDEV have no counterpart: the strings O_PATH and O_EMPTY_PATH do not appear in the header at all.
Design philosophy
Every *at call is an ordinary libc entry point with a fixed availability floor — openat is declared __OSX_AVAILABLE_STARTING(__MAC_10_10, __IPHONE_8_0) (fcntl.h) and sits in the syscall table as 463 … { int openat(int fd, user_addr_t path, int flags, int mode) NO_SYSCALL_STUB; } (syscalls.master). The Darwin-specific extensions are non-POSIX (_np) and gated to 10.12: renameatx_np and clonefileat are both __OSX_AVAILABLE(10.12) __IOS_AVAILABLE(10.0) (stdio.h, clonefile.h). Consumers therefore treat the platform as "openat is always there, everything richer is weak-linked" — see Dimension 5.
How it works
The *at surface, by syscall number (syscalls.master):
| Syscall | Number | Note |
|---|---|---|
openat / openat_nocancel | 463/464 | the base primitive |
renameat | 465 | 10.10 |
faccessat, fchmodat, fchownat | 466–468 | |
fstatat, fstatat64 | 469/470 | |
linkat, unlinkat, readlinkat, symlinkat, mkdirat | 471–475 | |
getattrlistat | 476 | attrlist-based stat relative to a dirfd |
getattrlistbulk | 461 | bulk directory enumeration on a dirfd (int dirfd, struct attrlist *alist, …) |
clonefileat / fclonefileat | 462/517 | APFS clone relative to dirfds |
renameatx_np | 488 | renameat plus a u_int flags word |
getdirentries64 | 344 | what fdopendir/readdir sit on |
freadlink | 551 | readlink on an O_SYMLINK fd |
openbyid_np, fsgetpath | 479/427 | open by (fsid, objid); path back from an id |
The three extension headers each carry the same two policy bits under a local name. renameatx_np (stdio.h):
#define RENAME_SECLUDE 0x00000001
#define RENAME_SWAP 0x00000002
#define RENAME_EXCL 0x00000004
#define RENAME_RESERVED1 0x00000008
#define RENAME_NOFOLLOW_ANY 0x00000010
#define RENAME_RESOLVE_BENEATH 0x00000020clonefileat (clonefile.h) has CLONE_NOFOLLOW (0x0001), CLONE_NOFOLLOW_ANY (0x0008, "Don't follow any symbolic links in the path") and CLONE_RESOLVE_BENEATH (0x0010, "path must reside in the hierarchy beneath the starting directory"). getattrlistbulk / getattrlistat (attr.h) take FSOPT_NOFOLLOW (0x1), FSOPT_NOFOLLOW_ANY (0x800), FSOPT_RESOLVE_BENEATH (0x1000) and FSOPT_UNIQUE (0x2000), and require ATTR_BULK_REQUIRED (ATTR_CMN_NAME | ATTR_CMN_RETURNED_ATTRS) so a per-entry ATTR_CMN_ERROR (0x20000000) can be reported inline instead of failing the whole batch.
Dimension 1 — Threat model
The in-scope adversary is whoever can write to a directory the walk passes through: a symlink swap or a directory rename between two components. What the surveyed consumers explicitly leave out of scope is instructive. Go's os.Root documents that on Unix, Chmod, Chown and Chtimes "are vulnerable to a race condition. If the target of the operation is changed from a regular file to a symlink while the operation is in progress, the operation may be performed on the link rather than the link target" — because fchmodat with AT_SYMLINK_NOFOLLOW acts on the link and without it follows it; there is no "fail if symlink" mode. The comment concludes: "We may act on the wrong file, but that file will be contained within the root" (root_unix.go). Mount crossings, /proc-style special files and device nodes are documented as not prohibited (root.go).
Dimension 2 — Resolution primitive
Two atomicity levels are available. With plain openat + O_NOFOLLOW the atomicity is per component: Go opens each intermediate with O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY and, on ELOOP/ENOTDIR, reads the link itself with readlinkat and re-injects its target into the component list (root_unix.go rootOpenDir, checkSymlink). Go binds these as libSystem trampolines — //go:cgo_import_dynamic libc_readlinkat readlinkat "/usr/lib/libSystem.B.dylib" (at_darwin.go) — never raw syscall numbers, so the kernel ABI is not a contract Go relies on.
O_NOFOLLOW_ANY and AT_RESOLVE_BENEATH lift that to whole-path atomicity inside one call. The vendored golang.org/x/sys carries O_NOFOLLOW_ANY = 0x20000000 and RENAME_NOFOLLOW_ANY = 0x10 (zerrors_darwin_arm64.go), and rustix exposes OFlags::NOFOLLOW_ANY under #[cfg(apple)] (types.rs) — but neither os.Root nor std::fs uses them at the surveyed revisions; every consumer walks per component. (The os.Root proposal discussed a portable O_NOFOLLOW_ANY "emulated with successive openat calls with O_NOFOLLOW" on platforms lacking it; see go-os-root.md.)
Dimension 3 — Symlink and .. policy
Symlinks are followed but confined, by user-space re-resolution of the link text. .. is handled without ever asking the kernel: Go's walker deletes the preceding component lexically and restarts from the root fd, because "We can't openat(dir, "..") to move up to the parent directory, because dir may have moved since we opened it" (root_openat.go). cap-std keeps the stack of parent handles instead and pops one per .., so "even if the directory is concurrently moved, we don't have to worry about .. leaving the sandbox" (manually/open.rs).
O_SYMLINK is the Darwin-specific inverse of O_NOFOLLOW: it opens the link itself as an fd (freadlink then reads it), which is how a link can be fstat-ed and read by handle rather than by name.
Dimension 4 — Boundaries
Nothing in fcntl.h restricts mount crossing; the only boundary flags are the *_RESOLVE_BENEATH family, which bound the walk to a directory, not to a device. Device identity comes from ATTR_CMN_DEVID (0x2) / ATTR_CMN_FSID (0x4) / ATTR_CMN_FILEID (0x02000000) in attr.h, or from st_dev/st_ino in fstatat. cap-std reads st_birthtime on macOS where Linux has no creation time (metadata_ext.rs) and resolves a handle back to a path with getpath (F_GETPATH) — the Darwin answer to /proc/self/fd/N (darwin/fs/file_path.rs).
Dimension 5 — Portability and fallback
The floor is 10.10, so openat is unconditional; the two 10.12 extensions are weak-linked. rustix's renameat2 on Apple platforms does weak! { fn renameatx_np(…) }, calls it if present, and otherwise falls back to plain rename — but only when flags are empty and both dirfds are AT_FDCWD, returning ENOSYS in every other case rather than silently dropping the RENAME_EXCL/RENAME_SWAP semantics (syscalls.rs). cap-std notes "MacOS prior to 10.12 don't support fclonefileat" and caches the availability in a global after the first ENOSYS-style failure (copy_impl.rs); Rust std's copy on target_vendor = "apple" tries fclonefileat first and falls through to fcopyfile on ENOTSUP | EEXIST | EXDEV (unix.rs).
What is lost without O_PATH: cap-std's target_o_path() returns OFlags::empty() for macOS/iOS (dir_utils.rs), so every intermediate directory in a walk is opened O_RDONLY|O_DIRECTORY — a real read handle, which fails on a directory the caller may traverse but not list. rsync is likewise skipped on Apple targets (oflags.rs).
Dimension 6 — Failure and partiality
Go maps ENOTSUP/EOPNOTSUPP from an intermediate open to ENOTDIR, and converts ELOOP to EEXIST for O_CREATE|O_EXCL on a dangling link so it matches ErrExists (root_unix.go). A ..-triggered restart is bounded: maxSteps = 255, maxRestarts = 8, and the walk fails with ENAMETOOLONG only when both are exceeded (root_openat.go). Rust's remove_dir_all treats ENOENT on a child as "already deleted by a concurrent caller" and continues (unix.rs).
Dimension 7 — Enumeration and deletion
Rust's remove_dir_all_recursive is the canonical shape: openat(parent, name, O_CLOEXEC|O_RDONLY|O_NOFOLLOW|O_DIRECTORY), hand the fd to fdopendir, recurse on d_type == DT_DIR, unlinkat(fd, child, 0) otherwise, and unlinkat(parent, name, AT_REMOVEDIR) last; a symlink shows up as ENOTDIR | ELOOP and is unlinked rather than entered (unix.rs). Go's removeAllFrom does the same over Readdirnames(1024) batches, closing and re-opening the directory between batches because deleting entries "may have caused the OS to reshuffle it" (removeall_at.go). On Darwin, Go's Getdirentries is itself "simulated using fdopendir/readdir_r/closedir", duplicating the fd via openat(fd, ".", O_RDONLY, 0) because fdopendir takes ownership (syscall_darwin.go). Rust's directory-handle tracking issue records getattrlistbulk as the native readdir on macOS and iOS (rust-lang/rust#120426).
Strengths
openatis unconditional (10.10 floor), and the whole*atfamily is present, so the per-component walk needs no fallback.- Whole-path policy exists as bits:
O_NOFOLLOW_ANY,AT_SYMLINK_NOFOLLOW_ANY,*_RESOLVE_BENEATH— repeated onrename,clonefileandgetattrlistso each op can be bounded, not onlyopen. - Handle→path is a real syscall (
F_GETPATH,fsgetpath), not a procfs convention. getattrlistbulkreturns per-entry errors (ATTR_CMN_ERROR) inside one bulk call.
Weaknesses
- No
O_PATH: an intermediate directory must be opened for reading. - No mount boundary (
RESOLVE_NO_XDEVhas no counterpart); only device ids after the fact. - The same policy under five names (
O_,AT_,RENAME_,CLONE_,FSOPT_), with different bit values each time. - Nothing surveyed uses the whole-path bits: Go, Rust std and cap-std all walk per component on Darwin, so the extra atomicity is unexercised in practice.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| Policy as flag bits on existing calls | No new syscall or how struct; per-op bounding | Five parallel namings; feature detection is per flag, not per syscall |
O_NOFOLLOW_ANY rather than RESOLVE_NO_SYMLINKS | Fits the O_ word | Gated behind __DARWIN_C_FULL; no AT_-style symmetry until later |
No O_PATH | Handles are always real opens | Traverse-only directories cannot be held as walk anchors |
renameatx_np with RENAME_EXCL / RENAME_SWAP | Atomic no-replace and exchange | _np, 10.12+, must be weak-linked |
getattrlistbulk over getdents | Typed attributes per entry, inline errors | An Apple-only enumeration API; libc readdir is a wrapper |
F_GETPATH / fsgetpath for handle→path | No procfs dependency | Path is advisory: it can be stale by the time it is used |
Sources
bsd/sys/fcntl.h—O_NOFOLLOW,O_SYMLINK,O_NOFOLLOW_ANY,O_RESOLVE_BENEATH,AT_*,openatavailabilitybsd/sys/stdio.h—RENAME_*,renameat(10.10),renameatx_np(10.12)bsd/sys/clonefile.h—CLONE_*,clonefileat/fclonefileat(10.12)bsd/sys/attr.h—FSOPT_*,ATTR_BULK_REQUIRED,ATTR_CMN_ERRORbsd/kern/syscalls.master— syscall numbers for the whole*atfamilysrc/os/root_unix.go,root_openat.go,root.go,removeall_at.go— the per-component walk and its documented racessrc/internal/syscall/unix/at_darwin.go,src/syscall/syscall_darwin.go,zerrors_darwin_arm64.go— libSystem trampolines;O_NOFOLLOW_ANYknown but unusedlibrary/std/src/sys/fs/unix.rs—remove_dir_alloveropenat/fdopendir/unlinkat;fclonefileatcopysrc/backend/libc/fs/syscalls.rs,types.rs,src/fs/at.rs—NOFOLLOW_ANY,RenameFlags, weakrenameatx_np- cap-std
rustix/fs/dir_utils.rs,oflags.rs,copy_impl.rs,metadata_ext.rs,darwin/fs/file_path.rs,fs/manually/open.rs - rust-lang/rust#120426 — platform survey naming
getattrlistbulkas macOS readdir
NOTE
Unverified. The header at this tag carries no availability macro on the O_NOFOLLOW_ANY, AT_SYMLINK_NOFOLLOW_ANY, O_RESOLVE_BENEATH or AT_RESOLVE_BENEATH defines, so the macOS versions that introduced them (the brief's "macOS 11+" for O_NOFOLLOW_ANY) could not be confirmed from the source read. The brief also anticipated a weak-linked pre-10.10 openat fallback in Rust std; at revision 3bf5c6d no such code exists (std's weak! uses in fs/unix.rs are Android and glibc-2.34 time functions), and the only Darwin weak link found is rustix's renameatx_np. Firmlinks and the APFS volume-group layout are not covered because no surveyed consumer handles them.