A pathname can change meaning while a process is resolving it. Directory renames, symbolic links, mount points, and .. components can redirect lookup away from the directory a program intended to treat as its boundary.
Linux openat2() attaches resolution policy to the lookup itself. Its struct open_how contains a resolve bit mask, so the kernel can reject a path when resolution violates a caller-selected constraint instead of relying only on checks performed before open().
A directory file descriptor establishes the starting point
Like openat(), openat2() can resolve a relative pathname from a directory file descriptor:
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
};
int fd = syscall(SYS_openat2, dirfd, "assets/config.json",
&how, sizeof(how));For a relative path, dirfd identifies the directory where lookup starts. This avoids dependence on the process working directory, but a starting directory alone does not prevent every escape. A path can contain .., encounter a symbolic link, or cross a mount point.
The resolve field adds constraints to those transitions.
RESOLVE_BENEATH rejects escapes above dirfd
RESOLVE_BENEATH requires every resolved component to remain below the directory identified by dirfd.
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS,
};A path such as images/logo.svg can resolve normally when all components remain descendants of dirfd. An attempted escape through .. is rejected. Absolute pathnames and absolute symbolic links are also incompatible with the beneath constraint.
The current kernel behavior also blocks magic-link traversal when RESOLVE_BENEATH is active. The Linux API documentation explicitly advises setting RESOLVE_NO_MAGICLINKS when that property is required, because applications should not depend on the current implicit behavior remaining permanent.
RESOLVE_IN_ROOT gives one lookup a temporary root
RESOLVE_IN_ROOT has different semantics. It treats dirfd as the root directory for that specific lookup.
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
.resolve = RESOLVE_IN_ROOT | RESOLVE_NO_MAGICLINKS,
};An absolute pathname is then interpreted relative to dirfd. An absolute symbolic link is handled inside that same temporary root. A .. component at the root does not move above it.
This resembles a per-open root boundary rather than a process-wide chroot(). It does not permanently change the calling process root directory.
RESOLVE_BENEATH and RESOLVE_IN_ROOT therefore solve related but distinct problems: the first rejects lookup that escapes beneath the starting directory, while the second changes the root semantics for one resolution.
Symbolic links and magic links are separate controls
RESOLVE_NO_SYMLINKS rejects symbolic-link traversal in any path component:
.resolve = RESOLVE_BENEATH |
RESOLVE_NO_SYMLINKS |
RESOLVE_NO_MAGICLINKS,When a symbolic link is encountered under RESOLVE_NO_SYMLINKS, openat2() can fail with ELOOP.
RESOLVE_NO_MAGICLINKS targets Linux magic links, notably objects such as entries under /proc/<pid>/fd. These objects can have behavior beyond ordinary symbolic-link text substitution. Keeping the two flags explicit makes the requested policy visible even when one restriction currently implies another in kernel behavior.
RESOLVE_NO_XDEV blocks mount transitions
A directory tree can contain ordinary mounts or bind mounts. Staying beneath a pathname prefix does not necessarily mean staying on one mount.
RESOLVE_NO_XDEV rejects lookup that crosses a mount point:
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
.resolve = RESOLVE_BENEATH |
RESOLVE_NO_MAGICLINKS |
RESOLVE_NO_XDEV,
};This includes bind mounts. That makes the flag useful for policies that require a lookup to remain on the starting mount, but it can also reject layouts that intentionally use mounted subtrees. It is a separate boundary from descendant containment.
RESOLVE_CACHED turns cache state into an explicit result
RESOLVE_CACHED requires the operation to complete from cached lookup information. If resolution needs revalidation or I/O, the call fails with EAGAIN.
struct open_how how = {
.flags = O_RDONLY | O_CLOEXEC,
.resolve = RESOLVE_CACHED,
};This flag is a latency-control mechanism rather than a containment boundary. A caller can use the cached attempt as a fast path and choose another execution path after EAGAIN.
The error is therefore part of the interface contract: it can indicate that the requested cache-only condition could not be met, not that the target pathname is permanently invalid.
Resolution failures carry policy information
Several errors expose which class of constraint stopped lookup. ELOOP can report prohibited symbolic-link or magic-link traversal. EXDEV can report an attempted escape under RESOLVE_BENEATH or RESOLVE_IN_ROOT, or a mount crossing under RESOLVE_NO_XDEV.
EAGAIN can also occur when the kernel cannot safely establish containment during a race involving ... The caller may retry. With RESOLVE_CACHED, EAGAIN instead signals that cache-only resolution was insufficient.
Applications should handle these results according to the policy they requested rather than collapsing every failure into a generic missing-file condition.
The kernel enforces the constraint during lookup
A userspace sequence that checks a path and later opens it separates validation from use. Filesystem state can change between those operations.
openat2() places selected constraints inside the kernel pathname-resolution operation:
dirfd
|
+-- pathname
|
+-- kernel path walk
|
+-- RESOLVE_* policy
|
+-- file descriptor or errorThe important boundary is not string normalization. It is the set of filesystem transitions the kernel permits while converting a pathname into a file descriptor.
openat2() does not replace application authorization, file permissions, or broader sandboxing. It gives Linux applications a narrower primitive: an open operation whose pathname traversal is constrained as part of the operation itself.