Before you start#
Read VFS objects and descriptors. Source behavior below refers to Linux v6.12, a historical teaching baseline. The lab creates only a temporary userspace directory in a disposable guest.
Learning goals#
Follow components through dentries and mount points; explain RCU-walk and its fallback; distinguish a symlink restriction from a containment restriction; reason about a rename race.
Source walkthrough#
- Starting path
Root or directory descriptor supplies mount and dentry.
- Component lookup
RCU lookup validates cached relationships.
- Retry when needed
Reference walk or error when optimistic assumptions fail.
- Final operation
Open, create and other operations interpret final component.
A pathname is a sequence of components, not a direct inode number. Lookup begins from an appropriate root or directory, crosses directories and potentially mount points, and resolves symbolic links subject to the operation's flags. The current location is a struct path: a dentry plus a mount. Each directory component must be traversable under applicable credentials, search permissions, mount rules and security hooks. A dentry can be positive (associated with an inode) or negative (a cached lookup of a missing name); filesystems may need to revalidate cached answers. See fs/namei.c and the path lookup documentation.
do_filp_open invokes path_openat with lookup flags, initially attempting an RCU-based walk where appropriate; it can retry with reference-based lookup. link_path_walk processes intermediate components and the final component is handled by the open-specific path. RCU-walk avoids taking ordinary references for every cached component on the fast path. RCU protects against premature reclamation for the read-side critical section; sequence checks validate that the relationships observed did not change. It does not freeze the directory tree. A reference walk obtains references and uses locking or revalidation as required. Some operations cannot safely complete in RCU mode and request a retry.
In lookup_fast, the RCU branch starts (GPL-2.0; surrounding statements omitted):
dentry = __d_lookup_rcu(parent, &nd->last, &nd->next_seq);
if (unlikely(!dentry)) {
if (!try_to_unlazy(nd))
return ERR_PTR(-ECHILD);
The lookup is tentative. On a miss, try_to_unlazy attempts to convert the walk to a referenced one; failure returns -ECHILD, an internal signal to retry in a suitable mode. Later the code checks the parent's sequence:
if (read_seqcount_retry(&parent->d_seq, nd->seq))
return ERR_PTR(-ECHILD);
A changed sequence invalidates the optimistic observation. -ECHILD here is not a statement that a userspace child process disappeared. The calling path interprets it as a lookup retry condition; user-visible errors depend on the eventual operation. See lookup_fast, complete_walk, and do_filp_open.
Worked path and rename race#
Suppose /lab/a/report exists, /lab is a directory, and the caller is permitted to search each directory. Lookup starts at the caller's root for the absolute path, finds lab, then a, then report. If a is a mount point, the path switches to the root dentry of the mounted tree before continuing. If a is a symbolic link, lookup must interpret its target and remaining components according to the relevant flags; a relative target is interpreted from the link's parent. The path obtained depends on the actual namespace, credentials and concurrent changes, not just the string.
Now another task renames a during an optimistic lookup. The cache lookup may observe an old relationship or miss a name that has moved. Sequence validation or inability to legitimize the path causes fallback rather than trusting an inconsistent snapshot. Even a successful path resolution does not promise the namespace cannot change after return. A file descriptor acquired by open refers to the opened file, whereas checking a path and opening it in a separate syscall leaves a time-of-check/time-of-use window. Use a single operation with the appropriate constraints where possible; do not treat this discussion as a complete sandbox design.
openat2 takes an open_how with resolve flags in include/uapi/linux/openat2.h. RESOLVE_NO_SYMLINKS rejects symlink resolution throughout the walk, not merely at the final component. RESOLVE_BENEATH constrains resolution beneath the supplied directory and rejects escapes, while RESOLVE_IN_ROOT treats the supplied directory as the root for that operation. RESOLVE_NO_XDEV prevents mount-point crossings, including bind mounts. These flags have distinct semantics and error cases; check the source and API contract for the deployed kernel. A mount crossing and a symlink are different mechanisms, so blocking one is not equivalent to blocking the other.
The mapping below groups the openat2 resolve flags by the lookup check that each one sets, using the v6.12 source names. It is a source map, not an exhaustive list of errors.
Read the text version
- RESOLVE_NO_SYMLINKS: symlink traversal anywhere in the walk → LOOKUP_NO_SYMLINKS: checked in pick_link (symlink check)
- RESOLVE_NO_XDEV: mount-point crossings, including bind mounts → LOOKUP_NO_XDEV: checked in nd_jump_root and nd_jump_link (mount check)
- RESOLVE_BENEATH: lexical escapes above the supplied directory → LOOKUP_BENEATH: escape rejected with -EXDEV in nd_jump_root (scope check)
- RESOLVE_IN_ROOT: jumps to / and .. scoped to the starting directory → LOOKUP_IN_ROOT: set in build_open_flags; walk treats dirfd as root (root redirection)
- RESOLVE_CACHED: completion only from cached lookup state → LOOKUP_CACHED: refused with -EAGAIN for create, truncate, tmpfile (cached-only check)
The final component is special#
Intermediate components must lead to directories, because lookup needs somewhere to look for the next component. The last component is interpreted according to the operation: open may create it, unlink removes a name, and a trailing slash can impose directory-related requirements. For creation, a negative dentry can describe the currently absent name, but it is not a permanent promise that another task will not create that name. Directory locking and rechecking are required around the operation. A caller must inspect the open flags, filesystem operation, and actual error path rather than inferring creation semantics from a cached negative dentry.
An absolute symlink target restarts from the operation's effective root, while a relative target is interpreted at the link's containing directory. Symlink traversal also has a finite limit; a cycle such as a -> b, b -> a does not make the kernel follow links forever. A path can traverse both a mount and a symlink in either order. RESOLVE_NO_XDEV can reject a bind-mount crossing even when the target looks like an ordinary directory, and RESOLVE_NO_SYMLINKS addresses links independently. In constrained lookups, a race around .. and namespace changes may cause the kernel to reject an operation when it cannot establish the requested containment; a caller should handle an error, not silently retry without the restriction.
In pick_link, v6.12, SPDX GPL-2.0 (surrounding code omitted), the symlink check refuses to follow a link under either of two conditions:
if (unlikely(nd->flags & LOOKUP_NO_SYMLINKS) ||
unlikely(link->mnt->mnt_flags & MNT_NOSYMFOLLOW))
return ERR_PTR(-ELOOP);
The caller flag LOOKUP_NO_SYMLINKS and the mount flag MNT_NOSYMFOLLOW each produce the same -ELOOP, so a mount policy can block a link even when the caller did not ask for RESOLVE_NO_SYMLINKS. The excerpt does not show how the final component is handled when O_NOFOLLOW is set.
Suppose a service checks that root/user/file starts with a textual prefix, then opens it. A symlink inside user can redirect resolution elsewhere even though the string has the prefix. Another task can also replace a checked component before the open. A directory descriptor plus suitable openat2 resolution flags changes what the kernel is asked to enforce during that particular lookup. Correct security still depends on directory ownership, credentials, other access paths, kernel support and the exact flags. A textual prefix, a prior stat, and a constrained lookup are different guarantees.
Permissions, revalidation, and the lookup result#
Directory search permission is checked during traversal; read permission on the final regular file is a different question. Being able to list a directory and being able to traverse a known component are also different operations. Credentials, ACLs, mount flags and Linux security hooks can affect the outcome. A cached dentry is a cached name lookup result, not a cached universal authorization decision. The current caller and operation still matter. A failed permission check can stop lookup even when the named object physically exists.
Some filesystems need d_revalidate to determine whether cached dentry information remains usable. The lookup_fast branch invokes it and interprets positive, zero and negative results differently. In RCU mode revalidation may be unable to proceed without blocking and request a retry; after conversion, the referenced walk can perform work under the appropriate contract. If revalidation says a cached entry is invalid, the code can invalidate it and release the reference on the relevant path. This is why using the dcache is not equivalent to skipping filesystem-specific coherence requirements.
For a concrete security example, let a service hold a directory fd for /srv/upload and accept reports/today. If the service resolves this relative to that fd using suitable constraints, the starting point is explicit. If an attacker changes reports into a symlink to an outside directory, RESOLVE_NO_SYMLINKS asks the kernel to reject the link rather than follow it. If reports is an ordinary mountpoint, that flag alone is not the mount restriction; RESOLVE_NO_XDEV is the relevant distinct control. For .. escapes and absolute paths, the caller must choose and understand RESOLVE_BENEATH or RESOLVE_IN_ROOT rather than relying on a string prefix. The kernel version and permitted flags must be checked, and errors must not trigger an unconstrained fallback.
The final fd produced by a successful open is the outcome of a lookup at a point in time. Later renames can change the name while the opened file remains referenced. A later open of the same string can find a different object, fail, or traverse a different mount. Thus a source walkthrough should state both the lookup context and the object whose lifetime continues afterward. The two are related, but they are not interchangeable evidence.
Retry, revalidation, and scoped lookup#
RCU-walk is an optimistic protocol, not a frozen snapshot. nameidata records dentry sequence information and mount/rename sequence observations. lookup_fast validates a parent dentry sequence and can return -ECHILD when the optimistic walk cannot be legitimized. try_to_unlazy and try_to_unlazy_next attempt to convert suitable RCU observations into referenced state; the resulting references must later be released. A mount transition changes both mount and dentry context and has its own sequence checks. Rename and mount changes can invalidate a scoped .. conclusion even if the current dentry pointer still exists. See fs/namei.c.
The do_filp_open retry sequence in v6.12 is (GPL-2.0; surrounding statements omitted):
filp = path_openat(&nd, op, flags | LOOKUP_RCU);
if (unlikely(filp == ERR_PTR(-ECHILD)))
filp = path_openat(&nd, op, flags);
if (unlikely(filp == ERR_PTR(-ESTALE)))
filp = path_openat(&nd, op, flags | LOOKUP_REVAL);
The first attempt requests RCU-walk. -ECHILD causes a non-RCU retry, while -ESTALE causes a retry with revalidation. Those are internal branch conditions here; the eventual syscall can still fail. A filesystem's revalidation can require additional work or report an error. A cache hit is not a promise that a remote filesystem's cached name is valid forever.
The flow below restates the three path_openat attempts in do_filp_open as a teaching model.
- RCU-walk attempt
do_filp_open first calls path_openat with LOOKUP_RCU
- -ECHILD retry
a failed legitimization or sequence check retries without RCU-walk
- Reference walk
the retry uses reference-based lookup with the same flags
- -ESTALE retry
an -ESTALE result retries once with LOOKUP_REVAL
- Caller result
a file, or an ordinary errno such as ENOENT or EACCES
Directory search permission is checked while traversing, using the relevant credentials including filesystem IDs and any ACL, mount and security policy checks. A descriptor to a directory provides a starting point; it does not universally confine a subsequent pathname without the appropriate operation and constraints. Ordinary O_NOFOLLOW concerns the final component, whereas RESOLVE_NO_SYMLINKS constrains symlink resolution throughout an openat2 walk. RESOLVE_NO_MAGICLINKS addresses magic links, including special procfs-style links, separately. RESOLVE_NO_XDEV rejects mount crossings including bind mounts. RESOLVE_BENEATH rejects escapes above the supplied directory; RESOLVE_IN_ROOT treats that directory as the lookup root for the operation. The v6.12 build_open_flags rejects using BENEATH and IN_ROOT together. Consult include/uapi/linux/openat2.h and fs/open.c.
For a hypothetical directory fd rooted at /srv/data, sub/file can be allowed under BENEATH if its components and permissions satisfy the restrictions. A ../outside escape can fail with EXDEV; an ordinary symlink or mount transition can fail under the corresponding no-symlink/no-xdev flags. During scoped .. handling, handle_dots checks the mount and rename sequence counters; if a concurrent change prevents proving containment, it can return EAGAIN. Retrying the same constrained request may be appropriate, but silently retrying without the constraint abandons the intended guarantee. These outcomes are conditional examples, not a universal mapping of every openat2 failure to one errno.
Component rules and scoped error examples#
Suppose /srv/data/sub/file is an ordinary file and every intermediate directory is searchable by the caller's filesystem credentials. A constrained BENEATH lookup of sub/file can succeed because it stays under the supplied directory. If sub is instead a symlink, RESOLVE_NO_SYMLINKS rejects following it even though it is an intermediate component. O_NOFOLLOW alone normally applies to the final component and is not an equivalent all-components restriction; the exact combination of O_PATH, O_NOFOLLOW and resolution flags has documented special cases for returning a link descriptor. An application must decide whether it needs to inspect the link itself or forbid traversing it.
With RESOLVE_IN_ROOT, absolute paths and absolute symlink targets are interpreted relative to the supplied root for that operation. RESOLVE_BENEATH instead rejects escape and absolute-path cases under its contract. A .. at a mount root can require a mount transition; NO_XDEV can reject that transition. In handle_dots, a scoped lookup that observes a conflicting rename or mount sequence can return EAGAIN because the kernel cannot establish the containment invariant. EXDEV, EAGAIN, ELOOP, ENOENT and permission errors have different causes; no one example guarantees which wins if several conditions fail concurrently.
A safer retry keeps the same directory fd, resolve flags and application policy, with a bounded retry or error report. Replacing a failed constrained lookup with ordinary open can allow a path that the original request deliberately rejected. Even a successful constrained open only gives the semantics of that operation and the returned descriptor; later names can change and a different syscall needs its own constraints. These examples are source-based reasoning rather than a claim that the symlink lab exercised openat2 or a live rename race.
Cached-only lookup and a missing component#
RESOLVE_CACHED asks openat2 to complete using the applicable cached lookup path without operations that would require the prohibited slow work. In v6.12, build_open_flags returns -EAGAIN for cached-only requests combined with create, truncate or tmpfile open; a cached-only path can also fail when a required lookup or revalidation cannot be completed under that restriction. EAGAIN is not proof that the file does not exist. A caller can choose to retry without the performance restriction while retaining its security restrictions, if its policy permits that change; it must not discard BENEATH, IN_ROOT or other required containment flags as an incidental fallback.
In build_open_flags, v6.12, SPDX GPL-2.0-only (surrounding code omitted), the resolve flags become lookup flags, and the cached-only request is refused for create, truncate and tmpfile opens:
if (how->resolve & RESOLVE_NO_XDEV)
lookup_flags |= LOOKUP_NO_XDEV;
if (how->resolve & RESOLVE_NO_MAGICLINKS)
lookup_flags |= LOOKUP_NO_MAGICLINKS;
if (how->resolve & RESOLVE_NO_SYMLINKS)
lookup_flags |= LOOKUP_NO_SYMLINKS;
if (how->resolve & RESOLVE_BENEATH)
lookup_flags |= LOOKUP_BENEATH;
if (how->resolve & RESOLVE_IN_ROOT)
lookup_flags |= LOOKUP_IN_ROOT;
if (how->resolve & RESOLVE_CACHED) {
/* Don't bother even trying for create/truncate/tmpfile open */
if (flags & (O_TRUNC | O_CREAT | __O_TMPFILE))
return -EAGAIN;
lookup_flags |= LOOKUP_CACHED;
}
The RESOLVE_CACHED branch returns -EAGAIN before any walk begins, so the caller receives an error rather than an answer about the name. The other flags only set lookup_flags; the checks that enforce them run later in the walk. The excerpt does not show those checks.
A negative dentry can cache an absent name, but a later create, rename or filesystem revalidation can change that result. A successful directory search for one component is not authorization to read the final file, and a final permission failure is different from a missing intermediate directory. For an absolute path, the starting root and mount namespace matter; for a relative openat2 request, the directory fd and selected resolve flags matter. The existing lab demonstrates a missing component, while the race and cached-only cases are source reasoning rather than measured observations.
Some permission and revalidation work cannot complete in the optimistic context without blocking. A path can request conversion or retry rather than sleeping while relying on an invalid RCU observation. The referenced walk then takes the references and locks required by that route, but it must still honor concurrent changes and filesystem errors. A successful fast lookup is therefore not a bypass of search permission, and a fallback is not evidence that the path was malicious. The distinction is whether the kernel can safely establish the requested result under the current walk's constraints.
For a magic-link example, a procfs-style link can resolve to a kernel-provided path rather than an ordinary stored symlink target. RESOLVE_NO_MAGICLINKS explicitly constrains that mechanism. RESOLVE_NO_SYMLINKS is broader for symlink resolution, but its interaction with opening a link itself using O_PATH and O_NOFOLLOW has specific documented exceptions. The correct flags depend on whether the application wants to inspect the link or follow its target; a blanket rule that every symlink-related open always fails would hide that distinction.
Practice#
Source exercise: rg -n -A55 'static struct dentry \*lookup_fast' "$KERNEL_SRC/fs/namei.c". Mark the cache miss, sequence check, d_revalidate, and return paths. Find do_filp_open and its retry logic in the same file; record the exact conditions instead of assuming all lookups take RCU-walk.
In a disposable guest, this unprivileged example creates and cleans a directory, a target, and a relative symlink:
python3 - <<'PY'
import os, tempfile
with tempfile.TemporaryDirectory() as d:
os.mkdir(d + '/a')
with open(d + '/a/file', 'w') as f: f.write('hello')
os.symlink('a', d + '/link')
print(os.readlink(d + '/link'))
with open(d + '/link/file') as f: print(f.read())
try: open(d + '/missing/file')
except FileNotFoundError: print('missing component')
PY
Expect a, hello, and missing component. The relative symlink is interpreted from the link's parent; the failed lookup shows that a missing intermediate component prevents the open. This is a userspace observation, not proof of which walk mode was used and not a test of openat2 restrictions. The temporary directory is removed automatically. Deliver a component-by-component path sketch and a description of the rename retry.
Knowledge check#
-
What two objects make up a path at a mount boundary?
-
What does an RCU sequence failure mean in
lookup_fast? -
Why does a pre-open pathname check not prevent a later rename race?
-
How do
RESOLVE_NO_SYMLINKSandRESOLVE_NO_XDEVdiffer? -
Why can scoped
..lookup returnEAGAIN, and how should a retry treat the resolution flags?
Answers#
-
The path pairs a dentry and a mount; crossing a mount can change both the mount and the current root dentry.
-
The optimistic observation cannot be trusted and the code returns
-ECHILDfor a retry; it is an internal walk condition. -
Another task can change a component between the check and open. A single constrained operation avoids that particular split check, subject to its actual flags.
-
One prohibits symlink resolution; the other prohibits crossing mount points. A pathname may encounter either independently.
-
A concurrent rename or mount change can prevent proving that
..stayed within the root. A retry should retain the same confinement flags and policy, rather than silently falling back to an unconstrained open.
Checkpoint#
Save the path sketch, expected lab output and an explanation of -ECHILD. Pass when you can explain the two kinds of walk and distinguish mount from symlink constraints. Re-read lookup_fast and redraw the race if needed; this is not certification.