A pathname is not a stable object reference. Between its starting directory and final component, Linux path resolution may follow symbolic links, cross mount points, process .., or encounter special links exposed by pseudo-filesystems. openat2() lets a caller attach constraints to that resolution operation so the kernel can reject a lookup that leaves the intended boundary.

The distinction is stronger than checking a normalized string before open(). String validation examines syntax. openat2() can constrain the kernel’s actual traversal while filesystem objects and mount topology participate in the lookup.

The starting descriptor defines the anchor

Like openat(), openat2() accepts a directory file descriptor and a pathname. A relative pathname begins from that descriptor rather than from the process working directory.

struct open_how how = {
    .flags = O_RDONLY | O_CLOEXEC,
    .resolve = RESOLVE_BENEATH,
};

int fd = syscall(SYS_openat2, rootfd, "assets/logo.svg",
                 &how, sizeof(how));

With RESOLVE_BENEATH, every component must resolve beneath rootfd. An absolute pathname is rejected, and traversal that escapes the anchor through .., an absolute symbolic link, or an absolute path supplied by a magic link is rejected.

This makes the directory descriptor part of the authority boundary. The application does not need to reconstruct the absolute pathname of rootfd, and changing the process working directory does not move that anchor.

RESOLVE_IN_ROOT gives absolute components a local root

RESOLVE_IN_ROOT has different semantics. During this lookup, the directory referenced by dirfd acts as a temporary root. An absolute input path is interpreted relative to that directory, and an absolute symbolic link is resolved from the same local root.

A .. component at that root does not escape above it. This resembles a scoped path-resolution root for one operation; it does not change the process root directory and is not equivalent to chroot().

The distinction between RESOLVE_BENEATH and RESOLVE_IN_ROOT is therefore observable. The first rejects resolution attempts that leave the subtree. The second redirects root-relative resolution into the supplied directory boundary. Software should choose the contract it needs rather than treating the flags as interchangeable hardening switches.

RESOLVE_NO_SYMLINKS rejects symbolic links in every component of the path. This is stricter than O_NOFOLLOW, whose ordinary role is to prevent following a symbolic link in the final pathname component.

A service may need symlinks inside a controlled tree, in which case RESOLVE_BENEATH or RESOLVE_IN_ROOT can preserve that feature while constraining escape. Another service may require a path made entirely of non-symlink components and can add RESOLVE_NO_SYMLINKS.

RESOLVE_NO_MAGICLINKS targets magic links such as selected entries in /proc. These links can have behavior beyond ordinary symlink text and can refer to process resources. Current kernels imply RESOLVE_NO_MAGICLINKS when RESOLVE_NO_SYMLINKS is requested, but relying on that implication obscures intent if magic links are the actual concern.

Mount crossings are a separate constraint

A path can remain lexically below a directory and still enter another mounted filesystem. RESOLVE_NO_XDEV rejects mount-point crossings during resolution, including bind mounts.

That policy is independent of symlink policy. A bind mount placed inside an otherwise controlled directory tree does not require a symlink to redirect lookup into another filesystem view. Conversely, forbidding mount crossings does not itself forbid symlinks that remain inside the permitted mount.

This separation matters for applications that map a directory tree to a security or data-ownership boundary. The relevant policy may need to constrain both namespace traversal and mount topology.

Resolution constraints close a check-use gap

A common path filter first canonicalizes or inspects a pathname and later opens it. Those are separate operations. If a mutable directory entry or symlink can change between the check and the open, the second lookup may traverse a different object graph.

openat2() applies its resolve rules during the lookup that returns the descriptor. There is no successful checked pathname that must later be resolved again for the same open operation.

This does not make every later operation race-free. If software receives a directory descriptor and then performs additional path operations beneath it, each later resolution needs an appropriate contract. The returned file descriptor stabilizes a reference to the opened object; it does not freeze the surrounding namespace.

Failure is part of the interface contract

A constrained lookup can fail with EXDEV when a resolution rule such as RESOLVE_BENEATH, RESOLVE_IN_ROOT, or RESOLVE_NO_XDEV detects a prohibited escape or crossing. ELOOP is used for prohibited symbolic-link or magic-link traversal under the relevant flags.

Applications should treat these failures as policy outcomes, not as signals to silently retry with plain openat(). A fallback that removes the constraint also removes the property the caller requested.

openat2() additionally uses the size of struct open_how as an extensibility boundary. Callers should zero-initialize the structure, set only defined fields, and pass the structure size they provide. Unknown nonzero fields or unsupported flag bits are rejected rather than ignored, which makes capability negotiation explicit.

Kernel-enforced resolution does not replace access control

The resolve field controls path traversal. The flags and mode fields retain open semantics, and normal filesystem permission checks still apply. A path staying beneath rootfd does not imply that the caller has permission to open it, and successful access does not prove that the surrounding directory is trustworthy.

The boundary also depends on the directory descriptor supplied by the application. If an attacker can replace that descriptor in application state or cause the service to select an unintended anchor, path-resolution constraints faithfully enforce the wrong boundary.

For descriptor-oriented designs, the stronger pattern is to establish the anchor once, retain it as a descriptor with controlled lifetime, and perform each untrusted relative lookup with explicit resolution constraints. That keeps pathname interpretation, namespace policy, and object acquisition inside one kernel operation.