Skip to content

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.

Kindkernel mechanism
Yearopenat family: macOS 10.10 (2014); renameatx_np / clonefileat: 10.12 (2016); O_NOFOLLOW_ANY, *_RESOLVE_BENEATH: present in the 2025 header, see Unverified
MaintainersApple (XNU, apple-oss-distributions)
LanguageC
LicenseAPSL 2.0
Repositoryapple-oss-distributions/xnu at tag xnu-12377.121.6
PlatformsmacOS, iOS, tvOS, watchOS, visionOS
Primitiveopenat(dirfd, …) with O_NOFOLLOW per component, or whole-path O_NOFOLLOW_ANY; AT_RESOLVE_BENEATH on the *at calls
Source readbsd/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):

c
#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):

SyscallNumberNote
openat / openat_nocancel463/464the base primitive
renameat46510.10
faccessat, fchmodat, fchownat466–468
fstatat, fstatat64469/470
linkat, unlinkat, readlinkat, symlinkat, mkdirat471–475
getattrlistat476attrlist-based stat relative to a dirfd
getattrlistbulk461bulk directory enumeration on a dirfd (int dirfd, struct attrlist *alist, …)
clonefileat / fclonefileat462/517APFS clone relative to dirfds
renameatx_np488renameat plus a u_int flags word
getdirentries64344what fdopendir/readdir sit on
freadlink551readlink on an O_SYMLINK fd
openbyid_np, fsgetpath479/427open 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):

c
#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          0x00000020

clonefileat (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.)

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 ​

  • openat is unconditional (10.10 floor), and the whole *at family 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 on rename, clonefile and getattrlist so each op can be bounded, not only open.
  • Handle→path is a real syscall (F_GETPATH, fsgetpath), not a procfs convention.
  • getattrlistbulk returns 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_XDEV has 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 ​

DecisionRationaleTrade-off
Policy as flag bits on existing callsNo new syscall or how struct; per-op boundingFive parallel namings; feature detection is per flag, not per syscall
O_NOFOLLOW_ANY rather than RESOLVE_NO_SYMLINKSFits the O_ wordGated behind __DARWIN_C_FULL; no AT_-style symmetry until later
No O_PATHHandles are always real opensTraverse-only directories cannot be held as walk anchors
renameatx_np with RENAME_EXCL / RENAME_SWAPAtomic no-replace and exchange_np, 10.12+, must be weak-linked
getattrlistbulk over getdentsTyped attributes per entry, inline errorsAn Apple-only enumeration API; libc readdir is a wrapper
F_GETPATH / fsgetpath for handle→pathNo procfs dependencyPath is advisory: it can be stale by the time it is used

Sources ​

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.